Swiftgram/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift
2022-09-05 03:57:37 +04:00

1404 lines
60 KiB
Swift

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<ContextController.Tip?, NoError>? { get }
var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)? { 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<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?
let tip: ContextController.Tip?
let tipSignal: Signal<ContextController.Tip?, NoError>?
init(
items: [ContextMenuItem],
reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?,
tip: ContextController.Tip?,
tipSignal: Signal<ContextController.Tip?, NoError>?
) {
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<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?
let tip: ContextController.Tip?
let tipSignal: Signal<ContextController.Tip?, NoError>?
init(
content: ContextControllerItemsContent,
reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?,
tip: ContextController.Tip?,
tipSignal: Signal<ContextController.Tip?, NoError>?
) {
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<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?
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<ContextController.Tip?, NoError>?
var tipNode: InnerTextSelectionTipContainerNode?
let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?
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<ContextController.Tip?, NoError>?,
reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)?,
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, 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 {
if let tipNode = self.tipNode {
self.tipNode = nil
tipNode.removeFromSupernode()
}
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 tipNode = self.tipNode {
let size = tipNode.updateLayout(widthClass: .compact, 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<MessageReaction.Reaction>, animationCache: AnimationCache, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?)? {
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, 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, 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()
}
}
}
}