import Foundation import AsyncDisplayKit import Display import ComponentFlow import SwiftSignalKit import Postbox import TelegramCore import AccountContext import TelegramPresentationData import UIKit import AnimatedAvatarSetNode import ContextUI import AvatarNode import ReactionImageComponent private let avatarFont = avatarPlaceholderFont(size: 16.0) public final class ReactionListContextMenuContent: ContextControllerItemsContent { private final class BackButtonNode: HighlightTrackingButtonNode { let highlightBackgroundNode: ASDisplayNode let titleLabelNode: ImmediateTextNode let separatorNode: ASDisplayNode let iconNode: ASImageNode var action: (() -> Void)? private var theme: PresentationTheme? init() { self.highlightBackgroundNode = ASDisplayNode() self.highlightBackgroundNode.isAccessibilityElement = false self.highlightBackgroundNode.alpha = 0.0 self.titleLabelNode = ImmediateTextNode() self.titleLabelNode.isAccessibilityElement = false self.titleLabelNode.maximumNumberOfLines = 1 self.titleLabelNode.isUserInteractionEnabled = false self.iconNode = ASImageNode() self.iconNode.isAccessibilityElement = false self.separatorNode = ASDisplayNode() self.separatorNode.isAccessibilityElement = false super.init() self.addSubnode(self.separatorNode) self.addSubnode(self.highlightBackgroundNode) self.addSubnode(self.titleLabelNode) self.addSubnode(self.iconNode) self.isAccessibilityElement = true self.highligthedChanged = { [weak self] highlighted in guard let strongSelf = self else { return } if highlighted { strongSelf.highlightBackgroundNode.alpha = 1.0 } else { let previousAlpha = strongSelf.highlightBackgroundNode.alpha strongSelf.highlightBackgroundNode.alpha = 0.0 strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) } } self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) } @objc private func pressed() { self.action?() } func update(size: CGSize, presentationData: PresentationData, isLast: Bool) { let standardIconWidth: CGFloat = 32.0 let sideInset: CGFloat = 16.0 let iconSideInset: CGFloat = 12.0 if self.theme !== presentationData.theme { self.theme = presentationData.theme self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: presentationData.theme.contextMenu.primaryColor) self.accessibilityLabel = presentationData.strings.Common_Back } self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.titleLabelNode.attributedText = NSAttributedString(string: presentationData.strings.Common_Back, font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor) let titleSize = self.titleLabelNode.updateLayout(CGSize(width: size.width - sideInset - standardIconWidth, height: 100.0)) self.titleLabelNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) if let iconImage = self.iconNode.image { let iconWidth = max(standardIconWidth, iconImage.size.width) let iconFrame = CGRect(origin: CGPoint(x: size.width - iconSideInset - iconWidth + floor((iconWidth - iconImage.size.width) / 2.0), y: floor((size.height - iconImage.size.height) / 2.0)), size: iconImage.size) self.iconNode.frame = iconFrame } self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)) self.separatorNode.isHidden = isLast } } private final class ReactionTabListNode: ASDisplayNode { private final class ItemNode: ASDisplayNode { let context: AccountContext let reaction: String? let count: Int let titleLabelNode: ImmediateTextNode let iconNode: ASImageNode? let reactionIconNode: ReactionImageNode? private var theme: PresentationTheme? var action: ((String?) -> Void)? init(context: AccountContext, availableReactions: AvailableReactions?, reaction: String?, count: Int) { self.context = context self.reaction = reaction self.count = count self.titleLabelNode = ImmediateTextNode() self.titleLabelNode.isUserInteractionEnabled = false if let reaction = reaction { self.reactionIconNode = ReactionImageNode(context: context, availableReactions: availableReactions, reaction: reaction, displayPixelSize: CGSize(width: 30.0 * UIScreenScale, height: 30.0 * UIScreenScale)) self.reactionIconNode?.isUserInteractionEnabled = false self.iconNode = nil } else { self.reactionIconNode = nil self.iconNode = ASImageNode() self.iconNode?.isUserInteractionEnabled = false } super.init() self.addSubnode(self.titleLabelNode) if let iconNode = self.iconNode { self.addSubnode(iconNode) } if let reactionIconNode = self.reactionIconNode { self.addSubnode(reactionIconNode) } self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.action?(self.reaction) } } func update(presentationData: PresentationData, constrainedSize: CGSize, isSelected: Bool) -> CGSize { if presentationData.theme !== self.theme { self.theme = presentationData.theme if let iconNode = self.iconNode { iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reactions"), color: presentationData.theme.contextMenu.primaryColor) } } let sideInset: CGFloat = 12.0 let iconSpacing: CGFloat = 4.0 var iconSize = CGSize(width: 22.0, height: 22.0) if let _ = self.reactionIconNode { } else if let iconNode = self.iconNode, let image = iconNode.image { iconSize = image.size.aspectFitted(iconSize) } self.titleLabelNode.attributedText = NSAttributedString(string: "\(count)", font: Font.medium(11.0), textColor: presentationData.theme.contextMenu.primaryColor) let titleSize = self.titleLabelNode.updateLayout(constrainedSize) let contentSize = CGSize(width: sideInset * 2.0 + titleSize.width + iconSize.width + iconSpacing, height: titleSize.height) self.titleLabelNode.frame = CGRect(origin: CGPoint(x: sideInset + iconSize.width + iconSpacing, y: floorToScreenPixels((constrainedSize.height - titleSize.height) / 2.0)), size: titleSize) if let reactionIconNode = self.reactionIconNode { reactionIconNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((constrainedSize.height - iconSize.height) / 2.0)), size: iconSize) reactionIconNode.update(size: iconSize) } else if let iconNode = self.iconNode { iconNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((constrainedSize.height - iconSize.height) / 2.0)), size: iconSize) } return CGSize(width: contentSize.width, height: constrainedSize.height) } } private let scrollNode: ASScrollNode private let selectionHighlightNode: ASDisplayNode private let itemNodes: [ItemNode] struct ScrollToTabReaction { var value: String? } var scrollToTabReaction: ScrollToTabReaction? var action: ((String?) -> Void)? init(context: AccountContext, availableReactions: AvailableReactions?, reactions: [(String?, Int)], message: EngineMessage) { self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.showsVerticalScrollIndicator = false self.scrollNode.view.showsHorizontalScrollIndicator = false if #available(iOS 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } self.scrollNode.view.disablesInteractiveTransitionGestureRecognizer = true self.itemNodes = reactions.map { reaction, count in return ItemNode(context: context, availableReactions: availableReactions, reaction: reaction, count: count) } self.selectionHighlightNode = ASDisplayNode() super.init() self.addSubnode(self.scrollNode) self.scrollNode.addSubnode(self.selectionHighlightNode) for itemNode in self.itemNodes { self.scrollNode.addSubnode(itemNode) itemNode.action = { [weak self] reaction in guard let strongSelf = self else { return } strongSelf.scrollToTabReaction = ScrollToTabReaction(value: reaction) strongSelf.action?(reaction) } } } func update(size: CGSize, presentationData: PresentationData, selectedReaction: String?, transition: ContainedViewLayoutTransition) { let sideInset: CGFloat = 11.0 let spacing: CGFloat = 0.0 let verticalInset: CGFloat = 7.0 self.selectionHighlightNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor let highlightHeight: CGFloat = size.height - verticalInset * 2.0 self.selectionHighlightNode.cornerRadius = highlightHeight / 2.0 var contentWidth: CGFloat = sideInset for i in 0 ..< self.itemNodes.count { if i != 0 { contentWidth += spacing } let itemNode = self.itemNodes[i] let itemSize = itemNode.update(presentationData: presentationData, constrainedSize: CGSize(width: size.width, height: size.height), isSelected: itemNode.reaction == selectedReaction) let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize) itemNode.frame = itemFrame if itemNode.reaction == selectedReaction { transition.updateFrame(node: self.selectionHighlightNode, frame: CGRect(origin: CGPoint(x: itemFrame.minX, y: verticalInset), size: CGSize(width: itemFrame.width, height: highlightHeight))) } contentWidth += itemSize.width } contentWidth += sideInset self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) let contentSize = CGSize(width: contentWidth, height: size.height) if self.scrollNode.view.contentSize != contentSize { self.scrollNode.view.contentSize = contentSize } if let scrollToTabReaction = self.scrollToTabReaction { self.scrollToTabReaction = nil for itemNode in self.itemNodes { if itemNode.reaction == scrollToTabReaction.value { self.scrollNode.view.scrollRectToVisible(itemNode.frame.insetBy(dx: -sideInset - 8.0, dy: 0.0), animated: transition.isAnimated) break } } } } } private final class ReactionsTabNode: ASDisplayNode, UIScrollViewDelegate { private final class ItemNode: HighlightTrackingButtonNode { let context: AccountContext let availableReactions: AvailableReactions? let highlightBackgroundNode: ASDisplayNode let avatarNode: AvatarNode let titleLabelNode: ImmediateTextNode let separatorNode: ASDisplayNode var reactionIconNode: ReactionImageNode? let action: () -> Void private var item: EngineMessageReactionListContext.Item? init(context: AccountContext, availableReactions: AvailableReactions?, action: @escaping () -> Void) { self.action = action self.context = context self.availableReactions = availableReactions self.avatarNode = AvatarNode(font: avatarFont) self.avatarNode.isAccessibilityElement = false self.highlightBackgroundNode = ASDisplayNode() self.highlightBackgroundNode.isAccessibilityElement = false self.highlightBackgroundNode.alpha = 0.0 self.titleLabelNode = ImmediateTextNode() self.titleLabelNode.isAccessibilityElement = false self.titleLabelNode.maximumNumberOfLines = 1 self.titleLabelNode.isUserInteractionEnabled = false self.separatorNode = ASDisplayNode() self.separatorNode.isAccessibilityElement = false super.init() self.isAccessibilityElement = true self.addSubnode(self.separatorNode) self.addSubnode(self.highlightBackgroundNode) self.addSubnode(self.avatarNode) self.addSubnode(self.titleLabelNode) self.highligthedChanged = { [weak self] highlighted in guard let strongSelf = self else { return } if highlighted { strongSelf.highlightBackgroundNode.alpha = 1.0 } else { let previousAlpha = strongSelf.highlightBackgroundNode.alpha strongSelf.highlightBackgroundNode.alpha = 0.0 strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) } } self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) } @objc private func pressed() { self.action() } func update(size: CGSize, presentationData: PresentationData, item: EngineMessageReactionListContext.Item, isLast: Bool, syncronousLoad: Bool) { let avatarInset: CGFloat = 12.0 let avatarSpacing: CGFloat = 8.0 let avatarSize: CGFloat = 28.0 let sideInset: CGFloat = 16.0 let reaction: String? = item.reaction if let reaction = reaction { if self.reactionIconNode == nil { let reactionIconNode = ReactionImageNode(context: self.context, availableReactions: self.availableReactions, reaction: reaction, displayPixelSize: CGSize(width: 30.0 * UIScreenScale, height: 30.0 * UIScreenScale)) self.reactionIconNode = reactionIconNode self.addSubnode(reactionIconNode) } } else if let reactionIconNode = self.reactionIconNode { reactionIconNode.removeFromSupernode() } if self.item != item { self.item = item self.accessibilityLabel = "\(item.peer.debugDisplayTitle) \(item.reaction ?? "")" } self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.avatarNode.frame = CGRect(origin: CGPoint(x: avatarInset, y: floor((size.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.setPeer(context: self.context, theme: presentationData.theme, peer: item.peer, synchronousLoad: true) self.titleLabelNode.attributedText = NSAttributedString(string: item.peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor) var maxTextWidth: CGFloat = size.width - avatarInset - avatarSize - avatarSpacing - sideInset if reactionIconNode != nil { maxTextWidth -= 32.0 } let titleSize = self.titleLabelNode.updateLayout(CGSize(width: maxTextWidth, height: 100.0)) self.titleLabelNode.frame = CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarSpacing, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) if let reactionIconNode = self.reactionIconNode { let reactionSize = CGSize(width: 22.0, height: 22.0) reactionIconNode.frame = CGRect(origin: CGPoint(x: size.width - 32.0 - floor((32.0 - reactionSize.width) / 2.0), y: floor((size.height - reactionSize.height) / 2.0)), size: reactionSize) reactionIconNode.update(size: reactionSize) } self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height), size: CGSize(width: size.width, height: UIScreenPixel)) self.separatorNode.isHidden = isLast } } private struct ItemsState { let listState: EngineMessageReactionListContext.State let readStats: MessageReadStats? let mergedItems: [EngineMessageReactionListContext.Item] init(listState: EngineMessageReactionListContext.State, readStats: MessageReadStats?) { self.listState = listState self.readStats = readStats var mergedItems: [EngineMessageReactionListContext.Item] = listState.items if !listState.canLoadMore, let readStats = readStats { var existingPeers = Set(mergedItems.map(\.peer.id)) for peer in readStats.peers { if !existingPeers.contains(peer.id) { existingPeers.insert(peer.id) mergedItems.append(EngineMessageReactionListContext.Item(peer: peer, reaction: nil)) } } } self.mergedItems = mergedItems } var totalCount: Int { if !self.listState.canLoadMore { return self.mergedItems.count } else { let reactionCount = self.listState.totalCount var value = reactionCount if let readStats = self.readStats { if reactionCount < readStats.peers.count && self.listState.hasOutgoingReaction { value = readStats.peers.count + 1 } else { value = max(reactionCount, readStats.peers.count) } } return value } } var canLoadMore: Bool { return self.listState.canLoadMore } func item(at index: Int) -> EngineMessageReactionListContext.Item? { if index < self.mergedItems.count { return self.mergedItems[index] } else { return nil } } } private let context: AccountContext private let availableReactions: AvailableReactions? let reaction: String? private let requestUpdate: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void private let requestUpdateApparentHeight: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void private let openPeer: (PeerId) -> Void private var hasMore: Bool = false private let scrollNode: ASScrollNode private var ignoreScrolling: Bool = false private var animateIn: Bool = false private var presentationData: PresentationData? private var currentSize: CGSize? private var apparentHeight: CGFloat = 0.0 private let listContext: EngineMessageReactionListContext private var state: ItemsState private var stateDisposable: Disposable? private var itemNodes: [Int: ItemNode] = [:] private var placeholderItemImage: UIImage? private var placeholderLayers: [Int: SimpleLayer] = [:] init( context: AccountContext, availableReactions: AvailableReactions?, message: EngineMessage, reaction: String?, readStats: MessageReadStats?, requestUpdate: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, openPeer: @escaping (PeerId) -> Void ) { self.context = context self.availableReactions = availableReactions self.reaction = reaction self.requestUpdate = requestUpdate self.requestUpdateApparentHeight = requestUpdateApparentHeight self.openPeer = openPeer self.listContext = context.engine.messages.messageReactionList(message: message, reaction: reaction) self.state = ItemsState(listState: EngineMessageReactionListContext.State(message: message, reaction: reaction), readStats: readStats) self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.showsVerticalScrollIndicator = false if #available(iOS 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } self.scrollNode.clipsToBounds = false super.init() self.addSubnode(self.scrollNode) self.scrollNode.view.delegate = self self.clipsToBounds = true self.stateDisposable = (self.listContext.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let strongSelf = self else { return } let updatedState = ItemsState(listState: state, readStats: strongSelf.state.readStats) var animateIn = false if strongSelf.state.item(at: 0) == nil && updatedState.item(at: 0) != nil { animateIn = true } strongSelf.state = updatedState strongSelf.animateIn = true strongSelf.requestUpdate(strongSelf, animateIn ? .animated(duration: 0.2, curve: .easeInOut) : .immediate) if animateIn { for (_, itemNode) in strongSelf.itemNodes { itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } }) } deinit { self.stateDisposable?.dispose() } func scrollViewDidScroll(_ scrollView: UIScrollView) { if self.ignoreScrolling { return } self.updateVisibleItems(animated: false, syncronousLoad: false) if let size = self.currentSize { var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height apparentHeight = max(apparentHeight, 44.0) apparentHeight = min(apparentHeight, size.height + 100.0) if self.apparentHeight != apparentHeight { self.apparentHeight = apparentHeight self.requestUpdateApparentHeight(self, .immediate) } } } private func updateVisibleItems(animated: Bool, syncronousLoad: Bool) { guard let size = self.currentSize else { return } guard let presentationData = self.presentationData else { return } let itemHeight: CGFloat = 44.0 let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -180.0) var validIds = Set() var validPlaceholderIds = Set() let minVisibleIndex = max(0, Int(floor(visibleBounds.minY / itemHeight))) let maxVisibleIndex = Int(ceil(visibleBounds.maxY / itemHeight)) if minVisibleIndex <= maxVisibleIndex { for index in minVisibleIndex ... maxVisibleIndex { let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: CGFloat(index) * itemHeight), size: CGSize(width: size.width, height: itemHeight)) if let item = self.state.item(at: index) { validIds.insert(index) let itemNode: ItemNode if let current = self.itemNodes[index] { itemNode = current } else { let openPeer = self.openPeer let peerId = item.peer.id itemNode = ItemNode(context: self.context, availableReactions: self.availableReactions, action: { openPeer(peerId) }) self.itemNodes[index] = itemNode self.scrollNode.addSubnode(itemNode) } itemNode.update(size: itemFrame.size, presentationData: presentationData, item: item, isLast: self.state.item(at: index + 1) == nil, syncronousLoad: syncronousLoad) itemNode.frame = itemFrame } else if index < self.state.totalCount { validPlaceholderIds.insert(index) let placeholderLayer: SimpleLayer if let current = self.placeholderLayers[index] { placeholderLayer = current } else { placeholderLayer = SimpleLayer() if let placeholderItemImage = self.placeholderItemImage { ASDisplayNodeSetResizableContents(placeholderLayer, placeholderItemImage) } self.placeholderLayers[index] = placeholderLayer self.scrollNode.layer.addSublayer(placeholderLayer) } placeholderLayer.frame = itemFrame } } } var removeIds: [Int] = [] for (id, itemNode) in self.itemNodes { if !validIds.contains(id) { removeIds.append(id) itemNode.removeFromSupernode() } } for id in removeIds { self.itemNodes.removeValue(forKey: id) } var removePlaceholderIds: [Int] = [] for (id, placeholderLayer) in self.placeholderLayers { if !validPlaceholderIds.contains(id) { removePlaceholderIds.append(id) if animated || self.animateIn { placeholderLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak placeholderLayer] _ in placeholderLayer?.removeFromSuperlayer() }) } else { placeholderLayer.removeFromSuperlayer() } } } for id in removePlaceholderIds { self.placeholderLayers.removeValue(forKey: id) } if self.state.canLoadMore && maxVisibleIndex >= self.state.listState.items.count - 16 { self.listContext.loadMore() } } func update(presentationData: PresentationData, constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> (height: CGFloat, apparentHeight: CGFloat) { let itemHeight: CGFloat = 44.0 if self.presentationData?.theme !== presentationData.theme { let sideInset: CGFloat = 40.0 let avatarInset: CGFloat = 12.0 let avatarSpacing: CGFloat = 8.0 let avatarSize: CGFloat = 28.0 let lineHeight: CGFloat = 8.0 let shimmeringForegroundColor: UIColor let shimmeringColor: UIColor if presentationData.theme.overallDarkAppearance { let backgroundColor = presentationData.theme.contextMenu.backgroundColor.blitOver(presentationData.theme.list.plainBackgroundColor, alpha: 1.0) shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.blitOver(backgroundColor, alpha: 0.1) shimmeringColor = presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.3) } else { shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07) shimmeringColor = presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.3) } let _ = shimmeringColor self.placeholderItemImage = generateImage(CGSize(width: avatarInset + avatarSize + avatarSpacing + lineHeight + 2.0 + sideInset, height: itemHeight), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(shimmeringForegroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: avatarInset, y: floor((size.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))) context.fillEllipse(in: CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarSpacing, y: floor((size.height - lineHeight) / 2.0)), size: CGSize(width: lineHeight + 2.0, height: lineHeight))) })?.stretchableImage(withLeftCapWidth: Int(avatarInset + avatarSize + avatarSpacing + lineHeight / 2.0 + 1.0), topCapHeight: 0) if let placeholderItemImage = self.placeholderItemImage { for (_, placeholderLayer) in self.placeholderLayers { ASDisplayNodeSetResizableContents(placeholderLayer, placeholderItemImage) } } } self.presentationData = presentationData let size = CGSize(width: constrainedSize.width, height: CGFloat(self.state.totalCount) * itemHeight) let containerSize = CGSize(width: size.width, height: min(constrainedSize.height, size.height)) self.currentSize = containerSize self.ignoreScrolling = true if self.scrollNode.frame != CGRect(origin: CGPoint(), size: containerSize) { self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize) } if self.scrollNode.view.contentSize != size { self.scrollNode.view.contentSize = size } self.ignoreScrolling = false self.updateVisibleItems(animated: transition.isAnimated, syncronousLoad: !transition.isAnimated) self.animateIn = false var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height apparentHeight = max(apparentHeight, 44.0) apparentHeight = min(apparentHeight, containerSize.height + 100.0) self.apparentHeight = apparentHeight return (containerSize.height, apparentHeight) } } final class ItemsNode: ASDisplayNode, ContextControllerItemsNode, UIGestureRecognizerDelegate { private let context: AccountContext private let availableReactions: AvailableReactions? private let message: EngineMessage private let readStats: MessageReadStats? private let reactions: [(String?, Int)] private let requestUpdate: (ContainedViewLayoutTransition) -> Void private let requestUpdateApparentHeight: (ContainedViewLayoutTransition) -> Void private var presentationData: PresentationData private var backButtonNode: BackButtonNode? private var separatorNode: ASDisplayNode? private var tabListNode: ReactionTabListNode? private var currentTabIndex: Int = 0 private var visibleTabNodes: [Int: ReactionsTabNode] = [:] private struct InteractiveTransitionState { var toIndex: Int var progress: CGFloat } private var interactiveTransitionState: InteractiveTransitionState? private let openPeer: (PeerId) -> Void private(set) var apparentHeight: CGFloat = 0.0 init( context: AccountContext, availableReactions: AvailableReactions?, message: EngineMessage, reaction: String?, readStats: MessageReadStats?, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void, back: (() -> Void)?, openPeer: @escaping (PeerId) -> Void ) { self.context = context self.availableReactions = availableReactions self.message = message self.readStats = readStats self.openPeer = openPeer self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) self.requestUpdate = requestUpdate self.requestUpdateApparentHeight = requestUpdateApparentHeight //var requestUpdateTab: ((ReactionsTabNode, ContainedViewLayoutTransition) -> Void)? //var requestUpdateTabApparentHeight: ((ReactionsTabNode, ContainedViewLayoutTransition) -> Void)? if let back = back { self.backButtonNode = BackButtonNode() self.backButtonNode?.action = { back() } } var reactions: [(String?, Int)] = [] var totalCount: Int = 0 if let reactionsAttribute = message._asMessage().reactionsAttribute { for listReaction in reactionsAttribute.reactions { if reaction == nil || listReaction.value == reaction { totalCount += Int(listReaction.count) reactions.append((listReaction.value, Int(listReaction.count))) } } } if reaction == nil { reactions.insert((nil, totalCount), at: 0) } if reactions.count > 2 && totalCount > 10 { self.tabListNode = ReactionTabListNode(context: context, availableReactions: availableReactions, reactions: reactions, message: message) } self.reactions = reactions super.init() if self.backButtonNode != nil || self.tabListNode != nil { self.separatorNode = ASDisplayNode() } if let backButtonNode = self.backButtonNode { self.addSubnode(backButtonNode) } if let tabListNode = self.tabListNode { self.addSubnode(tabListNode) } if let separatorNode = self.separatorNode { self.addSubnode(separatorNode) } self.tabListNode?.action = { [weak self] reaction in guard let strongSelf = self else { return } guard let tabIndex = strongSelf.reactions.firstIndex(where: { $0.0 == reaction }) else { return } guard strongSelf.currentTabIndex != tabIndex else { return } strongSelf.tabListNode?.scrollToTabReaction = ReactionTabListNode.ScrollToTabReaction(value: reaction) strongSelf.currentTabIndex = tabIndex /*let currentTabNode = ReactionsTabNode( context: context, availableReactions: availableReactions, message: message, reaction: reaction, readStats: nil, requestUpdate: { tab, transition in requestUpdateTab?(tab, transition) }, requestUpdateApparentHeight: { tab, transition in requestUpdateTabApparentHeight?(tab, transition) }, openPeer: { id in openPeer(id) } ) strongSelf.currentTabNode = currentTabNode strongSelf.addSubnode(currentTabNode)*/ strongSelf.requestUpdate(.animated(duration: 0.45, curve: .spring)) } /*requestUpdateTab = { [weak self] tab, transition in guard let strongSelf = self else { return } if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) { strongSelf.requestUpdate(transition) } } requestUpdateTabApparentHeight = { [weak self] tab, transition in guard let strongSelf = self else { return } if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) { strongSelf.requestUpdateApparentHeight(transition) } }*/ let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] point in guard let strongSelf = self else { return [] } if strongSelf.currentTabIndex == 0 { return .left } return [.left, .right] }) panRecognizer.delegate = self self.view.addGestureRecognizer(panRecognizer) } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return false } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer { return false } if let _ = otherGestureRecognizer as? UIPanGestureRecognizer { return true } return false } @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: break case .changed: let translation = recognizer.translation(in: self.view) if !self.bounds.isEmpty { let progress = translation.x / self.bounds.width var toIndex: Int if progress < 0.0 { toIndex = self.currentTabIndex + 1 } else { toIndex = self.currentTabIndex - 1 } toIndex = max(0, min(toIndex, self.reactions.count - 1)) self.interactiveTransitionState = InteractiveTransitionState(toIndex: toIndex, progress: abs(progress)) self.requestUpdate(.immediate) } case .cancelled, .ended: if let interactiveTransitionState = self.interactiveTransitionState { self.interactiveTransitionState = nil if interactiveTransitionState.progress >= 0.2 { self.currentTabIndex = interactiveTransitionState.toIndex self.tabListNode?.scrollToTabReaction = ReactionTabListNode.ScrollToTabReaction(value: self.reactions[self.currentTabIndex].0) } self.requestUpdate(.animated(duration: 0.45, curve: .spring)) } default: break } } func update(presentationData: PresentationData, constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, apparentHeight: CGFloat) { let constrainedSize = CGSize(width: min(260.0, constrainedWidth), height: maxHeight) var topContentHeight: CGFloat = 0.0 if let backButtonNode = self.backButtonNode { let backButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 44.0)) backButtonNode.update(size: backButtonFrame.size, presentationData: self.presentationData, isLast: self.tabListNode == nil) transition.updateFrame(node: backButtonNode, frame: backButtonFrame) topContentHeight += backButtonFrame.height } if let tabListNode = self.tabListNode { let tabListFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 44.0)) let selectedReaction: String? = self.reactions[self.currentTabIndex].0 tabListNode.update(size: tabListFrame.size, presentationData: self.presentationData, selectedReaction: selectedReaction, transition: transition) transition.updateFrame(node: tabListNode, frame: tabListFrame) topContentHeight += tabListFrame.height } if let separatorNode = self.separatorNode { let separatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 7.0)) separatorNode.backgroundColor = self.presentationData.theme.contextMenu.sectionSeparatorColor transition.updateFrame(node: separatorNode, frame: separatorFrame) topContentHeight += separatorFrame.height } var tabLayouts: [Int: (height: CGFloat, apparentHeight: CGFloat)] = [:] var visibleIndices: [Int] = [] visibleIndices.append(self.currentTabIndex) if let interactiveTransitionState = self.interactiveTransitionState { visibleIndices.append(interactiveTransitionState.toIndex) } let previousVisibleTabFrames: [(Int, CGRect)] = self.visibleTabNodes.map { key, value -> (Int, CGRect) in return (key, value.frame) } for index in visibleIndices { var tabTransition = transition let tabNode: ReactionsTabNode var initialReferenceFrame: CGRect? if let current = self.visibleTabNodes[index] { tabNode = current } else { for (previousIndex, previousFrame) in previousVisibleTabFrames { if index > previousIndex { initialReferenceFrame = previousFrame.offsetBy(dx: constrainedSize.width, dy: 0.0) } else { initialReferenceFrame = previousFrame.offsetBy(dx: -constrainedSize.width, dy: 0.0) } break } tabNode = ReactionsTabNode( context: self.context, availableReactions: self.availableReactions, message: self.message, reaction: self.reactions[index].0, readStats: self.reactions[index].0 == nil ? self.readStats : nil, requestUpdate: { [weak self] tab, transition in guard let strongSelf = self else { return } if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) { var transition = transition if strongSelf.interactiveTransitionState != nil { transition = .immediate } strongSelf.requestUpdate(transition) } }, requestUpdateApparentHeight: { [weak self] tab, transition in guard let strongSelf = self else { return } if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) { var transition = transition if strongSelf.interactiveTransitionState != nil { transition = .immediate } strongSelf.requestUpdateApparentHeight(transition) } }, openPeer: self.openPeer ) self.addSubnode(tabNode) self.visibleTabNodes[index] = tabNode tabTransition = .immediate } let tabLayout = tabNode.update(presentationData: presentationData, constrainedSize: CGSize(width: constrainedSize.width, height: constrainedSize.height - topContentHeight), transition: tabTransition) tabLayouts[index] = tabLayout let currentFractionalTabIndex: CGFloat if let interactiveTransitionState = self.interactiveTransitionState { currentFractionalTabIndex = CGFloat(self.currentTabIndex) * (1.0 - interactiveTransitionState.progress) + CGFloat(interactiveTransitionState.toIndex) * interactiveTransitionState.progress } else { currentFractionalTabIndex = CGFloat(self.currentTabIndex) } let xOffset: CGFloat = (CGFloat(index) - currentFractionalTabIndex) * constrainedSize.width let tabFrame = CGRect(origin: CGPoint(x: xOffset, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: tabLayout.height + 100.0)) tabTransition.updateFrame(node: tabNode, frame: tabFrame) if let initialReferenceFrame = initialReferenceFrame { transition.animatePositionAdditive(node: tabNode, offset: CGPoint(x: initialReferenceFrame.minX - tabFrame.minX, y: 0.0)) } } var removedIndices: [Int] = [] for (index, tabNode) in self.visibleTabNodes { if tabLayouts[index] == nil { removedIndices.append(index) var xOffset: CGFloat if index > self.currentTabIndex { xOffset = constrainedSize.width } else { xOffset = -constrainedSize.width } transition.updateFrame(node: tabNode, frame: CGRect(origin: CGPoint(x: xOffset, y: tabNode.frame.minY), size: tabNode.bounds.size), completion: { [weak tabNode] _ in tabNode?.removeFromSupernode() }) } } for index in removedIndices { self.visibleTabNodes.removeValue(forKey: index) } /*var currentTabTransition = transition if self.currentTabNode.bounds.isEmpty { currentTabTransition = .immediate } let currentTabLayout = self.currentTabNode.update(presentationData: presentationData, constrainedSize: CGSize(width: constrainedSize.width, height: constrainedSize.height - topContentHeight), transition: currentTabTransition) currentTabTransition.updateFrame(node: self.currentTabNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: currentTabLayout.size.width, height: currentTabLayout.size.height + 100.0))) if let dismissedTabNode = self.dismissedTabNode { self.dismissedTabNode = nil if let previousIndex = self.reactions.firstIndex(where: { $0.0 == dismissedTabNode.reaction }), let currentIndex = self.reactions.firstIndex(where: { $0.0 == self.currentTabNode.reaction }) { let offset = previousIndex < currentIndex ? currentTabLayout.size.width : -currentTabLayout.size.width transition.updateFrame(node: dismissedTabNode, frame: dismissedTabNode.frame.offsetBy(dx: -offset, dy: 0.0), completion: { [weak dismissedTabNode] _ in dismissedTabNode?.removeFromSupernode() }) transition.animatePositionAdditive(node: self.currentTabNode, offset: CGPoint(x: offset, y: 0.0)) } else { dismissedTabNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak dismissedTabNode] _ in dismissedTabNode?.removeFromSupernode() }) self.currentTabNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } }*/ var contentSize = CGSize(width: constrainedSize.width, height: topContentHeight) var apparentHeight = topContentHeight if let interactiveTransitionState = self.interactiveTransitionState, let fromTabLayout = tabLayouts[self.currentTabIndex], let toTabLayout = tabLayouts[interactiveTransitionState.toIndex] { let megedTabLayoutHeight = fromTabLayout.height * (1.0 - interactiveTransitionState.progress) + toTabLayout.height * interactiveTransitionState.progress let megedTabLayoutApparentHeight = fromTabLayout.apparentHeight * (1.0 - interactiveTransitionState.progress) + toTabLayout.apparentHeight * interactiveTransitionState.progress contentSize.height += megedTabLayoutHeight apparentHeight += megedTabLayoutApparentHeight } else if let tabLayout = tabLayouts[self.currentTabIndex] { contentSize.height += tabLayout.height apparentHeight += tabLayout.apparentHeight } return (contentSize, apparentHeight) } } let context: AccountContext let availableReactions: AvailableReactions? let message: EngineMessage let reaction: String? let readStats: MessageReadStats? let back: (() -> Void)? let openPeer: (PeerId) -> Void public init( context: AccountContext, availableReactions: AvailableReactions?, message: EngineMessage, reaction: String?, readStats: MessageReadStats?, back: (() -> Void)?, openPeer: @escaping (PeerId) -> Void ) { self.context = context self.availableReactions = availableReactions self.message = message self.reaction = reaction self.readStats = readStats self.back = back self.openPeer = openPeer } public func node( requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void ) -> ContextControllerItemsNode { return ItemsNode( context: self.context, availableReactions: self.availableReactions, message: self.message, reaction: self.reaction, readStats: self.readStats, requestUpdate: requestUpdate, requestUpdateApparentHeight: requestUpdateApparentHeight, back: self.back, openPeer: self.openPeer ) } }