import Foundation import UIKit import AsyncDisplayKit import Display private func textForTimeout(value: Int) -> String { //TODO: localize if value > 60 * 60 { let hours = value / (60 * 60) return "\(hours)h" } else { let minutes = value / 60 let seconds = value % 60 let secondsPadding = seconds < 10 ? "0" : "" return "\(minutes):\(secondsPadding)\(seconds)" } } private enum ContentState: Equatable { case clock(UIColor) case timeout(UIColor, CGFloat) } private struct ContentParticle { var position: CGPoint var direction: CGPoint var velocity: CGFloat var alpha: CGFloat var lifetime: Double var beginTime: Double init(position: CGPoint, direction: CGPoint, velocity: CGFloat, alpha: CGFloat, lifetime: Double, beginTime: Double) { self.position = position self.direction = direction self.velocity = velocity self.alpha = alpha self.lifetime = lifetime self.beginTime = beginTime } } final class PollBubbleTimerNode: ASDisplayNode { private struct Params: Equatable { var regularColor: UIColor var proximityColor: UIColor var timeout: Int32 var deadlineTimestamp: Int32? } private let hierarchyTrackingNode: HierarchyTrackingNode private var inHierarchyValue: Bool = false private var animator: ConstantDisplayLinkAnimator? private let textNode: ImmediateTextNode private let contentNode: ASDisplayNode private var currentContentState: ContentState? private var particles: [ContentParticle] = [] private var currentParams: Params? var reachedTimeout: (() -> Void)? override init() { var updateInHierarchy: ((Bool) -> Void)? self.hierarchyTrackingNode = HierarchyTrackingNode({ value in updateInHierarchy?(value) }) self.textNode = ImmediateTextNode() self.textNode.displaysAsynchronously = false self.contentNode = ASDisplayNode() super.init() self.addSubnode(self.textNode) self.addSubnode(self.contentNode) updateInHierarchy = { [weak self] value in guard let strongSelf = self else { return } strongSelf.inHierarchyValue = value strongSelf.animator?.isPaused = value } } deinit { self.animator?.invalidate() } func update(regularColor: UIColor, proximityColor: UIColor, timeout: Int32, deadlineTimestamp: Int32?) { let params = Params( regularColor: regularColor, proximityColor: proximityColor, timeout: timeout, deadlineTimestamp: deadlineTimestamp ) self.currentParams = params self.updateValues() } private func updateValues() { guard let params = self.currentParams else { return } let fractionalTimeout: Double if let deadlineTimestamp = params.deadlineTimestamp { let fractionalTimestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 fractionalTimeout = min(Double(params.timeout), max(0.0, Double(deadlineTimestamp) + 1.0 - fractionalTimestamp)) } else { fractionalTimeout = Double(params.timeout) } let timeout = Int(round(fractionalTimeout)) let proximityInterval: Double = 5.0 let timerInterval: Double = 60.0 let isProximity = timeout <= Int(proximityInterval) let isTimer = timeout <= Int(timerInterval) let color = isProximity ? params.proximityColor : params.regularColor self.textNode.attributedText = NSAttributedString(string: textForTimeout(value: timeout), font: Font.regular(14.0), textColor: color) let textSize = textNode.updateLayout(CGSize(width: 100.0, height: 100.0)) self.textNode.frame = CGRect(origin: CGPoint(x: -22.0 - textSize.width, y: 0.0), size: textSize) let contentState: ContentState if isTimer { var fraction: CGFloat = 1.0 if fractionalTimeout <= timerInterval { fraction = CGFloat(fractionalTimeout) / min(CGFloat(timerInterval), CGFloat(params.timeout)) } fraction = max(0.0, min(0.99, fraction)) contentState = .timeout(color, 1.0 - fraction) } else { contentState = .clock(color) } if self.currentContentState != contentState { self.currentContentState = contentState let image: UIImage? let diameter: CGFloat = 14.0 let inset: CGFloat = 8.0 let lineWidth: CGFloat = 1.2 switch contentState { case let .clock(color): image = generateImage(CGSize(width: diameter + inset, height: diameter + inset), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(color.cgColor) context.setLineWidth(lineWidth) context.setLineCap(.round) let clockFrame = CGRect(origin: CGPoint(x: (size.width - diameter) / 2.0, y: (size.height - diameter) / 2.0), size: CGSize(width: diameter, height: diameter)) context.strokeEllipse(in: clockFrame.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) context.move(to: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) context.addLine(to: CGPoint(x: size.width / 2.0, y: clockFrame.minY + 4.0)) context.strokePath() let topWidth: CGFloat = 4.0 context.move(to: CGPoint(x: size.width / 2.0 - topWidth / 2.0, y: clockFrame.minY - 2.0)) context.addLine(to: CGPoint(x: size.width / 2.0 + topWidth / 2.0, y: clockFrame.minY - 2.0)) context.strokePath() }) case let .timeout(color, fraction): let timestamp = CACurrentMediaTime() let center = CGPoint(x: (diameter + inset) / 2.0, y: (diameter + inset) / 2.0) let radius: CGFloat = (diameter - lineWidth / 2.0) / 2.0 let startAngle: CGFloat = -CGFloat.pi / 2.0 let endAngle: CGFloat = -CGFloat.pi / 2.0 + 2.0 * CGFloat.pi * fraction let v = CGPoint(x: sin(endAngle), y: -cos(endAngle)) let c = CGPoint(x: -v.y * radius + center.x, y: v.x * radius + center.y) let dt: CGFloat = 1.0 / 60.0 var removeIndices: [Int] = [] for i in 0 ..< self.particles.count { let currentTime = timestamp - self.particles[i].beginTime if currentTime > self.particles[i].lifetime { removeIndices.append(i) } else { let input: CGFloat = CGFloat(currentTime / self.particles[i].lifetime) let decelerated: CGFloat = (1.0 - (1.0 - input) * (1.0 - input)) self.particles[i].alpha = 1.0 - decelerated var p = self.particles[i].position let d = self.particles[i].direction let v = self.particles[i].velocity p = CGPoint(x: p.x + d.x * v * dt, y: p.y + d.y * v * dt) self.particles[i].position = p } } for i in removeIndices.reversed() { self.particles.remove(at: i) } let newParticleCount = 1 for _ in 0 ..< newParticleCount { let degrees: CGFloat = CGFloat(arc4random_uniform(140)) - 40.0 let angle: CGFloat = degrees * CGFloat.pi / 180.0 let direction = CGPoint(x: v.x * cos(angle) - v.y * sin(angle), y: v.x * sin(angle) + v.y * cos(angle)) let velocity = (20.0 + (CGFloat(arc4random()) / CGFloat(UINT32_MAX)) * 4.0) * 0.3 let lifetime = Double(0.4 + CGFloat(arc4random_uniform(100)) * 0.01) let particle = ContentParticle(position: c, direction: direction, velocity: velocity, alpha: 1.0, lifetime: lifetime, beginTime: timestamp) self.particles.append(particle) } image = generateImage(CGSize(width: diameter + inset, height: diameter + inset), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(color.cgColor) context.setFillColor(color.cgColor) context.setLineWidth(lineWidth) context.setLineCap(.round) let path = CGMutablePath() path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) context.addPath(path) context.strokePath() for particle in self.particles { let size: CGFloat = 1.15 context.setAlpha(particle.alpha) context.fillEllipse(in: CGRect(origin: CGPoint(x: particle.position.x - size / 2.0, y: particle.position.y - size / 2.0), size: CGSize(width: size, height: size))) } }) } self.contentNode.contents = image?.cgImage if let image = image { self.contentNode.frame = CGRect(origin: CGPoint(x: -image.size.width, y: -3.0), size: image.size) } } if let reachedTimeout = self.reachedTimeout, fractionalTimeout <= .ulpOfOne { reachedTimeout() } if fractionalTimeout <= .ulpOfOne { self.animator?.invalidate() self.animator = nil } else { if self.animator == nil { let animator = ConstantDisplayLinkAnimator(update: { [weak self] in self?.updateValues() }) self.animator = animator animator.isPaused = self.inHierarchyValue } } } }