import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import AnimationUI import TelegramPresentationData public final class MultilineText: Component { public let text: String public let font: UIFont public let color: UIColor public init( text: String, font: UIFont, color: UIColor ) { self.text = text self.font = font self.color = color } public static func ==(lhs: MultilineText, rhs: MultilineText) -> Bool { if lhs.text != rhs.text { return false } if lhs.font != rhs.font { return false } if lhs.color != rhs.color { return false } return true } public final class View: UIView { private let text: ImmediateTextNode init() { self.text = ImmediateTextNode() self.text.maximumNumberOfLines = 0 super.init(frame: CGRect()) self.addSubnode(self.text) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: MultilineText, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { self.text.attributedText = NSAttributedString(string: component.text, font: component.font, textColor: component.color, paragraphAlignment: nil) let textSize = self.text.updateLayout(availableSize) transition.setFrame(view: self.text.view, frame: CGRect(origin: CGPoint(), size: textSize)) return textSize } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } public final class LottieAnimationComponent: Component { public let name: String public init( name: String ) { self.name = name } public static func ==(lhs: LottieAnimationComponent, rhs: LottieAnimationComponent) -> Bool { if lhs.name != rhs.name { return false } return true } public final class View: UIView { private var animationNode: AnimationNode? private var currentName: String? init() { super.init(frame: CGRect()) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: LottieAnimationComponent, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { if self.currentName != component.name { self.currentName = component.name if let animationNode = self.animationNode { animationNode.removeFromSupernode() self.animationNode = nil } let animationNode = AnimationNode(animation: component.name, colors: [:], scale: 1.0) self.animationNode = animationNode self.addSubnode(animationNode) animationNode.play() } if let animationNode = self.animationNode { let preferredSize = animationNode.preferredSize() return preferredSize ?? CGSize(width: 32.0, height: 32.0) } else { return CGSize() } } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } private final class ScrollingTooltipAnimationComponent: Component { public init() { } public static func ==(lhs: ScrollingTooltipAnimationComponent, rhs: ScrollingTooltipAnimationComponent) -> Bool { return true } public final class View: UIView { private var progress: CGFloat = 0.0 private var previousTarget: CGFloat = 0.0 private var animator: DisplayLinkAnimator? init() { super.init(frame: CGRect()) self.isOpaque = false self.backgroundColor = nil self.previousTarget = CGFloat.random(in: 0.0 ... 1.0) self.startNextAnimation() } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func startNextAnimation() { self.animator?.invalidate() let previous = self.previousTarget let target = CGFloat.random(in: 0.0 ... 1.0) self.previousTarget = target let animator = DisplayLinkAnimator(duration: 1.0, from: previous, to: target, update: { [weak self] value in guard let strongSelf = self else { return } strongSelf.progress = listViewAnimationCurveEaseInOut(value) strongSelf.setNeedsDisplay() }, completion: { [weak self] in Queue.mainQueue().after(0.3, { guard let strongSelf = self else { return } strongSelf.startNextAnimation() }) }) self.animator = animator } func update(component: ScrollingTooltipAnimationComponent, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { return CGSize(width: 32.0, height: 32.0) } override func draw(_ rect: CGRect) { guard let context = UIGraphicsGetCurrentContext() else { return } let progressValue = self.progress let itemSize: CGFloat = 12.0 let itemSpacing: CGFloat = 1.0 let listItemCount: CGFloat = 100.0 let listHeight: CGFloat = itemSize * listItemCount + itemSpacing * (listItemCount - 1) context.setFillColor(UIColor(white: 1.0, alpha: 0.3).cgColor) let offset: CGFloat = progressValue * listHeight var minVisibleItemIndex: Int = Int(floor(offset / (itemSize + itemSpacing))) while true { let itemY: CGFloat = CGFloat(minVisibleItemIndex) * (itemSize + itemSpacing) - offset if itemY >= self.bounds.height { break } for i in 0 ..< 2 { UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: CGFloat(i) * (itemSize + itemSpacing), y: itemY), size: CGSize(width: itemSize, height: itemSize)), cornerRadius: 2.0).fill() } minVisibleItemIndex += 1 } let gradientFraction: CGFloat = 10.0 / self.bounds.height let colorsArray: [CGColor] = ([ UIColor(white: 1.0, alpha: 1.0), UIColor(white: 1.0, alpha: 0.0), UIColor(white: 1.0, alpha: 0.0), UIColor(white: 1.0, alpha: 1.0) ] as [UIColor]).map(\.cgColor) var locations: [CGFloat] = [0.0, gradientFraction, 1.0 - gradientFraction, 1.0] let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray as CFArray, locations: &locations)! context.setBlendMode(.destinationOut) context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: self.bounds.height), options: []) context.setBlendMode(.normal) context.setFillColor(UIColor.white.cgColor) let indicatorHeight: CGFloat = 10.0 let indicatorMinY: CGFloat = 0.0 let indicatorMaxY: CGFloat = self.bounds.height - indicatorHeight let indicatorX: CGFloat = (itemSize + itemSpacing) * 2.0 let indicatorY = indicatorMinY * (1.0 - progress) + indicatorMaxY * progress UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: indicatorX, y: indicatorY), size: CGSize(width: 3.0, height: indicatorHeight)), cornerRadius: 1.5).fill() UIBezierPath(roundedRect: CGRect(x: indicatorX - 4.0 - 19.0, y: indicatorY + (indicatorHeight - 8.0) / 2.0, width: 19.0, height: 8.0), cornerRadius: 4.0).fill() } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } public final class TooltipComponent: Component { public let icon: AnyComponent? public let content: AnyComponent public let arrowLocation: CGRect public init( icon: AnyComponent?, content: AnyComponent, arrowLocation: CGRect ) { self.icon = icon self.content = content self.arrowLocation = arrowLocation } public static func ==(lhs: TooltipComponent, rhs: TooltipComponent) -> Bool { if lhs.icon != rhs.icon { return false } if lhs.content != rhs.content { return false } if lhs.arrowLocation != rhs.arrowLocation { return false } return true } public final class View: UIView { private let backgroundView: UIView private let backgroundViewMask: UIImageView private var icon: ComponentHostView? private let content: ComponentHostView private let regularMaskImage: UIImage private let invertedMaskImage: UIImage init() { self.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) self.backgroundViewMask = UIImageView() self.regularMaskImage = generateImage(CGSize(width: 42.0, height: 42.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(UIColor.black.cgColor) let _ = try? drawSvgPath(context, path: "M0,18.0252 C0,14.1279 0,12.1792 0.5358,10.609 C1.5362,7.6772 3.8388,5.3746 6.7706,4.3742 C8.3409,3.8384 10.2895,3.8384 14.1868,3.8384 L16.7927,3.8384 C18.2591,3.8384 18.9923,3.8384 19.7211,3.8207 C25.1911,3.6877 30.6172,2.8072 35.8485,1.2035 C36.5454,0.9899 37.241,0.758 38.6321,0.2943 C39.1202,0.1316 39.3643,0.0503 39.5299,0.0245 C40.8682,-0.184 42.0224,0.9702 41.8139,2.3085 C41.7881,2.4741 41.7067,2.7181 41.544,3.2062 C41.0803,4.5974 40.8485,5.293 40.6348,5.99 C39.0312,11.2213 38.1507,16.6473 38.0177,22.1173 C38,22.846 38,23.5793 38,25.0457 L38,27.6516 C38,31.5489 38,33.4975 37.4642,35.0677 C36.4638,37.9995 34.1612,40.3022 31.2294,41.3026 C29.6591,41.8384 27.7105,41.8384 23.8132,41.8384 L16,41.8384 C10.3995,41.8384 7.5992,41.8384 5.4601,40.7484 C3.5785,39.7897 2.0487,38.2599 1.0899,36.3783 C0,34.2392 0,31.4389 0,25.8384 L0,18.0252 Z ") })!.stretchableImage(withLeftCapWidth: 16, topCapHeight: 33) self.invertedMaskImage = generateImage(CGSize(width: 42.0, height: 42.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(UIColor.black.cgColor) let _ = try? drawSvgPath(context, path: "M0,18.0252 C0,14.1279 0,12.1792 0.5358,10.609 C1.5362,7.6772 3.8388,5.3746 6.7706,4.3742 C8.3409,3.8384 10.2895,3.8384 14.1868,3.8384 L16.7927,3.8384 C18.2591,3.8384 18.9923,3.8384 19.7211,3.8207 C25.1911,3.6877 30.6172,2.8072 35.8485,1.2035 C36.5454,0.9899 37.241,0.758 38.6321,0.2943 C39.1202,0.1316 39.3643,0.0503 39.5299,0.0245 C40.8682,-0.184 42.0224,0.9702 41.8139,2.3085 C41.7881,2.4741 41.7067,2.7181 41.544,3.2062 C41.0803,4.5974 40.8485,5.293 40.6348,5.99 C39.0312,11.2213 38.1507,16.6473 38.0177,22.1173 C38,22.846 38,23.5793 38,25.0457 L38,27.6516 C38,31.5489 38,33.4975 37.4642,35.0677 C36.4638,37.9995 34.1612,40.3022 31.2294,41.3026 C29.6591,41.8384 27.7105,41.8384 23.8132,41.8384 L16,41.8384 C10.3995,41.8384 7.5992,41.8384 5.4601,40.7484 C3.5785,39.7897 2.0487,38.2599 1.0899,36.3783 C0,34.2392 0,31.4389 0,25.8384 L0,18.0252 Z ") })!.stretchableImage(withLeftCapWidth: 16, topCapHeight: 9) self.backgroundViewMask.image = self.regularMaskImage self.content = ComponentHostView() super.init(frame: CGRect()) self.addSubview(self.backgroundView) self.backgroundView.mask = self.backgroundViewMask self.addSubview(self.content) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: TooltipComponent, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { let insets = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0) let spacing: CGFloat = 8.0 var iconSize: CGSize? if let icon = component.icon { let iconView: ComponentHostView if let current = self.icon { iconView = current } else { iconView = ComponentHostView() self.icon = iconView self.addSubview(iconView) } iconSize = iconView.update( transition: transition, component: icon, environment: {}, containerSize: availableSize ) } else if let icon = self.icon { self.icon = nil icon.removeFromSuperview() } var contentLeftInset: CGFloat = 0.0 if let iconSize = iconSize { contentLeftInset += iconSize.width + spacing } let contentSize = self.content.update( transition: transition, component: component.content, environment: {}, containerSize: CGSize(width: min(200.0, availableSize.width - contentLeftInset), height: availableSize.height) ) var innerContentHeight = contentSize.height if let iconSize = iconSize, iconSize.height > innerContentHeight { innerContentHeight = iconSize.height } let combinedContentSize = CGSize(width: insets.left + insets.right + contentLeftInset + contentSize.width, height: insets.top + insets.bottom + innerContentHeight) var contentRect = CGRect(origin: CGPoint(x: component.arrowLocation.minX - combinedContentSize.width, y: component.arrowLocation.maxY), size: combinedContentSize) if contentRect.minX < 0.0 { contentRect.origin.x = component.arrowLocation.maxX } let maskedBackgroundFrame: CGRect if contentRect.maxY > availableSize.height { self.backgroundViewMask.image = self.invertedMaskImage contentRect.origin.y = component.arrowLocation.minY - contentRect.height - 4.0 maskedBackgroundFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: contentRect.minY - 4.0 + 3.0), size: CGSize(width: contentRect.width + 4.0, height: contentRect.height + 8.0)) self.backgroundViewMask.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: maskedBackgroundFrame.size) } else { self.backgroundViewMask.image = self.regularMaskImage maskedBackgroundFrame = CGRect(origin: CGPoint(x: contentRect.minX, y: contentRect.minY - 4.0), size: CGSize(width: contentRect.width + 4.0, height: contentRect.height + 4.0)) self.backgroundViewMask.frame = CGRect(origin: CGPoint(), size: maskedBackgroundFrame.size) } self.backgroundView.frame = maskedBackgroundFrame if let iconSize = iconSize, let icon = self.icon { transition.setFrame(view: icon, frame: CGRect(origin: CGPoint(x: contentRect.minX + insets.left, y: contentRect.minY + insets.top + floor((contentRect.height - insets.top - insets.bottom - iconSize.height) / 2.0)), size: iconSize)) } transition.setFrame(view: self.content, frame: CGRect(origin: CGPoint(x: contentRect.minX + insets.left + contentLeftInset, y: contentRect.minY + insets.top + floor((contentRect.height - insets.top - insets.bottom - contentSize.height) / 2.0)), size: contentSize)) return availableSize } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } private final class RoundedRectangle: Component { let color: UIColor init(color: UIColor) { self.color = color } static func ==(lhs: RoundedRectangle, rhs: RoundedRectangle) -> Bool { if !lhs.color.isEqual(rhs.color) { return false } return true } final class View: UIView { private let backgroundView: UIImageView private var currentColor: UIColor? private var currentDiameter: CGFloat? init() { self.backgroundView = UIImageView() super.init(frame: CGRect()) self.addSubview(self.backgroundView) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: RoundedRectangle, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { let shadowInset: CGFloat = 0.0 let diameter = min(availableSize.width, availableSize.height) var updated = false if let currentColor = self.currentColor { if !component.color.isEqual(currentColor) { updated = true } } else { updated = true } let diameterUpdated = self.currentDiameter != diameter if self.currentDiameter != diameter || updated { self.currentDiameter = diameter self.currentColor = component.color self.backgroundView.image = generateImage(CGSize(width: diameter + shadowInset * 2.0, height: diameter + shadowInset * 2.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(component.color.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowInset, y: shadowInset), size: CGSize(width: size.width - shadowInset * 2.0, height: size.height - shadowInset * 2.0))) })?.stretchableImage(withLeftCapWidth: Int(diameter + shadowInset * 2.0) / 2, topCapHeight: Int(diameter + shadowInset * 2.0) / 2) } transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: -shadowInset, y: -shadowInset), size: CGSize(width: availableSize.width + shadowInset * 2.0, height: availableSize.height + shadowInset * 2.0))) let _ = diameterUpdated return availableSize } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } private let shadowInset: CGFloat = 10.0 private final class ShadowRoundedRectangle: Component { let color: UIColor init(color: UIColor) { self.color = color } static func ==(lhs: ShadowRoundedRectangle, rhs: ShadowRoundedRectangle) -> Bool { if !lhs.color.isEqual(rhs.color) { return false } return true } final class View: UIView { private let backgroundView: UIImageView private var currentColor: UIColor? private var currentDiameter: CGFloat? init() { self.backgroundView = UIImageView() super.init(frame: CGRect()) self.addSubview(self.backgroundView) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: ShadowRoundedRectangle, availableSize: CGSize, environment: Environment, transition: ComponentTransition) -> CGSize { let diameter = min(availableSize.width, availableSize.height) var updated = false if let currentColor = self.currentColor { if !component.color.isEqual(currentColor) { updated = true } } else { updated = true } if self.currentDiameter != diameter || updated { self.currentDiameter = diameter self.currentColor = component.color self.backgroundView.image = generateImage(CGSize(width: diameter + shadowInset * 2.0, height: diameter + shadowInset * 2.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(component.color.cgColor) context.setShadow(offset: CGSize(width: 0.0, height: -1.0), blur: 4.0, color: UIColor(white: 0.0, alpha: 0.2).cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowInset, y: shadowInset), size: CGSize(width: size.width - shadowInset * 2.0, height: size.height - shadowInset * 2.0))) })?.stretchableImage(withLeftCapWidth: Int(diameter + shadowInset * 2.0) / 2, topCapHeight: Int(diameter + shadowInset * 2.0) / 2) } let shadowFrame = CGRect(origin: CGPoint(x: -shadowInset, y: -shadowInset), size: CGSize(width: availableSize.width + shadowInset * 2.0, height: availableSize.height + shadowInset * 2.0)) transition.setFrame(view: self.backgroundView, frame: shadowFrame) return shadowFrame.size } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } public final class RollingText: Component { public enum AnimationDirection { case up case down } private final class MeasureState: Equatable { let attributedText: NSAttributedString let availableSize: CGSize let size: CGSize init(attributedText: NSAttributedString, availableSize: CGSize, size: CGSize) { self.attributedText = attributedText self.availableSize = availableSize self.size = size } static func ==(lhs: MeasureState, rhs: MeasureState) -> Bool { if !lhs.attributedText.isEqual(rhs.attributedText) { return false } if lhs.availableSize != rhs.availableSize { return false } if lhs.size != rhs.size { return false } return true } } public final class View: UIView { private var measureState: MeasureState? private var containerView: UIImageView private var snapshotView: UIView? public override init(frame: CGRect) { self.containerView = UIImageView() super.init(frame: frame) self.addSubview(self.containerView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func update(component: RollingText, availableSize: CGSize) -> CGSize { let attributedText = NSAttributedString(string: component.text, attributes: [ NSAttributedString.Key.font: component.font, NSAttributedString.Key.foregroundColor: component.color ]) if let measureState = self.measureState { if measureState.attributedText.isEqual(to: attributedText) && measureState.availableSize == availableSize { return measureState.size } } var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil) boundingRect.size.width = ceil(boundingRect.size.width) boundingRect.size.height = ceil(boundingRect.size.height) if let animation = component.animation { if let snapshotView = self.snapshotView { self.snapshotView = nil snapshotView.removeFromSuperview() self.containerView.layer.removeAnimation(forKey: "opacity") } if let snapshotView = self.containerView.snapshotContentTree() { let horizontalOffset = boundingRect.width - snapshotView.frame.width let verticalOffset: CGFloat = 12.0 snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: horizontalOffset, y: animation == .up ? verticalOffset : -verticalOffset), duration: 0.2, removeOnCompletion: false, additive: true, completion: { [weak self, weak snapshotView] _ in self?.snapshotView = nil snapshotView?.removeFromSuperview() }) self.addSubview(snapshotView) self.snapshotView = snapshotView self.containerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.containerView.layer.animatePosition(from: CGPoint(x: -horizontalOffset, y: animation == .up ? -verticalOffset : verticalOffset), to: CGPoint(), duration: 0.2, additive: true) } } self.containerView.frame = CGRect(origin: CGPoint(), size: boundingRect.size) let measureState = MeasureState(attributedText: attributedText, availableSize: availableSize, size: boundingRect.size) if #available(iOS 10.0, *) { let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: measureState.size)) let image = renderer.image { context in UIGraphicsPushContext(context.cgContext) measureState.attributedText.draw(at: CGPoint()) UIGraphicsPopContext() } self.containerView.image = image } else { UIGraphicsBeginImageContextWithOptions(measureState.size, false, 0.0) measureState.attributedText.draw(at: CGPoint()) self.containerView.image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() } self.measureState = measureState return boundingRect.size } } public let text: String public let font: UIFont public let color: UIColor public let animation: AnimationDirection? public init(text: String, font: UIFont, color: UIColor, animation: AnimationDirection?) { self.text = text self.font = font self.color = color self.animation = animation } public static func ==(lhs: RollingText, rhs: RollingText) -> Bool { if lhs.text != rhs.text { return false } if !lhs.font.isEqual(rhs.font) { return false } if !lhs.color.isEqual(rhs.color) { return false } if lhs.animation != rhs.animation { return false } return true } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize) } } final class SparseItemGridScrollingIndicatorComponent: CombinedComponent { let backgroundColor: UIColor let shadowColor: UIColor let foregroundColor: UIColor let date: (String, Int32) let previousDate: (String, Int32)? init( backgroundColor: UIColor, shadowColor: UIColor, foregroundColor: UIColor, date: (String, Int32), previousDate: (String, Int32)? ) { self.backgroundColor = backgroundColor self.shadowColor = shadowColor self.foregroundColor = foregroundColor self.date = date self.previousDate = previousDate } static func ==(lhs: SparseItemGridScrollingIndicatorComponent, rhs: SparseItemGridScrollingIndicatorComponent) -> Bool { if lhs.backgroundColor != rhs.backgroundColor { return false } if lhs.shadowColor != rhs.shadowColor { return false } if lhs.foregroundColor != rhs.foregroundColor { return false } if lhs.date != rhs.date { return false } if lhs.previousDate?.0 != rhs.previousDate?.0 || lhs.previousDate?.1 != rhs.previousDate?.1 { return false } return true } static var body: Body { let rect = Child(ShadowRoundedRectangle.self) let textMonth = Child(RollingText.self) let textYear = Child(RollingText.self) return { context in context.view.clipsToBounds = true let date = context.component.date let components = date.0.components(separatedBy: " ") let month = String(components.prefix(upTo: components.count - 1).joined(separator: " ")) let year = components.last ?? "" var monthAnimation: RollingText.AnimationDirection? var yearAnimation: RollingText.AnimationDirection? if let previousDate = context.component.previousDate { func unpackDateTag(_ packedValue: Int32) -> (month: Int32, year: Int32) { let year = Int32(bitPattern: (UInt32(bitPattern: packedValue) >> 0) & 0xffff) let month = Int32(bitPattern: (UInt32(bitPattern: packedValue) >> 16) & 0xffff) return (month, year) } let currentValue = unpackDateTag(date.1) let previousValue = unpackDateTag(previousDate.1) if currentValue.year != previousValue.year { yearAnimation = currentValue.year > previousValue.year ? .up : .down monthAnimation = yearAnimation } else if currentValue.month != previousValue.month { monthAnimation = currentValue.month > previousValue.month ? .up : .down } } let textMonth = textMonth.update( component: RollingText( text: month, font: Font.with(size: 13.0, design: .regular, weight: .medium, traits: .monospacedNumbers), color: context.component.foregroundColor, animation: monthAnimation ), availableSize: CGSize(width: 200.0, height: 100.0), transition: .immediate ) let textYear = textYear.update( component: RollingText( text: year, font: Font.with(size: 13.0, design: .regular, weight: .medium, traits: .monospacedNumbers), color: context.component.foregroundColor, animation: yearAnimation ), availableSize: CGSize(width: 200.0, height: 100.0), transition: .immediate ) let rect = rect.update( component: ShadowRoundedRectangle( color: context.component.backgroundColor ), availableSize: CGSize(width: textMonth.size.width + 3.0 + textYear.size.width + 26.0, height: 32.0), transition: .easeInOut(duration: 0.2) ) let rectFrame = rect.size.centered(around: CGPoint( x: rect.size.width / 2.0, y: rect.size.height / 2.0 )) context.add(rect .position(CGPoint(x: rectFrame.midX + shadowInset, y: rectFrame.midY + shadowInset)) ) let offset = CGSize(width: textMonth.size.width + 3.0 + textYear.size.width, height: textMonth.size.height).centered(in: rectFrame) let monthTextFrame = textMonth.size.leftCentered(in: rectFrame).offsetBy(dx: offset.minX, dy: 0.0) let yearTextFrame = textYear.size.leftCentered(in: rectFrame).offsetBy(dx: offset.minX + monthTextFrame.width + 3.0, dy: 0.0) context.add(textMonth .position(CGPoint(x: monthTextFrame.midX, y: monthTextFrame.midY)) ) context.add(textYear .position(CGPoint(x: yearTextFrame.midX, y: yearTextFrame.midY)) ) return rect.size } } } public final class SparseItemGridScrollingArea: ASDisplayNode { private struct ShouldBegin { var shouldDelay: Bool init(shouldDelay: Bool) { self.shouldDelay = shouldDelay } } private final class DragGesture: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> ShouldBegin? private let began: () -> Void private let ended: () -> Void private let moved: (CGFloat) -> Void private var initialLocation: CGPoint? private var beginDelayTimer: SwiftSignalKit.Timer? public init( shouldBegin: @escaping (CGPoint) -> ShouldBegin?, began: @escaping () -> Void, ended: @escaping () -> Void, moved: @escaping (CGFloat) -> Void ) { self.shouldBegin = shouldBegin self.began = began self.ended = ended self.moved = moved super.init(target: nil, action: nil) } deinit { } override public func reset() { super.reset() self.initialLocation = nil self.initialLocation = nil self.beginDelayTimer?.invalidate() self.beginDelayTimer = nil } override public func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) if self.numberOfTouches > 1 { self.state = .failed self.ended() return } if self.state == .possible { if let location = touches.first?.location(in: self.view) { if let shouldBeginValue = self.shouldBegin(location) { self.initialLocation = location if shouldBeginValue.shouldDelay { self.state = .began if self.beginDelayTimer == nil { self.beginDelayTimer = SwiftSignalKit.Timer(timeout: 0.2, repeat: false, completion: { [weak self] in guard let self else { return } self.beginDelayTimer = nil if self.initialLocation != nil { self.began() } }, queue: .mainQueue()) self.beginDelayTimer?.start() } } else { self.state = .began self.began() } } else { self.state = .failed } } else { self.state = .failed } } } override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) self.initialLocation = nil if self.state == .began || self.state == .changed { self.ended() self.state = .failed } } override public func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.initialLocation = nil if self.state == .began || self.state == .changed { self.ended() self.state = .failed } } override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) if let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { let offset = location.y - initialLocation.y if self.beginDelayTimer != nil { if abs(offset) > 16.0 { self.ended() self.state = .failed } else { self.initialLocation = location } } else if (self.state == .began || self.state == .changed) { self.state = .changed self.moved(offset) } } } } private let dateIndicatorContainer: UIView private let dateIndicator: ComponentHostView private let lineIndicator: ComponentHostView private var displayedTooltip: Bool = false private var lineTooltip: ComponentHostView? private var containerSize: CGSize? private var indicatorPosition: CGFloat? private var scrollIndicatorHeight: CGFloat? private var dragGesture: DragGesture? public private(set) var isDragging: Bool = false private weak var draggingScrollView: UIScrollView? private var scrollingInitialOffset: CGFloat? private var activityTimer: SwiftSignalKit.Timer? public var beginScrolling: (() -> UIScrollView?)? public var finishedScrolling: (() -> Void)? public var setContentOffset: ((CGPoint) -> Void)? public var openCurrentDate: (() -> Void)? public var isDecelerating: (() -> Bool)? private var offsetBarTimer: SwiftSignalKit.Timer? private var beganAtDateIndicator = false private let hapticFeedback = HapticFeedback() private struct ProjectionData { var minY: CGFloat var maxY: CGFloat var indicatorHeight: CGFloat var scrollableHeight: CGFloat } private var projectionData: ProjectionData? public struct DisplayTooltip { public var animation: String? public var text: String public var completed: () -> Void public init(animation: String?, text: String, completed: @escaping () -> Void) { self.animation = animation self.text = text self.completed = completed } } public var displayTooltip: DisplayTooltip? private var theme: PresentationTheme? override public init() { self.dateIndicatorContainer = UIView() self.dateIndicatorContainer.isUserInteractionEnabled = false self.dateIndicator = ComponentHostView() self.lineIndicator = ComponentHostView() self.dateIndicator.alpha = 0.0 self.lineIndicator.alpha = 0.0 super.init() self.dateIndicator.isUserInteractionEnabled = false self.lineIndicator.isUserInteractionEnabled = false self.view.addSubview(self.dateIndicatorContainer) self.dateIndicatorContainer.addSubview(self.dateIndicator) self.view.addSubview(self.lineIndicator) let dragGesture = DragGesture( shouldBegin: { [weak self] point in guard let strongSelf = self else { return nil } if strongSelf.dateIndicator.frame.contains(point) { strongSelf.beganAtDateIndicator = true } else { strongSelf.beganAtDateIndicator = false } return ShouldBegin(shouldDelay: strongSelf.isDecelerating?() ?? false) }, began: { [weak self] in guard let strongSelf = self else { return } let offsetBarTimer = SwiftSignalKit.Timer(timeout: 0.2, repeat: false, completion: { guard let strongSelf = self else { return } strongSelf.performOffsetBarTimerEvent() }, queue: .mainQueue()) strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = offsetBarTimer offsetBarTimer.start() strongSelf.isDragging = true if let scrollView = strongSelf.beginScrolling?() { strongSelf.draggingScrollView = scrollView strongSelf.scrollingInitialOffset = scrollView.contentOffset.y strongSelf.setContentOffset?(scrollView.contentOffset) } strongSelf.updateActivityTimer(isScrolling: false) strongSelf.dismissLineTooltip() }, ended: { [weak self] in guard let strongSelf = self else { return } strongSelf.draggingScrollView = nil if strongSelf.offsetBarTimer != nil { strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = nil strongSelf.openCurrentDate?() } let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) transition.updateSublayerTransformOffset(layer: strongSelf.dateIndicator.layer, offset: CGPoint(x: 0.0, y: 0.0)) strongSelf.isDragging = false strongSelf.updateLineIndicator(transition: transition) strongSelf.updateActivityTimer(isScrolling: false) strongSelf.finishedScrolling?() }, moved: { [weak self] relativeOffset in guard let strongSelf = self else { return } guard let scrollView = strongSelf.draggingScrollView, let scrollingInitialOffset = strongSelf.scrollingInitialOffset else { return } guard let projectionData = strongSelf.projectionData else { return } if strongSelf.offsetBarTimer != nil { strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = nil strongSelf.performOffsetBarTimerEvent() } let indicatorArea = projectionData.maxY - projectionData.minY let scrollFraction = projectionData.scrollableHeight / indicatorArea var offset = scrollingInitialOffset + scrollFraction * relativeOffset if offset < 0.0 { offset = 0.0 } if offset > scrollView.contentSize.height - scrollView.bounds.height { offset = scrollView.contentSize.height - scrollView.bounds.height } strongSelf.setContentOffset?(CGPoint(x: 0.0, y: offset)) let _ = scrollView let _ = projectionData } ) self.dragGesture = dragGesture self.view.addGestureRecognizer(dragGesture) } private func performOffsetBarTimerEvent() { self.hapticFeedback.impact() self.offsetBarTimer = nil let transition: ContainedViewLayoutTransition = .animated(duration: 0.1, curve: .easeInOut) transition.updateSublayerTransformOffset(layer: self.dateIndicator.layer, offset: CGPoint(x: -80.0, y: 0.0)) self.updateLineIndicator(transition: transition) } public func feedbackTap() { self.hapticFeedback.tap() } private var currentDate: (String, Int32)? private var isScrolling: Bool = false public func update( containerSize: CGSize, containerInsets: UIEdgeInsets, contentHeight: CGFloat, contentOffset: CGFloat, isScrolling: Bool, date: (String, Int32), theme: PresentationTheme, transition: ContainedViewLayoutTransition ) { self.containerSize = containerSize self.theme = theme let previousDate = self.currentDate self.currentDate = date self.isScrolling = isScrolling if self.dateIndicator.alpha.isZero { let transition: ContainedViewLayoutTransition = .immediate transition.updateSublayerTransformOffset(layer: self.dateIndicator.layer, offset: CGPoint()) } if isScrolling { self.updateActivityTimer(isScrolling: true) } let animateIndicatorFrame = previousDate != nil && previousDate?.1 != date.1 let indicatorSize = self.dateIndicator.update( transition: animateIndicatorFrame ? .easeInOut(duration: 0.2) : .immediate, component: AnyComponent(SparseItemGridScrollingIndicatorComponent( backgroundColor: theme.list.itemBlocksBackgroundColor, shadowColor: .black, foregroundColor: theme.list.itemPrimaryTextColor, date: date, previousDate: previousDate )), environment: {}, containerSize: containerSize ) let scrollIndicatorHeightFraction = min(1.0, max(0.0, (containerSize.height - containerInsets.top - containerInsets.bottom) / contentHeight)) if scrollIndicatorHeightFraction >= 0.55 - .ulpOfOne { self.dateIndicator.isHidden = true self.lineIndicator.isHidden = true } else { self.dateIndicator.isHidden = false self.lineIndicator.isHidden = false } let indicatorVerticalInset: CGFloat = 3.0 let topIndicatorInset: CGFloat = indicatorVerticalInset + containerInsets.top let bottomIndicatorInset: CGFloat = indicatorVerticalInset + containerInsets.bottom let scrollIndicatorHeight: CGFloat = 44.0 let indicatorPositionFraction = min(1.0, max(0.0, contentOffset / (contentHeight - containerSize.height))) let indicatorTopPosition = topIndicatorInset let indicatorBottomPosition = containerSize.height - bottomIndicatorInset - scrollIndicatorHeight let dateIndicatorTopPosition = topIndicatorInset + floor(scrollIndicatorHeight - indicatorSize.height) / 2.0 let dateIndicatorBottomPosition = containerSize.height - bottomIndicatorInset - floor(scrollIndicatorHeight - indicatorSize.height) / 2.0 - indicatorSize.height self.indicatorPosition = indicatorTopPosition * (1.0 - indicatorPositionFraction) + indicatorBottomPosition * indicatorPositionFraction self.scrollIndicatorHeight = scrollIndicatorHeight let dateIndicatorPosition = dateIndicatorTopPosition * (1.0 - indicatorPositionFraction) + dateIndicatorBottomPosition * indicatorPositionFraction - UIScreenPixel self.projectionData = ProjectionData( minY: dateIndicatorTopPosition, maxY: dateIndicatorBottomPosition, indicatorHeight: indicatorSize.height, scrollableHeight: contentHeight - containerSize.height ) self.dateIndicatorContainer.frame = CGRect(origin: CGPoint(x: 0.0, y: dateIndicatorPosition), size: CGSize(width: containerSize.width, height: indicatorSize.height)) var indicatorFrameTransition = transition if animateIndicatorFrame { indicatorFrameTransition = .animated(duration: 0.2, curve: .easeInOut) } indicatorFrameTransition.updateFrame(view: self.dateIndicator, frame: CGRect(origin: CGPoint(x: containerSize.width - 12.0 - indicatorSize.width, y: 0.0), size: indicatorSize)) if isScrolling { let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: self.dateIndicator.layer, alpha: 1.0) transition.updateAlpha(layer: self.lineIndicator.layer, alpha: 1.0) } self.updateLineTooltip(containerSize: containerSize) self.updateLineIndicator(transition: transition) if isScrolling && !self.dateIndicator.isHidden { self.displayTooltipOnFirstScroll() } } private func updateLineIndicator(transition: ContainedViewLayoutTransition) { guard let indicatorPosition = self.indicatorPosition, let scrollIndicatorHeight = self.scrollIndicatorHeight, let theme = self.theme else { return } let lineIndicatorSize = CGSize(width: (self.isDragging || self.lineTooltip != nil) ? 6.0 : 3.0, height: scrollIndicatorHeight) let mappedTransition: ComponentTransition switch transition { case .immediate: mappedTransition = .immediate case let .animated(duration, _): mappedTransition = ComponentTransition(animation: .curve(duration: duration, curve: .easeInOut)) } let _ = self.lineIndicator.update( transition: mappedTransition, component: AnyComponent(RoundedRectangle( color: theme.list.scrollIndicatorColor )), environment: {}, containerSize: lineIndicatorSize ) transition.updateFrame(view: self.lineIndicator, frame: CGRect(origin: CGPoint(x: self.bounds.size.width - 3.0 - lineIndicatorSize.width, y: indicatorPosition), size: lineIndicatorSize)) } private func updateActivityTimer(isScrolling: Bool) { self.activityTimer?.invalidate() if self.isDragging { let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: self.dateIndicator.layer, alpha: 1.0) transition.updateAlpha(layer: self.lineIndicator.layer, alpha: 1.0) } else { self.activityTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: strongSelf.dateIndicator.layer, alpha: 0.0) transition.updateAlpha(layer: strongSelf.lineIndicator.layer, alpha: 0.0) strongSelf.dismissLineTooltip() }, queue: .mainQueue()) self.activityTimer?.start() } } private func dismissLineTooltip() { if let lineTooltip = self.lineTooltip { self.lineTooltip = nil lineTooltip.layer.animateAlpha(from: lineTooltip.alpha, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak lineTooltip] _ in lineTooltip?.removeFromSuperview() }) } } private func displayTooltipOnFirstScroll() { guard let displayTooltip = self.displayTooltip else { return } if self.displayedTooltip { return } self.displayedTooltip = true let lineTooltip = ComponentHostView() self.lineTooltip = lineTooltip self.view.addSubview(lineTooltip) if let containerSize = self.containerSize { self.updateLineTooltip(containerSize: containerSize) } lineTooltip.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) let transition: ContainedViewLayoutTransition = .immediate transition.updateSublayerTransformOffset(layer: self.dateIndicator.layer, offset: CGPoint(x: -3.0, y: 0.0)) displayTooltip.completed() //#if DEBUG //#else Queue.mainQueue().after(5.0, { [weak self] in self?.dismissLineTooltip() }) //#endif } private func updateLineTooltip(containerSize: CGSize) { guard let displayTooltip = self.displayTooltip else { return } guard let lineTooltip = self.lineTooltip else { return } let lineTooltipSize = lineTooltip.update( transition: .immediate, component: AnyComponent(TooltipComponent( icon: displayTooltip.animation.flatMap { animation in AnyComponent(ScrollingTooltipAnimationComponent()) }, content: AnyComponent(MultilineText( text: displayTooltip.text, font: Font.regular(13.0), color: .white )), arrowLocation: self.lineIndicator.frame.insetBy(dx: -3.0, dy: -8.0) )), environment: {}, containerSize: containerSize ) lineTooltip.frame = CGRect(origin: CGPoint(), size: lineTooltipSize) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.dateIndicator.alpha <= 0.01 { return nil } if self.dateIndicator.frame.offsetBy(dx: self.dateIndicatorContainer.frame.minX, dy: self.dateIndicatorContainer.frame.minY).contains(point) { return super.hitTest(point, with: event) } if self.lineIndicator.alpha <= 0.01 { return nil } if self.lineIndicator.frame.insetBy(dx: -4.0, dy: -2.0).contains(point) { return super.hitTest(point, with: event) } return nil } public func hideScroller() { let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: self.dateIndicator.layer, alpha: 0.0) transition.updateAlpha(layer: self.lineIndicator.layer, alpha: 0.0) self.dismissLineTooltip() } }