Swiftgram/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift
2023-08-08 23:23:47 +03:00

1619 lines
72 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
import AnimationUI
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 let additionalIconNode: ASImageNode
private var badgeIconNode: ASImageNode?
private var animationNode: AnimationNode?
private var currentBadge: (badge: ContextMenuActionBadge, image: UIImage)?
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.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
self.additionalIconNode = ASImageNode()
self.additionalIconNode.isAccessibilityElement = false
self.additionalIconNode.isUserInteractionEnabled = false
super.init()
self.isAccessibilityElement = true
self.accessibilityLabel = item.text
self.accessibilityTraits = [.button]
self.addSubnode(self.highlightBackgroundNode)
self.addSubnode(self.titleLabelNode)
self.addSubnode(self.subtitleNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.additionalIconNode)
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()
}
override func didLoad() {
super.didLoad()
self.view.isExclusiveTouch = true
}
@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()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.titleLabelNode.tapAttributeAction != nil {
if let result = self.titleLabelNode.hitTest(self.view.convert(point, to: self.titleLabelNode.view), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
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
}
var forcedHeight: CGFloat?
var titleVerticalOffset: CGFloat?
let titleFont: UIFont
let titleBoldFont: UIFont
switch self.item.textFont {
case let .custom(font, height, verticalOffset):
titleFont = font
titleBoldFont = font
forcedHeight = height
titleVerticalOffset = verticalOffset
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: titleBoldFont, textColor: presentationData.theme.list.itemAccentColor),
linkAttribute: { value in return ("URL", value) }
)
)
self.titleLabelNode.attributedText = attributedText
self.titleLabelNode.linkHighlightColor = presentationData.theme.list.itemAccentColor.withMultipliedAlpha(0.5)
self.titleLabelNode.highlightAttributeAction = { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] {
return NSAttributedString.Key(rawValue: "URL")
} else {
return nil
}
}
self.titleLabelNode.tapAttributeAction = { [weak item] attributes, _ in
if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] {
item?.textLinkAction()
}
}
} else {
self.titleLabelNode.attributedText = NSAttributedString(
string: self.item.text,
font: titleFont,
textColor: titleColor)
}
self.titleLabelNode.isUserInteractionEnabled = self.titleLabelNode.tapAttributeAction != nil && self.item.action == nil
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 if let animationName = self.item.animationName {
if self.animationNode == nil {
let animationNode = AnimationNode(animation: animationName, colors: ["__allcolors__": titleColor], scale: 1.0)
animationNode.loop(count: 3)
self.addSubnode(animationNode)
self.animationNode = animationNode
}
iconSize = CGSize(width: 24.0, height: 24.0)
} else {
let iconImage = self.item.icon(presentationData.theme)
self.iconNode.image = iconImage
iconSize = iconImage?.size
}
let additionalIcon = self.item.additionalLeftIcon?(presentationData.theme)
var additionalIconSize: CGSize?
self.additionalIconNode.image = additionalIcon
if let additionalIcon {
additionalIconSize = additionalIcon.size
}
let badgeSize: CGSize?
if let badge = self.item.badge {
var badgeImage: UIImage?
if let currentBadge = self.currentBadge, currentBadge.badge == badge {
badgeImage = currentBadge.image
} else {
switch badge.style {
case .badge:
let badgeTextColor: UIColor = presentationData.theme.list.itemCheckColors.foregroundColor
let badgeString = NSAttributedString(string: badge.value, font: Font.regular(13.0), textColor: badgeTextColor)
let badgeTextBounds = badgeString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil)
let badgeSideInset: CGFloat = 5.0
let badgeVerticalInset: CGFloat = 1.0
var badgeBackgroundSize = CGSize(width: badgeSideInset * 2.0 + ceil(badgeTextBounds.width), height: badgeVerticalInset * 2.0 + ceil(badgeTextBounds.height))
badgeBackgroundSize.width = max(badgeBackgroundSize.width, badgeBackgroundSize.height)
badgeImage = generateImage(badgeBackgroundSize, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(presentationData.theme.list.itemCheckColors.fillColor.cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.height * 0.5).cgPath)
context.fillPath()
UIGraphicsPushContext(context)
badgeString.draw(at: CGPoint(x: badgeTextBounds.minX + floor((badgeBackgroundSize.width - badgeTextBounds.width) * 0.5), y: badgeTextBounds.minY + badgeVerticalInset))
UIGraphicsPopContext()
})
case .label:
let badgeTextColor: UIColor = presentationData.theme.list.itemCheckColors.foregroundColor
let badgeString = NSAttributedString(string: badge.value, font: Font.semibold(11.0), textColor: badgeTextColor)
let badgeTextBounds = badgeString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil)
let badgeSideInset: CGFloat = 3.0
let badgeVerticalInset: CGFloat = 1.0
let badgeBackgroundSize = CGSize(width: badgeSideInset * 2.0 + ceil(badgeTextBounds.width), height: badgeVerticalInset * 2.0 + ceil(badgeTextBounds.height))
badgeImage = generateImage(badgeBackgroundSize, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(presentationData.theme.list.itemCheckColors.fillColor.cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 5.0).cgPath)
context.fillPath()
UIGraphicsPushContext(context)
badgeString.draw(at: CGPoint(x: badgeTextBounds.minX + badgeSideInset + UIScreenPixel, y: badgeTextBounds.minY + badgeVerticalInset + UIScreenPixel))
UIGraphicsPopContext()
})
}
}
let badgeIconNode: ASImageNode
if let current = self.badgeIconNode {
badgeIconNode = current
} else {
badgeIconNode = ASImageNode()
self.badgeIconNode = badgeIconNode
self.addSubnode(badgeIconNode)
}
badgeIconNode.image = badgeImage
badgeSize = badgeImage?.size
} else {
if let badgeIconNode = self.badgeIconNode {
self.badgeIconNode = nil
badgeIconNode.removeFromSupernode()
}
badgeSize = nil
}
var maxTextWidth: CGFloat = constrainedSize.width
maxTextWidth -= sideInset
if let iconSize = iconSize {
maxTextWidth -= max(standardIconWidth, iconSize.width)
maxTextWidth -= iconSpacing
} else {
maxTextWidth -= sideInset
}
if let badgeSize = badgeSize {
maxTextWidth -= badgeSize.width
maxTextWidth -= 8.0
}
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
}
if let forcedHeight {
minSize.height = forcedHeight
} else {
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
var titleFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: titleSize)
if let titleVerticalOffset {
titleFrame = titleFrame.offsetBy(dx: 0.0, dy: titleVerticalOffset)
}
var subtitleFrame = CGRect(origin: CGPoint(x: sideInset, y: titleFrame.maxY + titleSubtitleSpacing), size: subtitleSize)
if self.item.additionalLeftIcon != nil {
titleFrame = titleFrame.offsetBy(dx: 26.0, dy: 0.0)
subtitleFrame = subtitleFrame.offsetBy(dx: 26.0, dy: 0.0)
} else if self.item.iconPosition == .left {
titleFrame = titleFrame.offsetBy(dx: 36.0, dy: 0.0)
subtitleFrame = subtitleFrame.offsetBy(dx: 36.0, dy: 0.0)
}
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 badgeIconNode = self.badgeIconNode {
if let iconSize = badgeIconNode.image?.size {
transition.updateFrame(node: badgeIconNode, frame: CGRect(origin: CGPoint(x: titleFrame.maxX + 8.0, y: titleFrame.minY + floor((titleFrame.height - iconSize.height) * 0.5)), size: iconSize))
}
}
if let iconSize = iconSize {
let iconWidth = max(standardIconWidth, iconSize.width)
let iconFrame = CGRect(
origin: CGPoint(
x: self.item.iconPosition == .left ? iconSideInset : 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)
if let animationNode = self.animationNode {
transition.updateFrame(node: animationNode, frame: iconFrame, beginWithCurrentState: true)
}
}
if let additionalIconSize {
let iconFrame = CGRect(
origin: CGPoint(
x: 10.0,
y: floor((size.height - additionalIconSize.height) / 2.0)
),
size: additionalIconSize
)
transition.updateFrame(node: self.additionalIconNode, 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 let requestDismiss: (ContextMenuActionResult) -> Void
private var presentationData: PresentationData?
private var itemNode: ContextMenuCustomNode?
init(
getController: @escaping () -> ContextControllerProtocol?,
item: ContextMenuCustomItem,
requestDismiss: @escaping (ContextMenuActionResult) -> Void
) {
self.getController = getController
self.item = item
self.requestDismiss = requestDismiss
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
switch result {
case .dismissWithoutContent/* where ContextMenuActionResult.safeStreamRecordingDismissWithoutContent == .dismissWithoutContent*/:
self.requestDismiss(result)
default: break
}
}
)
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,
requestDismiss: requestDismiss
),
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 .twoLists(listItems1, listItems2):
return [ContextControllerActionsListStackItem(items: listItems1, reactionItems: nil, tip: nil, tipSignal: nil), ContextControllerActionsListStackItem(items: listItems2, reactionItems: nil, tip: nil, tipSignal: nil)]
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
case additional
}
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
case .additional:
self.backgroundNode.updateColor(color: presentationData.theme.contextMenu.backgroundColor.withMultipliedAlpha(0.5), 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, 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<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, 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.insertSubnode(tip.tipNode.shadowNode, at: 0)
self.addSubnode(tip.tipNode)
animateTipIn = transition.isAnimated
let tipFrame = CGRect(origin: CGPoint(x: previousNavigationContainerFrame.minX, y: previousNavigationContainerFrame.maxY + tipSpacing), size: CGSize(width: itemLayouts[i].size.width, height: tip.tipHeight))
tip.tipNode.frame = tipFrame
tip.tipNode.setActualSize(size: tipFrame.size, transition: .immediate)
transition.updateFrame(node: tip.tipNode.shadowNode, frame: tipFrame.insetBy(dx: -30.0, dy: -30.0))
}
let tipAlpha: CGFloat = itemLayouts[i].alphaTransitionFraction
let tipFrame = CGRect(origin: CGPoint(x: navigationContainerFrame.minX, y: navigationContainerFrame.maxY + tipSpacing), size: CGSize(width: itemLayouts[i].size.width, height: tip.tipHeight))
tipTransition.updateFrame(node: tip.tipNode, frame: tipFrame, beginWithCurrentState: true)
transition.updateFrame(node: tip.tipNode.shadowNode, frame: tipFrame.insetBy(dx: -30.0, dy: -30.0))
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)
tip.tipNode.shadowNode.alpha = tipAlpha
tip.tipNode.shadowNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
} else {
tipTransition.updateAlpha(node: tip.tipNode, alpha: tipAlpha, beginWithCurrentState: true)
tipTransition.updateAlpha(node: tip.tipNode.shadowNode, 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 {
let tipFrame = CGRect(origin: CGPoint(x: navigationContainerFrame.minX, y: navigationContainerFrame.maxY + tipSpacing), size: tipNode.frame.size)
transition.updateFrame(node: tipNode, frame: tipFrame, beginWithCurrentState: true)
transition.updateFrame(node: tipNode.shadowNode, frame: tipFrame.insetBy(dx: -30.0, dy: -30.0))
transition.updateAlpha(node: tipNode, alpha: 0.0, completion: { [weak tipNode] _ in
tipNode?.removeFromSupernode()
})
let shadowNode = tipNode.shadowNode
transition.updateAlpha(node: shadowNode, alpha: 0.0, completion: { [weak shadowNode] _ in
shadowNode?.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()
}
}
}
}