import Foundation import UIKit import AsyncDisplayKit import Display import TelegramPresentationData import TextSelectionNode import TelegramCore import SwiftSignalKit import AccountContext import ReactionSelectionNode import Markdown import EntityKeyboard import AnimationCache import MultiAnimationRenderer public protocol ContextControllerActionsStackItemNode: ASDisplayNode { var wantsFullWidth: Bool { get } func update( presentationData: PresentationData, constrainedSize: CGSize, standardMinWidth: CGFloat, standardMaxWidth: CGFloat, additionalBottomInset: CGFloat, transition: ContainedViewLayoutTransition ) -> (size: CGSize, apparentHeight: CGFloat) func highlightGestureMoved(location: CGPoint) func highlightGestureFinished(performAction: Bool) func decreaseHighlightedIndex() func increaseHighlightedIndex() } public protocol ContextControllerActionsStackItem: AnyObject { func node( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void ) -> ContextControllerActionsStackItemNode var tip: ContextController.Tip? { get } var tipSignal: Signal? { get } var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? { get } } protocol ContextControllerActionsListItemNode: ASDisplayNode { func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) func canBeHighlighted() -> Bool func updateIsHighlighted(isHighlighted: Bool) func performAction() } private final class ContextControllerActionsListActionItemNode: HighlightTrackingButtonNode, ContextControllerActionsListItemNode { private let getController: () -> ContextControllerProtocol? private let requestDismiss: (ContextMenuActionResult) -> Void private let requestUpdateAction: (AnyHashable, ContextMenuActionItem) -> Void private let item: ContextMenuActionItem private let highlightBackgroundNode: ASDisplayNode private let titleLabelNode: ImmediateTextNode private let subtitleNode: ImmediateTextNode private let iconNode: ASImageNode private var iconDisposable: Disposable? init( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdateAction: @escaping (AnyHashable, ContextMenuActionItem) -> Void, item: ContextMenuActionItem ) { self.getController = getController self.requestDismiss = requestDismiss self.requestUpdateAction = requestUpdateAction self.item = item self.highlightBackgroundNode = ASDisplayNode() self.highlightBackgroundNode.isAccessibilityElement = false self.highlightBackgroundNode.isUserInteractionEnabled = false self.highlightBackgroundNode.alpha = 0.0 self.titleLabelNode = ImmediateTextNode() self.titleLabelNode.isAccessibilityElement = false self.titleLabelNode.displaysAsynchronously = false self.titleLabelNode.isUserInteractionEnabled = false self.subtitleNode = ImmediateTextNode() self.subtitleNode.isAccessibilityElement = false self.subtitleNode.displaysAsynchronously = false self.subtitleNode.isUserInteractionEnabled = false self.iconNode = ASImageNode() self.iconNode.isAccessibilityElement = false self.iconNode.isUserInteractionEnabled = false super.init() self.isAccessibilityElement = true self.accessibilityLabel = item.text self.addSubnode(self.highlightBackgroundNode) self.addSubnode(self.titleLabelNode) self.addSubnode(self.subtitleNode) self.addSubnode(self.iconNode) self.isEnabled = self.canBeHighlighted() self.highligthedChanged = { [weak self] highlighted in guard let strongSelf = self else { return } if highlighted { strongSelf.highlightBackgroundNode.alpha = 1.0 } else { strongSelf.highlightBackgroundNode.alpha = 0.0 } } self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) } deinit { self.iconDisposable?.dispose() } @objc private func pressed() { guard let controller = self.getController() else { return } self.item.action?(ContextMenuActionItem.Action( controller: controller, dismissWithResult: { [weak self] result in guard let strongSelf = self else { return } strongSelf.requestDismiss(result) }, updateAction: { [weak self] id, updatedAction in guard let strongSelf = self else { return } strongSelf.requestUpdateAction(id, updatedAction) } )) } func canBeHighlighted() -> Bool { return self.item.action != nil } func updateIsHighlighted(isHighlighted: Bool) { self.highlightBackgroundNode.alpha = isHighlighted ? 1.0 : 0.0 } func performAction() { self.pressed() } func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { let sideInset: CGFloat = 16.0 let verticalInset: CGFloat = 11.0 let titleSubtitleSpacing: CGFloat = 1.0 let iconSideInset: CGFloat = 12.0 let standardIconWidth: CGFloat = 32.0 let iconSpacing: CGFloat = 8.0 self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor var subtitle: String? switch self.item.textLayout { case .singleLine: self.titleLabelNode.maximumNumberOfLines = 1 case .twoLinesMax: self.titleLabelNode.maximumNumberOfLines = 2 case let .secondLineWithValue(subtitleValue): self.titleLabelNode.maximumNumberOfLines = 1 subtitle = subtitleValue case .multiline: self.titleLabelNode.maximumNumberOfLines = 0 self.titleLabelNode.lineSpacing = 0.1 } let titleFont: UIFont let titleBoldFont: UIFont switch self.item.textFont { case let .custom(font): titleFont = font titleBoldFont = font case .small: let smallTextFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0)) titleFont = smallTextFont titleBoldFont = Font.semibold(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0)) case .regular: titleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) titleBoldFont = Font.semibold(presentationData.listsFontSize.baseDisplaySize) } let subtitleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0) let subtitleColor = presentationData.theme.contextMenu.secondaryColor let titleColor: UIColor switch self.item.textColor { case .primary: titleColor = presentationData.theme.contextMenu.primaryColor case .destructive: titleColor = presentationData.theme.contextMenu.destructiveColor case .disabled: titleColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4) } if self.item.parseMarkdown { let attributedText = parseMarkdownIntoAttributedString( self.item.text, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: titleFont, textColor: titleColor), bold: MarkdownAttributeSet(font: titleBoldFont, textColor: titleColor), link: MarkdownAttributeSet(font: titleFont, textColor: titleColor), linkAttribute: { _ in return nil } ) ) self.titleLabelNode.attributedText = attributedText } else { self.titleLabelNode.attributedText = NSAttributedString( string: self.item.text, font: titleFont, textColor: titleColor) } self.subtitleNode.attributedText = subtitle.flatMap { subtitle in return NSAttributedString( string: subtitle, font: subtitleFont, textColor: subtitleColor ) } let iconSize: CGSize? if let iconSource = self.item.iconSource { iconSize = iconSource.size if self.iconDisposable == nil { self.iconDisposable = (iconSource.signal |> deliverOnMainQueue).start(next: { [weak self] image in guard let strongSelf = self else { return } strongSelf.iconNode.image = image }) } } else if let image = self.iconNode.image { iconSize = image.size } else { let iconImage = self.item.icon(presentationData.theme) self.iconNode.image = iconImage iconSize = iconImage?.size } var maxTextWidth: CGFloat = constrainedSize.width maxTextWidth -= sideInset if let iconSize = iconSize { maxTextWidth -= max(standardIconWidth, iconSize.width) maxTextWidth -= iconSpacing } else { maxTextWidth -= sideInset } maxTextWidth = max(1.0, maxTextWidth) let titleSize = self.titleLabelNode.updateLayout(CGSize(width: maxTextWidth, height: 1000.0)) let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: maxTextWidth, height: 1000.0)) var minSize = CGSize() minSize.width += sideInset minSize.width += max(titleSize.width, subtitleSize.width) if let iconSize = iconSize { minSize.width += max(standardIconWidth, iconSize.width) minSize.width += iconSideInset minSize.width += iconSpacing } else { minSize.width += sideInset } minSize.height += verticalInset * 2.0 minSize.height += titleSize.height if subtitle != nil { minSize.height += titleSubtitleSpacing minSize.height += subtitleSize.height } return (minSize: minSize, apply: { size, transition in let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: titleSize) let subtitleFrame = CGRect(origin: CGPoint(x: sideInset, y: titleFrame.maxY + titleSubtitleSpacing), size: subtitleSize) transition.updateFrame(node: self.highlightBackgroundNode, frame: CGRect(origin: CGPoint(), size: size), beginWithCurrentState: true) transition.updateFrameAdditive(node: self.titleLabelNode, frame: titleFrame) transition.updateFrameAdditive(node: self.subtitleNode, frame: subtitleFrame) if let iconSize = iconSize { let iconWidth = max(standardIconWidth, iconSize.width) let iconFrame = CGRect(origin: CGPoint(x: size.width - iconSideInset - iconWidth + floor((iconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize) transition.updateFrame(node: self.iconNode, frame: iconFrame, beginWithCurrentState: true) } }) } } private final class ContextControllerActionsListSeparatorItemNode: ASDisplayNode, ContextControllerActionsListItemNode { func canBeHighlighted() -> Bool { return false } func updateIsHighlighted(isHighlighted: Bool) { } func performAction() { } override init() { super.init() } func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { return (minSize: CGSize(width: 0.0, height: 7.0), apply: { _, _ in self.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor }) } } private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, ContextControllerActionsListItemNode { func canBeHighlighted() -> Bool { if let itemNode = self.itemNode { return itemNode.canBeHighlighted() } else { return false } } func updateIsHighlighted(isHighlighted: Bool) { if let itemNode = self.itemNode { itemNode.updateIsHighlighted(isHighlighted: isHighlighted) } } func performAction() { if let itemNode = self.itemNode { itemNode.performAction() } } private let getController: () -> ContextControllerProtocol? private let item: ContextMenuCustomItem private var presentationData: PresentationData? private var itemNode: ContextMenuCustomNode? init( getController: @escaping () -> ContextControllerProtocol?, item: ContextMenuCustomItem ) { self.getController = getController self.item = item super.init() } func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { if self.presentationData?.theme !== presentationData.theme { if let itemNode = self.itemNode { itemNode.updateTheme(presentationData: presentationData) } } self.presentationData = presentationData let itemNode: ContextMenuCustomNode if let current = self.itemNode { itemNode = current } else { itemNode = self.item.node( presentationData: presentationData, getController: self.getController, actionSelected: { result in let _ = result } ) self.itemNode = itemNode self.addSubnode(itemNode) } let itemLayoutAndApply = itemNode.updateLayout(constrainedWidth: constrainedSize.width, constrainedHeight: constrainedSize.height) return (minSize: itemLayoutAndApply.0, apply: { size, transition in transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(), size: size), beginWithCurrentState: true) itemLayoutAndApply.1(size, transition) }) } } final class ContextControllerActionsListStackItem: ContextControllerActionsStackItem { private final class Node: ASDisplayNode, ContextControllerActionsStackItemNode { private final class Item { let node: ContextControllerActionsListItemNode let separatorNode: ASDisplayNode? init(node: ContextControllerActionsListItemNode, separatorNode: ASDisplayNode?) { self.node = node self.separatorNode = separatorNode } } private let requestUpdate: (ContainedViewLayoutTransition) -> Void private var items: [ContextMenuItem] private var itemNodes: [Item] private var hapticFeedback: HapticFeedback? private var highlightedItemNode: Item? var wantsFullWidth: Bool { return false } init( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, items: [ContextMenuItem] ) { self.requestUpdate = requestUpdate self.items = items var requestUpdateAction: ((AnyHashable, ContextMenuActionItem) -> Void)? self.itemNodes = items.map { item -> Item in switch item { case let .action(actionItem): return Item( node: ContextControllerActionsListActionItemNode( getController: getController, requestDismiss: requestDismiss, requestUpdateAction: { id, action in requestUpdateAction?(id, action) }, item: actionItem ), separatorNode: ASDisplayNode() ) case .separator: return Item( node: ContextControllerActionsListSeparatorItemNode(), separatorNode: nil ) case let .custom(customItem, _): return Item( node: ContextControllerActionsListCustomItemNode( getController: getController, item: customItem ), separatorNode: ASDisplayNode() ) } } super.init() for item in self.itemNodes { if let separatorNode = item.separatorNode { self.addSubnode(separatorNode) } } for item in self.itemNodes { self.addSubnode(item.node) } requestUpdateAction = { [weak self] id, action in guard let strongSelf = self else { return } loop: for i in 0 ..< strongSelf.items.count { switch strongSelf.items[i] { case let .action(currentAction): if currentAction.id == id { let previousNode = strongSelf.itemNodes[i] previousNode.node.removeFromSupernode() previousNode.separatorNode?.removeFromSupernode() let addedNode = Item( node: ContextControllerActionsListActionItemNode( getController: getController, requestDismiss: requestDismiss, requestUpdateAction: { id, action in requestUpdateAction?(id, action) }, item: action ), separatorNode: ASDisplayNode() ) strongSelf.itemNodes[i] = addedNode if let separatorNode = addedNode.separatorNode { strongSelf.insertSubnode(separatorNode, at: 0) } strongSelf.addSubnode(addedNode.node) strongSelf.requestUpdate(.immediate) break loop } default: break } } } } func update( presentationData: PresentationData, constrainedSize: CGSize, standardMinWidth: CGFloat, standardMaxWidth: CGFloat, additionalBottomInset: CGFloat, transition: ContainedViewLayoutTransition ) -> (size: CGSize, apparentHeight: CGFloat) { var itemNodeLayouts: [(minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void)] = [] var combinedSize = CGSize() for item in self.itemNodes { item.separatorNode?.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor let itemNodeLayout = item.node.update( presentationData: presentationData, constrainedSize: CGSize(width: standardMaxWidth, height: constrainedSize.height) ) itemNodeLayouts.append(itemNodeLayout) combinedSize.width = max(combinedSize.width, itemNodeLayout.minSize.width) combinedSize.height += itemNodeLayout.minSize.height } combinedSize.width = max(combinedSize.width, standardMinWidth) var nextItemOrigin = CGPoint() for i in 0 ..< self.itemNodes.count { let item = self.itemNodes[i] let itemNodeLayout = itemNodeLayouts[i] var itemTransition = transition if item.node.frame.isEmpty { itemTransition = .immediate } let itemSize = CGSize(width: combinedSize.width, height: itemNodeLayout.minSize.height) let itemFrame = CGRect(origin: nextItemOrigin, size: itemSize) itemTransition.updateFrame(node: item.node, frame: itemFrame, beginWithCurrentState: true) if let separatorNode = item.separatorNode { itemTransition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: itemFrame.minX, y: itemFrame.maxY), size: CGSize(width: itemFrame.width, height: UIScreenPixel)), beginWithCurrentState: true) if i != self.itemNodes.count - 1 { switch self.items[i + 1] { case .separator: separatorNode.isHidden = true case .action: separatorNode.isHidden = false case .custom: separatorNode.isHidden = false } } else { separatorNode.isHidden = true } } itemNodeLayout.apply(itemSize, itemTransition) nextItemOrigin.y += itemSize.height } return (combinedSize, combinedSize.height) } func highlightGestureMoved(location: CGPoint) { var highlightedItemNode: Item? for itemNode in self.itemNodes { if itemNode.node.frame.contains(location) { if itemNode.node.canBeHighlighted() { highlightedItemNode = itemNode } break } } if self.highlightedItemNode !== highlightedItemNode { self.highlightedItemNode?.node.updateIsHighlighted(isHighlighted: false) highlightedItemNode?.node.updateIsHighlighted(isHighlighted: true) self.highlightedItemNode = highlightedItemNode if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() } } func highlightGestureFinished(performAction: Bool) { if let highlightedItemNode = self.highlightedItemNode { self.highlightedItemNode = nil highlightedItemNode.node.updateIsHighlighted(isHighlighted: false) if performAction { highlightedItemNode.node.performAction() } } } func decreaseHighlightedIndex() { let previousHighlightedItemNode: Item? = self.highlightedItemNode if let highlightedItemNode = self.highlightedItemNode, let index = self.itemNodes.firstIndex(where: { $0 === highlightedItemNode }) { self.highlightedItemNode = self.itemNodes[max(0, index - 1)] } else { self.highlightedItemNode = self.itemNodes.first } if previousHighlightedItemNode !== self.highlightedItemNode { previousHighlightedItemNode?.node.updateIsHighlighted(isHighlighted: false) self.highlightedItemNode?.node.updateIsHighlighted(isHighlighted: true) } } func increaseHighlightedIndex() { let previousHighlightedItemNode: Item? = self.highlightedItemNode if let highlightedItemNode = self.highlightedItemNode, let index = self.itemNodes.firstIndex(where: { $0 === highlightedItemNode }) { self.highlightedItemNode = self.itemNodes[min(self.itemNodes.count - 1, index + 1)] } else { self.highlightedItemNode = self.itemNodes.first } if previousHighlightedItemNode !== self.highlightedItemNode { previousHighlightedItemNode?.node.updateIsHighlighted(isHighlighted: false) self.highlightedItemNode?.node.updateIsHighlighted(isHighlighted: true) } } } private let items: [ContextMenuItem] let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? let tip: ContextController.Tip? let tipSignal: Signal? init( items: [ContextMenuItem], reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, tip: ContextController.Tip?, tipSignal: Signal? ) { self.items = items self.reactionItems = reactionItems self.tip = tip self.tipSignal = tipSignal } func node( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void ) -> ContextControllerActionsStackItemNode { return Node( getController: getController, requestDismiss: requestDismiss, requestUpdate: requestUpdate, items: self.items ) } } final class ContextControllerActionsCustomStackItem: ContextControllerActionsStackItem { private final class Node: ASDisplayNode, ContextControllerActionsStackItemNode { private let requestUpdate: (ContainedViewLayoutTransition) -> Void private let contentNode: ContextControllerItemsNode init( content: ContextControllerItemsContent, getController: @escaping () -> ContextControllerProtocol?, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void ) { self.requestUpdate = requestUpdate self.contentNode = content.node(requestUpdate: { transition in requestUpdate(transition) }, requestUpdateApparentHeight: { transition in requestUpdateApparentHeight(transition) }) super.init() self.addSubnode(self.contentNode) } var wantsFullWidth: Bool { return true } func update( presentationData: PresentationData, constrainedSize: CGSize, standardMinWidth: CGFloat, standardMaxWidth: CGFloat, additionalBottomInset: CGFloat, transition: ContainedViewLayoutTransition ) -> (size: CGSize, apparentHeight: CGFloat) { let contentLayout = self.contentNode.update( presentationData: presentationData, constrainedWidth: constrainedSize.width, maxHeight: constrainedSize.height, bottomInset: additionalBottomInset, transition: transition ) transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(), size: contentLayout.cleanSize), beginWithCurrentState: true) return (contentLayout.cleanSize, contentLayout.apparentHeight) } func highlightGestureMoved(location: CGPoint) { } func highlightGestureFinished(performAction: Bool) { } func decreaseHighlightedIndex() { } func increaseHighlightedIndex() { } } private let content: ContextControllerItemsContent let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? let tip: ContextController.Tip? let tipSignal: Signal? init( content: ContextControllerItemsContent, reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, tip: ContextController.Tip?, tipSignal: Signal? ) { self.content = content self.reactionItems = reactionItems self.tip = tip self.tipSignal = tipSignal } func node( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void ) -> ContextControllerActionsStackItemNode { return Node( content: self.content, getController: getController, requestUpdate: requestUpdate, requestUpdateApparentHeight: requestUpdateApparentHeight ) } } func makeContextControllerActionsStackItem(items: ContextController.Items) -> ContextControllerActionsStackItem { var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? if let context = items.context, let animationCache = items.animationCache, !items.reactionItems.isEmpty { reactionItems = (context, items.reactionItems, items.selectedReactionItems, animationCache, items.getEmojiContent) } switch items.content { case let .list(listItems): return ContextControllerActionsListStackItem(items: listItems, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal) case let .custom(customContent): return ContextControllerActionsCustomStackItem(content: customContent, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal) } } final class ContextControllerActionsStackNode: ASDisplayNode { enum Presentation { case modal case inline } final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelegate { let backgroundNode: NavigationBackgroundNode let parentShadowNode: ASImageNode var requestUpdate: ((ContainedViewLayoutTransition) -> Void)? var requestPop: (() -> Void)? var transitionFraction: CGFloat = 0.0 private var panRecognizer: InteractiveTransitionGestureRecognizer? var isNavigationEnabled: Bool = false { didSet { self.panRecognizer?.isEnabled = self.isNavigationEnabled } } override init() { self.backgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: false) self.parentShadowNode = ASImageNode() self.parentShadowNode.image = UIImage(bundleImageName: "Components/Context Menu/Shadow")?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 48) super.init() self.addSubnode(self.backgroundNode) self.clipsToBounds = true self.cornerRadius = 14.0 let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] point in guard let strongSelf = self else { return [] } let _ = strongSelf return [.right] }) panRecognizer.delegate = self self.view.addGestureRecognizer(panRecognizer) self.panRecognizer = 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: self.transitionFraction = 0.0 case .changed: let distanceFactor: CGFloat = recognizer.translation(in: self.view).x / self.bounds.width let transitionFraction = max(0.0, min(1.0, distanceFactor)) if self.transitionFraction != transitionFraction { self.transitionFraction = transitionFraction self.requestUpdate?(.immediate) } case .ended, .cancelled: let distanceFactor: CGFloat = recognizer.translation(in: self.view).x / self.bounds.width let transitionFraction = max(0.0, min(1.0, distanceFactor)) if transitionFraction > 0.2 { self.transitionFraction = 0.0 self.requestPop?() } else { self.transitionFraction = 0.0 self.requestUpdate?(.animated(duration: 0.45, curve: .spring)) } default: break } } func update(presentationData: PresentationData, presentation: Presentation, size: CGSize, transition: ContainedViewLayoutTransition) { switch presentation { case .modal: self.backgroundNode.updateColor(color: presentationData.theme.contextMenu.backgroundColor, enableBlur: false, forceKeepBlur: false, transition: transition) self.parentShadowNode.isHidden = true case .inline: self.backgroundNode.updateColor(color: presentationData.theme.contextMenu.backgroundColor, enableBlur: true, forceKeepBlur: true, transition: transition) self.parentShadowNode.isHidden = false } self.backgroundNode.update(size: size, transition: transition) } } final class ItemContainer: ASDisplayNode { let getController: () -> ContextControllerProtocol? let requestUpdate: (ContainedViewLayoutTransition) -> Void let node: ContextControllerActionsStackItemNode let dimNode: ASDisplayNode var tip: ContextController.Tip? let tipSignal: Signal? var tipNode: InnerTextSelectionTipContainerNode? let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? var storedScrollingState: CGFloat? let positionLock: CGFloat? private var tipDisposable: Disposable? init( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void, item: ContextControllerActionsStackItem, tip: ContextController.Tip?, tipSignal: Signal?, reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)?, positionLock: CGFloat? ) { self.getController = getController self.requestUpdate = requestUpdate self.node = item.node( getController: getController, requestDismiss: requestDismiss, requestUpdate: requestUpdate, requestUpdateApparentHeight: requestUpdateApparentHeight ) self.dimNode = ASDisplayNode() self.dimNode.isUserInteractionEnabled = false self.dimNode.alpha = 0.0 self.reactionItems = reactionItems self.positionLock = positionLock self.tip = tip self.tipSignal = tipSignal super.init() self.clipsToBounds = true self.addSubnode(self.node) self.addSubnode(self.dimNode) if let tipSignal = tipSignal { self.tipDisposable = (tipSignal |> deliverOnMainQueue).start(next: { [weak self] tip in guard let strongSelf = self else { return } strongSelf.tip = tip requestUpdate(.immediate) }) } } deinit { self.tipDisposable?.dispose() } func update( presentationData: PresentationData, constrainedSize: CGSize, standardMinWidth: CGFloat, standardMaxWidth: CGFloat, additionalBottomInset: CGFloat, transitionFraction: CGFloat, transition: ContainedViewLayoutTransition ) -> (size: CGSize, apparentHeight: CGFloat) { let (size, apparentHeight) = self.node.update( presentationData: presentationData, constrainedSize: constrainedSize, standardMinWidth: standardMinWidth, standardMaxWidth: standardMaxWidth, additionalBottomInset: additionalBottomInset, transition: transition ) let maxScaleOffset: CGFloat = 10.0 let scaleOffset: CGFloat = 0.0 * transitionFraction + maxScaleOffset * (1.0 - transitionFraction) let scale: CGFloat = (size.width - scaleOffset) / size.width let yOffset: CGFloat = size.height * (1.0 - scale) let transitionOffset = (1.0 - transitionFraction) * size.width / 2.0 transition.updatePosition(node: self.node, position: CGPoint(x: size.width / 2.0 + scaleOffset / 2.0 + transitionOffset, y: size.height / 2.0 - yOffset / 2.0), beginWithCurrentState: true) transition.updateBounds(node: self.node, bounds: CGRect(origin: CGPoint(), size: size), beginWithCurrentState: true) transition.updateTransformScale(node: self.node, scale: scale, beginWithCurrentState: true) return (size, apparentHeight) } func updateTip(presentationData: PresentationData, presentation: ContextControllerActionsStackNode.Presentation, width: CGFloat, transition: ContainedViewLayoutTransition) -> (node: InnerTextSelectionTipContainerNode, height: CGFloat)? { if let tip = self.tip { var updatedTransition = transition if let tipNode = self.tipNode, tipNode.tip == tip { } else { let previousTipNode = self.tipNode updatedTransition = .immediate let tipNode = InnerTextSelectionTipContainerNode(presentationData: presentationData, tip: tip) tipNode.requestDismiss = { [weak self] completion in self?.getController()?.dismiss(completion: completion) } self.tipNode = tipNode if let previousTipNode = previousTipNode { previousTipNode.animateTransitionInside(other: tipNode) previousTipNode.removeFromSupernode() tipNode.animateContentIn() } } if let tipNode = self.tipNode { let size = tipNode.updateLayout(widthClass: .compact, presentation: presentation, width: width, transition: updatedTransition) return (tipNode, size.height) } else { return nil } } else { return nil } } func updateDimNode(presentationData: PresentationData, size: CGSize, transitionFraction: CGFloat, transition: ContainedViewLayoutTransition) { self.dimNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: size), beginWithCurrentState: true) transition.updateAlpha(node: self.dimNode, alpha: 1.0 - transitionFraction, beginWithCurrentState: true) } func highlightGestureMoved(location: CGPoint) { if let tipNode = self.tipNode { let tipLocation = self.view.convert(location, to: tipNode.view) tipNode.highlightGestureMoved(location: tipLocation) } self.node.highlightGestureMoved(location: self.view.convert(location, to: self.node.view)) } func highlightGestureFinished(performAction: Bool) { if let tipNode = self.tipNode { tipNode.highlightGestureFinished(performAction: performAction) } self.node.highlightGestureFinished(performAction: performAction) } func decreaseHighlightedIndex() { self.node.decreaseHighlightedIndex() } func increaseHighlightedIndex() { self.node.increaseHighlightedIndex() } } private let getController: () -> ContextControllerProtocol? private let requestDismiss: (ContextMenuActionResult) -> Void private let requestUpdate: (ContainedViewLayoutTransition) -> Void private let navigationContainer: NavigationContainer private var itemContainers: [ItemContainer] = [] private var dismissingItemContainers: [(container: ItemContainer, isPopped: Bool)] = [] private var selectionPanGesture: UIPanGestureRecognizer? var topReactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?)? { return self.itemContainers.last?.reactionItems } var topPositionLock: CGFloat? { return self.itemContainers.last?.positionLock } var storedScrollingState: CGFloat? { return self.itemContainers.last?.storedScrollingState } init( getController: @escaping () -> ContextControllerProtocol?, requestDismiss: @escaping (ContextMenuActionResult) -> Void, requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void ) { self.getController = getController self.requestDismiss = requestDismiss self.requestUpdate = requestUpdate self.navigationContainer = NavigationContainer() super.init() self.addSubnode(self.navigationContainer.parentShadowNode) self.addSubnode(self.navigationContainer) self.navigationContainer.requestUpdate = { [weak self] transition in guard let strongSelf = self else { return } strongSelf.requestUpdate(transition) } self.navigationContainer.requestPop = { [weak self] in guard let strongSelf = self else { return } strongSelf.pop() } let selectionPanGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) self.selectionPanGesture = selectionPanGesture self.view.addGestureRecognizer(selectionPanGesture) selectionPanGesture.isEnabled = false } @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .changed: let location = recognizer.location(in: self.view) self.highlightGestureMoved(location: location) case .ended: self.highlightGestureFinished(performAction: true) case .cancelled: self.highlightGestureFinished(performAction: false) default: break } } func replace(item: ContextControllerActionsStackItem, animated: Bool) { for itemContainer in self.itemContainers { if animated { self.dismissingItemContainers.append((itemContainer, false)) } else { itemContainer.tipNode?.removeFromSupernode() itemContainer.removeFromSupernode() } } self.itemContainers.removeAll() self.navigationContainer.isNavigationEnabled = self.itemContainers.count > 1 self.push(item: item, currentScrollingState: nil, positionLock: nil, animated: animated) } func push(item: ContextControllerActionsStackItem, currentScrollingState: CGFloat?, positionLock: CGFloat?, animated: Bool) { if let itemContainer = self.itemContainers.last { itemContainer.storedScrollingState = currentScrollingState } let itemContainer = ItemContainer( getController: self.getController, requestDismiss: self.requestDismiss, requestUpdate: self.requestUpdate, requestUpdateApparentHeight: { [weak self] transition in guard let strongSelf = self else { return } strongSelf.requestUpdate(transition) }, item: item, tip: item.tip, tipSignal: item.tipSignal, reactionItems: item.reactionItems, positionLock: positionLock ) self.itemContainers.append(itemContainer) self.navigationContainer.addSubnode(itemContainer) self.navigationContainer.isNavigationEnabled = self.itemContainers.count > 1 let transition: ContainedViewLayoutTransition if animated { transition = .animated(duration: self.itemContainers.count == 1 ? 0.3 : 0.45, curve: .spring) } else { transition = .immediate } self.requestUpdate(transition) } func clearStoredScrollingState() { self.itemContainers.last?.storedScrollingState = nil } func pop() { if self.itemContainers.count == 1 { //dismiss } else { let itemContainer = self.itemContainers[self.itemContainers.count - 1] self.itemContainers.remove(at: self.itemContainers.count - 1) self.dismissingItemContainers.append((itemContainer, true)) } self.navigationContainer.isNavigationEnabled = self.itemContainers.count > 1 let transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring) self.requestUpdate(transition) } func update( presentationData: PresentationData, constrainedSize: CGSize, presentation: Presentation, transition: ContainedViewLayoutTransition ) -> CGSize { let tipSpacing: CGFloat = 10.0 let animateAppearingContainers = transition.isAnimated && !self.dismissingItemContainers.isEmpty struct TipLayout { var tipNode: InnerTextSelectionTipContainerNode var tipHeight: CGFloat } struct ItemLayout { var size: CGSize var apparentHeight: CGFloat var transitionFraction: CGFloat var alphaTransitionFraction: CGFloat var itemTransition: ContainedViewLayoutTransition var animateAppearingContainer: Bool var tip: TipLayout? } var topItemSize = CGSize() var itemLayouts: [ItemLayout] = [] for i in 0 ..< self.itemContainers.count { let itemContainer = self.itemContainers[i] var animateAppearingContainer = false var itemContainerTransition = transition if itemContainer.bounds.isEmpty { itemContainerTransition = .immediate animateAppearingContainer = i == self.itemContainers.count - 1 && animateAppearingContainers || self.itemContainers.count > 1 } let itemConstrainedHeight: CGFloat = constrainedSize.height let transitionFraction: CGFloat let alphaTransitionFraction: CGFloat if i == self.itemContainers.count - 1 { transitionFraction = self.navigationContainer.transitionFraction alphaTransitionFraction = 1.0 } else if i == self.itemContainers.count - 2 { transitionFraction = self.navigationContainer.transitionFraction - 1.0 alphaTransitionFraction = self.navigationContainer.transitionFraction } else { transitionFraction = 0.0 alphaTransitionFraction = 0.0 } var tip: TipLayout? let itemContainerConstrainedSize: CGSize let standardMinWidth: CGFloat let standardMaxWidth: CGFloat let additionalBottomInset: CGFloat if itemContainer.node.wantsFullWidth { itemContainerConstrainedSize = CGSize(width: constrainedSize.width, height: itemConstrainedHeight) standardMaxWidth = 240.0 standardMinWidth = standardMaxWidth if let (tipNode, tipHeight) = itemContainer.updateTip(presentationData: presentationData, presentation: presentation, width: standardMaxWidth, transition: itemContainerTransition) { tip = TipLayout(tipNode: tipNode, tipHeight: tipHeight) additionalBottomInset = tipHeight + 10.0 } else { additionalBottomInset = 0.0 } } else { itemContainerConstrainedSize = CGSize(width: constrainedSize.width, height: itemConstrainedHeight) standardMinWidth = 220.0 standardMaxWidth = 240.0 additionalBottomInset = 0.0 } let itemSize = itemContainer.update( presentationData: presentationData, constrainedSize: itemContainerConstrainedSize, standardMinWidth: standardMinWidth, standardMaxWidth: standardMaxWidth, additionalBottomInset: additionalBottomInset, transitionFraction: alphaTransitionFraction, transition: itemContainerTransition ) if i == self.itemContainers.count - 1 { topItemSize = itemSize.size } if !itemContainer.node.wantsFullWidth { if let (tipNode, tipHeight) = itemContainer.updateTip(presentationData: presentationData, presentation: presentation, width: itemSize.size.width, transition: itemContainerTransition) { tip = TipLayout(tipNode: tipNode, tipHeight: tipHeight) } } itemLayouts.append(ItemLayout( size: itemSize.size, apparentHeight: itemSize.apparentHeight, transitionFraction: transitionFraction, alphaTransitionFraction: alphaTransitionFraction, itemTransition: itemContainerTransition, animateAppearingContainer: animateAppearingContainer, tip: tip )) } let topItemApparentHeight: CGFloat let topItemWidth: CGFloat if itemLayouts.isEmpty { topItemApparentHeight = 0.0 topItemWidth = 0.0 } else if itemLayouts.count == 1 { topItemApparentHeight = itemLayouts[0].apparentHeight topItemWidth = itemLayouts[0].size.width } else { let lastItemLayout = itemLayouts[itemLayouts.count - 1] let previousItemLayout = itemLayouts[itemLayouts.count - 2] let transitionFraction = self.navigationContainer.transitionFraction topItemApparentHeight = lastItemLayout.apparentHeight * (1.0 - transitionFraction) + previousItemLayout.apparentHeight * transitionFraction topItemWidth = lastItemLayout.size.width * (1.0 - transitionFraction) + previousItemLayout.size.width * transitionFraction } let navigationContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: topItemWidth, height: max(14 * 2.0, topItemApparentHeight))) let previousNavigationContainerFrame = self.navigationContainer.frame transition.updateFrame(node: self.navigationContainer, frame: navigationContainerFrame, beginWithCurrentState: true) self.navigationContainer.update(presentationData: presentationData, presentation: presentation, size: navigationContainerFrame.size, transition: transition) let navigationContainerShadowFrame = navigationContainerFrame.insetBy(dx: -30.0, dy: -30.0) transition.updateFrame(node: self.navigationContainer.parentShadowNode, frame: navigationContainerShadowFrame, beginWithCurrentState: true) for i in 0 ..< self.itemContainers.count { let xOffset: CGFloat if itemLayouts[i].transitionFraction < 0.0 { xOffset = itemLayouts[i].transitionFraction * itemLayouts[i].size.width } else { if i != 0 { xOffset = itemLayouts[i].transitionFraction * itemLayouts[i - 1].size.width } else { xOffset = itemLayouts[i].transitionFraction * topItemWidth } } let itemFrame = CGRect(origin: CGPoint(x: xOffset, y: 0.0), size: CGSize(width: itemLayouts[i].size.width, height: navigationContainerFrame.height)) itemLayouts[i].itemTransition.updateFrame(node: self.itemContainers[i], frame: itemFrame, beginWithCurrentState: true) if itemLayouts[i].animateAppearingContainer { transition.animatePositionAdditive(node: self.itemContainers[i], offset: CGPoint(x: itemFrame.width, y: 0.0)) } self.itemContainers[i].updateDimNode(presentationData: presentationData, size: CGSize(width: itemLayouts[i].size.width, height: navigationContainerFrame.size.height), transitionFraction: itemLayouts[i].alphaTransitionFraction, transition: transition) if let tip = itemLayouts[i].tip { let tipTransition = transition var animateTipIn = false if tip.tipNode.supernode == nil { self.addSubnode(tip.tipNode) animateTipIn = transition.isAnimated tip.tipNode.frame = CGRect(origin: CGPoint(x: previousNavigationContainerFrame.minX, y: previousNavigationContainerFrame.maxY + tipSpacing), size: CGSize(width: itemLayouts[i].size.width, height: tip.tipHeight)) tip.tipNode.setActualSize(size: tip.tipNode.bounds.size, transition: .immediate) } let tipAlpha: CGFloat = itemLayouts[i].alphaTransitionFraction tipTransition.updateFrame(node: tip.tipNode, frame: CGRect(origin: CGPoint(x: navigationContainerFrame.minX, y: navigationContainerFrame.maxY + tipSpacing), size: CGSize(width: itemLayouts[i].size.width, height: tip.tipHeight)), beginWithCurrentState: true) tip.tipNode.setActualSize(size: tip.tipNode.bounds.size, transition: tipTransition) if animateTipIn { tip.tipNode.alpha = tipAlpha tip.tipNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } else { tipTransition.updateAlpha(node: tip.tipNode, alpha: tipAlpha, beginWithCurrentState: true) } if i == self.itemContainers.count - 1 { topItemSize.height += tipSpacing + tip.tipHeight } } } for (itemContainer, isPopped) in self.dismissingItemContainers { var position = itemContainer.position if isPopped { position.x = itemContainer.bounds.width / 2.0 + topItemWidth } else { position.x = itemContainer.bounds.width / 2.0 - topItemWidth } transition.updatePosition(node: itemContainer, position: position, completion: { [weak itemContainer] _ in itemContainer?.removeFromSupernode() }) if let tipNode = itemContainer.tipNode { transition.updateFrame(node: tipNode, frame: CGRect(origin: CGPoint(x: navigationContainerFrame.minX, y: navigationContainerFrame.maxY + tipSpacing), size: tipNode.frame.size), beginWithCurrentState: true) transition.updateAlpha(node: tipNode, alpha: 0.0, completion: { [weak tipNode] _ in tipNode?.removeFromSupernode() }) } } self.dismissingItemContainers.removeAll() return CGSize(width: topItemWidth, height: topItemSize.height) } func highlightGestureMoved(location: CGPoint) { if let topItemContainer = self.itemContainers.last { topItemContainer.highlightGestureMoved(location: self.view.convert(location, to: topItemContainer.view)) } } func highlightGestureFinished(performAction: Bool) { if let topItemContainer = self.itemContainers.last { topItemContainer.highlightGestureFinished(performAction: performAction) } } func decreaseHighlightedIndex() { if let topItemContainer = self.itemContainers.last { topItemContainer.decreaseHighlightedIndex() } } func increaseHighlightedIndex() { if let topItemContainer = self.itemContainers.last { topItemContainer.increaseHighlightedIndex() } } func updatePanSelection(isEnabled: Bool) { if let selectionPanGesture = self.selectionPanGesture { selectionPanGesture.isEnabled = isEnabled } } func animateIn() { for itemContainer in self.itemContainers { if let tipNode = itemContainer.tipNode { tipNode.animateIn() } } } }