import Foundation import UIKit import Display import ComponentFlow import AnimatedTextComponent import ActivityIndicator import BundleIconComponent import ShimmerEffect public final class ButtonBadgeComponent: Component { let fillColor: UIColor let style: ButtonTextContentComponent.BadgeStyle let content: AnyComponent public init( fillColor: UIColor, style: ButtonTextContentComponent.BadgeStyle, content: AnyComponent ) { self.fillColor = fillColor self.style = style self.content = content } public static func ==(lhs: ButtonBadgeComponent, rhs: ButtonBadgeComponent) -> Bool { if lhs.fillColor != rhs.fillColor { return false } if lhs.style != rhs.style { return false } if lhs.content != rhs.content { return false } return true } public final class View: UIView { private let backgroundView: UIImageView private let content = ComponentView() private var component: ButtonBadgeComponent? override public init(frame: CGRect) { self.backgroundView = UIImageView() super.init(frame: frame) self.addSubview(self.backgroundView) } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func update(component: ButtonBadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let height: CGFloat switch component.style { case .round: height = 20.0 case .roundedRectangle: height = 18.0 } let contentInset: CGFloat = 10.0 let themeUpdated = self.component?.fillColor != component.fillColor self.component = component let contentSize = self.content.update( transition: transition, component: component.content, environment: {}, containerSize: availableSize ) let backgroundWidth: CGFloat = max(height, contentSize.width + contentInset) let backgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: backgroundWidth, height: height)) transition.setFrame(view: self.backgroundView, frame: backgroundFrame) if let contentView = self.content.view { if contentView.superview == nil { self.addSubview(contentView) } transition.setFrame(view: contentView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((backgroundFrame.width - contentSize.width) * 0.5), y: floorToScreenPixels((backgroundFrame.height - contentSize.height) * 0.5)), size: contentSize)) } if themeUpdated || backgroundFrame.height != self.backgroundView.image?.size.height { switch component.style { case .round: self.backgroundView.image = generateStretchableFilledCircleImage(diameter: backgroundFrame.height, color: component.fillColor) case .roundedRectangle: self.backgroundView.image = generateFilledRoundedRectImage(size: CGSize(width: height, height: height), cornerRadius: 4.0, color: component.fillColor)?.stretchableImage(withLeftCapWidth: Int(height / 2.0), topCapHeight: Int(height / 2.0)) } } return backgroundFrame.size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public final class ButtonTextContentComponent: Component { public enum BadgeStyle { case round case roundedRectangle } public let text: String public let badge: Int public let textColor: UIColor public let fontSize: CGFloat public let badgeBackground: UIColor public let badgeForeground: UIColor public let badgeStyle: BadgeStyle public let badgeIconName: String? public let combinedAlignment: Bool public init( text: String, badge: Int, textColor: UIColor, fontSize: CGFloat = 17.0, badgeBackground: UIColor, badgeForeground: UIColor, badgeStyle: BadgeStyle = .round, badgeIconName: String? = nil, combinedAlignment: Bool = false ) { self.text = text self.badge = badge self.textColor = textColor self.fontSize = fontSize self.badgeBackground = badgeBackground self.badgeForeground = badgeForeground self.badgeStyle = badgeStyle self.badgeIconName = badgeIconName self.combinedAlignment = combinedAlignment } public static func ==(lhs: ButtonTextContentComponent, rhs: ButtonTextContentComponent) -> Bool { if lhs.text != rhs.text { return false } if lhs.badge != rhs.badge { return false } if lhs.textColor != rhs.textColor { return false } if lhs.fontSize != rhs.fontSize { return false } if lhs.badgeBackground != rhs.badgeBackground { return false } if lhs.badgeForeground != rhs.badgeForeground { return false } if lhs.badgeStyle != rhs.badgeStyle { return false } if lhs.badgeIconName != rhs.badgeIconName { return false } if lhs.combinedAlignment != rhs.combinedAlignment { return false } return true } public final class View: UIView { private var component: ButtonTextContentComponent? private weak var componentState: EmptyComponentState? private let content = ComponentView() private var badge: ComponentView? override init(frame: CGRect) { super.init(frame: frame) } required init(coder: NSCoder) { preconditionFailure() } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } func update(component: ButtonTextContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousBadge = self.component?.badge self.component = component self.componentState = state var badgeSpacing: CGFloat = 6.0 if component.badgeIconName != nil { badgeSpacing += 4.0 } let contentSize = self.content.update( transition: .immediate, component: AnyComponent(Text( text: component.text, font: Font.semibold(component.fontSize), color: component.textColor )), environment: {}, containerSize: availableSize ) var badgeSize: CGSize? if component.badge > 0 { var badgeTransition = transition let badge: ComponentView if let current = self.badge { badge = current } else { badgeTransition = .immediate badge = ComponentView() self.badge = badge } var badgeContent: [AnyComponentWithIdentity] = [] if let badgeIconName = component.badgeIconName { badgeContent.append(AnyComponentWithIdentity( id: "icon", component: AnyComponent(BundleIconComponent( name: badgeIconName, tintColor: component.badgeForeground ))) ) } badgeContent.append(AnyComponentWithIdentity( id: "text", component: AnyComponent(AnimatedTextComponent( font: Font.with(size: 15.0, design: .round, weight: .semibold, traits: .monospacedNumbers), color: component.badgeForeground, items: [ AnimatedTextComponent.Item(id: AnyHashable(0), content: .number(component.badge, minDigits: 0)) ] ))) ) badgeSize = badge.update( transition: badgeTransition, component: AnyComponent(ButtonBadgeComponent( fillColor: component.badgeBackground, style: component.badgeStyle, content: AnyComponent(HStack(badgeContent, spacing: 2.0)) )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) } var size = contentSize var measurementSize = size if let badgeSize { if component.combinedAlignment { measurementSize.width += badgeSpacing measurementSize.width += badgeSize.width } size.height = max(size.height, badgeSize.height) } let contentFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - measurementSize.width) * 0.5), y: floorToScreenPixels((size.height - measurementSize.height) * 0.5)), size: measurementSize) if let contentView = self.content.view { if contentView.superview == nil { self.addSubview(contentView) } transition.setFrame(view: contentView, frame: CGRect(origin: contentFrame.origin, size: contentSize)) } if let badgeSize, let badge = self.badge { let badgeFrame = CGRect(origin: CGPoint(x: contentFrame.minX + contentSize.width + badgeSpacing, y: floorToScreenPixels((size.height - badgeSize.height) * 0.5) + 1.0), size: badgeSize) if let badgeView = badge.view { var animateIn = false if badgeView.superview == nil { animateIn = true self.addSubview(badgeView) } if animateIn { badgeView.frame = badgeFrame } else { transition.setFrame(view: badgeView, frame: badgeFrame) if !transition.animation.isImmediate, let previousBadge, previousBadge != component.badge { let middleScale: CGFloat = previousBadge < component.badge ? 1.1 : 0.9 let values: [NSNumber] = [1.0, middleScale as NSNumber, 1.0] badgeView.layer.animateKeyframes(values: values, duration: 0.25, keyPath: "transform.scale") } } if animateIn, !transition.animation.isImmediate { badgeView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) badgeView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4) } } } else { if let badge = self.badge { self.badge = nil if let badgeView = badge.view { if !transition.animation.isImmediate { badgeView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak badgeView] _ in badgeView?.removeFromSuperview() }) badgeView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.25, removeOnCompletion: false) } else { badgeView.removeFromSuperview() } } } } return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public final class ButtonComponent: Component { public struct Background: Equatable { public var color: UIColor public var foreground: UIColor public var pressedColor: UIColor public var cornerRadius: CGFloat public var isShimmering: Bool public init( color: UIColor, foreground: UIColor, pressedColor: UIColor, cornerRadius: CGFloat = 10.0, isShimmering: Bool = false ) { self.color = color self.foreground = foreground self.pressedColor = pressedColor self.cornerRadius = cornerRadius self.isShimmering = isShimmering } } public let background: Background public let content: AnyComponentWithIdentity public let isEnabled: Bool public let tintWhenDisabled: Bool public let allowActionWhenDisabled: Bool public let displaysProgress: Bool public let action: () -> Void public init( background: Background, content: AnyComponentWithIdentity, isEnabled: Bool, tintWhenDisabled: Bool = true, allowActionWhenDisabled: Bool = false, displaysProgress: Bool, action: @escaping () -> Void ) { self.background = background self.content = content self.isEnabled = isEnabled self.tintWhenDisabled = tintWhenDisabled self.allowActionWhenDisabled = allowActionWhenDisabled self.displaysProgress = displaysProgress self.action = action } public static func ==(lhs: ButtonComponent, rhs: ButtonComponent) -> Bool { if lhs.background != rhs.background { return false } if lhs.content != rhs.content { return false } if lhs.isEnabled != rhs.isEnabled { return false } if lhs.tintWhenDisabled != rhs.tintWhenDisabled { return false } if lhs.allowActionWhenDisabled != rhs.allowActionWhenDisabled { return false } if lhs.displaysProgress != rhs.displaysProgress { return false } return true } private final class ContentItem { let id: AnyHashable let view = ComponentView() init(id: AnyHashable) { self.id = id } } public final class View: HighlightTrackingButton { private var component: ButtonComponent? private weak var componentState: EmptyComponentState? private var shimmeringView: ButtonShimmeringView? private var contentItem: ContentItem? private var activityIndicator: ActivityIndicator? override init(frame: CGRect) { super.init(frame: frame) self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) self.highligthedChanged = { [weak self] highlighted in if let self, let component = self.component, component.isEnabled { if highlighted { self.layer.removeAnimation(forKey: "opacity") self.alpha = 0.7 } else { self.alpha = 1.0 self.layer.animateAlpha(from: 7, to: 1.0, duration: 0.2) } } } } required init(coder: NSCoder) { preconditionFailure() } @objc private func pressed() { guard let component = self.component else { return } component.action() } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } func update(component: ButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.componentState = state self.isEnabled = (component.isEnabled || component.allowActionWhenDisabled) && !component.displaysProgress transition.setBackgroundColor(view: self, color: component.background.color) transition.setCornerRadius(layer: self.layer, cornerRadius: component.background.cornerRadius) var contentAlpha: CGFloat = 1.0 if component.displaysProgress { contentAlpha = 0.0 } else if !component.isEnabled && component.tintWhenDisabled { contentAlpha = 0.7 } var previousContentItem: ContentItem? let contentItem: ContentItem var contentItemTransition = transition if let current = self.contentItem, current.id == component.content.id { contentItem = current } else { contentItemTransition = .immediate previousContentItem = self.contentItem contentItem = ContentItem(id: component.content.id) self.contentItem = contentItem } let contentSize = contentItem.view.update( transition: contentItemTransition, component: component.content.component, environment: {}, containerSize: availableSize ) if let contentView = contentItem.view.view { var animateIn = false var contentTransition = transition if contentView.superview == nil { contentTransition = .immediate animateIn = true contentView.isUserInteractionEnabled = false self.addSubview(contentView) } let contentFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - contentSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - contentSize.height) * 0.5)), size: contentSize) contentTransition.setFrame(view: contentView, frame: contentFrame) contentTransition.setAlpha(view: contentView, alpha: contentAlpha) if animateIn && previousContentItem != nil && !transition.animation.isImmediate { contentView.layer.animateScale(from: 0.4, to: 1.0, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring) contentView.layer.animateAlpha(from: 0.0, to: contentAlpha, duration: 0.1) contentView.layer.animatePosition(from: CGPoint(x: 0.0, y: -availableSize.height * 0.15), to: CGPoint(), duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } } if let previousContentItem, let previousContentView = previousContentItem.view.view { if !transition.animation.isImmediate { previousContentView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) previousContentView.layer.animateAlpha(from: contentAlpha, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak previousContentView] _ in previousContentView?.removeFromSuperview() }) previousContentView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: availableSize.height * 0.35), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) } else { previousContentView.removeFromSuperview() } } if component.displaysProgress { let activityIndicator: ActivityIndicator var activityIndicatorTransition = transition if let current = self.activityIndicator { activityIndicator = current } else { activityIndicatorTransition = .immediate activityIndicator = ActivityIndicator(type: .custom(component.background.foreground, 22.0, 2.0, true)) activityIndicator.view.alpha = 0.0 self.activityIndicator = activityIndicator self.addSubview(activityIndicator.view) } let indicatorSize = CGSize(width: 22.0, height: 22.0) transition.setAlpha(view: activityIndicator.view, alpha: 1.0) activityIndicatorTransition.setFrame(view: activityIndicator.view, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - indicatorSize.width) / 2.0), y: floorToScreenPixels((availableSize.height - indicatorSize.height) / 2.0)), size: indicatorSize)) } else { if let activityIndicator = self.activityIndicator { self.activityIndicator = nil transition.setAlpha(view: activityIndicator.view, alpha: 0.0, completion: { [weak activityIndicator] _ in activityIndicator?.view.removeFromSuperview() }) } } if component.background.isShimmering { let shimmeringView: ButtonShimmeringView var shimmeringTransition = transition if let current = self.shimmeringView { shimmeringView = current } else { shimmeringTransition = .immediate shimmeringView = ButtonShimmeringView(frame: .zero) self.shimmeringView = shimmeringView self.insertSubview(shimmeringView, at: 0) } shimmeringView.update(size: availableSize, background: component.background, cornerRadius: component.background.cornerRadius, transition: shimmeringTransition) shimmeringTransition.setFrame(view: shimmeringView, frame: CGRect(origin: .zero, size: availableSize)) } else if let shimmeringView = self.shimmeringView { self.shimmeringView = nil shimmeringView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in shimmeringView.removeFromSuperview() }) } return availableSize } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private class ButtonShimmeringView: UIView { private var shimmerView = ShimmerEffectForegroundView() private var borderView = UIView() private var borderMaskView = UIView() private var borderShimmerView = ShimmerEffectForegroundView() override init(frame: CGRect) { self.borderView.isUserInteractionEnabled = false self.borderMaskView.layer.borderWidth = 1.0 + UIScreenPixel self.borderMaskView.layer.borderColor = UIColor.white.cgColor self.borderView.mask = self.borderMaskView self.borderView.addSubview(self.borderShimmerView) super.init(frame: frame) self.isUserInteractionEnabled = false self.addSubview(self.shimmerView) self.addSubview(self.borderView) } required init?(coder: NSCoder) { preconditionFailure() } func update(size: CGSize, background: ButtonComponent.Background, cornerRadius: CGFloat, transition: ComponentTransition) { let color = background.foreground let alpha: CGFloat let borderAlpha: CGFloat let compositingFilter: String? if color.lightness > 0.5 { alpha = 0.5 borderAlpha = 0.75 compositingFilter = "overlayBlendMode" } else { alpha = 0.2 borderAlpha = 0.3 compositingFilter = nil } self.backgroundColor = background.color self.layer.cornerRadius = cornerRadius self.borderMaskView.layer.cornerRadius = cornerRadius self.shimmerView.update(backgroundColor: .clear, foregroundColor: color.withAlphaComponent(alpha), gradientSize: 70.0, globalTimeOffset: false, duration: 4.0, horizontal: true) self.shimmerView.layer.compositingFilter = compositingFilter self.borderShimmerView.update(backgroundColor: .clear, foregroundColor: color.withAlphaComponent(borderAlpha), gradientSize: 70.0, globalTimeOffset: false, duration: 4.0, horizontal: true) self.borderShimmerView.layer.compositingFilter = compositingFilter let bounds = CGRect(origin: .zero, size: size) transition.setFrame(view: self.shimmerView, frame: bounds) transition.setFrame(view: self.borderView, frame: bounds) transition.setFrame(view: self.borderMaskView, frame: bounds) transition.setFrame(view: self.borderShimmerView, frame: bounds) self.shimmerView.updateAbsoluteRect(CGRect(origin: CGPoint(x: size.width * 4.0, y: 0.0), size: size), within: CGSize(width: size.width * 9.0, height: size.height)) self.borderShimmerView.updateAbsoluteRect(CGRect(origin: CGPoint(x: size.width * 4.0, y: 0.0), size: size), within: CGSize(width: size.width * 9.0, height: size.height)) } }