import Foundation import UIKit import Display import SwiftSignalKit import ComponentFlow import TelegramCore import TelegramPresentationData import AppBundle import AccountContext import EmojiTextAttachmentView import TextFormat import PeerInfoCoverComponent import AnimatedStickerNode import TelegramAnimatedStickerNode import EmojiStatusComponent import UIKitRuntimeUtils public final class GiftCompositionComponent: Component { public class ExternalState { public fileprivate(set) var previewPatternColor: UIColor? public init() { self.previewPatternColor = nil } } public enum Subject: Equatable { case generic(TelegramMediaFile) case unique([StarGift.UniqueGift.Attribute]?, StarGift.UniqueGift) case preview([StarGift.UniqueGift.Attribute]) } let context: AccountContext let theme: PresentationTheme let subject: Subject let animationOffset: CGPoint? let animationScale: CGFloat? let displayAnimationStars: Bool let revealedAttributes: Set let externalState: ExternalState? let requestUpdate: (ComponentTransition) -> Void public init( context: AccountContext, theme: PresentationTheme, subject: Subject, animationOffset: CGPoint? = nil, animationScale: CGFloat? = nil, displayAnimationStars: Bool = false, revealedAttributes: Set = Set(), externalState: ExternalState? = nil, requestUpdate: @escaping (ComponentTransition) -> Void = { _ in } ) { self.context = context self.theme = theme self.subject = subject self.animationOffset = animationOffset self.animationScale = animationScale self.displayAnimationStars = displayAnimationStars self.revealedAttributes = revealedAttributes self.externalState = externalState self.requestUpdate = requestUpdate } public static func ==(lhs: GiftCompositionComponent, rhs: GiftCompositionComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.subject != rhs.subject { return false } if lhs.animationOffset != rhs.animationOffset { return false } if lhs.animationScale != rhs.animationScale { return false } if lhs.displayAnimationStars != rhs.displayAnimationStars { return false } if lhs.revealedAttributes != rhs.revealedAttributes { return false } return true } public final class View: UIView { private var component: GiftCompositionComponent? private weak var componentState: EmptyComponentState? private var starsLayer: StarsEffectLayer? private let background = ComponentView() private var animationNode: AnimatedStickerNode? private var disposables = DisposableSet() private var fetchedFiles = Set() private var previewTimer: SwiftSignalKit.Timer? private var currentFile: TelegramMediaFile? private var previewModels: [StarGift.UniqueGift.Attribute] = [] private var previewBackdrops: [StarGift.UniqueGift.Attribute] = [] private var previewPatterns: [StarGift.UniqueGift.Attribute] = [] private var previewModelIndex: Int32 = 0 private var previewBackdropIndex: Int32 = 0 private var previewPatternIndex: Int32 = 0 private var animatePreviewTransition = false private var animateBackdropSwipe = false private enum SpinState { case idle, spinning, decelerating, settled } private var spinState: SpinState = .idle private var spinLink: SharedDisplayLinkDriver.Link? private var lastSpawnTime: CFTimeInterval? private var lastPatternChangeTime: CFTimeInterval? private var lastBackdropChangeTime: CFTimeInterval? private var currentInterval: Double = 0.0 private var deceleraionQueue: [StarGift.UniqueGift.Attribute] = [] private var decelerationTotalSteps: Int = 0 private var decelerationStepIndex: Int = 0 private var decelContainer: UIView? private var decelItemHosts: [UIView] = [] private let decelAnimationKey = "decel.container.move" private var activeWrappers: [UIView] = [] private struct SpinGeom { var availableSize: CGSize var iconSize: CGSize var scale: CGFloat var centerX: CGFloat var centerY: CGFloat } private var spinGeom: SpinGeom? private var spinPool: [StarGift.UniqueGift.Attribute] = [] private var spinPoolIndex: Int = 0 private let baseAnimDuration: Double = 0.4 private let maxAnimDuration: Double = 1.3 private let spacingX: CGFloat = 50.0 override init(frame: CGRect) { super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.disposables.dispose() self.previewTimer?.invalidate() } @objc private func handleTap() { guard let animationNode = animationNode as? DefaultAnimatedStickerNodeImpl else { return } if case .once = animationNode.playbackMode, !animationNode.isPlaying { animationNode.playOnce() } } private func stopSpinIfNeeded() { self.spinState = .idle self.spinLink?.invalidate() self.spinLink = nil self.lastSpawnTime = nil self.currentInterval = 0.0 self.deceleraionQueue.removeAll() self.decelerationTotalSteps = 0 self.decelerationStepIndex = 0 self.spinPool.removeAll() self.spinPoolIndex = 0 self.spinGeom = nil for wrapper in self.activeWrappers { wrapper.layer.removeAllAnimations() wrapper.removeFromSuperview() } self.activeWrappers.removeAll() if let c = self.decelContainer { c.layer.removeAllAnimations() c.removeFromSuperview() } self.decelContainer = nil self.decelItemHosts.removeAll() } private func ensureDisplayLink() { if self.spinLink != nil { return } self.spinLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in self?.tick() }) } private func spawnModelItem( _ attribute: StarGift.UniqueGift.Attribute, animDuration: Double ) { guard let geom = self.spinGeom, case let .model(_, file, _) = attribute else { return } let node = DefaultAnimatedStickerNodeImpl() node.isUserInteractionEnabled = false let pathPrefix = self.component!.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) node.setup( source: AnimatedStickerResourceSource(account: self.component!.context.account, resource: file.resource, isVideo: file.isVideoSticker), width: Int(geom.iconSize.width * 1.6), height: Int(geom.iconSize.height * 1.6), playbackMode: .still(.start), mode: .direct(cachePathPrefix: pathPrefix) ) node.updateLayout(size: geom.iconSize) let scaleValue = geom.scale let visualSize = CGSize(width: geom.iconSize.width * scaleValue, height: geom.iconSize.height * scaleValue) let wrapper = UIView(frame: CGRect(origin: .zero, size: visualSize)) wrapper.clipsToBounds = false let host = node.view host.frame = CGRect(origin: .zero, size: geom.iconSize) host.layer.bounds = CGRect(origin: .zero, size: geom.iconSize) host.layer.position = CGPoint(x: geom.iconSize.width / 2.0, y: geom.iconSize.height / 2.0) host.layer.transform = CATransform3DMakeScale(scaleValue, scaleValue, 1.0) wrapper.addSubview(host) self.addSubview(wrapper) self.activeWrappers.append(wrapper) let centerY = geom.centerY - visualSize.height / 2.0 let startX = -visualSize.width * 1.5 let endX = geom.availableSize.width + visualSize.width wrapper.frame.origin = CGPoint(x: endX, y: centerY) let travelDistance = abs(startX - endX) let pitch = visualSize.width + self.spacingX wrapper.layer.animatePosition( from: CGPoint(x: -travelDistance, y: 0.0), to: .zero, duration: animDuration, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, additive: true, completion: { [weak self, weak wrapper] _ in if let self, let w = wrapper { self.activeWrappers.removeAll { $0 === w } w.removeFromSuperview() } } ) self.currentInterval = Double(pitch / travelDistance) * animDuration * 0.6 } private func finishSettled() { guard self.spinState != .settled else { return } self.spinState = .settled self.spinLink?.invalidate() self.spinLink = nil for v in self.activeWrappers { v.layer.removeAllAnimations() v.removeFromSuperview() } self.activeWrappers.removeAll() } private func startSpinningUnique( availableSize: CGSize, iconSize: CGSize, scale: CGFloat, pool: [StarGift.UniqueGift.Attribute] ) { self.stopSpinIfNeeded() self.spinPool = pool self.spinPoolIndex = 0 let centerY = 88.0 + (self.component?.animationOffset?.y ?? 0.0) self.spinGeom = SpinGeom( availableSize: availableSize, iconSize: iconSize, scale: scale, centerX: availableSize.width / 2.0 + (self.component?.animationOffset?.x ?? 0.0), centerY: centerY ) self.spinState = .spinning self.lastSpawnTime = nil self.currentInterval = 0 self.ensureDisplayLink() } private func beginDecelerationWithQueue( tail: [StarGift.UniqueGift.Attribute], availableSize: CGSize, iconSize: CGSize, scale: CGFloat ) { guard let geom = self.spinGeom, !tail.isEmpty else { return } // Build container let visualSize = CGSize(width: iconSize.width * scale, height: iconSize.height * scale) let pitch = visualSize.width + self.spacingX let count = tail.count let containerWidth = CGFloat(count) * visualSize.width + CGFloat(max(count - 1, 0)) * self.spacingX let containerHeight = visualSize.height let container = UIView(frame: CGRect(origin: .zero, size: CGSize(width: containerWidth, height: containerHeight))) container.isUserInteractionEnabled = false container.clipsToBounds = false // Y is fixed; we’ll animate X only let containerY = geom.centerY - containerHeight / 2.0 container.frame.origin.y = containerY self.addSubview(container) self.decelContainer = container self.decelItemHosts.removeAll() // Fill container with hosts at fixed pitch for (i, attribute) in tail.reversed().enumerated() { guard case let .model(_, file, _) = attribute else { continue } // Node let node = DefaultAnimatedStickerNodeImpl() node.isUserInteractionEnabled = false let pathPrefix = self.component!.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) node.setup( source: AnimatedStickerResourceSource(account: self.component!.context.account, resource: file.resource, isVideo: file.isVideoSticker), width: Int(iconSize.width * 1.6), height: Int(iconSize.height * 1.6), playbackMode: .still(.start), mode: .direct(cachePathPrefix: pathPrefix) ) node.updateLayout(size: iconSize) node.visibility = true if i < 4 { node.playOnce(); } let host = node.view host.bounds = CGRect(origin: .zero, size: iconSize) host.layer.transform = CATransform3DMakeScale(scale, scale, 1.0) let hostView = UIView(frame: CGRect(origin: CGPoint(x: CGFloat(i) * pitch, y: 0), size: visualSize)) host.center = CGPoint(x: visualSize.width / 2.0, y: visualSize.height / 2.0) hostView.addSubview(host) container.addSubview(hostView) self.decelItemHosts.append(hostView) if i == 0 { self.animationNode = node let factors: [CGFloat] = [1.0, 1.3, 0.92, 1.18, 0.98, 1.0] let values = factors.map { NSNumber(value: Double($0)) } let scaleAnim = CAKeyframeAnimation(keyPath: "transform.scale") scaleAnim.beginTime = CACurrentMediaTime() + 0.6 scaleAnim.values = values scaleAnim.keyTimes = [0.0, 0.35, 0.55, 0.75, 0.9, 1.0].map(NSNumber.init) scaleAnim.timingFunctions = [ CAMediaTimingFunction(name: .easeOut), CAMediaTimingFunction(name: .easeIn), CAMediaTimingFunction(name: .easeOut), CAMediaTimingFunction(name: .easeIn), CAMediaTimingFunction(name: .easeOut) ] scaleAnim.duration = 0.85 scaleAnim.isRemovedOnCompletion = true host.layer.add(scaleAnim, forKey: "bounce") } if i == 1 { hostView.alpha = 0.0 hostView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.7) } } container.frame.origin.x = floor((availableSize.width - visualSize.width) / 2.0) container.layer.animatePosition( from: CGPoint(x: -containerWidth - visualSize.width * 0.5 + containerWidth / 2.0 - self.spacingX - 70.0, y: container.frame.center.y), to: CGPoint(x: container.frame.center.x, y: container.frame.center.y), duration: self.maxAnimDuration, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in guard let self, let container = self.decelContainer else { return } let _ = container //self.handleDecelArrived(container: container, iconSize: iconSize, visualSize: visualSize) } ) self.spinState = .decelerating self.spinLink?.invalidate() self.spinLink = nil } private func handleDecelArrived(container: UIView, iconSize: CGSize, visualSize: CGSize) { let isFinalIndex = self.decelItemHosts.count - 1 guard isFinalIndex >= 0, let finalHostView = self.decelItemHosts.last else { return } // Prepare final node guard let node = self.animationNode as? DefaultAnimatedStickerNodeImpl else { return } node.playbackMode = .once node.visibility = true // Convert final host center into self coords let finalCenterInSelf = container.convert(finalHostView.center, to: self) // Place node directly in self, exactly where the final host is let host = node.view if host.superview !== self { self.addSubview(host) } node.updateLayout(size: iconSize) host.bounds = CGRect(origin: .zero, size: iconSize) host.layer.position = finalCenterInSelf // Remove container container.removeFromSuperview() self.decelContainer = nil self.decelItemHosts.removeAll() // Bounce scale (same values you already used) let factors: [CGFloat] = [1.0, 1.3, 0.92, 1.18, 0.98, 1.0] let values = factors.map { NSNumber(value: Double($0)) } let scaleAnim = CAKeyframeAnimation(keyPath: "transform.scale") scaleAnim.values = values scaleAnim.keyTimes = [0.0, 0.35, 0.55, 0.75, 0.9, 1.0].map(NSNumber.init) scaleAnim.timingFunctions = [ CAMediaTimingFunction(name: .easeOut), CAMediaTimingFunction(name: .easeIn), CAMediaTimingFunction(name: .easeOut), CAMediaTimingFunction(name: .easeIn), CAMediaTimingFunction(name: .easeOut) ] scaleAnim.duration = 0.85 scaleAnim.isRemovedOnCompletion = true host.layer.add(scaleAnim, forKey: "bounce") // Play the “once” sticker & finish node.playOnce() self.finishSettled() } private func tick() { guard let component = self.component else { return } let now = CACurrentMediaTime() switch self.spinState { case .spinning: if self.lastSpawnTime == nil || now - (self.lastSpawnTime ?? now) >= self.currentInterval { self.lastSpawnTime = now guard !self.spinPool.isEmpty else { return } if self.spinPoolIndex >= self.spinPool.count { self.spinPoolIndex = 0 } let next = self.spinPool[self.spinPoolIndex] self.spinPoolIndex += 1 self.spawnModelItem(next, animDuration: self.baseAnimDuration) } var updateNeeded = false if self.lastPatternChangeTime == nil || now - (self.lastPatternChangeTime ?? now) >= self.currentInterval * 6.0 { self.lastPatternChangeTime = now if component.revealedAttributes.contains(.pattern) { if self.previewPatternIndex != -1 { self.previewPatternIndex = -1 self.animatePreviewTransition = true updateNeeded = true } } else { let previousPatternIndex = self.previewPatternIndex var randomPatternIndex = previousPatternIndex while randomPatternIndex == previousPatternIndex && !self.previewPatterns.isEmpty { randomPatternIndex = Int32.random(in: 0 ..< Int32(self.previewPatterns.count)) } if !self.previewPatterns.isEmpty { self.previewPatternIndex = randomPatternIndex } self.animatePreviewTransition = true updateNeeded = true } } if self.lastBackdropChangeTime == nil || now - (self.lastBackdropChangeTime ?? now) >= self.currentInterval * 3.55 { self.lastBackdropChangeTime = now if component.revealedAttributes.contains(.backdrop) { if self.previewBackdropIndex != -1 { self.previewBackdropIndex = -1 self.animateBackdropSwipe = true updateNeeded = true } } else { let previousBackdropIndex = self.previewBackdropIndex var randomBackdropIndex = previousBackdropIndex while randomBackdropIndex == previousBackdropIndex && !self.previewBackdrops.isEmpty { randomBackdropIndex = Int32.random(in: 0 ..< Int32(self.previewBackdrops.count)) } if !self.previewBackdrops.isEmpty { self.previewBackdropIndex = randomBackdropIndex } self.animateBackdropSwipe = true updateNeeded = true } } if updateNeeded { self.componentState?.updated(transition: .easeInOut(duration: 0.25)) self.component?.requestUpdate(.easeInOut(duration: 0.25)) } case .decelerating: break case .idle, .settled: self.spinLink?.invalidate() self.spinLink = nil } } public func update(component: GiftCompositionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousComponent = self.component self.component = component self.componentState = state var animationFile: TelegramMediaFile? var backgroundColor: UIColor? var secondBackgroundColor: UIColor? var patternColor: UIColor? var patternFile: TelegramMediaFile? var files: [Int64: TelegramMediaFile] = [:] var loop = false var uniqueSpinContext: (previewAttributes: [StarGift.UniqueGift.Attribute], mainGift: StarGift.UniqueGift)? = nil switch component.subject { case let .generic(file): animationFile = file self.currentFile = file self.stopSpinIfNeeded() if let previewTimer = self.previewTimer { previewTimer.invalidate() self.previewTimer = nil } if !self.fetchedFiles.contains(file.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched(account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) self.fetchedFiles.insert(file.fileId.id) } case let .unique(previewAttributesOpt, gift): if let previewTimer = self.previewTimer { previewTimer.invalidate() self.previewTimer = nil } for attribute in gift.attributes { switch attribute { case let .model(_, file, _): animationFile = file if !self.fetchedFiles.contains(file.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched(account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) self.fetchedFiles.insert(file.fileId.id) } case let .pattern(_, file, _): patternFile = file files[file.fileId.id] = file case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _): backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) default: break } } if let previewAttributes = previewAttributesOpt, !previewAttributes.isEmpty { if self.previewPatternIndex != -1, case let .pattern(_, file, _) = self.previewPatterns[Int(self.previewPatternIndex)] { patternFile = file files[file.fileId.id] = file } if self.previewBackdropIndex != -1, case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _) = self.previewBackdrops[Int(self.previewBackdropIndex)] { backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) } uniqueSpinContext = (previewAttributes, gift) } else { self.stopSpinIfNeeded() } case let .preview(sampleAttributes): loop = true self.stopSpinIfNeeded() if self.previewModels.isEmpty { var models: [StarGift.UniqueGift.Attribute] = [] var patterns: [StarGift.UniqueGift.Attribute] = [] var backdrops: [StarGift.UniqueGift.Attribute] = [] for attribute in sampleAttributes { switch attribute { case .model: models.append(attribute) case .pattern: patterns.append(attribute) case .backdrop: backdrops.append(attribute) default: break } } self.previewModels = models self.previewPatterns = patterns self.previewBackdrops = backdrops } for case let .model(_, file, _) in self.previewModels where !self.fetchedFiles.contains(file.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched(account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) self.fetchedFiles.insert(file.fileId.id) } for case let .pattern(_, file, _) in self.previewPatterns where !self.fetchedFiles.contains(file.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched(account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) self.fetchedFiles.insert(file.fileId.id) } if !self.previewModels.isEmpty { if self.previewPatternIndex < 0 { self.previewPatternIndex = 0 } if self.previewBackdropIndex < 0 { self.previewBackdropIndex = 0 } if case let .model(_, file, _) = self.previewModels[Int(self.previewModelIndex)] { animationFile = file } if case let .pattern(_, file, _) = self.previewPatterns[Int(self.previewPatternIndex)] { patternFile = file files[file.fileId.id] = file } if case let .backdrop(_, _, innerColorValue, outerColorValue, patternColorValue, _, _) = self.previewBackdrops[Int(self.previewBackdropIndex)] { backgroundColor = UIColor(rgb: UInt32(bitPattern: outerColorValue)) secondBackgroundColor = UIColor(rgb: UInt32(bitPattern: innerColorValue)) patternColor = UIColor(rgb: UInt32(bitPattern: patternColorValue)) } } if self.previewTimer == nil { self.previewTimer = SwiftSignalKit.Timer(timeout: 2.0, repeat: true, completion: { [weak self] in guard let self, !self.previewModels.isEmpty else { return } self.previewModelIndex = (self.previewModelIndex + 1) % Int32(self.previewModels.count) let previousPatternIndex = self.previewPatternIndex var randomPatternIndex = previousPatternIndex while randomPatternIndex == previousPatternIndex && !self.previewPatterns.isEmpty { randomPatternIndex = Int32.random(in: 0 ..< Int32(self.previewPatterns.count)) } if !self.previewPatterns.isEmpty { self.previewPatternIndex = randomPatternIndex } let previousBackdropIndex = self.previewBackdropIndex var randomBackdropIndex = previousBackdropIndex while randomBackdropIndex == previousBackdropIndex && !self.previewBackdrops.isEmpty { randomBackdropIndex = Int32.random(in: 0 ..< Int32(self.previewBackdrops.count)) } if !self.previewBackdrops.isEmpty { self.previewBackdropIndex = randomBackdropIndex } self.animatePreviewTransition = true self.componentState?.updated(transition: .easeInOut(duration: 0.25)) self.component?.requestUpdate(.easeInOut(duration: 0.25)) }, queue: Queue.mainQueue()) self.previewTimer?.start() } } component.externalState?.previewPatternColor = secondBackgroundColor var animateBackdropSwipe = false if self.animateBackdropSwipe { animateBackdropSwipe = true self.animateBackdropSwipe = false } var animateTransition = false if self.animatePreviewTransition { animateTransition = true self.animatePreviewTransition = false } else if let previousComponent, case .preview = previousComponent.subject, case .unique = component.subject { animateTransition = true } else if let previousComponent, case .generic = previousComponent.subject, case .preview = component.subject { animateTransition = true } else if let previousComponent, case .preview = previousComponent.subject, case .generic = component.subject { animateTransition = true } if let backgroundColor { var backgroundTransition = transition if let backgroundView = self.background.view as? PeerInfoCoverComponent.View { if animateTransition { var bounce = true var background = true if case .unique = component.subject { bounce = self.previewPatternIndex == -1 background = false } backgroundView.animateTransition(background: background, bounce: bounce) } if animateBackdropSwipe { backgroundView.animateSwipeTransition() } } var avatarCenter = CGPoint(x: availableSize.width / 2.0, y: 104.0) if let _ = component.animationScale { avatarCenter = CGPoint(x: avatarCenter.x, y: 67.0) } let _ = self.background.update( transition: backgroundTransition, component: AnyComponent(PeerInfoCoverComponent( context: component.context, subject: .custom(backgroundColor, secondBackgroundColor, patternColor, patternFile?.fileId.id), files: files, isDark: false, avatarCenter: avatarCenter, avatarScale: 1.0, defaultHeight: 300.0, gradientOnTop: true, avatarTransitionFraction: 0.0, patternTransitionFraction: 0.0 )), environment: {}, containerSize: availableSize ) if let backgroundView = self.background.view { if backgroundView.superview == nil { backgroundTransition = .immediate backgroundView.clipsToBounds = true backgroundView.isUserInteractionEnabled = false self.insertSubview(backgroundView, at: 0) if previousComponent != nil { backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } } backgroundTransition.setFrame(view: backgroundView, frame: CGRect(origin: .zero, size: availableSize)) } } else if let backgroundView = self.background.view, backgroundView.superview != nil { backgroundView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in backgroundView.removeFromSuperview() }) } let iconSize = CGSize(width: 136.0, height: 136.0) if let (previewAttributes, mainGift) = uniqueSpinContext { var mainModelFile: TelegramMediaFile? for attribute in mainGift.attributes { if case let .model(_, file, _) = attribute { mainModelFile = file; break } } var models: [StarGift.UniqueGift.Attribute] = [] for attribute in previewAttributes { if case let .model(_, file, _) = attribute, file.fileId.id != mainModelFile?.fileId.id { models.append(attribute) } } if models.isEmpty, let _ = mainModelFile { return availableSize } for case let .model(_, file, _) in models where !self.fetchedFiles.contains(file.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched( account: component.context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource ).start()) self.fetchedFiles.insert(file.fileId.id) } if let mainModelFile, !self.fetchedFiles.contains(mainModelFile.fileId.id) { self.disposables.add(freeMediaFileResourceInteractiveFetched( account: component.context.account, userLocation: .other, fileReference: .standalone(media: mainModelFile), resource: mainModelFile.resource ).start()) self.fetchedFiles.insert(mainModelFile.fileId.id) } let wasAnimatingModel = previousComponent != nil && !(previousComponent!.revealedAttributes.contains(.model)) let isAnimatingModel = !component.revealedAttributes.contains(.model) let wasAnimating = wasAnimatingModel let nowAnimating = isAnimatingModel if nowAnimating { if let disappearing = self.animationNode { self.animationNode = nil disappearing.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.12, removeOnCompletion: false, completion: { _ in disappearing.view.removeFromSuperview() }) } } let scaleValue: CGFloat = component.animationScale ?? 1.0 if nowAnimating && (!wasAnimating || self.spinState != .spinning) { self.startSpinningUnique( availableSize: availableSize, iconSize: iconSize, scale: scaleValue, pool: models ) } else if !nowAnimating && wasAnimating { var tail = Array(models.shuffled().prefix(6)) if let mainModelFile { tail.append(.model(name: "", file: mainModelFile, rarity: 0)) } self.beginDecelerationWithQueue( tail: tail, availableSize: availableSize, iconSize: iconSize, scale: scaleValue ) } else if self.spinState == .spinning { let centerY = 88.0 + (component.animationOffset?.y ?? 0.0) self.spinGeom = SpinGeom( availableSize: availableSize, iconSize: iconSize, scale: scaleValue, centerX: availableSize.width / 2.0 + (component.animationOffset?.x ?? 0.0), centerY: centerY ) } return availableSize } if self.spinState != .idle && self.spinState != .settled { self.stopSpinIfNeeded() } var startFromIndex: Int? var animationTransition = transition if animateTransition, let disappearingAnimationNode = self.animationNode { self.animationNode = nil startFromIndex = disappearingAnimationNode.currentFrameIndex disappearingAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in disappearingAnimationNode.view.removeFromSuperview() }) animationTransition = .immediate } if let file = animationFile, self.animationNode == nil { animationTransition = .immediate let node = DefaultAnimatedStickerNodeImpl() node.isUserInteractionEnabled = false self.animationNode = node self.addSubview(node.view) let pathPrefix = component.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) node.setup(source: AnimatedStickerResourceSource(account: component.context.account, resource: file.resource, isVideo: file.isVideoSticker), width: Int(iconSize.width * 1.6), height: Int(iconSize.height * 1.6), playbackMode: loop ? .loop : .once, mode: .direct(cachePathPrefix: pathPrefix)) if let startFromIndex { node.play(firstFrame: false, fromIndex: startFromIndex) } else { if loop { node.playLoop() } else { node.playOnce() } } node.visibility = true node.updateLayout(size: iconSize) if animateTransition { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } } if let animationNode = self.animationNode { let offset = component.animationOffset ?? .zero var size = CGSize(width: iconSize.width, height: iconSize.height) if let scale = component.animationScale { size = CGSize(width: size.width * scale, height: size.height * scale) } let animationFrame = CGRect( origin: CGPoint(x: availableSize.width / 2.0 + offset.x - size.width / 2.0, y: 88.0 + offset.y - size.height / 2.0), size: size ) animationNode.layer.bounds = CGRect(origin: .zero, size: iconSize) animationTransition.setPosition(layer: animationNode.layer, position: animationFrame.center) animationTransition.setScale(layer: animationNode.layer, scale: size.width / iconSize.width) if component.displayAnimationStars { var starsTransition = transition let starsLayer: StarsEffectLayer if let current = self.starsLayer { starsLayer = current } else { starsTransition = .immediate starsLayer = StarsEffectLayer() self.layer.insertSublayer(starsLayer, below: animationNode.layer) self.starsLayer = starsLayer starsLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } let starsSize = CGSize(width: 36.0, height: 36.0) starsLayer.update(color: .white, size: starsSize) starsLayer.bounds = CGRect(origin: .zero, size: starsSize) starsTransition.setPosition(layer: starsLayer, position: animationFrame.center) } else if let starsLayer = self.starsLayer { self.starsLayer = nil transition.setPosition(layer: starsLayer, position: animationFrame.center) starsLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in starsLayer.removeFromSuperlayer() }) } } 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 final class StarsEffectLayer: SimpleLayer { private let emitterLayer = CAEmitterLayer() override init() { super.init() self.addSublayer(self.emitterLayer) } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setup(color: UIColor) { let emitter = CAEmitterCell() emitter.name = "emitter" emitter.contents = UIImage(bundleImageName: "Premium/Stars/Particle")?.cgImage emitter.birthRate = 8.0 emitter.lifetime = 2.0 emitter.velocity = 0.1 emitter.scale = 0.12 emitter.scaleRange = 0.02 emitter.alphaRange = 0.1 emitter.emissionRange = .pi * 2.0 let staticColors: [Any] = [ color.withAlphaComponent(0.0).cgColor, color.withAlphaComponent(0.55).cgColor, color.withAlphaComponent(0.55).cgColor, color.withAlphaComponent(0.0).cgColor ] let staticColorBehavior = CAEmitterCell.createEmitterBehavior(type: "colorOverLife") staticColorBehavior.setValue(staticColors, forKey: "colors") emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors") self.emitterLayer.emitterCells = [emitter] } func update(color: UIColor, size: CGSize) { if self.emitterLayer.emitterCells == nil { self.setup(color: color) } self.emitterLayer.emitterShape = .circle self.emitterLayer.emitterSize = size self.emitterLayer.emitterMode = .surface self.emitterLayer.frame = CGRect(origin: .zero, size: size) self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0) } }