2023-01-27 14:06:44 +01:00

2890 lines
144 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
import AnimatedStickerNode
import TelegramCore
import Postbox
import TelegramPresentationData
import AccountContext
import TelegramAnimatedStickerNode
import ReactionButtonListComponent
import SwiftSignalKit
import Lottie
import AppBundle
import AvatarNode
import ComponentFlow
import EmojiStatusSelectionComponent
import EntityKeyboard
import ComponentDisplayAdapters
import AnimationCache
import MultiAnimationRenderer
import EmojiTextAttachmentView
import TextFormat
import GZip
public final class ReactionItem {
public struct Reaction: Equatable {
public var rawValue: MessageReaction.Reaction
public init(rawValue: MessageReaction.Reaction) {
self.rawValue = rawValue
}
}
public let reaction: ReactionItem.Reaction
public let appearAnimation: TelegramMediaFile
public let stillAnimation: TelegramMediaFile
public let listAnimation: TelegramMediaFile
public let largeListAnimation: TelegramMediaFile
public let applicationAnimation: TelegramMediaFile?
public let largeApplicationAnimation: TelegramMediaFile?
public let isCustom: Bool
public init(
reaction: ReactionItem.Reaction,
appearAnimation: TelegramMediaFile,
stillAnimation: TelegramMediaFile,
listAnimation: TelegramMediaFile,
largeListAnimation: TelegramMediaFile,
applicationAnimation: TelegramMediaFile?,
largeApplicationAnimation: TelegramMediaFile?,
isCustom: Bool
) {
self.reaction = reaction
self.appearAnimation = appearAnimation
self.stillAnimation = stillAnimation
self.listAnimation = listAnimation
self.largeListAnimation = largeListAnimation
self.applicationAnimation = applicationAnimation
self.largeApplicationAnimation = largeApplicationAnimation
self.isCustom = isCustom
}
var updateMessageReaction: UpdateMessageReaction {
switch self.reaction.rawValue {
case let .builtin(value):
return .builtin(value)
case let .custom(fileId):
return .custom(fileId: fileId, file: self.listAnimation)
}
}
}
public enum ReactionContextItem {
case reaction(ReactionItem)
case premium
public var reaction: ReactionItem.Reaction? {
if case let .reaction(item) = self {
return item.reaction
} else {
return nil
}
}
}
private let largeCircleSize: CGFloat = 16.0
private let smallCircleSize: CGFloat = 8.0
private final class ExpandItemView: UIView {
private let arrowView: UIImageView
let tintView: UIView
override init(frame: CGRect) {
self.tintView = UIView()
self.tintView.backgroundColor = .white
self.arrowView = UIImageView()
self.arrowView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReactionExpandArrow"), color: .white)
super.init(frame: frame)
self.addSubview(self.arrowView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateTheme(theme: PresentationTheme) {
self.backgroundColor = theme.chat.inputMediaPanel.panelContentControlVibrantOverlayColor.mixedWith(theme.contextMenu.backgroundColor.withMultipliedAlpha(0.4), alpha: 0.5)
}
func update(size: CGSize, transition: ContainedViewLayoutTransition) {
transition.updateCornerRadius(layer: self.layer, cornerRadius: size.width / 2.0)
transition.updateCornerRadius(layer: self.tintView.layer, cornerRadius: size.width / 2.0)
if let image = self.arrowView.image {
transition.updateFrame(view: self.arrowView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels(size.height - size.width + (size.width - image.size.height) / 2.0 + 1.0)), size: image.size))
}
}
}
public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate {
private struct ItemLayout {
var itemSize: CGFloat
var visibleItemCount: Int
init(
itemSize: CGFloat,
visibleItemCount: Int
) {
self.itemSize = itemSize
self.visibleItemCount = visibleItemCount
}
}
private final class ContentScrollView: UIScrollView {
override static var layerClass: AnyClass {
return EmojiPagerContentComponent.View.ContentScrollLayer.self
}
init(mirrorView: UIView) {
super.init(frame: CGRect())
(self.layer as? EmojiPagerContentComponent.View.ContentScrollLayer)?.mirrorLayer = mirrorView.layer
}
required init(coder: NSCoder) {
preconditionFailure()
}
}
private final class ContentScrollNode: ASDisplayNode {
override var view: ContentScrollView {
return super.view as! ContentScrollView
}
init(mirrorView: UIView) {
super.init()
self.setViewBlock({
return ContentScrollView(mirrorView: mirrorView)
})
}
}
private struct EmojiSearchResult {
var groups: [EmojiPagerContentComponent.ItemGroup]
var id: AnyHashable
var version: Int
var isPreset: Bool
}
private struct EmojiSearchState {
var result: EmojiSearchResult?
var isSearching: Bool
init(result: EmojiSearchResult?, isSearching: Bool) {
self.result = result
self.isSearching = isSearching
}
}
private let context: AccountContext
private let presentationData: PresentationData
private let animationCache: AnimationCache
private let animationRenderer: MultiAnimationRenderer
private let items: [ReactionContextItem]
private let selectedItems: Set<MessageReaction.Reaction>
private let getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?
private let isExpandedUpdated: (ContainedViewLayoutTransition) -> Void
private let requestLayout: (ContainedViewLayoutTransition) -> Void
private let requestUpdateOverlayWantsToBeBelowKeyboard: (ContainedViewLayoutTransition) -> Void
private let backgroundNode: ReactionContextBackgroundNode
private let contentTintContainer: ASDisplayNode
private let contentContainer: ASDisplayNode
private let contentContainerMask: UIImageView
private let leftBackgroundMaskNode: ASDisplayNode
private let rightBackgroundMaskNode: ASDisplayNode
private let backgroundMaskNode: ASDisplayNode
private let mirrorContentScrollView: UIView
private let scrollNode: ContentScrollNode
private let previewingItemContainer: ASDisplayNode
private var visibleItemNodes: [Int: ReactionItemNode] = [:]
private var disappearingVisibleItemNodes: [Int: ReactionItemNode] = [:]
private var visibleItemMaskNodes: [Int: ASDisplayNode] = [:]
private let expandItemView: ExpandItemView?
private var reactionSelectionComponentHost: ComponentView<Empty>?
private var longPressRecognizer: UILongPressGestureRecognizer?
private var longPressTimer: SwiftSignalKit.Timer?
private var highlightedReaction: ReactionItem.Reaction?
private var highlightedByHover = false
private var didTriggerExpandedReaction: Bool = false
private var continuousHaptic: Any?
private var validLayout: (CGSize, UIEdgeInsets, CGRect, Bool)?
private var isLeftAligned: Bool = true
private var itemLayout: ItemLayout?
private var customReactionSource: (view: UIView, rect: CGRect, layer: CALayer, item: ReactionItem)?
public var reactionSelected: ((UpdateMessageReaction, Bool) -> Void)?
public var premiumReactionsSelected: ((TelegramMediaFile?) -> Void)?
private var hapticFeedback: HapticFeedback?
private var standaloneReactionAnimation: StandaloneReactionAnimation?
private weak var animationTargetView: UIView?
private var animationHideNode: Bool = false
private var didAnimateIn: Bool = false
public var contentHeight: CGFloat {
return self.currentContentHeight
}
private var currentContentHeight: CGFloat = 46.0
public private(set) var isExpanded: Bool = false
public private(set) var canBeExpanded: Bool = false
private var animateFromExtensionDistance: CGFloat = 0.0
private var extensionDistance: CGFloat = 0.0
public private(set) var visibleExtensionDistance: CGFloat = 0.0
private var emojiContentLayout: EmojiPagerContentComponent.CustomLayout?
private var emojiContent: EmojiPagerContentComponent?
private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation?
private var emojiContentDisposable: Disposable?
private let emojiSearchDisposable = MetaDisposable()
private let emojiSearchState = Promise<EmojiSearchState>(EmojiSearchState(result: nil, isSearching: false))
private var emojiSearchStateValue: EmojiSearchState = EmojiSearchState(result: nil, isSearching: false)
private var emptyResultEmojis: [TelegramMediaFile] = []
private var stableEmptyResultEmoji: TelegramMediaFile?
private let stableEmptyResultEmojiDisposable = MetaDisposable()
private var horizontalExpandRecognizer: UIPanGestureRecognizer?
private var horizontalExpandStartLocation: CGPoint?
private var horizontalExpandDistance: CGFloat = 0.0
private var animateInInfo: (centerX: CGFloat, width: CGFloat)?
private var availableReactions: AvailableReactions?
private var availableReactionsDisposable: Disposable?
private var hasPremium: Bool?
private var hasPremiumDisposable: Disposable?
private var genericReactionEffectDisposable: Disposable?
private var genericReactionEffect: String?
private var isReactionSearchActive: Bool = false
public static func randomGenericReactionEffect(context: AccountContext) -> Signal<String?, NoError> {
return context.engine.stickers.loadedStickerPack(reference: .emojiGenericAnimations, forceActualized: false)
|> map { result -> [TelegramMediaFile]? in
switch result {
case let .result(_, items, _):
return items.map(\.file)
default:
return nil
}
}
|> filter { $0 != nil }
|> take(1)
|> mapToSignal { items -> Signal<String?, NoError> in
guard let items = items else {
return .single(nil)
}
guard let file = items.randomElement() else {
return .single(nil)
}
return Signal { subscriber in
let fetchDisposable = freeMediaFileInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file)).start()
let dataDisposable = (context.account.postbox.mediaBox.resourceData(file.resource)
|> filter(\.complete)
|> take(1)).start(next: { data in
subscriber.putNext(data.path)
subscriber.putCompletion()
})
return ActionDisposable {
fetchDisposable.dispose()
dataDisposable.dispose()
}
}
}
}
public init(context: AccountContext, animationCache: AnimationCache, presentationData: PresentationData, items: [ReactionContextItem], selectedItems: Set<MessageReaction.Reaction>, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?, isExpandedUpdated: @escaping (ContainedViewLayoutTransition) -> Void, requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateOverlayWantsToBeBelowKeyboard: @escaping (ContainedViewLayoutTransition) -> Void) {
self.context = context
self.presentationData = presentationData
self.items = items
self.selectedItems = selectedItems
self.getEmojiContent = getEmojiContent
self.isExpandedUpdated = isExpandedUpdated
self.requestLayout = requestLayout
self.requestUpdateOverlayWantsToBeBelowKeyboard = requestUpdateOverlayWantsToBeBelowKeyboard
self.animationCache = animationCache
self.animationRenderer = MultiAnimationRendererImpl()
self.backgroundMaskNode = ASDisplayNode()
self.backgroundNode = ReactionContextBackgroundNode(largeCircleSize: largeCircleSize, smallCircleSize: smallCircleSize, maskNode: self.backgroundMaskNode)
self.leftBackgroundMaskNode = ASDisplayNode()
self.leftBackgroundMaskNode.backgroundColor = .black
self.rightBackgroundMaskNode = ASDisplayNode()
self.rightBackgroundMaskNode.backgroundColor = .black
self.backgroundMaskNode.addSubnode(self.leftBackgroundMaskNode)
self.backgroundMaskNode.addSubnode(self.rightBackgroundMaskNode)
self.mirrorContentScrollView = UIView()
self.mirrorContentScrollView.isUserInteractionEnabled = false
self.scrollNode = ContentScrollNode(mirrorView: self.mirrorContentScrollView)
self.scrollNode.view.disablesInteractiveTransitionGestureRecognizer = true
self.scrollNode.view.showsVerticalScrollIndicator = false
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.scrollsToTop = false
self.scrollNode.view.delaysContentTouches = false
self.scrollNode.view.canCancelContentTouches = true
self.scrollNode.clipsToBounds = false
if #available(iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
self.previewingItemContainer = ASDisplayNode()
self.previewingItemContainer.isUserInteractionEnabled = false
self.contentContainer = ASDisplayNode()
self.contentContainer.clipsToBounds = true
self.contentContainer.addSubnode(self.scrollNode)
self.contentTintContainer = ASDisplayNode()
self.contentTintContainer.clipsToBounds = true
self.contentTintContainer.isUserInteractionEnabled = false
self.contentTintContainer.view.addSubview(self.mirrorContentScrollView)
self.contentContainerMask = UIImageView()
self.contentContainerMask.image = generateImage(CGSize(width: 46.0, height: 46.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0, y: 1.1)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
let shadowColor = UIColor.black
let stepCount = 10
var colors: [CGColor] = []
var locations: [CGFloat] = []
for i in 0 ... stepCount {
let t = CGFloat(i) / CGFloat(stepCount)
colors.append(shadowColor.withAlphaComponent(t).cgColor)
locations.append(t)
}
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colors as CFArray, locations: &locations)!
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
let gradientWidth = 6.0
context.drawRadialGradient(gradient, startCenter: center, startRadius: size.width / 2.0, endCenter: center, endRadius: size.width / 2.0 - gradientWidth, options: [])
context.setFillColor(shadowColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: gradientWidth - 1.0, dy: gradientWidth - 1.0))
})?.stretchableImage(withLeftCapWidth: Int(46.0 / 2.0), topCapHeight: Int(46.0 / 2.0))
if self.getEmojiContent == nil {
self.contentContainer.view.mask = self.contentContainerMask
}
if getEmojiContent != nil {
let expandItemView = ExpandItemView()
self.expandItemView = expandItemView
self.contentContainer.view.addSubview(expandItemView)
self.contentTintContainer.view.addSubview(expandItemView.tintView)
} else {
self.expandItemView = nil
}
super.init()
self.addSubnode(self.backgroundNode)
self.scrollNode.view.delegate = self
self.addSubnode(self.contentContainer)
self.addSubnode(self.previewingItemContainer)
self.availableReactionsDisposable = (context.engine.stickers.availableReactions()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] availableReactions in
guard let strongSelf = self else {
return
}
strongSelf.availableReactions = availableReactions
})
self.hasPremiumDisposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let strongSelf = self else {
return
}
strongSelf.hasPremium = peer?.isPremium ?? false
})
if let getEmojiContent = getEmojiContent {
let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks)
self.stableEmptyResultEmojiDisposable.set((self.context.account.postbox.combinedView(keys: [viewKey])
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] views in
guard let strongSelf = self, let view = views.views[viewKey] as? OrderedItemListView else {
return
}
var filteredFiles: [TelegramMediaFile] = []
let filterList: [String] = ["😖", "😫", "🫠", "😨", ""]
for featuredEmojiPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) {
for item in featuredEmojiPack.topItems {
for attribute in item.file.attributes {
switch attribute {
case let .CustomEmoji(_, _, alt, _):
if filterList.contains(alt) {
filteredFiles.append(item.file)
}
default:
break
}
}
}
}
strongSelf.emptyResultEmojis = filteredFiles
}))
self.emojiContentDisposable = combineLatest(queue: .mainQueue(),
getEmojiContent(self.animationCache, self.animationRenderer),
self.emojiSearchState.get()
).start(next: { [weak self] emojiContent, emojiSearchState in
guard let strongSelf = self else {
return
}
var emojiContent = emojiContent
if let emojiSearchResult = emojiSearchState.result {
var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults?
if !emojiSearchResult.groups.contains(where: { !$0.items.isEmpty }) {
if strongSelf.stableEmptyResultEmoji == nil {
strongSelf.stableEmptyResultEmoji = strongSelf.emptyResultEmojis.randomElement()
}
emptySearchResults = EmojiPagerContentComponent.EmptySearchResults(
text: strongSelf.presentationData.strings.EmojiSearch_SearchReactionsEmptyResult,
iconFile: strongSelf.stableEmptyResultEmoji
)
} else {
strongSelf.stableEmptyResultEmoji = nil
}
emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : .active)
} else {
strongSelf.stableEmptyResultEmoji = nil
}
strongSelf.emojiContent = emojiContent
if !strongSelf.canBeExpanded {
strongSelf.canBeExpanded = true
let horizontalExpandRecognizer = UIPanGestureRecognizer(target: strongSelf, action: #selector(strongSelf.horizontalExpandGesture(_:)))
strongSelf.view.addGestureRecognizer(horizontalExpandRecognizer)
strongSelf.horizontalExpandRecognizer = horizontalExpandRecognizer
}
strongSelf.updateEmojiContent(emojiContent)
if let reactionSelectionComponentHost = strongSelf.reactionSelectionComponentHost, let componentView = reactionSelectionComponentHost.view {
var emojiTransition: Transition = .immediate
if let scheduledEmojiContentAnimationHint = strongSelf.scheduledEmojiContentAnimationHint {
strongSelf.scheduledEmojiContentAnimationHint = nil
let contentAnimation = scheduledEmojiContentAnimationHint
emojiTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(contentAnimation)
}
let _ = reactionSelectionComponentHost.update(
transition: emojiTransition,
component: AnyComponent(EmojiStatusSelectionComponent(
theme: strongSelf.presentationData.theme,
strings: strongSelf.presentationData.strings,
deviceMetrics: DeviceMetrics.iPhone13,
emojiContent: emojiContent,
backgroundColor: .clear,
separatorColor: strongSelf.presentationData.theme.list.itemPlainSeparatorColor.withMultipliedAlpha(0.5),
hideTopPanel: strongSelf.isReactionSearchActive,
hideTopPanelUpdated: { hideTopPanel, transition in
guard let strongSelf = self else {
return
}
strongSelf.isReactionSearchActive = hideTopPanel
strongSelf.requestLayout(transition.containedViewLayoutTransition)
}
)),
environment: {},
containerSize: CGSize(width: componentView.bounds.width, height: 300.0)
)
}
})
}
self.genericReactionEffectDisposable = (ReactionContextNode.randomGenericReactionEffect(context: context)
|> deliverOnMainQueue).start(next: { [weak self] path in
self?.genericReactionEffect = path
})
}
deinit {
self.emojiContentDisposable?.dispose()
self.availableReactionsDisposable?.dispose()
self.hasPremiumDisposable?.dispose()
self.genericReactionEffectDisposable?.dispose()
self.emojiSearchDisposable.dispose()
self.stableEmptyResultEmojiDisposable.dispose()
}
override public func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:)))
longPressRecognizer.minimumPressDuration = 0.2
self.longPressRecognizer = longPressRecognizer
self.view.addGestureRecognizer(longPressRecognizer)
}
@objc private func horizontalExpandGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
self.horizontalExpandStartLocation = recognizer.location(in: self.view)
case .changed:
if let horizontalExpandStartLocation = self.horizontalExpandStartLocation {
let currentLocation = recognizer.location(in: self.view)
let distance = -min(0.0, currentLocation.x - horizontalExpandStartLocation.x)
self.horizontalExpandDistance = distance
let maxCompressionDistance: CGFloat = 100.0
var compressionFactor: CGFloat = max(0.0, min(1.0, self.horizontalExpandDistance / maxCompressionDistance))
compressionFactor = compressionFactor * compressionFactor
if compressionFactor >= 0.95 {
self.horizontalExpandStartLocation = nil
self.expand()
} else {
self.extensionDistance = 20.0 * compressionFactor
self.visibleExtensionDistance = self.extensionDistance
self.requestLayout(.immediate)
}
}
case .cancelled, .ended:
if let _ = self.horizontalExpandStartLocation, self.horizontalExpandDistance != 0.0 {
if self.horizontalExpandDistance >= 90.0 {
self.expand()
} else {
self.horizontalExpandDistance = 0.0
self.extensionDistance = 0.0
self.visibleExtensionDistance = 0.0
self.requestLayout(.animated(duration: 0.4, curve: .spring))
}
}
default:
break
}
}
public func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, isCoveredByInput: Bool, isAnimatingOut: Bool, transition: ContainedViewLayoutTransition) {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: isAnimatingOut, transition: transition, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil)
}
public func updateIsIntersectingContent(isIntersectingContent: Bool, transition: ContainedViewLayoutTransition) {
self.backgroundNode.updateIsIntersectingContent(isIntersectingContent: isIntersectingContent, transition: transition)
}
public func updateExtension(distance: CGFloat) {
if self.extensionDistance != distance {
self.extensionDistance = distance
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil)
}
}
}
public func wantsDisplayBelowKeyboard() -> Bool {
if let emojiView = self.reactionSelectionComponentHost?.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("emoji"))) as? EmojiPagerContentComponent.View {
return emojiView.wantsDisplayBelowKeyboard()
} else {
return false
}
}
private func calculateBackgroundFrame(containerSize: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, contentSize: CGSize) -> (backgroundFrame: CGRect, visualBackgroundFrame: CGRect, isLeftAligned: Bool, cloudSourcePoint: CGFloat) {
var contentSize = contentSize
contentSize.width = max(46.0, contentSize.width)
contentSize.height = self.currentContentHeight
let sideInset: CGFloat = 11.0 + insets.left
let backgroundOffset: CGPoint = CGPoint(x: 22.0, y: -7.0)
var rect: CGRect
let isLeftAligned: Bool
if anchorRect.minX < containerSize.width - anchorRect.maxX {
rect = CGRect(origin: CGPoint(x: anchorRect.maxX - contentSize.width + backgroundOffset.x, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize)
isLeftAligned = true
} else {
rect = CGRect(origin: CGPoint(x: anchorRect.minX - backgroundOffset.x - 4.0, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize)
isLeftAligned = false
}
rect.origin.x = max(sideInset, rect.origin.x)
rect.origin.y = max(insets.top + sideInset, rect.origin.y)
rect.origin.x = min(containerSize.width - contentSize.width - sideInset, rect.origin.x)
let rightEdge = containerSize.width - sideInset
if rect.maxX > rightEdge {
rect.origin.x = containerSize.width - sideInset - rect.width
}
if rect.minX < sideInset {
rect.origin.x = sideInset
}
let cloudSourcePoint: CGFloat
if isLeftAligned {
cloudSourcePoint = min(rect.maxX - 46.0 / 2.0, anchorRect.maxX - 4.0)
} else {
cloudSourcePoint = max(rect.minX + 46.0 / 2.0, anchorRect.minX)
}
var visualRect = rect
visualRect.size.height += self.extensionDistance
return (rect, visualRect, isLeftAligned, cloudSourcePoint)
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.updateScrolling(transition: .immediate)
}
private func updateScrolling(transition: ContainedViewLayoutTransition) {
guard let itemLayout = self.itemLayout else {
return
}
let sideInset: CGFloat = 6.0
let itemSpacing: CGFloat = 8.0
let itemSize: CGFloat = itemLayout.itemSize
let containerHeight: CGFloat = 46.0
var contentHeight: CGFloat = containerHeight
if self.highlightedReaction != nil {
contentHeight = floor(contentHeight * 0.9)
}
let totalVisibleCount: CGFloat = CGFloat(min(7, self.items.count))
let totalVisibleWidth: CGFloat = totalVisibleCount * itemSize + (totalVisibleCount - 1.0) * itemSpacing
let selectedItemSize = floor(itemSize * 1.5)
let remainingVisibleWidth = totalVisibleWidth - selectedItemSize
let remainingVisibleCount = totalVisibleCount - 1.0
let remainingItemSize = floor((remainingVisibleWidth - (remainingVisibleCount - 1.0) * itemSpacing) / remainingVisibleCount)
var visibleBounds = self.scrollNode.view.bounds
self.previewingItemContainer.bounds = visibleBounds
if self.highlightedReaction != nil {
visibleBounds = visibleBounds.insetBy(dx: remainingItemSize - selectedItemSize, dy: 0.0)
}
let appearBounds = visibleBounds.insetBy(dx: 16.0, dy: 0.0)
let highlightedReactionIndex: Int?
if let highlightedReaction = self.highlightedReaction {
highlightedReactionIndex = self.items.firstIndex(where: { $0.reaction == highlightedReaction })
} else {
highlightedReactionIndex = nil
}
var currentMaskFrame: CGRect?
var maskTransition: ContainedViewLayoutTransition?
let maxCompressionDistance: CGFloat = 100.0
let compressionFactor: CGFloat = max(0.0, min(1.0, self.horizontalExpandDistance / maxCompressionDistance))
let minItemSpacing: CGFloat = 2.0
let effectiveItemSpacing: CGFloat = minItemSpacing + (1.0 - compressionFactor) * (itemSpacing - minItemSpacing)
var topVisibleItems: Int
if self.getEmojiContent != nil {
topVisibleItems = min(self.items.count, itemLayout.visibleItemCount)
} else {
topVisibleItems = self.items.count
}
var loopIdle = false
for i in 0 ..< min(self.items.count, itemLayout.visibleItemCount) {
if let reaction = self.items[i].reaction {
switch reaction.rawValue {
case .builtin:
break
case .custom:
loopIdle = true
}
}
}
#if DEBUG
loopIdle = true
#endif
var validIndices = Set<Int>()
var nextX: CGFloat = sideInset
for i in 0 ..< self.items.count {
var currentItemSize = itemSize
if let highlightedReactionIndex = highlightedReactionIndex {
if highlightedReactionIndex == i {
currentItemSize = selectedItemSize
} else {
currentItemSize = remainingItemSize
}
}
var baseItemFrame = CGRect(origin: CGPoint(x: nextX, y: containerHeight - contentHeight + floor((contentHeight - currentItemSize) / 2.0)), size: CGSize(width: currentItemSize, height: currentItemSize))
if highlightedReactionIndex == i {
let updatedSize = floor(itemSize * 2.0)
baseItemFrame = baseItemFrame.insetBy(dx: (baseItemFrame.width - updatedSize) / 2.0, dy: (baseItemFrame.height - updatedSize) / 2.0)
baseItemFrame.origin.y = containerHeight - contentHeight + floor((contentHeight - itemSize) / 2.0) + itemSize + 4.0 - updatedSize
}
nextX += currentItemSize + effectiveItemSpacing
if i >= topVisibleItems {
if let itemNode = self.visibleItemNodes[i] {
self.visibleItemNodes.removeValue(forKey: i)
if self.disappearingVisibleItemNodes[i] == nil {
self.disappearingVisibleItemNodes[i] = itemNode
itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak self, weak itemNode] _ in
guard let strongSelf = self, let itemNode = itemNode else {
return
}
itemNode.removeFromSupernode()
if strongSelf.disappearingVisibleItemNodes[i] === itemNode {
strongSelf.disappearingVisibleItemNodes.removeValue(forKey: i)
}
})
itemNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.1, removeOnCompletion: false)
}
}
}
if i >= topVisibleItems {
if let itemNode = self.disappearingVisibleItemNodes[i] {
transition.updatePosition(node: itemNode, position: baseItemFrame.center, beginWithCurrentState: true)
}
break
}
if appearBounds.intersects(baseItemFrame) || (self.visibleItemNodes[i] != nil && visibleBounds.intersects(baseItemFrame)) {
validIndices.insert(i)
var itemFrame = baseItemFrame
var selectionItemFrame = itemFrame
let normalItemScale: CGFloat = 1.0
var isPreviewing = false
if let highlightedReaction = self.highlightedReaction, highlightedReaction == self.items[i].reaction {
isPreviewing = true
}
if let reaction = self.items[i].reaction, self.selectedItems.contains(reaction.rawValue), !isPreviewing {
itemFrame = itemFrame.insetBy(dx: (itemFrame.width - 0.8 * itemFrame.width) * 0.5, dy: (itemFrame.height - 0.8 * itemFrame.height) * 0.5)
}
var animateIn = false
let maskNode: ASDisplayNode?
let itemNode: ReactionItemNode
var itemTransition = transition
if let current = self.visibleItemNodes[i] {
itemNode = current
maskNode = self.visibleItemMaskNodes[i]
} else {
animateIn = self.didAnimateIn
itemTransition = .immediate
if case let .reaction(item) = self.items[i] {
itemNode = ReactionNode(context: self.context, theme: self.presentationData.theme, item: item, animationCache: self.animationCache, animationRenderer: self.animationRenderer, loopIdle: loopIdle)
maskNode = nil
} else {
itemNode = PremiumReactionsNode(theme: self.presentationData.theme)
maskNode = itemNode.maskNode
}
self.visibleItemNodes[i] = itemNode
self.scrollNode.addSubnode(itemNode)
if let itemNode = itemNode as? ReactionNode {
if let reaction = self.items[i].reaction, self.selectedItems.contains(reaction.rawValue) {
self.mirrorContentScrollView.addSubview(itemNode.selectionTintView)
self.scrollNode.view.addSubview(itemNode.selectionView)
}
}
if let maskNode = maskNode {
self.visibleItemMaskNodes[i] = maskNode
self.backgroundMaskNode.addSubnode(maskNode)
}
}
maskTransition = itemTransition
if let maskNode = maskNode {
let maskFrame = CGRect(origin: CGPoint(x: -self.scrollNode.view.contentOffset.x + itemFrame.minX, y: 0.0), size: CGSize(width: itemFrame.width, height: itemFrame.height + 12.0))
itemTransition.updateFrame(node: maskNode, frame: maskFrame)
currentMaskFrame = maskFrame
}
if let reaction = self.items[i].reaction, case .custom = reaction.rawValue, self.selectedItems.contains(reaction.rawValue) {
itemNode.layer.masksToBounds = true
itemNode.layer.cornerRadius = 12.0
} else {
itemNode.layer.masksToBounds = false
itemNode.layer.cornerRadius = 0.0
}
if !itemNode.isExtracted {
if isPreviewing {
if itemNode.supernode !== self.previewingItemContainer {
self.previewingItemContainer.addSubnode(itemNode)
}
}
if self.getEmojiContent != nil && i == itemLayout.visibleItemCount - 1 {
itemFrame.origin.x -= (1.0 - compressionFactor) * selectionItemFrame.width * 0.5
selectionItemFrame.origin.x -= (1.0 - compressionFactor) * selectionItemFrame.width * 0.5
itemNode.isUserInteractionEnabled = false
} else {
itemNode.isUserInteractionEnabled = true
}
itemTransition.updateFrame(node: itemNode, frame: itemFrame, beginWithCurrentState: true, completion: { [weak self, weak itemNode] completed in
guard let strongSelf = self, let itemNode = itemNode else {
return
}
if !completed {
return
}
if !isPreviewing {
if itemNode.supernode !== strongSelf.scrollNode {
strongSelf.scrollNode.addSubnode(itemNode)
}
}
})
itemNode.updateLayout(size: itemFrame.size, isExpanded: false, largeExpanded: false, isPreviewing: isPreviewing, transition: itemTransition)
if let itemNode = itemNode as? ReactionNode {
if let reaction = self.items[i].reaction, self.selectedItems.contains(reaction.rawValue) {
itemNode.selectionTintView.isHidden = false
itemNode.selectionView.isHidden = false
}
itemTransition.updatePosition(layer: itemNode.selectionTintView.layer, position: selectionItemFrame.center)
itemTransition.updateBounds(layer: itemNode.selectionTintView.layer, bounds: CGRect(origin: CGPoint(), size: selectionItemFrame.size))
itemTransition.updateCornerRadius(layer: itemNode.selectionTintView.layer, cornerRadius: min(selectionItemFrame.width, selectionItemFrame.height) / 2.0)
itemTransition.updatePosition(layer: itemNode.selectionView.layer, position: selectionItemFrame.center)
itemTransition.updateBounds(layer: itemNode.selectionView.layer, bounds: CGRect(origin: CGPoint(), size: selectionItemFrame.size))
itemTransition.updateCornerRadius(layer: itemNode.selectionView.layer, cornerRadius: min(selectionItemFrame.width, selectionItemFrame.height) / 2.0)
}
if animateIn {
itemNode.appear(animated: !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion)
}
if self.getEmojiContent != nil, i == itemLayout.visibleItemCount - 1, let itemNode = itemNode as? ReactionNode {
let itemScale: CGFloat = 0.001 * (1.0 - compressionFactor) + normalItemScale * compressionFactor
transition.updateSublayerTransformScale(node: itemNode, scale: itemScale)
transition.updateTransformScale(layer: itemNode.selectionView.layer, scale: CGPoint(x: itemScale, y: itemScale))
transition.updateTransformScale(layer: itemNode.selectionTintView.layer, scale: CGPoint(x: itemScale, y: itemScale))
let alphaFraction = min(compressionFactor, 0.2) / 0.2
transition.updateAlpha(node: itemNode, alpha: alphaFraction)
transition.updateAlpha(layer: itemNode.selectionView.layer, alpha: alphaFraction)
transition.updateAlpha(layer: itemNode.selectionTintView.layer, alpha: alphaFraction)
} else {
transition.updateSublayerTransformScale(node: itemNode, scale: normalItemScale)
if let itemNode = itemNode as? ReactionNode {
transition.updateSublayerTransformScale(layer: itemNode.selectionView.layer, scale: CGPoint(x: normalItemScale, y: normalItemScale))
transition.updateSublayerTransformScale(layer: itemNode.selectionTintView.layer, scale: CGPoint(x: normalItemScale, y: normalItemScale))
}
}
}
}
}
if let expandItemView = self.expandItemView {
let expandItemSize: CGFloat
let expandTintOffset: CGFloat
if self.highlightedReaction != nil {
expandItemSize = floor(30.0 * 0.9)
expandTintOffset = contentHeight - containerHeight
} else {
expandItemSize = 30.0
expandTintOffset = 0.0
}
let baseNextFrame = CGRect(origin: CGPoint(x: self.scrollNode.view.bounds.width - expandItemSize - 9.0, y: containerHeight - contentHeight + floor((contentHeight - expandItemSize) / 2.0) + (self.isExpanded ? (46.0 + 54.0 - 4.0) : 0.0)), size: CGSize(width: expandItemSize, height: expandItemSize + self.extensionDistance))
transition.updateFrame(view: expandItemView, frame: baseNextFrame)
transition.updateFrame(view: expandItemView.tintView, frame: baseNextFrame.offsetBy(dx: 0.0, dy: expandTintOffset))
expandItemView.update(size: baseNextFrame.size, transition: transition)
}
if let currentMaskFrame = currentMaskFrame {
let transition = maskTransition ?? transition
transition.updateFrame(node: self.leftBackgroundMaskNode, frame: CGRect(x: -1000.0 + currentMaskFrame.minX, y: 0.0, width: 1000.0, height: self.currentContentHeight + self.extensionDistance))
transition.updateFrame(node: self.rightBackgroundMaskNode, frame: CGRect(x: currentMaskFrame.maxX, y: 0.0, width: 1000.0, height: self.currentContentHeight + self.extensionDistance))
} else {
transition.updateFrame(node: self.leftBackgroundMaskNode, frame: CGRect(x: 0.0, y: 0.0, width: 1000.0, height: self.currentContentHeight + self.extensionDistance))
self.rightBackgroundMaskNode.frame = CGRect(origin: .zero, size: .zero)
}
var removedIndices: [Int] = []
for (index, itemNode) in self.visibleItemNodes {
if !validIndices.contains(index) {
removedIndices.append(index)
itemNode.removeFromSupernode()
}
}
for (index, maskNode) in self.visibleItemMaskNodes {
if !validIndices.contains(index) {
maskNode.removeFromSupernode()
}
}
for index in removedIndices {
self.visibleItemNodes.removeValue(forKey: index)
self.visibleItemMaskNodes.removeValue(forKey: index)
}
}
private func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, isCoveredByInput: Bool, isAnimatingOut: Bool, transition: ContainedViewLayoutTransition, animateInFromAnchorRect: CGRect?, animateOutToAnchorRect: CGRect?, animateReactionHighlight: Bool = false) {
if let expandItemView = self.expandItemView {
expandItemView.updateTheme(theme: self.presentationData.theme)
}
self.validLayout = (size, insets, anchorRect, isCoveredByInput)
let externalSideInset: CGFloat = 4.0
let sideInset: CGFloat = 6.0
let itemSpacing: CGFloat = 8.0
var itemSize: CGFloat = 36.0
let verticalInset: CGFloat = 13.0
let rowHeight: CGFloat = 30.0
var itemCount: Int
var visibleContentWidth: CGFloat
var completeContentWidth: CGFloat
if self.getEmojiContent != nil {
let totalItemSlotCount = self.items.count + 1
var maxRowItemCount = Int(floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing) / (itemSize + itemSpacing)))
if maxRowItemCount < 8 {
itemSize = floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing - 8 * itemSpacing) / 8.0)
maxRowItemCount = Int(floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing) / (itemSize + itemSpacing)))
}
maxRowItemCount = min(maxRowItemCount, 8)
itemCount = min(totalItemSlotCount, maxRowItemCount)
if self.isExpanded {
itemCount = maxRowItemCount
}
let minVisibleItemCount: CGFloat = CGFloat(itemCount)
completeContentWidth = CGFloat(itemCount) * itemSize + (CGFloat(itemCount) - 1.0) * itemSpacing + sideInset * 2.0
visibleContentWidth = floor(minVisibleItemCount * itemSize + (minVisibleItemCount - 1.0) * itemSpacing + sideInset * 2.0)
if visibleContentWidth > size.width - sideInset * 2.0 {
visibleContentWidth = size.width - sideInset * 2.0
}
} else {
itemCount = self.items.count
completeContentWidth = floor(CGFloat(itemCount) * itemSize + (CGFloat(itemCount) - 1.0) * itemSpacing + sideInset * 2.0)
let minVisibleItemCount = min(CGFloat(self.items.count), 6.5)
visibleContentWidth = floor(minVisibleItemCount * itemSize + (minVisibleItemCount - 1.0) * itemSpacing + sideInset * 2.0)
if visibleContentWidth > size.width - sideInset * 2.0 {
visibleContentWidth = size.width - sideInset * 2.0
}
}
let contentHeight = verticalInset * 2.0 + rowHeight
var backgroundInsets = insets
backgroundInsets.left += sideInset
backgroundInsets.right += sideInset
let (actualBackgroundFrame, visualBackgroundFrame, isLeftAligned, cloudSourcePoint) = self.calculateBackgroundFrame(containerSize: CGSize(width: size.width, height: size.height), insets: backgroundInsets, anchorRect: anchorRect, contentSize: CGSize(width: visibleContentWidth, height: contentHeight))
self.isLeftAligned = isLeftAligned
self.itemLayout = ItemLayout(
itemSize: itemSize,
visibleItemCount: itemCount
)
var scrollFrame = CGRect(origin: CGPoint(x: 0.0, y: self.isExpanded ? (46.0 + 54.0 - 4.0) : 0.0), size: actualBackgroundFrame.size)
scrollFrame.origin.y += floorToScreenPixels(self.extensionDistance / 2.0)
transition.updateFrame(node: self.contentContainer, frame: visualBackgroundFrame, beginWithCurrentState: true)
transition.updateFrame(node: self.contentTintContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: visualBackgroundFrame.size), beginWithCurrentState: true)
transition.updateFrame(view: self.contentContainerMask, frame: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size), beginWithCurrentState: true)
transition.updateFrame(node: self.scrollNode, frame: scrollFrame, beginWithCurrentState: true)
transition.updateFrame(node: self.previewingItemContainer, frame: visualBackgroundFrame, beginWithCurrentState: true)
self.scrollNode.view.contentSize = CGSize(width: completeContentWidth, height: scrollFrame.size.height)
self.updateScrolling(transition: transition)
self.emojiContentLayout = EmojiPagerContentComponent.CustomLayout(
itemsPerRow: itemCount,
itemSize: itemSize,
sideInset: sideInset,
itemSpacing: itemSpacing
)
if (self.isExpanded || self.reactionSelectionComponentHost != nil), let _ = self.getEmojiContent {
let reactionSelectionComponentHost: ComponentView<Empty>
var componentTransition = Transition(transition)
if let current = self.reactionSelectionComponentHost {
reactionSelectionComponentHost = current
} else {
componentTransition = .immediate
reactionSelectionComponentHost = ComponentView<Empty>()
self.reactionSelectionComponentHost = reactionSelectionComponentHost
}
if let emojiContent = self.emojiContent {
self.updateEmojiContent(emojiContent)
if let scheduledEmojiContentAnimationHint = self.scheduledEmojiContentAnimationHint {
self.scheduledEmojiContentAnimationHint = nil
let contentAnimation = scheduledEmojiContentAnimationHint
componentTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(contentAnimation)
}
let _ = reactionSelectionComponentHost.update(
transition: componentTransition,
component: AnyComponent(EmojiStatusSelectionComponent(
theme: self.presentationData.theme,
strings: self.presentationData.strings,
deviceMetrics: DeviceMetrics.iPhone13,
emojiContent: emojiContent,
backgroundColor: .clear,
separatorColor: self.presentationData.theme.list.itemPlainSeparatorColor.withMultipliedAlpha(0.5),
hideTopPanel: self.isReactionSearchActive,
hideTopPanelUpdated: { [weak self] hideTopPanel, transition in
guard let strongSelf = self else {
return
}
strongSelf.isReactionSearchActive = hideTopPanel
strongSelf.requestLayout(transition.containedViewLayoutTransition)
}
)),
environment: {},
containerSize: CGSize(width: actualBackgroundFrame.width, height: 300.0)
)
if let componentView = reactionSelectionComponentHost.view {
var animateIn = false
if componentView.superview == nil {
componentView.layer.cornerRadius = 26.0
componentView.clipsToBounds = true
self.contentContainer.view.insertSubview(componentView, belowSubview: self.scrollNode.view)
self.contentContainer.view.mask = nil
for (_, itemNode) in self.visibleItemNodes {
itemNode.isHidden = true
if let itemNode = itemNode as? ReactionNode {
itemNode.selectionView.isHidden = true
itemNode.selectionTintView.isHidden = true
}
}
if let emojiView = reactionSelectionComponentHost.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("emoji"))) as? EmojiPagerContentComponent.View {
var initialPositionAndFrame: [MediaId: (frame: CGRect, cornerRadius: CGFloat, frameIndex: Int, placeholder: UIImage)] = [:]
for (_, itemNode) in self.visibleItemNodes {
guard let itemNode = itemNode as? ReactionNode else {
continue
}
guard let placeholder = itemNode.currentFrameImage else {
continue
}
if itemNode.alpha.isZero {
continue
}
initialPositionAndFrame[itemNode.item.stillAnimation.fileId] = (
frame: itemNode.frame,
cornerRadius: itemNode.layer.cornerRadius,
frameIndex: itemNode.currentFrameIndex,
placeholder: placeholder
)
}
emojiView.animateInReactionSelection(sourceItems: initialPositionAndFrame)
if let mirrorContentClippingView = emojiView.mirrorContentClippingView {
mirrorContentClippingView.clipsToBounds = false
Transition(transition).animateBoundsOrigin(view: mirrorContentClippingView, from: CGPoint(x: 0.0, y: 46.0 + 54.0 - 4.0), to: CGPoint(), additive: true, completion: { [weak mirrorContentClippingView] _ in
mirrorContentClippingView?.clipsToBounds = true
})
}
}
if let topPanelView = reactionSelectionComponentHost.findTaggedView(tag: EntityKeyboardTopPanelComponent.Tag(id: AnyHashable("emoji"))) as? EntityKeyboardTopPanelComponent.View {
topPanelView.animateIn()
}
if let expandItemView = self.expandItemView {
expandItemView.alpha = 0.0
expandItemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.scrollNode.isHidden = true
strongSelf.mirrorContentScrollView.isHidden = true
})
expandItemView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
expandItemView.tintView.alpha = 0.0
expandItemView.tintView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
expandItemView.tintView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
animateIn = true
}
let componentFrame = CGRect(origin: CGPoint(), size: actualBackgroundFrame.size)
componentTransition.setFrame(view: componentView, frame: CGRect(origin: componentFrame.origin, size: CGSize(width: componentFrame.width, height: componentFrame.height)))
if animateIn {
transition.animatePositionAdditive(layer: componentView.layer, offset: CGPoint(x: 0.0, y: -(46.0 + 54.0 - 4.0) + floorToScreenPixels(self.animateFromExtensionDistance / 2.0)))
}
}
}
}
transition.updateFrame(node: self.backgroundNode, frame: visualBackgroundFrame, beginWithCurrentState: true)
self.backgroundNode.update(
theme: self.presentationData.theme,
size: visualBackgroundFrame.size,
cloudSourcePoint: cloudSourcePoint - visualBackgroundFrame.minX,
isLeftAligned: isLeftAligned,
isMinimized: self.highlightedReaction != nil && !self.highlightedByHover,
isCoveredByInput: isCoveredByInput,
transition: transition
)
if let vibrancyEffectView = self.backgroundNode.vibrancyEffectView {
if self.contentTintContainer.view.superview !== vibrancyEffectView.contentView {
vibrancyEffectView.contentView.addSubview(self.contentTintContainer.view)
}
}
if let animateInFromAnchorRect = animateInFromAnchorRect {
let springDuration: Double = 0.5
let springDamping: CGFloat = 104.0
let springScaleDelay: Double = 0.1
let springDelay: Double = springScaleDelay + 0.01
let sourceBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: backgroundInsets, anchorRect: animateInFromAnchorRect, contentSize: CGSize(width: visualBackgroundFrame.height, height: contentHeight)).0
self.backgroundNode.animateInFromAnchorRect(size: visualBackgroundFrame.size, sourceBackgroundFrame: sourceBackgroundFrame.offsetBy(dx: -visualBackgroundFrame.minX, dy: -visualBackgroundFrame.minY))
self.animateInInfo = (sourceBackgroundFrame.minX - visualBackgroundFrame.minX, visualBackgroundFrame.width)
self.contentContainer.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: sourceBackgroundFrame.midX - visualBackgroundFrame.midX, y: 0.0)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping, additive: true)
self.contentContainer.layer.animateSpring(from: NSValue(cgRect: CGRect(origin: CGPoint(x: (sourceBackgroundFrame.minX - visualBackgroundFrame.minX), y: 0.0), size: sourceBackgroundFrame.size)), to: NSValue(cgRect: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size)), keyPath: "bounds", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping)
self.contentTintContainer.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: sourceBackgroundFrame.midX - visualBackgroundFrame.midX, y: 0.0)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping, additive: true)
self.contentTintContainer.layer.animateSpring(from: NSValue(cgRect: CGRect(origin: CGPoint(x: (sourceBackgroundFrame.minX - visualBackgroundFrame.minX), y: 0.0), size: sourceBackgroundFrame.size)), to: NSValue(cgRect: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size)), keyPath: "bounds", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping)
} else if let animateOutToAnchorRect = animateOutToAnchorRect {
let targetBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: backgroundInsets, anchorRect: animateOutToAnchorRect, contentSize: CGSize(width: visibleContentWidth, height: contentHeight)).0
let offset = CGPoint(x: -(targetBackgroundFrame.minX - visualBackgroundFrame.minX), y: -(targetBackgroundFrame.minY - visualBackgroundFrame.minY))
self.position = CGPoint(x: self.position.x - offset.x, y: self.position.y - offset.y)
self.layer.animatePosition(from: offset, to: CGPoint(), duration: 0.2, removeOnCompletion: true, additive: true)
}
}
private func updateEmojiContent(_ emojiContent: EmojiPagerContentComponent) {
guard let emojiContentLayout = self.emojiContentLayout else {
return
}
emojiContent.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction(
performItemAction: { [weak self] groupId, item, sourceView, sourceRect, sourceLayer, isLongPress in
guard let strongSelf = self, let availableReactions = strongSelf.availableReactions, let itemFile = item.itemFile else {
return
}
strongSelf.didTriggerExpandedReaction = isLongPress
var found = false
for reaction in availableReactions.reactions {
guard let centerAnimation = reaction.centerAnimation, let aroundAnimation = reaction.aroundAnimation else {
continue
}
if reaction.selectAnimation.fileId == itemFile.fileId {
found = true
let updateReaction: UpdateMessageReaction
switch reaction.value {
case let .builtin(value):
updateReaction = .builtin(value)
case let .custom(fileId):
updateReaction = .custom(fileId: fileId, file: nil)
}
let reactionItem = ReactionItem(
reaction: ReactionItem.Reaction(rawValue: reaction.value),
appearAnimation: reaction.appearAnimation,
stillAnimation: reaction.selectAnimation,
listAnimation: centerAnimation,
largeListAnimation: reaction.activateAnimation,
applicationAnimation: aroundAnimation,
largeApplicationAnimation: reaction.effectAnimation,
isCustom: false
)
if case .custom = reactionItem.updateMessageReaction, let hasPremium = strongSelf.hasPremium, !hasPremium {
strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation)
} else {
strongSelf.customReactionSource = (sourceView, sourceRect, sourceLayer, reactionItem)
strongSelf.reactionSelected?(updateReaction, isLongPress)
}
break
}
}
if !found {
let reactionItem = ReactionItem(
reaction: ReactionItem.Reaction(rawValue: .custom(itemFile.fileId.id)),
appearAnimation: itemFile,
stillAnimation: itemFile,
listAnimation: itemFile,
largeListAnimation: itemFile,
applicationAnimation: nil,
largeApplicationAnimation: nil,
isCustom: true
)
strongSelf.customReactionSource = (sourceView, sourceRect, sourceLayer, reactionItem)
if case .custom = reactionItem.updateMessageReaction, let hasPremium = strongSelf.hasPremium, !hasPremium {
strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation)
} else {
strongSelf.reactionSelected?(reactionItem.updateMessageReaction, isLongPress)
}
}
},
deleteBackwards: {
},
openStickerSettings: {
},
openFeatured: {
},
openSearch: {
},
addGroupAction: { [weak self] groupId, isPremiumLocked, _ in
guard let strongSelf = self, let collectionId = groupId.base as? ItemCollectionId else {
return
}
if isPremiumLocked {
strongSelf.premiumReactionsSelected?(nil)
return
}
let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks)
let _ = (strongSelf.context.account.postbox.combinedView(keys: [viewKey])
|> take(1)
|> deliverOnMainQueue).start(next: { views in
guard let strongSelf = self, let view = views.views[viewKey] as? OrderedItemListView else {
return
}
for featuredEmojiPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) {
if featuredEmojiPack.info.id == collectionId {
if let strongSelf = self {
strongSelf.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupInstalled(id: collectionId, scrollToGroup: true))
}
let _ = strongSelf.context.engine.stickers.addStickerPackInteractively(info: featuredEmojiPack.info, items: featuredEmojiPack.topItems).start()
break
}
}
})
},
clearGroup: { [weak self] groupId in
guard let strongSelf = self else {
return
}
if groupId == AnyHashable("popular") {
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize))
var items: [ActionSheetItem] = []
let context = strongSelf.context
items.append(ActionSheetTextItem(title: presentationData.strings.Chat_ClearReactionsAlertText, parseMarkdown: true))
items.append(ActionSheetButtonItem(title: presentationData.strings.Chat_ClearReactionsAlertAction, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
guard let strongSelf = self else {
return
}
strongSelf.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupRemoved(id: "popular"))
let _ = strongSelf.context.engine.stickers.clearRecentlyUsedReactions().start()
}))
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet)
}
},
pushController: { _ in
},
presentController: { _ in
},
presentGlobalOverlayController: { _ in
},
navigationController: {
return nil
},
requestUpdate: { [weak self] transition in
guard let strongSelf = self else {
return
}
strongSelf.requestUpdateOverlayWantsToBeBelowKeyboard(transition.containedViewLayoutTransition)
},
updateSearchQuery: { [weak self] query in
guard let self else {
return
}
switch query {
case .none:
self.emojiSearchDisposable.set(nil)
self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false)))
case let .text(rawQuery, languageCode):
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
if query.isEmpty {
self.emojiSearchDisposable.set(nil)
self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false)))
} else {
let context = self.context
var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false)
if !languageCode.lowercased().hasPrefix("en") {
signal = signal
|> mapToSignal { keywords in
return .single(keywords)
|> then(
context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3)
|> map { englishKeywords in
return keywords + englishKeywords
}
)
}
}
let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}
|> distinctUntilChanged
let resultSignal = signal
|> mapToSignal { keywords -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
context.engine.stickers.availableReactions(),
hasPremium
)
|> take(1)
|> map { view, availableReactions, hasPremium -> [EmojiPagerContentComponent.ItemGroup] in
var result: [(String, TelegramMediaFile?, String)] = []
var allEmoticons: [String: String] = [:]
for keyword in keywords {
for emoticon in keyword.emoticons {
allEmoticons[emoticon] = keyword.keyword
}
}
for entry in view.entries {
guard let item = entry.item as? StickerPackItem else {
continue
}
for attribute in item.file.attributes {
switch attribute {
case let .CustomEmoji(_, _, alt, _):
if !item.file.isPremiumEmoji || hasPremium {
if !alt.isEmpty, let keyword = allEmoticons[alt] {
result.append((alt, item.file, keyword))
} else if alt == query {
result.append((alt, item.file, alt))
}
}
default:
break
}
}
}
var items: [EmojiPagerContentComponent.Item] = []
var existingIds = Set<MediaId>()
for item in result {
if let itemFile = item.1 {
if existingIds.contains(itemFile.fileId) {
continue
}
existingIds.insert(itemFile.fileId)
let animationData = EntityKeyboardAnimationData(file: itemFile)
let item = EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: itemFile, subgroupId: nil,
icon: .none,
tintMode: animationData.isTemplate ? .primary : .none
)
items.append(item)
}
}
return [EmojiPagerContentComponent.ItemGroup(
supergroupId: "search",
groupId: "search",
title: nil,
subtitle: nil,
actionButtonTitle: nil,
isFeatured: false,
isPremiumLocked: false,
isEmbedded: false,
hasClear: false,
collapsedLineCount: nil,
displayPremiumBadges: false,
headerItem: nil,
items: items
)]
}
}
var version = 0
self.emojiSearchStateValue.isSearching = true
self.emojiSearchDisposable.set((resultSignal
|> delay(0.15, queue: .mainQueue())
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false)
version += 1
}))
}
case let .category(value):
let resultSignal = self.context.engine.stickers.searchEmoji(emojiString: value)
|> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in
var items: [EmojiPagerContentComponent.Item] = []
var existingIds = Set<MediaId>()
for itemFile in files {
if existingIds.contains(itemFile.fileId) {
continue
}
existingIds.insert(itemFile.fileId)
let animationData = EntityKeyboardAnimationData(file: itemFile)
let item = EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: itemFile, subgroupId: nil,
icon: .none,
tintMode: animationData.isTemplate ? .primary : .none
)
items.append(item)
}
return .single(([EmojiPagerContentComponent.ItemGroup(
supergroupId: "search",
groupId: "search",
title: nil,
subtitle: nil,
actionButtonTitle: nil,
isFeatured: false,
isPremiumLocked: false,
isEmbedded: false,
hasClear: false,
collapsedLineCount: nil,
displayPremiumBadges: false,
headerItem: nil,
items: items
)], isFinalResult))
}
var version = 0
self.emojiSearchDisposable.set((resultSignal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
guard let group = result.items.first else {
return
}
if group.items.isEmpty && !result.isFinalResult {
self.emojiSearchStateValue.isSearching = true
return
}
self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value), version: version, isPreset: true), isSearching: false)
version += 1
}))
}
},
updateScrollingToItemGroup: {
},
onScroll: {},
chatPeerId: nil,
peekBehavior: nil,
customLayout: emojiContentLayout,
externalBackground: EmojiPagerContentComponent.ExternalBackground(
effectContainerView: self.backgroundNode.vibrancyEffectView?.contentView
),
externalExpansionView: self.view,
useOpaqueTheme: false,
hideBackground: false
)
}
public func animateIn(from sourceAnchorRect: CGRect) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: sourceAnchorRect, animateOutToAnchorRect: nil)
}
let mainCircleDelay: Double = 0.01
self.backgroundNode.animateIn()
self.didAnimateIn = true
if !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion {
for i in 0 ..< self.items.count {
guard let itemNode = self.visibleItemNodes[i] else {
continue
}
if let itemLayout = self.itemLayout, self.getEmojiContent != nil, i == itemLayout.visibleItemCount - 1 {
itemNode.appear(animated: false)
continue
}
let itemDelay: Double
if let animateInInfo = self.animateInInfo {
let distance = abs(itemNode.frame.center.x - animateInInfo.centerX)
let distanceNorm = distance / animateInInfo.width
let adjustedDistanceNorm = distanceNorm//listViewAnimationCurveSystem(distanceNorm)
itemDelay = mainCircleDelay + adjustedDistanceNorm * 0.3
} else {
itemDelay = mainCircleDelay + Double(i) * 0.06
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + itemDelay * UIView.animationDurationFactor(), execute: { [weak itemNode] in
guard let itemNode = itemNode else {
return
}
itemNode.appear(animated: true)
})
}
if let expandItemView = self.expandItemView {
let itemDelay: Double
if let animateInInfo = self.animateInInfo {
let distance = abs(expandItemView.frame.center.x - animateInInfo.centerX)
let distanceNorm = distance / animateInInfo.width
let adjustedDistanceNorm = distanceNorm//listViewAnimationCurveSystem(distanceNorm)
itemDelay = mainCircleDelay + adjustedDistanceNorm * 0.3
} else {
itemDelay = mainCircleDelay + Double(8) * 0.06
}
expandItemView.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, delay: itemDelay)
expandItemView.tintView.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, delay: itemDelay)
}
} else {
for i in 0 ..< self.items.count {
guard let itemNode = self.visibleItemNodes[i] else {
continue
}
itemNode.appear(animated: false)
}
}
}
public func animateOut(to targetAnchorRect: CGRect?, animatingOutToReaction: Bool) {
self.backgroundNode.animateOut()
for (_, itemNode) in self.visibleItemNodes {
if itemNode.isExtracted {
continue
}
itemNode.layer.animateAlpha(from: itemNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
if let reactionComponentView = self.reactionSelectionComponentHost?.view {
reactionComponentView.alpha = 0.0
reactionComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
if let expandItemView = self.expandItemView {
expandItemView.alpha = 0.0
expandItemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
expandItemView.tintView.alpha = 0.0
expandItemView.tintView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
if let targetAnchorRect = targetAnchorRect, let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: nil, animateOutToAnchorRect: targetAnchorRect)
}
}
private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) {
guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else {
completion()
return
}
//targetSnapshotView.layer.sublayers![0].backgroundColor = UIColor.green.cgColor
let sourceFrame = itemNode.view.convert(itemNode.bounds, to: self.view)
var selfTargetBounds = targetView.bounds
if let targetView = targetView as? ReactionIconView, let iconFrame = targetView.iconFrame, !"".isEmpty {
selfTargetBounds = iconFrame
}
/*if case .builtin = itemNode.item.reaction.rawValue {
selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5)
}*/
let targetFrame = self.view.convert(targetView.convert(selfTargetBounds, to: nil), from: nil)
targetSnapshotView.frame = targetFrame
//targetSnapshotView.backgroundColor = .blue
self.view.insertSubview(targetSnapshotView, belowSubview: itemNode.view)
var completedTarget = false
var targetScaleCompleted = false
let intermediateCompletion: () -> Void = {
if completedTarget && targetScaleCompleted {
completion()
}
}
let targetPosition = targetFrame.center
let duration: Double = 0.16
itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false)
//itemNode.layer.isHidden = true
/*targetView.alpha = 1.0
targetView.isHidden = false
if let targetView = targetView as? ReactionIconView {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
}*/
itemNode.layer.animatePosition(from: itemNode.layer.position, to: targetPosition, duration: duration, removeOnCompletion: false)
targetSnapshotView.alpha = 1.0
targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8)
targetSnapshotView.layer.animatePosition(from: sourceFrame.center, to: targetPosition, duration: duration, removeOnCompletion: false)
targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak targetSnapshotView] _ in
completedTarget = true
intermediateCompletion()
targetSnapshotView?.isHidden = true
if hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
if let targetView = targetView as? ReactionIconView {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
}
targetSnapshotView?.isHidden = true
targetScaleCompleted = true
intermediateCompletion()
} else {
targetScaleCompleted = true
intermediateCompletion()
}
})
itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false)
}
public func willAnimateOutToReaction(value: MessageReaction.Reaction) {
for (_, itemNode) in self.visibleItemNodes {
if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value {
itemNode.isExtracted = true
}
}
}
public func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) {
var foundItemNode: ReactionNode?
for (_, itemNode) in self.visibleItemNodes {
if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value {
foundItemNode = itemNode
break
}
}
if let customReactionSource = self.customReactionSource {
let itemNode = ReactionNode(context: self.context, theme: self.presentationData.theme, item: customReactionSource.item, animationCache: self.animationCache, animationRenderer: self.animationRenderer, loopIdle: false, useDirectRendering: false)
if let contents = customReactionSource.layer.contents {
itemNode.setCustomContents(contents: contents)
}
self.scrollNode.addSubnode(itemNode)
itemNode.frame = customReactionSource.view.convert(customReactionSource.rect, to: self.scrollNode.view)
itemNode.updateLayout(size: itemNode.frame.size, isExpanded: false, largeExpanded: false, isPreviewing: false, transition: .immediate)
customReactionSource.layer.isHidden = true
foundItemNode = itemNode
}
guard let itemNode = foundItemNode else {
completion()
return
}
let switchToInlineImmediately: Bool
if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isAnimatedSticker || itemNode.item.listAnimation.isStaticEmoji {
switch itemNode.item.reaction.rawValue {
case .builtin:
switchToInlineImmediately = false
case .custom:
switchToInlineImmediately = !self.didTriggerExpandedReaction
}
} else {
switchToInlineImmediately = !self.didTriggerExpandedReaction
}
self.animationTargetView = targetView
self.animationHideNode = hideNode
if hideNode {
if let animateTargetContainer = animateTargetContainer {
animateTargetContainer.isHidden = true
targetView.isHidden = true
} else {
targetView.alpha = 0.0
targetView.layer.animateAlpha(from: targetView.alpha, to: 0.0, duration: 0.2)
}
}
itemNode.isExtracted = true
let selfSourceRect = itemNode.view.convert(itemNode.view.bounds, to: self.view)
var selfTargetBounds = targetView.bounds
if case .builtin = itemNode.item.reaction.rawValue {
selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5)
}
let selfTargetRect = self.view.convert(selfTargetBounds, from: targetView)
var expandedSize: CGSize = selfTargetRect.size
if self.didTriggerExpandedReaction {
if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isStaticEmoji {
expandedSize = CGSize(width: 80.0, height: 80.0)
} else {
expandedSize = CGSize(width: 120.0, height: 120.0)
}
}
let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize)
var effectFrame: CGRect
let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0
if self.didTriggerExpandedReaction {
let expandFactor: CGFloat = 0.5
effectFrame = expandedFrame.insetBy(dx: -expandedFrame.width * expandFactor, dy: -expandedFrame.height * expandFactor).offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0)
} else {
effectFrame = expandedFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height)
if itemNode.item.isCustom {
effectFrame = effectFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height)
}
}
let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .linear)
self.addSubnode(itemNode)
itemNode.position = expandedFrame.center
transition.updateBounds(node: itemNode, bounds: CGRect(origin: CGPoint(), size: expandedFrame.size))
itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, largeExpanded: self.didTriggerExpandedReaction, isPreviewing: false, transition: transition)
let additionalAnimationNode: DefaultAnimatedStickerNodeImpl?
var genericAnimationView: AnimationView?
var additionalAnimation: TelegramMediaFile?
if self.didTriggerExpandedReaction {
additionalAnimation = itemNode.item.largeApplicationAnimation
} else {
additionalAnimation = itemNode.item.applicationAnimation
if additionalAnimation == nil && itemNode.item.isCustom {
outer: for attribute in itemNode.item.stillAnimation.attributes {
if case let .CustomEmoji(_, _, alt, _) = attribute {
if let availableReactions = self.availableReactions {
for availableReaction in availableReactions.reactions {
if availableReaction.value == .builtin(alt) {
additionalAnimation = availableReaction.aroundAnimation
break outer
}
}
}
break
}
}
}
}
if let additionalAnimation = additionalAnimation {
let additionalAnimationNodeValue = DefaultAnimatedStickerNodeImpl()
additionalAnimationNode = additionalAnimationNodeValue
if self.didTriggerExpandedReaction {
if incomingMessage {
additionalAnimationNodeValue.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
}
}
additionalAnimationNodeValue.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: additionalAnimation.resource), width: Int(effectFrame.width * 2.0), height: Int(effectFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(additionalAnimation.resource.id)))
additionalAnimationNodeValue.frame = effectFrame
additionalAnimationNodeValue.updateLayout(size: effectFrame.size)
self.addSubnode(additionalAnimationNodeValue)
} else if itemNode.item.isCustom {
additionalAnimationNode = nil
var effectData: Data?
if self.didTriggerExpandedReaction {
if let url = getAppBundle().url(forResource: "generic_reaction_effect", withExtension: "json") {
effectData = try? Data(contentsOf: url)
}
} else if let genericReactionEffect = self.genericReactionEffect, let data = try? Data(contentsOf: URL(fileURLWithPath: genericReactionEffect)) {
effectData = TGGUnzipData(data, 5 * 1024 * 1024) ?? data
} else {
if let url = getAppBundle().url(forResource: "generic_reaction_small_effect", withExtension: "json") {
effectData = try? Data(contentsOf: url)
}
}
if let effectData = effectData, let composition = try? Animation.from(data: effectData) {
let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable))
view.animationSpeed = 1.0
view.backgroundColor = nil
view.isOpaque = false
if incomingMessage {
view.layer.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
}
genericAnimationView = view
let animationCache = itemNode.context.animationCache
let animationRenderer = itemNode.context.animationRenderer
for i in 1 ... 32 {
let allLayers = view.allLayers(forKeypath: AnimationKeypath(keypath: "placeholder_\(i)"))
for animationLayer in allLayers {
let baseItemLayer = InlineStickerItemLayer(
context: itemNode.context,
userLocation: .other,
attemptSynchronousLoad: false,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: itemNode.item.listAnimation.fileId.id, file: itemNode.item.listAnimation),
file: itemNode.item.listAnimation,
cache: animationCache,
renderer: animationRenderer,
placeholderColor: UIColor(white: 0.0, alpha: 0.0),
pointSize: CGSize(width: self.didTriggerExpandedReaction ? 64.0 : 32.0, height: self.didTriggerExpandedReaction ? 64.0 : 32.0)
)
if let sublayers = animationLayer.sublayers {
for sublayer in sublayers {
sublayer.isHidden = true
}
}
baseItemLayer.isVisibleForAnimations = true
baseItemLayer.frame = CGRect(origin: CGPoint(x: -0.0, y: -0.0), size: CGSize(width: 500.0, height: 500.0))
animationLayer.addSublayer(baseItemLayer)
}
}
if self.didTriggerExpandedReaction {
view.frame = effectFrame.insetBy(dx: -10.0, dy: -10.0).offsetBy(dx: incomingMessage ? 22.0 : -22.0, dy: 0.0)
} else {
view.frame = effectFrame.insetBy(dx: -20.0, dy: -20.0)
}
self.view.addSubview(view)
}
} else {
additionalAnimationNode = nil
}
var mainAnimationCompleted = false
var additionalAnimationCompleted = false
let intermediateCompletion: () -> Void = {
if mainAnimationCompleted && additionalAnimationCompleted {
completion()
}
}
if let additionalAnimationNode = additionalAnimationNode {
additionalAnimationNode.completed = { _ in
additionalAnimationCompleted = true
intermediateCompletion()
}
} else if let genericAnimationView = genericAnimationView {
genericAnimationView.play(completion: { _ in
additionalAnimationCompleted = true
intermediateCompletion()
})
} else {
additionalAnimationCompleted = true
}
transition.animatePositionWithKeyframes(node: itemNode, keyframes: generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: 30.0), completion: { [weak self, weak itemNode, weak targetView, weak animateTargetContainer] _ in
let afterCompletion: () -> Void = {
guard let strongSelf = self else {
return
}
if strongSelf.didTriggerExpandedReaction {
return
}
guard let itemNode = itemNode else {
return
}
if let animateTargetContainer = animateTargetContainer {
animateTargetContainer.isHidden = false
}
if let targetView = targetView {
targetView.isHidden = false
targetView.alpha = 1.0
targetView.layer.removeAnimation(forKey: "opacity")
}
guard let targetView = targetView as? ReactionIconView else {
return
}
if switchToInlineImmediately {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
itemNode.isHidden = true
} else {
targetView.updateIsAnimationHidden(isAnimationHidden: true, transition: .immediate)
targetView.addSubnode(itemNode)
itemNode.frame = selfTargetBounds
}
if strongSelf.hapticFeedback == nil {
strongSelf.hapticFeedback = HapticFeedback()
}
strongSelf.hapticFeedback?.tap()
if switchToInlineImmediately {
mainAnimationCompleted = true
intermediateCompletion()
}
}
if switchToInlineImmediately {
afterCompletion()
} else {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: afterCompletion)
}
})
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15 * UIView.animationDurationFactor(), execute: {
additionalAnimationNode?.visibility = true
if let animateTargetContainer = animateTargetContainer {
animateTargetContainer.isHidden = false
animateTargetContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
animateTargetContainer.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
}
})
if !switchToInlineImmediately {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + min(5.0, 2.0 * UIView.animationDurationFactor()), execute: {
if self.didTriggerExpandedReaction {
self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: hideNode, completion: { [weak self] in
if let strongSelf = self, strongSelf.didTriggerExpandedReaction, let addStandaloneReactionAnimation = addStandaloneReactionAnimation {
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: strongSelf.genericReactionEffect)
addStandaloneReactionAnimation(standaloneReactionAnimation)
standaloneReactionAnimation.animateReactionSelection(
context: strongSelf.context,
theme: strongSelf.context.sharedContext.currentPresentationData.with({ $0 }).theme,
animationCache: strongSelf.animationCache,
reaction: itemNode.item,
avatarPeers: [],
playHaptic: false,
isLarge: false,
targetView: targetView,
addStandaloneReactionAnimation: nil,
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.removeFromSupernode()
}
)
}
mainAnimationCompleted = true
intermediateCompletion()
})
} else {
if hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
if let targetView = targetView as? ReactionIconView {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
itemNode.removeFromSupernode()
}
}
mainAnimationCompleted = true
intermediateCompletion()
}
})
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let contentPoint = self.contentContainer.view.convert(point, from: self.view)
if self.contentContainer.bounds.contains(contentPoint) {
return self.contentContainer.hitTest(contentPoint, with: event)
}
return nil
}
private let longPressDuration: Double = 0.5
@objc private func longPressGesture(_ recognizer: UILongPressGestureRecognizer) {
switch recognizer.state {
case .began:
let point = recognizer.location(in: self.view)
if let itemNode = self.reactionItemNode(at: point) as? ReactionNode {
if self.selectedItems.contains(itemNode.item.reaction.rawValue) {
recognizer.state = .cancelled
return
}
if !itemNode.isAnimationLoaded {
recognizer.state = .cancelled
return
}
self.highlightedReaction = itemNode.item.reaction
if #available(iOS 13.0, *) {
self.continuousHaptic = try? ContinuousHaptic(duration: longPressDuration)
}
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: longPressDuration, curve: .linear), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
self.longPressTimer?.invalidate()
self.longPressTimer = SwiftSignalKit.Timer(timeout: longPressDuration, repeat: false, completion: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.longPressRecognizer?.state = .ended
}, queue: .mainQueue())
self.longPressTimer?.start()
}
case .changed:
let point = recognizer.location(in: self.view)
var shouldCancel = false
if let itemNode = self.reactionItemNode(at: point) as? ReactionNode {
if self.highlightedReaction != itemNode.item.reaction {
shouldCancel = true
}
} else {
shouldCancel = true
}
if shouldCancel {
self.longPressRecognizer?.state = .cancelled
}
case .cancelled:
self.longPressTimer?.invalidate()
self.continuousHaptic = nil
self.highlightedReaction = nil
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.3, curve: .spring), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
case .ended:
self.longPressTimer?.invalidate()
self.continuousHaptic = nil
self.didTriggerExpandedReaction = true
self.highlightGestureFinished(performAction: true, isLarge: true)
default:
break
}
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
switch recognizer.state {
case .ended:
let point = recognizer.location(in: self.view)
if let expandItemView = self.expandItemView, expandItemView.bounds.contains(self.view.convert(point, to: self.expandItemView)) {
self.currentContentHeight = 300.0
self.isExpanded = true
self.longPressRecognizer?.isEnabled = false
self.isExpandedUpdated(.animated(duration: 0.4, curve: .spring))
} else if let reaction = self.reaction(at: point) {
switch reaction {
case let .reaction(reactionItem):
if case .custom = reactionItem.updateMessageReaction, let hasPremium = self.hasPremium, !hasPremium {
self.premiumReactionsSelected?(reactionItem.stillAnimation)
} else {
self.reactionSelected?(reactionItem.updateMessageReaction, false)
}
case .premium:
self.premiumReactionsSelected?(nil)
}
}
default:
break
}
}
public func hasSpaceInTheBottom(insets: UIEdgeInsets, height: CGFloat) -> Bool {
if self.backgroundNode.frame.maxY < self.bounds.height - insets.bottom - height {
return true
} else {
return false
}
}
public func expand() {
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
self.hapticFeedback?.tap()
self.longPressRecognizer?.isEnabled = false
self.animateFromExtensionDistance = self.extensionDistance
self.extensionDistance = 0.0
self.visibleExtensionDistance = 0.0
self.currentContentHeight = 300.0
self.isExpanded = true
self.isExpandedUpdated(.animated(duration: 0.4, curve: .spring))
}
public func highlightGestureMoved(location: CGPoint, hover: Bool) {
let highlightedReaction = self.previewReaction(at: location)?.reaction
if self.highlightedReaction != highlightedReaction {
self.highlightedReaction = highlightedReaction
self.highlightedByHover = hover && highlightedReaction != nil
if !hover {
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
self.hapticFeedback?.tap()
}
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
}
}
public func highlightGestureFinished(performAction: Bool) {
self.highlightGestureFinished(performAction: performAction, isLarge: false)
}
private func highlightGestureFinished(performAction: Bool, isLarge: Bool) {
if let highlightedReaction = self.highlightedReaction {
self.highlightedReaction = nil
if performAction {
self.performReactionSelection(reaction: highlightedReaction, isLarge: isLarge)
} else {
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
}
}
}
private func previewReaction(at point: CGPoint) -> ReactionItem? {
let scrollPoint = self.view.convert(point, to: self.scrollNode.view)
if !self.scrollNode.bounds.contains(scrollPoint) {
return nil
}
let itemSize: CGFloat = 40.0
var closestItem: (index: Int, distance: CGFloat)?
for (index, itemNode) in self.visibleItemNodes {
let intersectionItemFrame = CGRect(origin: CGPoint(x: itemNode.position.x - itemSize / 2.0, y: itemNode.position.y - 1.0), size: CGSize(width: itemSize, height: 2.0))
if !self.scrollNode.bounds.contains(intersectionItemFrame) {
continue
}
let distance = abs(scrollPoint.x - intersectionItemFrame.midX)
if let (_, currentDistance) = closestItem {
if currentDistance > distance {
closestItem = (index, distance)
}
} else {
closestItem = (index, distance)
}
}
if let closestItem = closestItem, let closestItemNode = self.visibleItemNodes[closestItem.index] as? ReactionNode {
return closestItemNode.item
}
return nil
}
private func reactionItemNode(at point: CGPoint) -> ReactionItemNode? {
for i in 0 ..< 2 {
let touchInset: CGFloat = i == 0 ? 0.0 : 8.0
for (_, itemNode) in self.visibleItemNodes {
if itemNode.supernode === self.scrollNode && !self.scrollNode.bounds.intersects(itemNode.frame) {
continue
}
if !itemNode.isUserInteractionEnabled {
continue
}
let itemPoint = self.view.convert(point, to: itemNode.view)
if itemNode.bounds.insetBy(dx: -touchInset, dy: -touchInset).contains(itemPoint) {
return itemNode
}
}
}
return nil
}
public func reaction(at point: CGPoint) -> ReactionContextItem? {
let itemNode = self.reactionItemNode(at: point)
if let itemNode = itemNode as? ReactionNode {
if !itemNode.isAnimationLoaded {
return nil
}
return .reaction(itemNode.item)
} else if let _ = itemNode as? PremiumReactionsNode {
return .premium
}
return nil
}
public func performReactionSelection(reaction: ReactionItem.Reaction, isLarge: Bool) {
for (_, itemNode) in self.visibleItemNodes {
if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction == reaction {
if case .custom = itemNode.item.updateMessageReaction, let hasPremium = self.hasPremium, !hasPremium {
self.premiumReactionsSelected?(itemNode.item.stillAnimation)
} else {
self.reactionSelected?(itemNode.item.updateMessageReaction, isLarge)
}
break
}
}
}
public func cancelReactionAnimation() {
self.standaloneReactionAnimation?.cancel()
if let animationTargetView = self.animationTargetView, self.animationHideNode {
animationTargetView.alpha = 1.0
animationTargetView.isHidden = false
}
}
public func setHighlightedReaction(_ value: ReactionItem.Reaction?) {
self.highlightedReaction = value
if let (size, insets, anchorRect, isCoveredByInput) = self.validLayout {
self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
}
}
public final class StandaloneReactionAnimation: ASDisplayNode {
private let genericReactionEffect: String?
private let useDirectRendering: Bool
private var itemNode: ReactionNode? = nil
private var itemNodeIsEmbedded: Bool = false
private let hapticFeedback = HapticFeedback()
private var isCancelled: Bool = false
private weak var targetView: UIView?
public init(genericReactionEffect: String?, useDirectRendering: Bool = false) {
self.genericReactionEffect = genericReactionEffect
self.useDirectRendering = useDirectRendering
super.init()
self.isUserInteractionEnabled = false
}
public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, animationCache: AnimationCache, reaction: ReactionItem, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, forceSmallEffectAnimation: Bool = false, targetView: UIView, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) {
self.animateReactionSelection(context: context, theme: theme, animationCache: animationCache, reaction: reaction, avatarPeers: avatarPeers, playHaptic: playHaptic, isLarge: isLarge, forceSmallEffectAnimation: forceSmallEffectAnimation, targetView: targetView, addStandaloneReactionAnimation: addStandaloneReactionAnimation, currentItemNode: nil, completion: completion)
}
public var currentDismissAnimation: (() -> Void)?
public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, animationCache: AnimationCache, reaction: ReactionItem, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, forceSmallEffectAnimation: Bool = false, targetView: UIView, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, currentItemNode: ReactionNode?, completion: @escaping () -> Void) {
guard let sourceSnapshotView = targetView.snapshotContentTree() else {
completion()
return
}
if playHaptic {
self.hapticFeedback.tap()
}
self.targetView = targetView
let itemNode: ReactionNode
if let currentItemNode = currentItemNode {
itemNode = currentItemNode
} else {
let animationRenderer = MultiAnimationRendererImpl()
itemNode = ReactionNode(context: context, theme: theme, item: reaction, animationCache: animationCache, animationRenderer: animationRenderer, loopIdle: false)
}
self.itemNode = itemNode
let switchToInlineImmediately: Bool
if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isAnimatedSticker || itemNode.item.listAnimation.isStaticEmoji {
switch itemNode.item.reaction.rawValue {
case .builtin:
switchToInlineImmediately = false
case .custom:
switchToInlineImmediately = true
}
} else {
switchToInlineImmediately = false
}
if !forceSmallEffectAnimation && !switchToInlineImmediately {
if let targetView = targetView as? ReactionIconView, !isLarge {
self.itemNodeIsEmbedded = true
targetView.addSubnode(itemNode)
} else {
self.addSubnode(itemNode)
}
}
itemNode.expandedAnimationDidBegin = { [weak self, weak targetView] in
guard let strongSelf = self, let targetView = targetView else {
return
}
if let targetView = targetView as? ReactionIconView, !isLarge {
strongSelf.itemNodeIsEmbedded = true
targetView.updateIsAnimationHidden(isAnimationHidden: true, transition: .immediate)
} else {
targetView.isHidden = true
}
}
itemNode.isExtracted = true
var selfTargetBounds = targetView.bounds
if let targetView = targetView as? ReactionIconView, let iconFrame = targetView.iconFrame {
selfTargetBounds = iconFrame
}
/*if case .builtin = itemNode.item.reaction.rawValue {
selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5)
}*/
let selfTargetRect = self.view.convert(selfTargetBounds, from: targetView)
var expandedSize: CGSize = selfTargetRect.size
if isLarge {
expandedSize = CGSize(width: 120.0, height: 120.0)
}
let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize)
let effectFrame: CGRect
let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0
if isLarge && !forceSmallEffectAnimation {
effectFrame = expandedFrame.insetBy(dx: -expandedFrame.width * 0.5, dy: -expandedFrame.height * 0.5).offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0)
} else {
effectFrame = expandedFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height)
}
if !self.itemNodeIsEmbedded {
sourceSnapshotView.frame = selfTargetRect
self.view.addSubview(sourceSnapshotView)
sourceSnapshotView.alpha = 0.0
sourceSnapshotView.layer.animateSpring(from: 1.0 as NSNumber, to: (expandedFrame.width / selfTargetRect.width) as NSNumber, keyPath: "transform.scale", duration: 0.7)
sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.01, completion: { [weak sourceSnapshotView] _ in
sourceSnapshotView?.removeFromSuperview()
})
}
if self.itemNodeIsEmbedded {
itemNode.frame = selfTargetBounds
} else {
itemNode.frame = expandedFrame
itemNode.layer.animateSpring(from: (selfTargetRect.width / expandedFrame.width) as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.7)
}
itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, largeExpanded: isLarge, isPreviewing: false, transition: .immediate)
let additionalAnimation: TelegramMediaFile?
if isLarge && !forceSmallEffectAnimation {
additionalAnimation = itemNode.item.largeApplicationAnimation
} else {
additionalAnimation = itemNode.item.applicationAnimation
}
let additionalAnimationNode: AnimatedStickerNode?
var genericAnimationView: AnimationView?
if let additionalAnimation = additionalAnimation {
let additionalAnimationNodeValue: AnimatedStickerNode
if self.useDirectRendering {
additionalAnimationNodeValue = DirectAnimatedStickerNode()
} else {
additionalAnimationNodeValue = DefaultAnimatedStickerNodeImpl()
}
additionalAnimationNode = additionalAnimationNodeValue
if isLarge && !forceSmallEffectAnimation {
if incomingMessage {
additionalAnimationNodeValue.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
}
}
var additionalCachePathPrefix: String?
additionalCachePathPrefix = itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(additionalAnimation.resource.id)
//#if DEBUG
additionalCachePathPrefix = nil
//#endif
additionalAnimationNodeValue.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: additionalAnimation.resource), width: Int(effectFrame.width * 1.33), height: Int(effectFrame.height * 1.33), playbackMode: .once, mode: .direct(cachePathPrefix: additionalCachePathPrefix))
additionalAnimationNodeValue.frame = effectFrame
additionalAnimationNodeValue.updateLayout(size: effectFrame.size)
self.addSubnode(additionalAnimationNodeValue)
} else if itemNode.item.isCustom {
additionalAnimationNode = nil
var effectData: Data?
if let genericReactionEffect = self.genericReactionEffect, let data = try? Data(contentsOf: URL(fileURLWithPath: genericReactionEffect)) {
effectData = TGGUnzipData(data, 5 * 1024 * 1024) ?? data
} else {
if let url = getAppBundle().url(forResource: "generic_reaction_small_effect", withExtension: "json") {
effectData = try? Data(contentsOf: url)
}
}
if let effectData = effectData, let composition = try? Animation.from(data: effectData) {
let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable))
view.animationSpeed = 1.0
view.backgroundColor = nil
view.isOpaque = false
if incomingMessage {
view.layer.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
}
genericAnimationView = view
let animationCache = itemNode.context.animationCache
let animationRenderer = itemNode.context.animationRenderer
for i in 1 ... 7 {
let allLayers = view.allLayers(forKeypath: AnimationKeypath(keypath: "placeholder_\(i)"))
for animationLayer in allLayers {
let baseItemLayer = InlineStickerItemLayer(
context: itemNode.context,
userLocation: .other,
attemptSynchronousLoad: false,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: itemNode.item.listAnimation.fileId.id, file: itemNode.item.listAnimation),
file: itemNode.item.listAnimation,
cache: animationCache,
renderer: animationRenderer,
placeholderColor: UIColor(white: 0.0, alpha: 0.0),
pointSize: CGSize(width: 32.0, height: 32.0)
)
if let sublayers = animationLayer.sublayers {
for sublayer in sublayers {
sublayer.isHidden = true
}
}
baseItemLayer.isVisibleForAnimations = true
baseItemLayer.frame = CGRect(origin: CGPoint(x: -0.0, y: -0.0), size: CGSize(width: 500.0, height: 500.0))
animationLayer.addSublayer(baseItemLayer)
}
}
view.frame = effectFrame.insetBy(dx: -20.0, dy: -20.0)//.offsetBy(dx: incomingMessage ? 22.0 : -22.0, dy: 0.0)
self.view.addSubview(view)
}
} else {
additionalAnimationNode = nil
}
if let additionalAnimationNode = additionalAnimationNode, !isLarge, !avatarPeers.isEmpty, let url = getAppBundle().url(forResource: "effectavatar", withExtension: "json"), let composition = Animation.filepath(url.path) {
let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable))
view.animationSpeed = 1.0
view.backgroundColor = nil
view.isOpaque = false
var avatarIndex = 0
let keypathIndices: [Int] = Array((1 ... 3).map({ $0 }).shuffled())
for i in keypathIndices {
var peer: EnginePeer?
if avatarIndex < avatarPeers.count {
peer = avatarPeers[avatarIndex]
}
avatarIndex += 1
if let peer = peer {
let avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 16.0))
let avatarContainer = UIView(frame: CGRect(origin: CGPoint(x: -100.0, y: -100.0), size: CGSize(width: 200.0, height: 200.0)))
avatarNode.frame = CGRect(origin: CGPoint(x: floor((200.0 - 40.0) / 2.0), y: floor((200.0 - 40.0) / 2.0)), size: CGSize(width: 40.0, height: 40.0))
avatarNode.setPeer(context: context, theme: context.sharedContext.currentPresentationData.with({ $0 }).theme, peer: peer)
avatarNode.transform = CATransform3DMakeScale(200.0 / 40.0, 200.0 / 40.0, 1.0)
avatarContainer.addSubnode(avatarNode)
let animationSubview = AnimationSubview()
animationSubview.addSubview(avatarContainer)
view.addSubview(animationSubview, forLayerAt: AnimationKeypath(keypath: "Avatar \(i).Ellipse 1"))
}
view.setValueProvider(ColorValueProvider(UIColor.clear.lottieColorValue), keypath: AnimationKeypath(keypath: "Avatar \(i).Ellipse 1.Fill 1.Color"))
/*let colorCallback = LOTColorValueCallback(color: UIColor.clear.cgColor)
self.colorCallbacks.append(colorCallback)
view.setValueDelegate(colorCallback, for: LOTKeypath(string: "Avatar \(i).Ellipse 1.Fill 1.Color"))*/
}
view.frame = additionalAnimationNode.bounds
additionalAnimationNode.view.addSubview(view)
view.play()
}
var mainAnimationCompleted = false
var additionalAnimationCompleted = false
let intermediateCompletion: () -> Void = {
if mainAnimationCompleted && additionalAnimationCompleted {
completion()
}
}
var didBeginDismissAnimation = false
let beginDismissAnimation: () -> Void = { [weak self, weak additionalAnimationNode] in
if !didBeginDismissAnimation {
didBeginDismissAnimation = true
guard let strongSelf = self else {
mainAnimationCompleted = true
intermediateCompletion()
return
}
/*if switchToInlineImmediately {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
itemNode.isHidden = true
} else {
targetView.updateIsAnimationHidden(isAnimationHidden: true, transition: .immediate)
targetView.addSubnode(itemNode)
itemNode.frame = selfTargetBounds
}*/
if forceSmallEffectAnimation {
if let additionalAnimationNode = additionalAnimationNode {
additionalAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak additionalAnimationNode] _ in
additionalAnimationNode?.removeFromSupernode()
})
}
mainAnimationCompleted = true
intermediateCompletion()
} else {
if isLarge {
let genericReactionEffect = strongSelf.genericReactionEffect
strongSelf.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: true, completion: {
if let addStandaloneReactionAnimation = addStandaloneReactionAnimation {
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: genericReactionEffect)
addStandaloneReactionAnimation(standaloneReactionAnimation)
standaloneReactionAnimation.animateReactionSelection(
context: itemNode.context,
theme: itemNode.context.sharedContext.currentPresentationData.with({ $0 }).theme,
animationCache: animationCache,
reaction: itemNode.item,
avatarPeers: avatarPeers,
playHaptic: false,
isLarge: false,
targetView: targetView,
addStandaloneReactionAnimation: nil,
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.removeFromSupernode()
}
)
}
mainAnimationCompleted = true
intermediateCompletion()
})
} else {
if let targetView = strongSelf.targetView {
if let targetView = targetView as? ReactionIconView, !isLarge {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
} else {
targetView.alpha = 1.0
targetView.isHidden = false
}
}
if strongSelf.itemNodeIsEmbedded {
strongSelf.itemNode?.removeFromSupernode()
}
mainAnimationCompleted = true
intermediateCompletion()
}
}
}
}
self.currentDismissAnimation = beginDismissAnimation
let maybeBeginDismissAnimation: () -> Void = {
if mainAnimationCompleted && additionalAnimationCompleted {
beginDismissAnimation()
}
}
if forceSmallEffectAnimation {
//itemNode.mainAnimationCompletion = {
mainAnimationCompleted = true
maybeBeginDismissAnimation()
//}
}
if let additionalAnimationNode = additionalAnimationNode {
additionalAnimationNode.completed = { [weak additionalAnimationNode] _ in
additionalAnimationNode?.alpha = 0.0
additionalAnimationNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
additionalAnimationCompleted = true
intermediateCompletion()
if forceSmallEffectAnimation {
maybeBeginDismissAnimation()
} else {
beginDismissAnimation()
}
}
additionalAnimationNode.visibility = true
} else if let genericAnimationView = genericAnimationView {
genericAnimationView.play(completion: { _ in
additionalAnimationNode?.alpha = 0.0
additionalAnimationNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
additionalAnimationCompleted = true
intermediateCompletion()
if forceSmallEffectAnimation {
maybeBeginDismissAnimation()
} else {
beginDismissAnimation()
}
})
} else {
additionalAnimationCompleted = true
}
if !forceSmallEffectAnimation {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0, execute: {
beginDismissAnimation()
})
}
}
private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) {
guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else {
completion()
return
}
let sourceFrame = itemNode.view.convert(itemNode.bounds, to: self.view)
var selfTargetBounds = targetView.bounds
if let itemNode = self.itemNode, case .builtin = itemNode.item.reaction.rawValue {
selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5)
}
var targetFrame = self.view.convert(targetView.convert(selfTargetBounds, to: nil), from: nil)
if let itemNode = self.itemNode, case .builtin = itemNode.item.reaction.rawValue {
targetFrame = targetFrame.insetBy(dx: -targetFrame.width * 0.5, dy: -targetFrame.height * 0.5)
}
targetSnapshotView.frame = targetFrame
self.view.insertSubview(targetSnapshotView, belowSubview: itemNode.view)
var completedTarget = false
var targetScaleCompleted = false
let intermediateCompletion: () -> Void = {
if completedTarget && targetScaleCompleted {
completion()
}
}
let targetPosition = targetFrame.center
let duration: Double = 0.16
itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false)
itemNode.layer.animatePosition(from: itemNode.layer.position, to: targetPosition, duration: duration, removeOnCompletion: false)
targetSnapshotView.alpha = 1.0
targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8)
targetSnapshotView.layer.animatePosition(from: sourceFrame.center, to: targetPosition, duration: duration, removeOnCompletion: false)
targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak targetSnapshotView] _ in
completedTarget = true
intermediateCompletion()
targetSnapshotView?.isHidden = true
if hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
if let targetView = targetView as? ReactionIconView {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
}
targetSnapshotView?.isHidden = true
targetScaleCompleted = true
intermediateCompletion()
} else {
targetScaleCompleted = true
intermediateCompletion()
}
})
itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false)
}
public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
self.bounds = self.bounds.offsetBy(dx: 0.0, dy: offset.y)
transition.animateOffsetAdditive(node: self, offset: -offset.y)
}
public func cancel() {
self.isCancelled = true
if let targetView = self.targetView {
if let targetView = targetView as? ReactionIconView, self.itemNodeIsEmbedded {
targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate)
} else {
targetView.alpha = 1.0
targetView.isHidden = false
}
}
if self.itemNodeIsEmbedded {
self.itemNode?.removeFromSupernode()
}
}
}
public final class StandaloneDismissReactionAnimation: ASDisplayNode {
private let hapticFeedback = HapticFeedback()
override public init() {
super.init()
self.isUserInteractionEnabled = false
}
public func animateReactionDismiss(sourceView: UIView, hideNode: Bool, isIncoming: Bool, completion: @escaping () -> Void) {
guard let sourceSnapshotView = sourceView.snapshotContentTree() else {
completion()
return
}
if hideNode {
sourceView.isHidden = true
}
let sourceRect = self.view.convert(sourceView.bounds, from: sourceView)
sourceSnapshotView.frame = sourceRect
self.view.addSubview(sourceSnapshotView)
var targetOffset: CGFloat = 120.0
if !isIncoming {
targetOffset = -targetOffset
}
let targetPoint = CGPoint(x: sourceRect.midX + targetOffset, y: sourceRect.midY)
let hapticFeedback = self.hapticFeedback
hapticFeedback.prepareImpact(.soft)
let keyframes = generateParabollicMotionKeyframes(from: sourceRect.center, to: targetPoint, elevation: 25.0)
let transition: ContainedViewLayoutTransition = .animated(duration: 0.18, curve: .easeInOut)
sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.04, delay: 0.18 - 0.04, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak sourceSnapshotView, weak hapticFeedback] _ in
sourceSnapshotView?.removeFromSuperview()
hapticFeedback?.impact(.soft)
completion()
})
transition.animatePositionWithKeyframes(layer: sourceSnapshotView.layer, keyframes: keyframes, removeOnCompletion: false)
}
public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
self.bounds = self.bounds.offsetBy(dx: 0.0, dy: offset.y)
transition.animateOffsetAdditive(node: self, offset: -offset.y)
}
}
private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat) -> [CGPoint] {
let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation)
let x1 = sourcePoint.x
let y1 = sourcePoint.y
let x2 = midPoint.x
let y2 = midPoint.y
let x3 = targetPosition.x
let y3 = targetPosition.y
var keyframes: [CGPoint] = []
if abs(y1 - y3) < 5.0 && abs(x1 - x3) < 5.0 {
for i in 0 ..< 10 {
let k = CGFloat(i) / CGFloat(10 - 1)
let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k
let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k
keyframes.append(CGPoint(x: x, y: y))
}
} else {
let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
for i in 0 ..< 10 {
let k = CGFloat(i) / CGFloat(10 - 1)
let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k
let y = a * x * x + b * x + c
keyframes.append(CGPoint(x: x, y: y))
}
}
return keyframes
}