Preparing the summary aggregation

This commit is contained in:
Yannick Loriot 2016-10-17 10:55:08 +02:00
parent 4c689171d5
commit ab834520fd
5 changed files with 212 additions and 78 deletions

View File

@ -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 */,

View File

@ -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
}
}
}

View File

@ -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 Layers 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()
}
}

View File

@ -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

View File

@ -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)
}
}