Preparing the summary aggregation
This commit is contained in:
parent
4c689171d5
commit
ab834520fd
|
@ -61,6 +61,7 @@
|
|||
CE874F011DA2D916000D309E /* ReactionSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE874F001DA2D916000D309E /* ReactionSummary.swift */; };
|
||||
CE874F031DA2DA64000D309E /* ComponentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE874F021DA2DA64000D309E /* ComponentBuilder.swift */; };
|
||||
CEB867BC1DA41CB600031B0D /* UIReactionControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB867BB1DA41CB600031B0D /* UIReactionControl.swift */; };
|
||||
CECBB78E1DB4C87D007DCA25 /* CAReactionSummaryLayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECBB78D1DB4C87D007DCA25 /* CAReactionSummaryLayerTests.swift */; };
|
||||
CED4FCE21D9FD95D00F54838 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED4FCE11D9FD95D00F54838 /* AppDelegate.swift */; };
|
||||
CED4FCE41D9FD95D00F54838 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED4FCE31D9FD95D00F54838 /* ViewController.swift */; };
|
||||
CED4FCE71D9FD95D00F54838 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CED4FCE51D9FD95D00F54838 /* Main.storyboard */; };
|
||||
|
@ -133,6 +134,7 @@
|
|||
CE874F001DA2D916000D309E /* ReactionSummary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReactionSummary.swift; sourceTree = "<group>"; };
|
||||
CE874F021DA2DA64000D309E /* ComponentBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComponentBuilder.swift; sourceTree = "<group>"; };
|
||||
CEB867BB1DA41CB600031B0D /* UIReactionControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIReactionControl.swift; sourceTree = "<group>"; };
|
||||
CECBB78D1DB4C87D007DCA25 /* CAReactionSummaryLayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CAReactionSummaryLayerTests.swift; sourceTree = "<group>"; };
|
||||
CED4FCDE1D9FD95D00F54838 /* ReactionsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReactionsExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CED4FCE11D9FD95D00F54838 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
CED4FCE31D9FD95D00F54838 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -216,6 +218,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
CE0385EF1DAA560F00D7F482 /* nibs */,
|
||||
CECBB78D1DB4C87D007DCA25 /* CAReactionSummaryLayerTests.swift */,
|
||||
CE83F8D01DAA50A5000943AB /* ReactionButtonTests.swift */,
|
||||
CE0386071DAA8C7B00D7F482 /* ReactionFeedbackTests.swift */,
|
||||
CE03860B1DAAA63F00D7F482 /* ReactionSelectorConfigTests.swift */,
|
||||
|
@ -486,6 +489,7 @@
|
|||
CE0385F71DAA87C100D7F482 /* Configurable.swift in Sources */,
|
||||
CE0385FA1DAA87C100D7F482 /* Reaction.swift in Sources */,
|
||||
CE0385FB1DAA87C100D7F482 /* ReactionAlignment.swift in Sources */,
|
||||
CECBB78E1DB4C87D007DCA25 /* CAReactionSummaryLayerTests.swift in Sources */,
|
||||
CE03860A1DAAA58D00D7F482 /* ReactionSelectorTests.swift in Sources */,
|
||||
CE0386011DAA87C100D7F482 /* ReactionSelectorConfig.swift in Sources */,
|
||||
CE0386021DAA87C100D7F482 /* ReactionSummary.swift in Sources */,
|
||||
|
|
|
@ -39,10 +39,11 @@ class ViewController: UIViewController, ReactionFeedbackDelegate {
|
|||
reactionSummary.reactions = Reaction.facebook.all
|
||||
reactionSummary.text = "You, Chris Lattner, and 16 others"
|
||||
reactionSummary.config = ReactionSummaryConfig {
|
||||
$0.spacing = 8
|
||||
$0.font = UIFont(name: "HelveticaNeue", size: 12)
|
||||
$0.textColor = UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1)
|
||||
$0.alignment = .centerRight
|
||||
$0.spacing = 8
|
||||
$0.font = UIFont(name: "HelveticaNeue", size: 12)
|
||||
$0.textColor = UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1)
|
||||
$0.alignment = .left
|
||||
$0.isAggregated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,84 +27,137 @@
|
|||
import CoreText
|
||||
import UIKit
|
||||
|
||||
fileprivate extension CATextLayer {
|
||||
func layoutWithConfig(_ config: ReactionSummaryConfig) {
|
||||
font = config.font
|
||||
fontSize = config.font.pointSize
|
||||
foregroundColor = config.textColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience layer to draw the summary icon and labels
|
||||
final class CAReactionSummaryLayer: CALayer {
|
||||
private var indicatorLayers: [CALayer] = [] {
|
||||
private var reactionsLayers: [(CALayer, CATextLayer)] = [] {
|
||||
didSet {
|
||||
for l in oldValue {
|
||||
l.removeFromSuperlayer()
|
||||
for (iconLayer, textLayer) in oldValue {
|
||||
iconLayer.removeFromSuperlayer()
|
||||
textLayer.removeFromSuperlayer()
|
||||
}
|
||||
|
||||
for index in 0 ..< indicatorLayers.count {
|
||||
let l = indicatorLayers[indicatorLayers.count - 1 - index]
|
||||
for index in 0 ..< reactionsLayers.count {
|
||||
let (iconLayer, textLayer) = reactionsLayers[reactionsLayers.count - 1 - index]
|
||||
|
||||
addSublayer(l)
|
||||
addSublayer(iconLayer)
|
||||
addSublayer(textLayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reactionPairs: [(Reaction, Int)] = [] {
|
||||
didSet {
|
||||
reactionsLayers = reactionPairs.map({
|
||||
let iconLayer = CALayer()
|
||||
iconLayer.contents = $0.0.icon.cgImage
|
||||
iconLayer.masksToBounds = true
|
||||
iconLayer.borderColor = UIColor.white.cgColor
|
||||
iconLayer.borderWidth = 2
|
||||
iconLayer.contentsScale = UIScreen.main.scale
|
||||
|
||||
let textLayer = CATextLayer()
|
||||
textLayer.string = "\($0.1)"
|
||||
textLayer.contentsScale = UIScreen.main.scale
|
||||
|
||||
return (iconLayer, textLayer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var reactions: [Reaction] = [] {
|
||||
didSet {
|
||||
indicatorLayers = reactions.uniq().map({
|
||||
let l = CALayer()
|
||||
l.contents = $0.icon.cgImage
|
||||
l.masksToBounds = true
|
||||
l.borderColor = UIColor.white.cgColor
|
||||
l.borderWidth = 2
|
||||
reactionPairs = reactions.uniq().map({ reaction in
|
||||
let reactionCount = reactions.filter({ $0 == reaction }).count
|
||||
|
||||
return l
|
||||
return (reaction, reactionCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var config: ReactionSummaryConfig = ReactionSummaryConfig()
|
||||
|
||||
var margin: CGFloat = 0
|
||||
var config: ReactionSummaryConfig = ReactionSummaryConfig() {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Providing the Layer’s Content
|
||||
|
||||
override func draw(in ctx: CGContext) {
|
||||
super.draw(in: ctx)
|
||||
|
||||
/*var b = bounds
|
||||
for (index, (iconLayer, textLayer)) in reactionsLayers.enumerated() {
|
||||
let rect = reactionFrameAt(index)
|
||||
|
||||
for i in 0 ..< indicatorIcons.count {
|
||||
b.origin.x = b.height + 50 * CGFloat(i)
|
||||
textLayer.isHidden = config.isAggregated
|
||||
|
||||
let path = CGPath(rect: b, transform: nil)
|
||||
let str = NSMutableAttributedString(string: "0")
|
||||
|
||||
str.addAttribute(kCTForegroundColorAttributeName as String, value: UIColor.black, range: NSMakeRange(0,str.length))
|
||||
|
||||
let fontRef = UIFont.systemFont(ofSize: 20)
|
||||
str.addAttribute(kCTFontAttributeName as String, value: fontRef, range:NSMakeRange(0, str.length))
|
||||
|
||||
let frameSetter = CTFramesetterCreateWithAttributedString(str)
|
||||
let ctFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0,str.length), path, nil)
|
||||
|
||||
CTFrameDraw(ctFrame, ctx)
|
||||
}*/
|
||||
|
||||
ctx.translateBy(x: 0, y: bounds.height)
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
|
||||
for index in 0 ..< reactions.count {
|
||||
updateIconAtIndex(index, with: bounds.height - config.iconMarging * 2, in: ctx)
|
||||
updateIconLayer(iconLayer, textLayer: textLayer, in: rect)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIconAtIndex(_ index: Int, with size: CGFloat, in ctx: CGContext) {
|
||||
let x: CGFloat
|
||||
let layer = indicatorLayers[index]
|
||||
private func updateIconLayer(_ iconLayer: CALayer, textLayer: CATextLayer, in rect: CGRect) {
|
||||
var iconFrame = rect
|
||||
iconFrame.size.width = iconFrame.height
|
||||
|
||||
let textSize = sizeForText(textLayer.string as! String)
|
||||
var textFrame = rect
|
||||
textFrame.origin.y += (rect.height - textSize.height) / 2
|
||||
textFrame.size.width = textFrame.width - iconFrame.height
|
||||
|
||||
switch config.alignment {
|
||||
case .left: x = (size - 3) * CGFloat(index)
|
||||
case .right: x = bounds.width - size - (size - 3) * CGFloat(index)
|
||||
case .centerLeft: x = margin + (size - 3) * CGFloat(index)
|
||||
case .centerRight: x = bounds.width - size - (size - 3) * CGFloat(index) - margin
|
||||
case .left, .centerLeft:
|
||||
textFrame.origin.x += iconFrame.width
|
||||
case .right, .centerRight:
|
||||
textFrame.origin.x = bounds.width - textFrame.origin.x - rect.width
|
||||
iconFrame.origin.x = bounds.width - iconFrame.origin.x - rect.width + textFrame.width
|
||||
}
|
||||
|
||||
let iconFrame = CGRect(x: x, y: (bounds.height - size) / 2, width: size, height: size)
|
||||
iconLayer.frame = iconFrame
|
||||
iconLayer.cornerRadius = iconFrame.height / 2
|
||||
|
||||
layer.frame = iconFrame
|
||||
layer.cornerRadius = iconFrame.height / 2
|
||||
layer.draw(in: ctx)
|
||||
textLayer.frame = textFrame
|
||||
textLayer.layoutWithConfig(config)
|
||||
}
|
||||
|
||||
func sizeToFit() -> CGSize {
|
||||
let lastReactionFrame = reactionFrameAt(reactionPairs.count - 1)
|
||||
let width: CGFloat = lastReactionFrame.origin.x + lastReactionFrame.width
|
||||
|
||||
return CGSize(width: width, height: bounds.height)
|
||||
}
|
||||
|
||||
private func reactionFrameAt(_ index: Int) -> CGRect {
|
||||
guard index >= 0 else { return .zero }
|
||||
|
||||
let iconHeight = bounds.height - config.iconMarging * 2
|
||||
|
||||
guard !config.isAggregated else {
|
||||
return CGRect(x: (iconHeight - 3) * CGFloat(index), y: config.iconMarging, width: iconHeight, height: iconHeight)
|
||||
}
|
||||
|
||||
let previousReactionFrame = reactionFrameAt(index - 1)
|
||||
let reactionPair = reactionPairs[index]
|
||||
|
||||
let textSize = sizeForText("\(reactionPair.1)")
|
||||
var offsetX = previousReactionFrame.origin.x + previousReactionFrame.width
|
||||
offsetX = offsetX > 0 ? offsetX + config.spacing : 0
|
||||
|
||||
return CGRect(x: offsetX, y: config.iconMarging, width: iconHeight + textSize.width, height: iconHeight)
|
||||
}
|
||||
|
||||
private func sizeForText(_ text: String) -> CGSize {
|
||||
let attributedText = NSAttributedString(string: text, attributes: [
|
||||
NSFontAttributeName: config.font,
|
||||
NSForegroundColorAttributeName: config.textColor
|
||||
])
|
||||
|
||||
return attributedText.size()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,13 +28,13 @@ import UIKit
|
|||
|
||||
/**
|
||||
A `ReactionSummary` component aims to display a list of reactions as a thumbnail associate to a text description.
|
||||
|
||||
|
||||
You can configure/skin the summary using a `ReactionSummaryConfig`.
|
||||
*/
|
||||
public final class ReactionSummary: UIReactionControl {
|
||||
private let textLabel = UILabel()
|
||||
private var summaryLayer = CAReactionSummaryLayer()
|
||||
|
||||
|
||||
/**
|
||||
The reaction summary configuration.
|
||||
*/
|
||||
|
@ -84,34 +84,51 @@ public final class ReactionSummary: UIReactionControl {
|
|||
// MARK: - Updating Object State
|
||||
|
||||
override func update() {
|
||||
updateComponentConfig()
|
||||
updateComponentFrame()
|
||||
}
|
||||
|
||||
private func updateComponentConfig() {
|
||||
textLabel.font = config.font
|
||||
textLabel.textColor = config.textColor
|
||||
|
||||
var textSize = textLabel.sizeThatFits(CGSize(width: bounds.width, height: bounds.height))
|
||||
|
||||
if textSize.height == 0 {
|
||||
textSize.height = bounds.height
|
||||
}
|
||||
|
||||
let iconSize = bounds.height - config.iconMarging * 2
|
||||
let iconWidth = (iconSize - 3) * CGFloat(summaryLayer.reactions.count) + config.spacing
|
||||
let margin = (bounds.width - iconWidth - textSize.width) / 2
|
||||
|
||||
let textX: CGFloat
|
||||
|
||||
switch config.alignment {
|
||||
case .left: textX = iconWidth
|
||||
case .right: textX = bounds.width - iconWidth - textSize.width
|
||||
case .centerLeft: textX = margin + iconWidth
|
||||
case .centerRight: textX = bounds.width - iconWidth - textSize.width - margin
|
||||
}
|
||||
|
||||
summaryLayer.frame = bounds
|
||||
summaryLayer.config = config
|
||||
summaryLayer.margin = margin
|
||||
summaryLayer.setNeedsDisplay()
|
||||
|
||||
textLabel.frame = CGRect(x: textX, y: 0, width: textSize.width, height: bounds.height)
|
||||
switch config.alignment {
|
||||
case .left, .centerLeft:
|
||||
textLabel.lineBreakMode = .byTruncatingTail
|
||||
case .right, .centerRight:
|
||||
textLabel.lineBreakMode = .byTruncatingHead
|
||||
}
|
||||
}
|
||||
|
||||
private func updateComponentFrame() {
|
||||
let textLabelSize = textLabel.sizeThatFits(bounds.size)
|
||||
let summaryLayerSize = summaryLayer.sizeToFit()
|
||||
|
||||
let textLabelX: CGFloat
|
||||
let summaryLayerX: CGFloat
|
||||
|
||||
let textLabelWidth = min(textLabelSize.width, bounds.width - summaryLayerSize.width - config.spacing)
|
||||
let margin = (bounds.width - (summaryLayerSize.width + config.spacing + textLabelWidth)) / 2
|
||||
|
||||
switch config.alignment {
|
||||
case .left:
|
||||
summaryLayerX = 0
|
||||
textLabelX = summaryLayerSize.width + config.spacing
|
||||
case .right:
|
||||
summaryLayerX = bounds.width - summaryLayerSize.width
|
||||
textLabelX = bounds.width - summaryLayerSize.width - config.spacing - textLabelWidth
|
||||
case .centerLeft:
|
||||
summaryLayerX = margin
|
||||
textLabelX = margin + textLabelWidth + config.spacing
|
||||
case .centerRight:
|
||||
summaryLayerX = margin + textLabelWidth + config.spacing
|
||||
textLabelX = margin
|
||||
}
|
||||
|
||||
textLabel.frame = CGRect(x: textLabelX, y: 0, width: textLabelWidth, height: bounds.height)
|
||||
summaryLayer.frame = CGRect(x: summaryLayerX, y: 0, width: summaryLayerSize.width, height: bounds.height)
|
||||
}
|
||||
|
||||
// MARK: - Responding to Gesture Events
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Reactions
|
||||
*
|
||||
* Copyright 2016-present Yannick Loriot.
|
||||
* http://yannickloriot.com
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
import XCTest
|
||||
|
||||
class CAReactionSummaryLayerTests: XCTestCase {
|
||||
func testSetConfig() {
|
||||
guard let context = CGContext(data: nil, width: 100, height: 100, bitsPerComponent: 8, bytesPerRow: 100 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue) else { return }
|
||||
|
||||
let summaryLayer = CAReactionSummaryLayer()
|
||||
summaryLayer.reactions = Reaction.facebook.all
|
||||
summaryLayer.config = ReactionSummaryConfig {
|
||||
$0.alignment = .right
|
||||
}
|
||||
|
||||
summaryLayer.draw(in: context)
|
||||
|
||||
XCTAssertEqual(summaryLayer.config.alignment, .right)
|
||||
|
||||
summaryLayer.config = ReactionSummaryConfig {
|
||||
$0.alignment = .centerLeft
|
||||
}
|
||||
|
||||
summaryLayer.draw(in: context)
|
||||
|
||||
XCTAssertEqual(summaryLayer.config.alignment, .centerLeft)
|
||||
|
||||
summaryLayer.config = ReactionSummaryConfig {
|
||||
$0.alignment = .centerRight
|
||||
}
|
||||
|
||||
summaryLayer.draw(in: context)
|
||||
|
||||
XCTAssertEqual(summaryLayer.config.alignment, .centerRight)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue