mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-15 21:45:19 +00:00
1808 lines
78 KiB
Swift
1808 lines
78 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
|
|
import ComponentFlow
|
|
import LottieComponent
|
|
|
|
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 struct ContextControllerReactionItems {
|
|
public var context: AccountContext
|
|
public var reactionItems: [ReactionContextItem]
|
|
public var selectedReactionItems: Set<MessageReaction.Reaction>
|
|
public var reactionsTitle: String?
|
|
public var animationCache: AnimationCache
|
|
public var alwaysAllowPremiumReactions: Bool
|
|
public var allPresetReactionsAreAvailable: Bool
|
|
public var getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?
|
|
|
|
public init(context: AccountContext, reactionItems: [ReactionContextItem], selectedReactionItems: Set<MessageReaction.Reaction>, reactionsTitle: String?, animationCache: AnimationCache, alwaysAllowPremiumReactions: Bool, allPresetReactionsAreAvailable: Bool, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal<EmojiPagerContentComponent, NoError>)?) {
|
|
self.context = context
|
|
self.reactionItems = reactionItems
|
|
self.selectedReactionItems = selectedReactionItems
|
|
self.reactionsTitle = reactionsTitle
|
|
self.animationCache = animationCache
|
|
self.alwaysAllowPremiumReactions = alwaysAllowPremiumReactions
|
|
self.allPresetReactionsAreAvailable = allPresetReactionsAreAvailable
|
|
self.getEmojiContent = getEmojiContent
|
|
}
|
|
}
|
|
|
|
public protocol ContextControllerActionsStackItem: AnyObject {
|
|
func node(
|
|
getController: @escaping () -> ContextControllerProtocol?,
|
|
requestDismiss: @escaping (ContextMenuActionResult) -> Void,
|
|
requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void,
|
|
requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void
|
|
) -> ContextControllerActionsStackItemNode
|
|
|
|
var id: AnyHashable? { get }
|
|
var tip: ContextController.Tip? { get }
|
|
var tipSignal: Signal<ContextController.Tip?, NoError>? { get }
|
|
var reactionItems: ContextControllerReactionItems? { get }
|
|
var dismissed: (() -> Void)? { 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 var 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 currentAnimatedIconContent: ContextMenuActionItem.IconAnimation?
|
|
private var animatedIcon: ComponentView<Empty>?
|
|
|
|
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 setItem(item: ContextMenuActionItem) {
|
|
self.item = item
|
|
self.accessibilityLabel = item.text
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
var iconSize: CGSize?
|
|
if let iconSource = self.item.iconSource {
|
|
iconSize = iconSource.size
|
|
self.iconNode.cornerRadius = iconSource.cornerRadius
|
|
self.iconNode.contentMode = iconSource.contentMode
|
|
self.iconNode.clipsToBounds = true
|
|
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
|
|
}).strict()
|
|
}
|
|
} 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
|
|
}
|
|
|
|
if let iconAnimation = self.item.iconAnimation {
|
|
let animatedIcon: ComponentView<Empty>
|
|
if let current = self.animatedIcon {
|
|
animatedIcon = current
|
|
} else {
|
|
animatedIcon = ComponentView()
|
|
self.animatedIcon = animatedIcon
|
|
}
|
|
|
|
let animatedIconSize = CGSize(width: 24.0, height: 24.0)
|
|
let _ = animatedIcon.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(LottieComponent(
|
|
content: LottieComponent.AppBundleContent(name: iconAnimation.name),
|
|
color: titleColor,
|
|
startingPosition: .end,
|
|
loop: false
|
|
)),
|
|
environment: {},
|
|
containerSize: animatedIconSize
|
|
)
|
|
|
|
iconSize = animatedIconSize
|
|
} else if let animatedIcon = self.animatedIcon {
|
|
self.animatedIcon = nil
|
|
animatedIcon.view?.removeFromSuperview()
|
|
}
|
|
|
|
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 self.item.additionalLeftIcon != nil {
|
|
minSize.width += 24.0
|
|
minSize.width += iconSideInset
|
|
minSize.width += iconSpacing
|
|
}
|
|
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 animatedIconView = self.animatedIcon?.view {
|
|
if animatedIconView.superview == nil {
|
|
self.view.addSubview(animatedIconView)
|
|
animatedIconView.frame = iconFrame
|
|
} else {
|
|
transition.updateFrame(view: animatedIconView, frame: iconFrame, beginWithCurrentState: true)
|
|
if let currentAnimatedIconContent = self.currentAnimatedIconContent, currentAnimatedIconContent != self.item.iconAnimation {
|
|
if let animatedIconView = animatedIconView as? LottieComponent.View {
|
|
animatedIconView.playOnce()
|
|
}
|
|
}
|
|
}
|
|
|
|
self.currentAnimatedIconContent = self.item.iconAnimation
|
|
}
|
|
}
|
|
|
|
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:
|
|
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 {
|
|
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 let getController: () -> ContextControllerProtocol?
|
|
private let requestDismiss: (ContextMenuActionResult) -> Void
|
|
private var items: [ContextMenuItem]
|
|
private var itemNodes: [Item]
|
|
|
|
private var hapticFeedback: HapticFeedback?
|
|
private var highlightedItemNode: Item?
|
|
|
|
private var invalidatedItemNodes: Bool = false
|
|
|
|
var wantsFullWidth: Bool {
|
|
return false
|
|
}
|
|
|
|
init(
|
|
getController: @escaping () -> ContextControllerProtocol?,
|
|
requestDismiss: @escaping (ContextMenuActionResult) -> Void,
|
|
requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void,
|
|
items: [ContextMenuItem]
|
|
) {
|
|
self.requestUpdate = requestUpdate
|
|
self.getController = getController
|
|
self.requestDismiss = requestDismiss
|
|
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 self else {
|
|
return
|
|
}
|
|
self.requestUpdateAction(id: id, action: action)
|
|
}
|
|
}
|
|
|
|
func updateItems(items: [ContextMenuItem]) {
|
|
self.items = items
|
|
for i in 0 ..< items.count {
|
|
if self.itemNodes.count < i {
|
|
break
|
|
}
|
|
if case let .action(action) = items[i] {
|
|
if let itemNode = self.itemNodes[i].node as? ContextControllerActionsListActionItemNode {
|
|
itemNode.setItem(item: action)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestUpdateAction(id: AnyHashable, action: ContextMenuActionItem) {
|
|
loop: for i in 0 ..< self.items.count {
|
|
switch self.items[i] {
|
|
case let .action(currentAction):
|
|
if currentAction.id == id {
|
|
let previousNode = self.itemNodes[i]
|
|
previousNode.node.removeFromSupernode()
|
|
previousNode.separatorNode?.removeFromSupernode()
|
|
|
|
let addedNode = Item(
|
|
node: ContextControllerActionsListActionItemNode(
|
|
getController: self.getController,
|
|
requestDismiss: self.requestDismiss,
|
|
requestUpdateAction: { [weak self] id, action in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.requestUpdateAction(id: id, action: action)
|
|
},
|
|
item: action
|
|
),
|
|
separatorNode: ASDisplayNode()
|
|
)
|
|
self.itemNodes[i] = addedNode
|
|
if let separatorNode = addedNode.separatorNode {
|
|
self.insertSubnode(separatorNode, at: 0)
|
|
}
|
|
self.addSubnode(addedNode.node)
|
|
|
|
self.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
|
|
}
|
|
self.invalidatedItemNodes = false
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
let id: AnyHashable?
|
|
let items: [ContextMenuItem]
|
|
let reactionItems: ContextControllerReactionItems?
|
|
let tip: ContextController.Tip?
|
|
let tipSignal: Signal<ContextController.Tip?, NoError>?
|
|
let dismissed: (() -> Void)?
|
|
|
|
init(
|
|
id: AnyHashable?,
|
|
items: [ContextMenuItem],
|
|
reactionItems: ContextControllerReactionItems?,
|
|
tip: ContextController.Tip?,
|
|
tipSignal: Signal<ContextController.Tip?, NoError>?,
|
|
dismissed: (() -> Void)?
|
|
) {
|
|
self.id = id
|
|
self.items = items
|
|
self.reactionItems = reactionItems
|
|
self.tip = tip
|
|
self.tipSignal = tipSignal
|
|
self.dismissed = dismissed
|
|
}
|
|
|
|
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() {
|
|
}
|
|
}
|
|
|
|
let id: AnyHashable?
|
|
private let content: ContextControllerItemsContent
|
|
let reactionItems: ContextControllerReactionItems?
|
|
let tip: ContextController.Tip?
|
|
let tipSignal: Signal<ContextController.Tip?, NoError>?
|
|
let dismissed: (() -> Void)?
|
|
|
|
init(
|
|
id: AnyHashable?,
|
|
content: ContextControllerItemsContent,
|
|
reactionItems: ContextControllerReactionItems?,
|
|
tip: ContextController.Tip?,
|
|
tipSignal: Signal<ContextController.Tip?, NoError>?,
|
|
dismissed: (() -> Void)?
|
|
) {
|
|
self.id = id
|
|
self.content = content
|
|
self.reactionItems = reactionItems
|
|
self.tip = tip
|
|
self.tipSignal = tipSignal
|
|
self.dismissed = dismissed
|
|
}
|
|
|
|
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: ContextControllerReactionItems?
|
|
if let context = items.context, let animationCache = items.animationCache, !items.reactionItems.isEmpty {
|
|
reactionItems = ContextControllerReactionItems(
|
|
context: context,
|
|
reactionItems: items.reactionItems,
|
|
selectedReactionItems: items.selectedReactionItems,
|
|
reactionsTitle: items.reactionsTitle,
|
|
animationCache: animationCache,
|
|
alwaysAllowPremiumReactions: items.alwaysAllowPremiumReactions,
|
|
allPresetReactionsAreAvailable: items.allPresetReactionsAreAvailable,
|
|
getEmojiContent: items.getEmojiContent
|
|
)
|
|
}
|
|
switch items.content {
|
|
case let .list(listItems):
|
|
return [ContextControllerActionsListStackItem(id: items.id, items: listItems, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal, dismissed: items.dismissed)]
|
|
case let .twoLists(listItems1, listItems2):
|
|
return [ContextControllerActionsListStackItem(id: items.id, items: listItems1, reactionItems: nil, tip: nil, tipSignal: nil, dismissed: items.dismissed), ContextControllerActionsListStackItem(id: nil, items: listItems2, reactionItems: nil, tip: nil, tipSignal: nil, dismissed: nil)]
|
|
case let .custom(customContent):
|
|
return [ContextControllerActionsCustomStackItem(id: items.id, content: customContent, reactionItems: reactionItems, tip: items.tip, tipSignal: items.tipSignal, dismissed: items.dismissed)]
|
|
}
|
|
}
|
|
|
|
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 item: ContextControllerActionsStackItem
|
|
let node: ContextControllerActionsStackItemNode
|
|
let dimNode: ASDisplayNode
|
|
var tip: ContextController.Tip?
|
|
let tipSignal: Signal<ContextController.Tip?, NoError>?
|
|
var tipNode: InnerTextSelectionTipContainerNode?
|
|
let reactionItems: ContextControllerReactionItems?
|
|
let itemDismissed: (() -> Void)?
|
|
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: ContextControllerReactionItems?,
|
|
itemDismissed: (() -> Void)?,
|
|
positionLock: CGFloat?
|
|
) {
|
|
self.getController = getController
|
|
self.requestUpdate = requestUpdate
|
|
self.item = item
|
|
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.itemDismissed = itemDismissed
|
|
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)
|
|
}).strict()
|
|
}
|
|
}
|
|
|
|
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: ContextControllerReactionItems? {
|
|
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?) {
|
|
if let item = item as? ContextControllerActionsListStackItem, let topContainer = self.itemContainers.first, let topItem = topContainer.item as? ContextControllerActionsListStackItem, let topId = topItem.id, let id = item.id, topId == id, item.items.count == topItem.items.count {
|
|
if let topNode = topContainer.node as? ContextControllerActionsListStackItem.Node {
|
|
var matches = true
|
|
for i in 0 ..< item.items.count {
|
|
switch item.items[i] {
|
|
case .action:
|
|
if case .action = topItem.items[i] {
|
|
} else {
|
|
matches = false
|
|
}
|
|
case .custom:
|
|
if case .custom = topItem.items[i] {
|
|
} else {
|
|
matches = false
|
|
}
|
|
case .separator:
|
|
if case .separator = topItem.items[i] {
|
|
} else {
|
|
matches = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches {
|
|
topNode.updateItems(items: item.items)
|
|
self.requestUpdate(.animated(duration: 0.3, curve: .spring))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
var resolvedAnimated = false
|
|
if let animated {
|
|
resolvedAnimated = animated
|
|
} else {
|
|
if let id = item.id, let lastId = self.itemContainers.last?.item.id {
|
|
if id != lastId {
|
|
resolvedAnimated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for itemContainer in self.itemContainers {
|
|
if resolvedAnimated {
|
|
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: resolvedAnimated)
|
|
}
|
|
|
|
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,
|
|
itemDismissed: item.dismissed,
|
|
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))
|
|
|
|
itemContainer.itemDismissed?()
|
|
}
|
|
|
|
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
|
|
if topItemApparentHeight > 0.0 {
|
|
navigationContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: topItemWidth, height: max(14 * 2.0, topItemApparentHeight)))
|
|
} else {
|
|
navigationContainerFrame = .zero
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|