import Foundation import UIKit import Display import ComponentFlow final class AnimatedCounterItemComponent: Component { public let font: UIFont public let color: UIColor public let text: String public let numericValue: Int public let alignment: CGFloat public init( font: UIFont, color: UIColor, text: String, numericValue: Int, alignment: CGFloat ) { self.font = font self.color = color self.text = text self.numericValue = numericValue self.alignment = alignment } public static func ==(lhs: AnimatedCounterItemComponent, rhs: AnimatedCounterItemComponent) -> Bool { if lhs.font != rhs.font { return false } if lhs.color != rhs.color { return false } if lhs.text != rhs.text { return false } if lhs.numericValue != rhs.numericValue { return false } if lhs.alignment != rhs.alignment { return false } return true } public final class View: UIView { private let contentView: UIImageView private var component: AnimatedCounterItemComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.contentView = UIImageView() super.init(frame: frame) self.addSubview(self.contentView) } required init(coder: NSCoder) { preconditionFailure() } func update(component: AnimatedCounterItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let previousNumericValue = self.component?.numericValue self.component = component self.state = state let text = NSAttributedString(string: component.text, font: component.font, textColor: component.color) let textBounds = text.boundingRect(with: availableSize, options: [.usesLineFragmentOrigin], context: nil) let size = CGSize(width: ceil(textBounds.width), height: ceil(textBounds.height)) let previousContentImage = self.contentView.image let previousContentFrame = self.contentView.frame self.contentView.image = generateImage(size, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) UIGraphicsPushContext(context) text.draw(at: textBounds.origin) UIGraphicsPopContext() }) self.contentView.frame = CGRect(origin: CGPoint(), size: size) if !transition.animation.isImmediate, let previousContentImage, !previousContentFrame.isEmpty, let previousNumericValue, previousNumericValue != component.numericValue { let previousContentView = UIImageView() previousContentView.image = previousContentImage previousContentView.frame = CGRect(origin: CGPoint(x: size.width * component.alignment - previousContentFrame.width * component.alignment, y: previousContentFrame.minY), size: previousContentFrame.size) self.addSubview(previousContentView) let offsetY: CGFloat = size.height * 0.6 * (previousNumericValue < component.numericValue ? -1.0 : 1.0) let subTransition = Transition(animation: .curve(duration: 0.16, curve: .easeInOut)) subTransition.animatePosition(view: self.contentView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) subTransition.animateAlpha(view: self.contentView, from: 0.0, to: 1.0) subTransition.setPosition(view: previousContentView, position: CGPoint(x: previousContentView.layer.position.x, y: previousContentView.layer.position.y - offsetY)) subTransition.setAlpha(view: previousContentView, alpha: 0.0, completion: { [weak previousContentView] _ in previousContentView?.removeFromSuperview() }) } return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public final class AnimatedCounterComponent: Component { public enum Alignment { case left case right } public struct Item: Equatable { public var id: AnyHashable public var text: String public var numericValue: Int public init(id: AnyHashable, text: String, numericValue: Int) { self.id = id self.text = text self.numericValue = numericValue } } public let font: UIFont public let color: UIColor public let alignment: Alignment public let items: [Item] public init( font: UIFont, color: UIColor, alignment: Alignment, items: [Item] ) { self.font = font self.color = color self.alignment = alignment self.items = items } public static func ==(lhs: AnimatedCounterComponent, rhs: AnimatedCounterComponent) -> Bool { if lhs.font != rhs.font { return false } if lhs.color != rhs.color { return false } if lhs.alignment != rhs.alignment { return false } if lhs.items != rhs.items { return false } return true } private final class ItemView { let view = ComponentView() } public final class View: UIView { private var itemViews: [AnyHashable: ItemView] = [:] private var component: AnimatedCounterComponent? private weak var state: EmptyComponentState? private var measuredSpaceWidth: CGFloat? override init(frame: CGRect) { super.init(frame: frame) } required init(coder: NSCoder) { preconditionFailure() } func update(component: AnimatedCounterComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let spaceWidth: CGFloat if let measuredSpaceWidth = self.measuredSpaceWidth, let previousComponent = self.component, previousComponent.font.pointSize == component.font.pointSize { spaceWidth = measuredSpaceWidth } else { spaceWidth = ceil(NSAttributedString(string: " ", font: component.font, textColor: .black).boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil).width) self.measuredSpaceWidth = spaceWidth } self.component = component self.state = state var size = CGSize() var validIds: [AnyHashable] = [] for item in component.items { if size.width != 0.0 { size.width += spaceWidth } validIds.append(item.id) let itemView: ItemView var itemTransition = transition if let current = self.itemViews[item.id] { itemView = current } else { itemTransition = .immediate itemView = ItemView() self.itemViews[item.id] = itemView } let itemSize = itemView.view.update( transition: itemTransition, component: AnyComponent(AnimatedCounterItemComponent( font: component.font, color: component.color, text: item.text, numericValue: item.numericValue, alignment: component.alignment == .left ? 0.0 : 1.0 )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) if let itemComponentView = itemView.view.view { if itemComponentView.superview == nil { self.addSubview(itemComponentView) } let itemFrame = CGRect(origin: CGPoint(x: size.width, y: 0.0), size: itemSize) switch component.alignment { case .left: itemComponentView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) itemTransition.setPosition(view: itemComponentView, position: CGPoint(x: itemFrame.minX, y: itemFrame.midY)) case .right: itemComponentView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5) itemTransition.setPosition(view: itemComponentView, position: CGPoint(x: itemFrame.maxX, y: itemFrame.midY)) } itemComponentView.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) } size.width += itemSize.width size.height = max(size.height, itemSize.height) } var removeIds: [AnyHashable] = [] for (id, itemView) in self.itemViews { if !validIds.contains(id) { removeIds.append(id) if let componentView = itemView.view.view { transition.setAlpha(view: componentView, alpha: 0.0, completion: { [weak componentView] _ in componentView?.removeFromSuperview() }) } } } return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }