Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ContextUI",
module_name = "ContextUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TextSelectionNode:TextSelectionNode",
"//submodules/AppBundle:AppBundle",
"//submodules/AccountContext:AccountContext",
"//submodules/ReactionSelectionNode:ReactionSelectionNode",
"//submodules/Markdown:Markdown",
"//submodules/TextFormat:TextFormat",
"//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities",
"//submodules/TelegramUI/Components/EntityKeyboard:EntityKeyboard",
"//submodules/UndoUI:UndoUI",
"//submodules/AnimationUI:AnimationUI",
"//submodules/ComponentFlow",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/TabSelectorComponent",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/PlainButtonComponent",
"//submodules/Components/MultilineTextComponent",
"//submodules/UIKitRuntimeUtils",
"//submodules/TelegramUI/Components/EmojiStatusComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,457 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import SwiftSignalKit
import Markdown
public enum ContextActionSibling {
case none
case item
case separator
}
public protocol ContextActionNodeProtocol: ASDisplayNode {
func setIsHighlighted(_ value: Bool)
func performAction()
func actionNode(at point: CGPoint) -> ContextActionNodeProtocol
var isActionEnabled: Bool { get }
}
public final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol {
private var presentationData: PresentationData
private(set) var action: ContextMenuActionItem
private let getController: () -> ContextControllerProtocol?
private let actionSelected: (ContextMenuActionResult) -> Void
private let requestLayout: () -> Void
private let requestUpdateAction: (AnyHashable, ContextMenuActionItem) -> Void
private let backgroundNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private let statusNode: ImmediateTextNode?
private let iconNode: ASImageNode
private let titleIconNode: ASImageNode
private let badgeBackgroundNode: ASImageNode
private let badgeTextNode: ImmediateTextNode
private let buttonNode: HighlightTrackingButtonNode
private var iconDisposable: Disposable?
private var pointerInteraction: PointerInteraction?
public var isActionEnabled: Bool {
return true
}
public init(presentationData: PresentationData, action: ContextMenuActionItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, requestUpdateAction: @escaping (AnyHashable, ContextMenuActionItem) -> Void) {
self.presentationData = presentationData
self.action = action
self.getController = getController
self.actionSelected = actionSelected
self.requestLayout = requestLayout
self.requestUpdateAction = requestUpdateAction
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
let smallTextFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
let boldTextFont = Font.semibold(presentationData.listsFontSize.baseDisplaySize)
let smallBoldTextFont = Font.semibold(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isAccessibilityElement = false
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isAccessibilityElement = false
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.highlightedBackgroundNode.alpha = 0.0
self.textNode = ImmediateTextNode()
self.textNode.isAccessibilityElement = false
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
let textColor: UIColor
switch action.textColor {
case .primary:
textColor = presentationData.theme.contextMenu.primaryColor
case .destructive:
textColor = presentationData.theme.contextMenu.destructiveColor
case .disabled:
textColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)
}
let titleFont: UIFont
let titleBoldFont: UIFont
switch action.textFont {
case .regular:
titleFont = textFont
titleBoldFont = boldTextFont
case .small:
titleFont = smallTextFont
titleBoldFont = smallBoldTextFont
case let .custom(customFont, _, _):
titleFont = customFont
titleBoldFont = customFont
}
let subtitleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0)
if action.parseMarkdown {
let attributedText = parseMarkdownIntoAttributedString(action.text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: titleFont, textColor: textColor), bold: MarkdownAttributeSet(font: titleBoldFont, textColor: textColor), link: MarkdownAttributeSet(font: titleFont, textColor: textColor), linkAttribute: { _ in
return nil
}))
self.textNode.attributedText = attributedText
} else {
self.textNode.attributedText = NSAttributedString(string: action.text, font: titleFont, textColor: textColor)
}
switch action.textLayout {
case .singleLine:
self.textNode.maximumNumberOfLines = 1
self.statusNode = nil
case .twoLinesMax:
self.textNode.maximumNumberOfLines = 2
self.statusNode = nil
case let .secondLineWithValue(value):
self.textNode.maximumNumberOfLines = 1
let statusNode = ImmediateTextNode()
statusNode.isAccessibilityElement = false
statusNode.isUserInteractionEnabled = false
statusNode.displaysAsynchronously = false
statusNode.attributedText = NSAttributedString(string: value, font: subtitleFont, textColor: presentationData.theme.contextMenu.secondaryColor)
statusNode.maximumNumberOfLines = 1
self.statusNode = statusNode
case let .secondLineWithAttributedValue(value):
self.textNode.maximumNumberOfLines = 1
let statusNode = ImmediateTextNode()
statusNode.isAccessibilityElement = false
statusNode.isUserInteractionEnabled = false
statusNode.displaysAsynchronously = false
let mutableString = value.mutableCopy() as! NSMutableAttributedString
mutableString.addAttribute(.foregroundColor, value: presentationData.theme.contextMenu.secondaryColor, range: NSRange(location: 0, length: mutableString.length))
mutableString.addAttribute(.font, value: subtitleFont, range: NSRange(location: 0, length: mutableString.length))
statusNode.attributedText = mutableString
statusNode.maximumNumberOfLines = 1
self.statusNode = statusNode
case .multiline:
self.textNode.maximumNumberOfLines = 0
self.statusNode = nil
}
self.iconNode = ASImageNode()
self.iconNode.isAccessibilityElement = false
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.isUserInteractionEnabled = false
if let iconSource = action.iconSource {
self.iconNode.clipsToBounds = true
self.iconNode.contentMode = iconSource.contentMode
self.iconNode.cornerRadius = iconSource.cornerRadius
} else {
self.iconNode.image = action.icon(presentationData.theme)
}
self.titleIconNode = ASImageNode()
self.titleIconNode.isAccessibilityElement = false
self.titleIconNode.displaysAsynchronously = false
self.titleIconNode.displayWithoutProcessing = true
self.titleIconNode.isUserInteractionEnabled = false
self.titleIconNode.image = action.textIcon(presentationData.theme)
self.badgeBackgroundNode = ASImageNode()
self.badgeBackgroundNode.isAccessibilityElement = false
self.badgeBackgroundNode.displaysAsynchronously = false
self.badgeBackgroundNode.displayWithoutProcessing = true
self.badgeBackgroundNode.isUserInteractionEnabled = false
self.badgeTextNode = ImmediateTextNode()
if let badge = action.badge {
let badgeFillColor: UIColor
let badgeForegroundColor: UIColor
switch badge.color {
case .accent:
badgeForegroundColor = presentationData.theme.contextMenu.badgeForegroundColor
badgeFillColor = presentationData.theme.contextMenu.badgeFillColor
case .inactive:
badgeForegroundColor = presentationData.theme.contextMenu.badgeInactiveForegroundColor
badgeFillColor = presentationData.theme.contextMenu.badgeInactiveFillColor
}
self.badgeBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 18.0, color: badgeFillColor)
self.badgeTextNode.attributedText = NSAttributedString(string: badge.value, font: Font.regular(14.0), textColor: badgeForegroundColor)
}
self.badgeTextNode.isAccessibilityElement = false
self.badgeTextNode.isUserInteractionEnabled = false
self.badgeTextNode.displaysAsynchronously = false
self.buttonNode = HighlightTrackingButtonNode()
self.buttonNode.isAccessibilityElement = true
self.buttonNode.accessibilityLabel = action.text
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.textNode)
self.statusNode.flatMap(self.addSubnode)
self.addSubnode(self.iconNode)
self.addSubnode(self.badgeBackgroundNode)
self.addSubnode(self.badgeTextNode)
self.addSubnode(self.buttonNode)
if let _ = self.titleIconNode.image {
self.addSubnode(self.titleIconNode)
}
self.buttonNode.highligthedChanged = { [weak self] highligted in
guard let strongSelf = self else {
return
}
if highligted {
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.isUserInteractionEnabled = self.action.action != nil
if let iconSource = action.iconSource {
self.iconDisposable = (iconSource.signal
|> deliverOnMainQueue).start(next: { [weak self] image in
guard let strongSelf = self else {
return
}
strongSelf.iconNode.image = image
}).strict()
}
}
deinit {
self.iconDisposable?.dispose()
}
public override func didLoad() {
super.didLoad()
self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { [weak self] in
if let strongSelf = self {
strongSelf.highlightedBackgroundNode.alpha = 0.75
}
}, willExit: { [weak self] in
if let strongSelf = self {
strongSelf.highlightedBackgroundNode.alpha = 0.0
}
})
}
public func updateLayout(constrainedWidth: CGFloat, previous: ContextActionSibling, next: ContextActionSibling) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let sideInset: CGFloat = 16.0
let iconSideInset: CGFloat = 12.0
let verticalInset: CGFloat = 12.0
let iconSize: CGSize
if let iconSource = self.action.iconSource {
iconSize = iconSource.size
} else {
iconSize = self.iconNode.image.flatMap({ $0.size }) ?? CGSize()
}
let standardIconWidth: CGFloat = 32.0
var rightTextInset: CGFloat = sideInset
if !iconSize.width.isZero {
rightTextInset = max(iconSize.width, standardIconWidth) + iconSideInset + sideInset
}
if let iconSize = self.titleIconNode.image?.size {
rightTextInset += iconSize.width + 10.0
}
let badgeTextSize = self.badgeTextNode.updateLayout(CGSize(width: constrainedWidth, height: .greatestFiniteMagnitude))
let badgeInset: CGFloat = 4.0
let badgeSize: CGSize
let badgeWidthSpace: CGFloat
let badgeSpacing: CGFloat = 10.0
if badgeTextSize.width.isZero {
badgeSize = CGSize()
badgeWidthSpace = 0.0
} else {
badgeSize = CGSize(width: max(18.0, badgeTextSize.width + badgeInset * 2.0), height: 18.0)
badgeWidthSpace = badgeSize.width + badgeSpacing
}
let textSize = self.textNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset - badgeWidthSpace, height: .greatestFiniteMagnitude))
let statusSize = self.statusNode?.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset - badgeWidthSpace, height: .greatestFiniteMagnitude)) ?? CGSize()
if !statusSize.width.isZero, let statusNode = self.statusNode {
let verticalSpacing: CGFloat = 2.0
let combinedTextHeight = textSize.height + verticalSpacing + statusSize.height
return (CGSize(width: max(textSize.width, statusSize.width) + sideInset + rightTextInset + badgeWidthSpace, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in
let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
transition.updateFrameAdditive(node: statusNode, frame: CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin + verticalSpacing + textSize.height), size: statusSize))
let badgeFrame = CGRect(origin: CGPoint(x: textFrame.maxX + badgeSpacing, y: floor((size.height - badgeSize.height) / 2.0)), size: badgeSize)
transition.updateFrame(node: self.badgeBackgroundNode, frame: badgeFrame)
transition.updateFrame(node: self.badgeTextNode, frame: CGRect(origin: CGPoint(x: badgeFrame.minX + floorToScreenPixels((badgeFrame.width - badgeTextSize.width) / 2.0), y: badgeFrame.minY + floor((badgeFrame.height - badgeTextSize.height) / 2.0)), size: badgeTextSize))
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: size.width - standardIconWidth - iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
if let iconSize = self.titleIconNode.image?.size {
transition.updateFrame(node: self.titleIconNode, frame: CGRect(origin: CGPoint(x: self.textNode.frame.maxX + 7.0, y: floorToScreenPixels(self.textNode.frame.midY - iconSize.height / 2.0)), size: iconSize))
}
})
} else {
return (CGSize(width: textSize.width + sideInset + rightTextInset + badgeWidthSpace, height: verticalInset * 2.0 + textSize.height), { size, transition in
let verticalOrigin = floor((size.height - textSize.height) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: size.width - standardIconWidth - iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
let badgeFrame = CGRect(origin: CGPoint(x: textFrame.maxX + badgeSpacing, y: floor((size.height - badgeSize.height) / 2.0)), size: badgeSize)
transition.updateFrame(node: self.badgeBackgroundNode, frame: badgeFrame)
transition.updateFrame(node: self.badgeTextNode, frame: CGRect(origin: CGPoint(x: badgeFrame.minX + floorToScreenPixels((badgeFrame.width - badgeTextSize.width) / 2.0), y: badgeFrame.minY + floor((badgeFrame.height - badgeTextSize.height) / 2.0)), size: badgeTextSize))
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
if let iconSize = self.titleIconNode.image?.size {
transition.updateFrame(node: self.titleIconNode, frame: CGRect(origin: CGPoint(x: self.textNode.frame.maxX + 7.0, y: floorToScreenPixels(self.textNode.frame.midY - iconSize.height / 2.0)), size: iconSize))
}
})
}
}
public func updateTheme(presentationData: PresentationData) {
self.presentationData = presentationData
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
let textColor: UIColor
switch action.textColor {
case .primary:
textColor = presentationData.theme.contextMenu.primaryColor
case .destructive:
textColor = presentationData.theme.contextMenu.destructiveColor
case .disabled:
textColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)
}
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
let smallTextFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
let titleFont: UIFont
switch self.action.textFont {
case .regular:
titleFont = textFont
case .small:
titleFont = smallTextFont
case let .custom(customFont, _, _):
titleFont = customFont
}
self.textNode.attributedText = NSAttributedString(string: self.action.text, font: titleFont, textColor: textColor)
let subtitleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
switch self.action.textLayout {
case let .secondLineWithValue(value):
self.statusNode?.attributedText = NSAttributedString(string: value, font: subtitleFont, textColor: presentationData.theme.contextMenu.secondaryColor)
case let .secondLineWithAttributedValue(value):
let mutableString = value.mutableCopy() as! NSMutableAttributedString
mutableString.addAttribute(.foregroundColor, value: presentationData.theme.contextMenu.secondaryColor, range: NSRange(location: 0, length: mutableString.length))
mutableString.addAttribute(.font, value: subtitleFont, range: NSRange(location: 0, length: mutableString.length))
self.statusNode?.attributedText = mutableString
default:
break
}
if self.action.iconSource == nil {
self.iconNode.image = self.action.icon(presentationData.theme)
}
self.badgeBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 18.0, color: presentationData.theme.contextMenu.badgeFillColor)
self.badgeTextNode.attributedText = NSAttributedString(string: self.badgeTextNode.attributedText?.string ?? "", font: Font.regular(14.0), textColor: presentationData.theme.contextMenu.badgeForegroundColor)
}
@objc private func buttonPressed() {
self.performAction()
}
func updateAction(item: ContextMenuActionItem) {
self.action = item
let textColor: UIColor
switch self.action.textColor {
case .primary:
textColor = self.presentationData.theme.contextMenu.primaryColor
case .destructive:
textColor = self.presentationData.theme.contextMenu.destructiveColor
case .disabled:
textColor = self.presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)
}
let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize)
let smallTextFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
let titleFont: UIFont
switch self.action.textFont {
case .regular:
titleFont = textFont
case .small:
titleFont = smallTextFont
case let .custom(customFont, _, _):
titleFont = customFont
}
self.textNode.attributedText = NSAttributedString(string: self.action.text, font: titleFont, textColor: textColor)
if self.action.iconSource == nil {
self.iconNode.image = self.action.icon(self.presentationData.theme)
}
self.requestLayout()
}
private var performedAction = false
public func performAction() {
guard let controller = self.getController(), !self.performedAction else {
return
}
self.action.action?(ContextMenuActionItem.Action(
controller: controller,
dismissWithResult: { [weak self] result in
self?.performedAction = true
self?.actionSelected(result)
},
updateAction: { [weak self] id, updatedAction in
guard let strongSelf = self else {
return
}
strongSelf.requestUpdateAction(id, updatedAction)
}
))
}
public func setIsHighlighted(_ value: Bool) {
if value && self.buttonNode.isUserInteractionEnabled {
self.highlightedBackgroundNode.alpha = 1.0
} else {
self.highlightedBackgroundNode.alpha = 0.0
}
}
public func actionNode(at point: CGPoint) -> ContextActionNodeProtocol {
return self
}
}
@@ -0,0 +1,935 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import TextSelectionNode
import Markdown
import AppBundle
import TextFormat
import TextNodeWithEntities
import SwiftSignalKit
private final class ContextActionsSelectionGestureRecognizer: UIPanGestureRecognizer {
var updateLocation: ((CGPoint, Bool) -> Void)?
var completed: ((Bool) -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
self.updateLocation?(touches.first!.location(in: self.view), false)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
self.updateLocation?(touches.first!.location(in: self.view), true)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.completed?(true)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.completed?(false)
}
}
private enum ContextItemNode {
case action(ContextActionNode)
case custom(ContextMenuCustomNode)
case itemSeparator(ASDisplayNode)
case separator(ASDisplayNode)
}
private final class InnerActionsContainerNode: ASDisplayNode {
private let blurBackground: Bool
private let presentationData: PresentationData
private let containerNode: ASDisplayNode
private var effectView: UIVisualEffectView?
private var itemNodes: [ContextItemNode]
private let feedbackTap: () -> Void
private(set) var gesture: UIGestureRecognizer?
private var currentHighlightedActionNode: ContextActionNodeProtocol?
var panSelectionGestureEnabled: Bool = true {
didSet {
if self.panSelectionGestureEnabled != oldValue, let gesture = self.gesture {
gesture.isEnabled = self.panSelectionGestureEnabled
self.itemNodes.forEach({ itemNode in
switch itemNode {
case let .action(actionNode):
actionNode.isUserInteractionEnabled = !self.panSelectionGestureEnabled
default:
break
}
})
}
}
}
init(presentationData: PresentationData, items: [ContextMenuItem], getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) {
self.presentationData = presentationData
self.feedbackTap = feedbackTap
self.blurBackground = blurBackground
self.containerNode = ASDisplayNode()
self.containerNode.clipsToBounds = true
self.containerNode.cornerRadius = 14.0
self.containerNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor
var requestUpdateAction: ((AnyHashable, ContextMenuActionItem) -> Void)?
var itemNodes: [ContextItemNode] = []
for i in 0 ..< items.count {
switch items[i] {
case let .action(action):
itemNodes.append(.action(ContextActionNode(presentationData: presentationData, action: action, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, requestUpdateAction: { id, action in
requestUpdateAction?(id, action)
})))
if i != items.count - 1 {
switch items[i + 1] {
case .action, .custom:
let separatorNode = ASDisplayNode()
separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
itemNodes.append(.itemSeparator(separatorNode))
default:
break
}
}
case let .custom(item, _):
let itemNode = item.node(presentationData: presentationData, getController: getController, actionSelected: actionSelected)
itemNodes.append(.custom(itemNode))
if i != items.count - 1 && itemNode.needsSeparator {
switch items[i + 1] {
case .action, .custom:
let separatorNode = ASDisplayNode()
separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
itemNodes.append(.itemSeparator(separatorNode))
default:
break
}
}
case .separator:
let separatorNode = ASDisplayNode()
separatorNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor
itemNodes.append(.separator(separatorNode))
}
}
self.itemNodes = itemNodes
super.init()
requestUpdateAction = { [weak self] id, action in
guard let strongSelf = self else {
return
}
loop: for itemNode in strongSelf.itemNodes {
switch itemNode {
case let .action(contextActionNode):
if contextActionNode.action.id == id {
contextActionNode.updateAction(item: action)
break loop
}
default:
break
}
}
}
self.addSubnode(self.containerNode)
self.itemNodes.forEach({ itemNode in
switch itemNode {
case let .action(actionNode):
actionNode.isUserInteractionEnabled = false
self.containerNode.addSubnode(actionNode)
case let .custom(itemNode):
self.containerNode.addSubnode(itemNode)
case let .itemSeparator(separatorNode):
self.containerNode.addSubnode(separatorNode)
case let .separator(separatorNode):
self.containerNode.addSubnode(separatorNode)
}
})
let gesture = ContextActionsSelectionGestureRecognizer(target: nil, action: nil)
self.gesture = gesture
gesture.updateLocation = { [weak self] point, moved in
guard let strongSelf = self else {
return
}
var actionNode = strongSelf.actionNode(at: point)
if let actionNodeValue = actionNode, !actionNodeValue.isActionEnabled {
actionNode = nil
}
if actionNode !== strongSelf.currentHighlightedActionNode {
if actionNode != nil, moved {
strongSelf.feedbackTap()
}
strongSelf.currentHighlightedActionNode?.setIsHighlighted(false)
}
strongSelf.currentHighlightedActionNode = actionNode
actionNode?.setIsHighlighted(true)
}
gesture.completed = { [weak self] performAction in
guard let strongSelf = self else {
return
}
if let currentHighlightedActionNode = strongSelf.currentHighlightedActionNode {
strongSelf.currentHighlightedActionNode = nil
currentHighlightedActionNode.setIsHighlighted(false)
if performAction {
currentHighlightedActionNode.performAction()
}
}
}
self.view.addGestureRecognizer(gesture)
gesture.isEnabled = self.panSelectionGestureEnabled
}
func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> CGSize {
var minActionsWidth: CGFloat = 250.0
if let minimalWidth = minimalWidth, minimalWidth > minActionsWidth {
minActionsWidth = minimalWidth
}
switch widthClass {
case .compact:
minActionsWidth = max(minActionsWidth, floor(constrainedWidth / 3.0))
if let effectView = self.effectView {
self.effectView = nil
effectView.removeFromSuperview()
}
case .regular:
if self.effectView == nil {
let effectView: UIVisualEffectView
if #available(iOS 13.0, *) {
if self.presentationData.theme.rootController.keyboardColor == .dark {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
} else {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialLight))
}
} else {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
}
self.effectView = effectView
self.containerNode.view.insertSubview(effectView, at: 0)
}
}
minActionsWidth = min(minActionsWidth, constrainedWidth)
let separatorHeight: CGFloat = 8.0
var maxWidth: CGFloat = 0.0
var contentHeight: CGFloat = 0.0
var heightsAndCompletions: [(CGFloat, (CGSize, ContainedViewLayoutTransition) -> Void)?] = []
for i in 0 ..< self.itemNodes.count {
switch self.itemNodes[i] {
case let .action(itemNode):
let previous: ContextActionSibling
let next: ContextActionSibling
if i == 0 {
previous = .none
} else if case .separator = self.itemNodes[i - 1] {
previous = .separator
} else {
previous = .item
}
if i == self.itemNodes.count - 1 {
next = .none
} else if case .separator = self.itemNodes[i + 1] {
next = .separator
} else {
next = .item
}
let (minSize, complete) = itemNode.updateLayout(constrainedWidth: constrainedWidth, previous: previous, next: next)
maxWidth = max(maxWidth, minSize.width)
heightsAndCompletions.append((minSize.height, complete))
contentHeight += minSize.height
case let .custom(itemNode):
let (minSize, complete) = itemNode.updateLayout(constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight)
maxWidth = max(maxWidth, minSize.width)
heightsAndCompletions.append((minSize.height, complete))
contentHeight += minSize.height
case .itemSeparator:
heightsAndCompletions.append(nil)
contentHeight += UIScreenPixel
case .separator:
heightsAndCompletions.append(nil)
contentHeight += separatorHeight
}
}
maxWidth = max(maxWidth, minActionsWidth)
var verticalOffset: CGFloat = 0.0
for i in 0 ..< heightsAndCompletions.count {
switch self.itemNodes[i] {
case let .action(itemNode):
if let (itemHeight, itemCompletion) = heightsAndCompletions[i] {
let itemSize = CGSize(width: maxWidth, height: itemHeight)
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: itemSize))
itemCompletion(itemSize, transition)
verticalOffset += itemHeight
}
case let .custom(itemNode):
if let (itemHeight, itemCompletion) = heightsAndCompletions[i] {
let itemSize = CGSize(width: maxWidth, height: itemHeight)
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: itemSize))
itemCompletion(itemSize, transition)
verticalOffset += itemHeight
}
case let .itemSeparator(separatorNode):
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: maxWidth, height: UIScreenPixel)))
verticalOffset += UIScreenPixel
case let .separator(separatorNode):
transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: maxWidth, height: separatorHeight)))
verticalOffset += separatorHeight
}
}
let size = CGSize(width: maxWidth, height: verticalOffset)
let bounds = CGRect(origin: CGPoint(), size: size)
transition.updateFrame(node: self.containerNode, frame: bounds)
if let effectView = self.effectView {
transition.updateFrame(view: effectView, frame: bounds)
}
return size
}
func updateTheme(presentationData: PresentationData) {
for itemNode in self.itemNodes {
switch itemNode {
case let .action(action):
action.updateTheme(presentationData: presentationData)
case let .custom(item):
item.updateTheme(presentationData: presentationData)
case let .separator(separator):
separator.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor
case let .itemSeparator(itemSeparator):
itemSeparator.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
}
}
self.containerNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor
}
func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? {
for itemNode in self.itemNodes {
switch itemNode {
case let .action(actionNode):
if actionNode.frame.contains(point) {
return actionNode
}
case let .custom(node):
if let node = node as? ContextActionNodeProtocol, node.frame.contains(point) {
return node.actionNode(at: self.convert(point, to: node))
}
default:
break
}
}
return nil
}
}
final class InnerTextSelectionTipContainerNode: ASDisplayNode {
private let presentationData: PresentationData
let shadowNode: ASImageNode
private let backgroundNode: ASDisplayNode
private var effectView: UIVisualEffectView?
private let highlightBackgroundNode: ASDisplayNode
private let buttonNode: HighlightTrackingButtonNode
private let textNode: TextNodeWithEntities
private var textSelectionNode: TextSelectionNode?
private let iconNode: ASImageNode
private let placeholderNode: ASDisplayNode
var tip: ContextController.Tip
private let text: String
private var arguments: TextNodeWithEntities.Arguments?
private var file: TelegramMediaFile?
private let targetSelectionIndex: Int?
private var hapticFeedback: HapticFeedback?
private var action: (() -> Void)?
var requestDismiss: (@escaping () -> Void) -> Void = { _ in }
init(presentationData: PresentationData, tip: ContextController.Tip) {
self.tip = tip
self.presentationData = presentationData
self.shadowNode = ASImageNode()
self.shadowNode.displaysAsynchronously = false
self.shadowNode.displayWithoutProcessing = true
self.shadowNode.image = UIImage(bundleImageName: "Components/Context Menu/Shadow")?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 60)
self.shadowNode.contentMode = .scaleToFill
self.shadowNode.isHidden = true
self.backgroundNode = ASDisplayNode()
self.highlightBackgroundNode = ASDisplayNode()
self.highlightBackgroundNode.isAccessibilityElement = false
self.highlightBackgroundNode.alpha = 0.0
self.buttonNode = HighlightTrackingButtonNode()
self.textNode = TextNodeWithEntities()
self.textNode.textNode.displaysAsynchronously = false
self.textNode.textNode.isUserInteractionEnabled = false
var isUserInteractionEnabled = false
var icon: UIImage?
switch tip {
case .textSelection:
var rawText = self.presentationData.strings.ChatContextMenu_TextSelectionTip2
if let range = rawText.range(of: "|") {
rawText.removeSubrange(range)
self.text = rawText
self.targetSelectionIndex = NSRange(range, in: rawText).lowerBound
} else {
self.text = rawText
self.targetSelectionIndex = 1
}
icon = UIImage(bundleImageName: "Chat/Context Menu/Tip")
case .quoteSelection:
var rawText = presentationData.strings.ChatContextMenu_QuoteSelectionTip
if let range = rawText.range(of: "|") {
rawText.removeSubrange(range)
self.text = rawText
self.targetSelectionIndex = NSRange(range, in: rawText).lowerBound
} else {
self.text = rawText
self.targetSelectionIndex = 1
}
icon = UIImage(bundleImageName: "Chat/Context Menu/Tip")
case .messageViewsPrivacy:
self.text = self.presentationData.strings.ChatContextMenu_MessageViewsPrivacyTip
self.targetSelectionIndex = nil
icon = UIImage(bundleImageName: "Chat/Context Menu/Tip")
case let .messageCopyProtection(isChannel):
self.text = isChannel ? self.presentationData.strings.Conversation_CopyProtectionInfoChannel : self.presentationData.strings.Conversation_CopyProtectionInfoGroup
self.targetSelectionIndex = nil
icon = UIImage(bundleImageName: "Chat/Context Menu/ReportCopyright")
case let .animatedEmoji(text, arguments, file, action):
self.action = action
self.text = text ?? ""
self.arguments = arguments
self.file = file
self.targetSelectionIndex = nil
icon = nil
isUserInteractionEnabled = text != nil
case let .notificationTopicExceptions(text, action):
self.action = action
self.text = text
self.targetSelectionIndex = nil
icon = nil
isUserInteractionEnabled = action != nil
case let .starsReactions(topCount):
self.action = nil
self.text = self.presentationData.strings.Chat_SendStarsToBecomeTopInfo("\(topCount)").string
self.targetSelectionIndex = nil
icon = nil
isUserInteractionEnabled = action != nil
case .videoProcessing:
self.action = nil
self.text = self.presentationData.strings.Chat_VideoProcessingInfo
self.targetSelectionIndex = nil
icon = nil
isUserInteractionEnabled = action != nil
case .collageReordering:
self.action = nil
self.text = self.presentationData.strings.Camera_CollageReorderingInfo
self.targetSelectionIndex = nil
icon = UIImage(bundleImageName: "Chat/Context Menu/Tip")
}
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.image = generateTintedImage(image: icon, color: presentationData.theme.contextMenu.primaryColor)
self.placeholderNode = ASDisplayNode()
self.placeholderNode.clipsToBounds = true
self.placeholderNode.cornerRadius = 4.0
self.placeholderNode.isUserInteractionEnabled = false
super.init()
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor
self.backgroundNode.clipsToBounds = true
self.backgroundNode.cornerRadius = 14.0
self.highlightBackgroundNode.clipsToBounds = true
self.highlightBackgroundNode.cornerRadius = 14.0
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { _ in
}, present: { _, _ in
}, rootNode: { [weak self] in
return self
}, performAction: { _, _ in
})
self.textSelectionNode = textSelectionNode
self.addSubnode(self.backgroundNode)
self.addSubnode(self.highlightBackgroundNode)
self.addSubnode(self.textNode.textNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.placeholderNode)
self.textSelectionNode.flatMap(self.addSubnode)
self.addSubnode(textSelectionNode.highlightAreaNode)
self.addSubnode(self.buttonNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self else {
return
}
strongSelf.isButtonHighlighted = highlighted
strongSelf.updateHighlight(animated: false)
}
self.buttonNode.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
let shimmeringForegroundColor: UIColor
if presentationData.theme.overallDarkAppearance {
let backgroundColor = presentationData.theme.contextMenu.backgroundColor.blitOver(presentationData.theme.list.plainBackgroundColor, alpha: 1.0)
shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.blitOver(backgroundColor, alpha: 0.1)
} else {
shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07)
}
self.placeholderNode.backgroundColor = shimmeringForegroundColor
self.isUserInteractionEnabled = isUserInteractionEnabled
}
@objc func pressed() {
self.requestDismiss({
self.action?()
})
}
func animateTransitionInside(other: InnerTextSelectionTipContainerNode) {
let nodes: [ASDisplayNode] = [
self.textNode.textNode,
self.iconNode,
self.placeholderNode
]
for node in nodes {
other.addSubnode(node)
node.layer.animateAlpha(from: node.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak node] _ in
node?.removeFromSupernode()
})
}
}
func animateContentIn() {
let nodes: [ASDisplayNode] = [
self.textNode.textNode,
self.iconNode
]
for node in nodes {
node.layer.animateAlpha(from: 0.0, to: node.alpha, duration: 0.25)
}
}
func updateLayout(widthClass: ContainerViewLayoutSizeClass, presentation: ContextControllerActionsStackNode.Presentation, width: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize {
var needsBlur = false
if case .regular = widthClass {
needsBlur = true
} else if case .inline = presentation {
needsBlur = true
}
if !needsBlur {
if let effectView = self.effectView {
self.effectView = nil
effectView.removeFromSuperview()
}
} else {
if self.effectView == nil {
let effectView: UIVisualEffectView
if #available(iOS 13.0, *) {
if self.presentationData.theme.overallDarkAppearance {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
} else {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialLight))
}
} else {
effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
}
effectView.clipsToBounds = true
effectView.layer.cornerRadius = self.backgroundNode.cornerRadius
self.effectView = effectView
self.view.insertSubview(effectView, at: 0)
}
}
let verticalInset: CGFloat = 10.0
let horizontalInset: CGFloat = 16.0
let standardIconWidth: CGFloat = 32.0
let iconSideInset: CGFloat = 12.0
let textFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
let boldTextFont = Font.bold(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0))
let textColor = self.presentationData.theme.contextMenu.primaryColor
let linkColor = self.presentationData.theme.overallDarkAppearance ? UIColor(rgb: 0x64d2ff) : self.presentationData.theme.contextMenu.badgeFillColor
let iconSize = self.iconNode.image?.size ?? CGSize(width: 16.0, height: 16.0)
let text = self.text.replacingOccurrences(of: "#", with: "# ")
let attributedText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in
return nil
})))
if let file = self.file {
let range = (attributedText.string as NSString).range(of: "#")
if range.location != NSNotFound {
attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), range: range)
}
}
let shimmeringForegroundColor: UIColor
if presentationData.theme.overallDarkAppearance {
let backgroundColor = presentationData.theme.contextMenu.backgroundColor.blitOver(presentationData.theme.list.plainBackgroundColor, alpha: 1.0)
shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.blitOver(backgroundColor, alpha: 0.1)
} else {
shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07)
}
let textRightInset: CGFloat
if let _ = self.iconNode.image {
textRightInset = iconSize.width - 2.0
} else {
textRightInset = 0.0
}
let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - horizontalInset * 2.0 - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil))
let _ = textApply(self.arguments?.withUpdatedPlaceholderColor(shimmeringForegroundColor))
let textFrame = CGRect(origin: CGPoint(x: horizontalInset, y: verticalInset), size: textLayout.size)
transition.updateFrame(node: self.textNode.textNode, frame: textFrame)
if textFrame.size.height.isZero {
self.textNode.textNode.alpha = 0.0
} else if self.textNode.textNode.alpha.isZero {
self.textNode.textNode.alpha = 1.0
self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.placeholderNode.layer.animateAlpha(from: self.placeholderNode.alpha, to: 1.0, duration: 0.2)
}
self.textNode.visibilityRect = CGRect.infinite
var contentHeight = textLayout.size.height
if contentHeight.isZero {
contentHeight = 32.0
}
let size = CGSize(width: width, height: contentHeight + verticalInset * 2.0)
let lineHeight: CGFloat = 8.0
transition.updateFrame(node: self.placeholderNode, frame: CGRect(origin: CGPoint(x: horizontalInset, y: floorToScreenPixels((size.height - lineHeight) / 2.0)), size: CGSize(width: width - horizontalInset * 2.0, height: lineHeight)))
transition.updateAlpha(node: self.placeholderNode, alpha: textFrame.height.isZero ? 1.0 : 0.0)
let iconFrame = CGRect(origin: CGPoint(x: size.width - standardIconWidth - iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize)
transition.updateFrame(node: self.iconNode, frame: iconFrame)
if let textSelectionNode = self.textSelectionNode {
transition.updateFrame(node: textSelectionNode, frame: textFrame)
textSelectionNode.highlightAreaNode.frame = textFrame
}
switch presentation {
case .modal:
self.shadowNode.isHidden = true
case .inline, .additional:
self.shadowNode.isHidden = false
}
if let effectView = self.effectView {
transition.updateFrame(view: effectView, frame: CGRect(origin: CGPoint(), size: size))
}
self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
return size
}
func setActualSize(size: CGSize, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size)
self.buttonNode.frame = CGRect(origin: CGPoint(), size: size)
}
func updateTheme(presentationData: PresentationData) {
self.backgroundColor = presentationData.theme.contextMenu.backgroundColor
}
func animateIn() {
if let textSelectionNode = self.textSelectionNode, let targetSelectionIndex = self.targetSelectionIndex {
textSelectionNode.pretendInitiateSelection()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.textSelectionNode?.pretendExtendSelection(to: targetSelectionIndex)
})
}
}
func updateHighlight(animated: Bool) {
if self.isButtonHighlighted || self.isHighlighted {
self.highlightBackgroundNode.alpha = 1.0
} else {
if animated {
let previousAlpha = self.highlightBackgroundNode.alpha
self.highlightBackgroundNode.alpha = 0.0
self.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2)
} else {
self.highlightBackgroundNode.alpha = 0.0
}
}
}
private var isButtonHighlighted = false
private var isHighlighted = false
func setHighlighted(_ highlighted: Bool) {
guard self.isHighlighted != highlighted else {
return
}
self.isHighlighted = highlighted
if highlighted {
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
self.hapticFeedback?.tap()
}
self.updateHighlight(animated: false)
}
func highlightGestureMoved(location: CGPoint) {
if self.bounds.contains(location) && self.isUserInteractionEnabled {
self.setHighlighted(true)
} else {
self.setHighlighted(false)
}
}
func highlightGestureFinished(performAction: Bool) {
if self.isHighlighted {
self.setHighlighted(false)
if performAction {
self.pressed()
}
}
}
}
final class ContextActionsContainerNode: ASDisplayNode {
private let presentationData: PresentationData
private let getController: () -> ContextControllerProtocol?
private let blurBackground: Bool
private let shadowNode: ASImageNode
private let additionalShadowNode: ASImageNode?
private let additionalActionsNode: InnerActionsContainerNode?
private let actionsNode: InnerActionsContainerNode
private let scrollNode: ASScrollNode
private var tip: ContextController.Tip?
private var textSelectionTipNode: InnerTextSelectionTipContainerNode?
private var textSelectionTipNodeDisposable: Disposable?
var panSelectionGestureEnabled: Bool = true {
didSet {
if self.panSelectionGestureEnabled != oldValue {
self.actionsNode.panSelectionGestureEnabled = self.panSelectionGestureEnabled
}
}
}
var hasAdditionalActions: Bool {
return self.additionalActionsNode != nil
}
init(presentationData: PresentationData, items: ContextController.Items, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) {
self.presentationData = presentationData
self.getController = getController
self.blurBackground = blurBackground
self.shadowNode = ASImageNode()
self.shadowNode.displaysAsynchronously = false
self.shadowNode.displayWithoutProcessing = true
self.shadowNode.image = UIImage(bundleImageName: "Components/Context Menu/Shadow")?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 60)
self.shadowNode.contentMode = .scaleToFill
self.shadowNode.isHidden = true
var items = items
if case var .list(itemList) = items.content, let firstItem = itemList.first, case let .custom(_, additional) = firstItem, additional {
let additionalShadowNode = ASImageNode()
additionalShadowNode.displaysAsynchronously = false
additionalShadowNode.displayWithoutProcessing = true
additionalShadowNode.image = self.shadowNode.image
additionalShadowNode.contentMode = .scaleToFill
additionalShadowNode.isHidden = true
self.additionalShadowNode = additionalShadowNode
self.additionalActionsNode = InnerActionsContainerNode(presentationData: presentationData, items: [firstItem], getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground)
itemList.removeFirst()
items.content = .list(itemList)
} else {
self.additionalShadowNode = nil
self.additionalActionsNode = nil
}
var itemList: [ContextMenuItem] = []
if case let .list(list) = items.content {
itemList = list
}
self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: itemList, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground)
self.tip = items.tip
self.scrollNode = ASScrollNode()
self.scrollNode.canCancelAllTouchesInViews = true
self.scrollNode.view.delaysContentTouches = false
self.scrollNode.view.showsVerticalScrollIndicator = false
if #available(iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
super.init()
self.addSubnode(self.shadowNode)
self.additionalShadowNode.flatMap(self.addSubnode)
self.additionalActionsNode.flatMap(self.scrollNode.addSubnode)
self.scrollNode.addSubnode(self.actionsNode)
self.addSubnode(self.scrollNode)
if let tipSignal = items.tipSignal {
self.textSelectionTipNodeDisposable = (tipSignal
|> deliverOnMainQueue).start(next: { [weak self] tip in
guard let strongSelf = self else {
return
}
strongSelf.tip = tip
requestLayout()
}).strict()
}
}
deinit {
self.textSelectionTipNodeDisposable?.dispose()
}
func updateLayout(widthClass: ContainerViewLayoutSizeClass, presentation: ContextControllerActionsStackNode.Presentation, constrainedWidth: CGFloat, constrainedHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize {
var widthClass = widthClass
if !self.blurBackground {
widthClass = .regular
}
var contentSize = CGSize()
let actionsSize = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight, minimalWidth: nil, transition: transition)
if let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode {
let additionalActionsSize = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsSize.width, constrainedHeight: constrainedHeight, minimalWidth: actionsSize.width, transition: transition)
contentSize = additionalActionsSize
let bounds = CGRect(origin: CGPoint(), size: additionalActionsSize)
transition.updateFrame(node: additionalShadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0))
additionalShadowNode.isHidden = widthClass == .compact
transition.updateFrame(node: additionalActionsNode, frame: CGRect(origin: CGPoint(), size: additionalActionsSize))
contentSize.height += 8.0
}
let bounds = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: actionsSize)
transition.updateFrame(node: self.shadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0))
self.shadowNode.isHidden = widthClass == .compact
contentSize.width = max(contentSize.width, actionsSize.width)
contentSize.height += actionsSize.height
transition.updateFrame(node: self.actionsNode, frame: bounds)
if let tip = self.tip {
if let textSelectionTipNode = self.textSelectionTipNode, textSelectionTipNode.tip == tip {
} else {
if let textSelectionTipNode = self.textSelectionTipNode {
self.textSelectionTipNode = nil
textSelectionTipNode.removeFromSupernode()
}
let textSelectionTipNode = InnerTextSelectionTipContainerNode(presentationData: self.presentationData, tip: tip)
let getController = self.getController
textSelectionTipNode.requestDismiss = { completion in
getController()?.dismiss(completion: completion)
}
self.textSelectionTipNode = textSelectionTipNode
self.scrollNode.addSubnode(textSelectionTipNode)
}
} else {
if let textSelectionTipNode = self.textSelectionTipNode {
self.textSelectionTipNode = nil
textSelectionTipNode.removeFromSupernode()
}
}
if let textSelectionTipNode = self.textSelectionTipNode {
contentSize.height += 8.0
let textSelectionTipSize = textSelectionTipNode.updateLayout(widthClass: widthClass, presentation: presentation, width: actionsSize.width, transition: transition)
transition.updateFrame(node: textSelectionTipNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: textSelectionTipSize))
textSelectionTipNode.setActualSize(size: textSelectionTipSize, transition: transition)
contentSize.height += textSelectionTipSize.height
}
return contentSize
}
func updateSize(containerSize: CGSize, contentSize: CGSize) {
self.scrollNode.view.contentSize = contentSize
self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize)
}
func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? {
return self.actionsNode.actionNode(at: self.view.convert(point, to: self.actionsNode.view))
}
func updateTheme(presentationData: PresentationData) {
self.actionsNode.updateTheme(presentationData: presentationData)
self.textSelectionTipNode?.updateTheme(presentationData: presentationData)
}
func animateIn() {
self.textSelectionTipNode?.animateIn()
}
func animateOut(offset: CGFloat, transition: ContainedViewLayoutTransition) {
guard let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode else {
return
}
transition.animatePosition(node: additionalActionsNode, to: CGPoint(x: 0.0, y: offset / 2.0), additive: true)
transition.animatePosition(node: additionalShadowNode, to: CGPoint(x: 0.0, y: offset / 2.0), additive: true)
additionalActionsNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
additionalShadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
additionalActionsNode.layer.animateScale(from: 1.0, to: 0.75, duration: 0.15, removeOnCompletion: false)
additionalShadowNode.layer.animateScale(from: 1.0, to: 0.75, duration: 0.15, removeOnCompletion: false)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TextSelectionNode
import TelegramCore
import SwiftSignalKit
import ReactionSelectionNode
enum ContextControllerPresentationNodeStateTransition {
case animateIn
case animateOut(result: ContextMenuActionResult, completion: () -> Void)
}
protocol ContextControllerPresentationNode: ASDisplayNode {
var ready: Signal<Bool, NoError> { get }
func replaceItems(items: ContextController.Items, animated: Bool?)
func pushItems(items: ContextController.Items)
func popItems()
func wantsDisplayBelowKeyboard() -> Bool
func update(
presentationData: PresentationData,
layout: ContainerViewLayout,
transition: ContainedViewLayoutTransition,
stateTransition: ContextControllerPresentationNodeStateTransition?
)
func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, onHit: (() -> Void)?, completion: @escaping () -> Void)
func cancelReactionAnimation()
func highlightGestureMoved(location: CGPoint, hover: Bool)
func highlightGestureFinished(performAction: Bool)
func decreaseHighlightedIndex()
func increaseHighlightedIndex()
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition)
}
@@ -0,0 +1,885 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import SwiftSignalKit
import TelegramCore
import ReactionSelectionNode
import ComponentFlow
import TabSelectorComponent
import PlainButtonComponent
import MultilineTextComponent
import ComponentDisplayAdapters
import AccountContext
final class ContextSourceContainer: ASDisplayNode {
final class Source {
weak var controller: ContextController?
let id: AnyHashable
let title: String
let footer: String?
let context: AccountContext?
let source: ContextContentSource
let closeActionTitle: String?
let closeAction: (() -> Void)?
private var _presentationNode: ContextControllerPresentationNode?
var presentationNode: ContextControllerPresentationNode {
return self._presentationNode!
}
var currentPresentationStateTransition: ContextControllerPresentationNodeStateTransition?
var validLayout: ContainerViewLayout?
var presentationData: PresentationData?
var delayLayoutUpdate: Bool = false
var isAnimatingOut: Bool = false
var itemsDisposables = DisposableSet()
let ready = Promise<Bool>()
private let contentReady = Promise<Bool>()
private let actionsReady = Promise<Bool>()
init(
controller: ContextController,
id: AnyHashable,
title: String,
footer: String?,
context: AccountContext?,
source: ContextContentSource,
items: Signal<ContextController.Items, NoError>,
closeActionTitle: String? = nil,
closeAction: (() -> Void)? = nil
) {
self.controller = controller
self.id = id
self.title = title
self.footer = footer
self.context = context
self.source = source
self.closeActionTitle = closeActionTitle
self.closeAction = closeAction
self.ready.set(combineLatest(queue: .mainQueue(), self.contentReady.get(), self.actionsReady.get())
|> map { a, b -> Bool in
return a && b
}
|> distinctUntilChanged)
switch source {
case let .location(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .location(source)
)
self._presentationNode = presentationNode
case let .reference(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .reference(source)
)
self._presentationNode = presentationNode
case let .extracted(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
if let _ = self.closeActionTitle {
} else {
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
}
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .extracted(source)
)
self._presentationNode = presentationNode
case let .controller(source):
self.contentReady.set(source.controller.ready.get())
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .controller(source)
)
self._presentationNode = presentationNode
}
self.itemsDisposables.add((items |> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.setItems(items: items, animated: nil)
self.actionsReady.set(.single(true))
}))
}
deinit {
self.itemsDisposables.dispose()
}
func animateIn() {
self.currentPresentationStateTransition = .animateIn
self.update(transition: .animated(duration: 0.5, curve: .spring))
}
func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) {
self.currentPresentationStateTransition = .animateOut(result: result, completion: completion)
if let _ = self.validLayout {
if case let .custom(transition) = result {
self.delayLayoutUpdate = true
Queue.mainQueue().after(0.1) {
self.delayLayoutUpdate = false
self.update(transition: transition)
self.isAnimatingOut = true
}
} else {
self.update(transition: .animated(duration: 0.35, curve: .easeInOut))
}
}
}
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
self.presentationNode.addRelativeContentOffset(offset, transition: transition)
}
func cancelReactionAnimation() {
self.presentationNode.cancelReactionAnimation()
}
func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, onHit: (() -> Void)?, completion: @escaping () -> Void) {
self.presentationNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, onHit: onHit, completion: completion)
}
func setItems(items: Signal<ContextController.Items, NoError>, animated: Bool) {
self.itemsDisposables.dispose()
self.itemsDisposables = DisposableSet()
self.itemsDisposables.add((items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.setItems(items: items, animated: animated)
}))
}
func setItems(items: ContextController.Items, animated: Bool?) {
self.presentationNode.replaceItems(items: items, animated: animated)
}
func pushItems(items: Signal<ContextController.Items, NoError>) {
self.itemsDisposables.add((items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.presentationNode.pushItems(items: items)
}))
}
func popItems() {
self.itemsDisposables.removeLast()
self.presentationNode.popItems()
}
func update(transition: ContainedViewLayoutTransition) {
guard let validLayout = self.validLayout else {
return
}
guard let presentationData = self.presentationData else {
return
}
self.update(presentationData: presentationData, layout: validLayout, transition: transition)
}
func update(
presentationData: PresentationData,
layout: ContainerViewLayout,
transition: ContainedViewLayoutTransition
) {
if self.isAnimatingOut || self.delayLayoutUpdate {
return
}
self.validLayout = layout
self.presentationData = presentationData
let presentationStateTransition = self.currentPresentationStateTransition
self.currentPresentationStateTransition = .none
self.presentationNode.update(
presentationData: presentationData,
layout: layout,
transition: transition,
stateTransition: presentationStateTransition
)
}
}
private struct PanState {
var fraction: CGFloat
init(fraction: CGFloat) {
self.fraction = fraction
}
}
private weak var controller: ContextController?
private let backgroundNode: NavigationBackgroundNode
var sources: [Source] = []
var activeIndex: Int = 0
private var tabSelector: ComponentView<Empty>?
private var footer: ComponentView<Empty>?
private var closeButton: ComponentView<Empty>?
private var presentationData: PresentationData?
private var validLayout: ContainerViewLayout?
private var panState: PanState?
let ready = Promise<Bool>()
var activeSource: Source? {
if self.activeIndex >= self.sources.count {
return nil
}
return self.sources[self.activeIndex]
}
var overlayWantsToBeBelowKeyboard: Bool {
return self.activeSource?.presentationNode.wantsDisplayBelowKeyboard() ?? false
}
init(controller: ContextController, configuration: ContextController.Configuration, context: AccountContext?) {
self.controller = controller
self.backgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: false)
super.init()
self.addSubnode(self.backgroundNode)
for i in 0 ..< configuration.sources.count {
let source = configuration.sources[i]
let mappedSource = Source(
controller: controller,
id: source.id,
title: source.title,
footer: source.footer,
context: context,
source: source.source,
items: source.items,
closeActionTitle: source.closeActionTitle,
closeAction: source.closeAction
)
self.sources.append(mappedSource)
self.addSubnode(mappedSource.presentationNode)
if source.id == configuration.initialId {
self.activeIndex = i
}
}
self.ready.set(self.sources[self.activeIndex].ready.get())
self.view.addGestureRecognizer(InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] _ in
guard let self else {
return []
}
if self.sources.count <= 1 {
return []
}
return [.left, .right]
}))
}
@objc private func panGesture(_ recognizer: InteractiveTransitionGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
if let validLayout = self.validLayout {
var translationX = recognizer.translation(in: self.view).x
if self.activeIndex == 0 && translationX > 0.0 {
translationX = scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0)
} else if self.activeIndex == self.sources.count - 1 && translationX < 0.0 {
translationX = -scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0)
}
self.panState = PanState(fraction: translationX / validLayout.size.width)
self.update(transition: .immediate)
}
case .cancelled, .ended:
if let panState = self.panState {
self.panState = nil
let velocity = recognizer.velocity(in: self.view)
var nextIndex = self.activeIndex
if panState.fraction < -0.4 {
nextIndex += 1
} else if panState.fraction > 0.4 {
nextIndex -= 1
} else if abs(velocity.x) >= 200.0 {
if velocity.x < 0.0 {
nextIndex += 1
} else {
nextIndex -= 1
}
}
if nextIndex < 0 {
nextIndex = 0
}
if nextIndex > self.sources.count - 1 {
nextIndex = self.sources.count - 1
}
if nextIndex != self.activeIndex {
self.activeIndex = nextIndex
}
self.update(transition: .animated(duration: 0.4, curve: .spring))
}
default:
break
}
}
func animateIn() {
self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
// if let activeSource = self.activeSource {
// activeSource.animateIn()
// }
for source in self.sources {
source.animateIn()
}
if let footerView = self.footer?.view {
footerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if let tabSelectorView = self.tabSelector?.view {
tabSelectorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if let closeButtonView = self.closeButton?.view {
closeButtonView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) {
let delayDismissal = self.activeSource?.closeAction != nil
let delay: Double = delayDismissal ? 0.2 : 0.0
let duration: Double = delayDismissal ? 0.35 : 0.2
self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false, completion: { _ in
if delayDismissal {
Queue.mainQueue().after(0.55) {
completion()
}
}
})
if let footerView = self.footer?.view {
footerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false)
}
if let tabSelectorView = self.tabSelector?.view {
tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false)
}
if let closeButtonView = self.closeButton?.view {
closeButtonView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false)
}
for source in self.sources {
if source !== self.activeSource {
source.animateOut(result: result, completion: {})
}
}
if let activeSource = self.activeSource {
activeSource.animateOut(result: result, completion: delayDismissal ? {} : completion)
} else {
completion()
}
}
func highlightGestureMoved(location: CGPoint, hover: Bool) {
if self.activeIndex >= self.sources.count {
return
}
self.sources[self.activeIndex].presentationNode.highlightGestureMoved(location: location, hover: hover)
}
func highlightGestureFinished(performAction: Bool) {
if self.activeIndex >= self.sources.count {
return
}
self.sources[self.activeIndex].presentationNode.highlightGestureFinished(performAction: performAction)
}
func performHighlightedAction() {
self.activeSource?.presentationNode.highlightGestureFinished(performAction: true)
}
func decreaseHighlightedIndex() {
self.activeSource?.presentationNode.decreaseHighlightedIndex()
}
func increaseHighlightedIndex() {
self.activeSource?.presentationNode.increaseHighlightedIndex()
}
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
if let activeSource = self.activeSource {
activeSource.addRelativeContentOffset(offset, transition: transition)
}
}
func cancelReactionAnimation() {
if let activeSource = self.activeSource {
activeSource.cancelReactionAnimation()
}
}
func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, onHit: (() -> Void)?, completion: @escaping () -> Void) {
if let activeSource = self.activeSource {
activeSource.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, onHit: onHit, completion: completion)
} else {
completion()
}
}
func setItems(items: Signal<ContextController.Items, NoError>, animated: Bool) {
if let activeSource = self.activeSource {
activeSource.setItems(items: items, animated: animated)
}
}
func pushItems(items: Signal<ContextController.Items, NoError>) {
if let activeSource = self.activeSource {
activeSource.pushItems(items: items)
}
}
func popItems() {
if let activeSource = self.activeSource {
activeSource.popItems()
}
}
private func update(transition: ContainedViewLayoutTransition) {
if let presentationData = self.presentationData, let validLayout = self.validLayout {
self.update(presentationData: presentationData, layout: validLayout, transition: transition)
}
}
func update(
presentationData: PresentationData,
layout: ContainerViewLayout,
transition: ContainedViewLayoutTransition
) {
self.presentationData = presentationData
self.validLayout = layout
var childLayout = layout
if let activeSource = self.activeSource {
switch activeSource.source {
case .location, .reference:
self.backgroundNode.updateColor(
color: .clear,
enableBlur: false,
forceKeepBlur: false,
transition: .immediate
)
case let .extracted(extracted):
self.backgroundNode.updateColor(
color: extracted.blurBackground ? presentationData.theme.contextMenu.dimColor : .clear,
enableBlur: extracted.blurBackground,
forceKeepBlur: extracted.blurBackground,
transition: .immediate
)
case .controller:
if case .regular = layout.metrics.widthClass {
self.backgroundNode.updateColor(
color: UIColor(white: 0.0, alpha: 0.4),
enableBlur: false,
forceKeepBlur: false,
transition: .immediate
)
} else {
self.backgroundNode.updateColor(
color: presentationData.theme.contextMenu.dimColor,
enableBlur: true,
forceKeepBlur: true,
transition: .immediate
)
}
}
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size), beginWithCurrentState: true)
self.backgroundNode.update(size: layout.size, transition: transition)
if self.sources.count > 1 {
let tabSelector: ComponentView<Empty>
if let current = self.tabSelector {
tabSelector = current
} else {
tabSelector = ComponentView()
self.tabSelector = tabSelector
}
let mappedItems = self.sources.map { source -> TabSelectorComponent.Item in
return TabSelectorComponent.Item(id: source.id, title: source.title)
}
let tabSelectorSize = tabSelector.update(
transition: ComponentTransition(transition),
component: AnyComponent(TabSelectorComponent(
colors: TabSelectorComponent.Colors(
foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8),
selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1)
),
theme: presentationData.theme,
customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0),
spacing: 9.0
),
items: mappedItems,
selectedId: self.activeSource?.id,
setSelectedId: { [weak self] id in
guard let self else {
return
}
if let index = self.sources.firstIndex(where: { $0.id == id }) {
self.activeIndex = index
self.update(transition: .animated(duration: 0.4, curve: .spring))
}
}
)),
environment: {},
containerSize: CGSize(width: layout.size.width, height: 44.0)
)
childLayout.intrinsicInsets.bottom += 30.0
if let footerText = self.activeSource?.footer {
var footerTransition = transition
let footer: ComponentView<Empty>
if let current = self.footer {
footer = current
} else {
footerTransition = .immediate
footer = ComponentView()
self.footer = footer
}
let footerSize = footer.update(
transition: ComponentTransition(footerTransition),
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: footerText, font: Font.regular(13.0), textColor: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4))),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.1
)
),
environment: {},
containerSize: CGSize(width: layout.size.width, height: 144.0)
)
let spacing: CGFloat = 20.0
childLayout.intrinsicInsets.bottom += footerSize.height + spacing
if let footerView = footer.view {
if footerView.superview == nil {
self.view.addSubview(footerView)
footerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
footerTransition.updateFrame(view: footerView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - footerSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - tabSelectorSize.height - footerSize.height - spacing), size: footerSize))
}
} else if let footer = self.footer {
self.footer = nil
footer.view?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
footer.view?.removeFromSuperview()
})
}
if let tabSelectorView = tabSelector.view {
if tabSelectorView.superview == nil {
self.view.addSubview(tabSelectorView)
}
transition.updateFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - tabSelectorSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - tabSelectorSize.height), size: tabSelectorSize))
}
} else if let source = self.sources.first, let closeActionTitle = source.closeActionTitle {
let closeButton: ComponentView<Empty>
if let current = self.closeButton {
closeButton = current
} else {
closeButton = ComponentView()
self.closeButton = closeButton
}
let closeButtonSize = closeButton.update(
transition: ComponentTransition(transition),
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(
CloseButtonComponent(
backgroundColor: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1),
text: closeActionTitle
)
),
effectAlignment: .center,
action: { [weak self, weak source] in
guard let self else {
return
}
if let source, let closeAction = source.closeAction {
closeAction()
} else {
self.controller?.dismiss(result: .dismissWithoutContent, completion: nil)
}
},
animateAlpha: false
)),
environment: {},
containerSize: CGSize(width: layout.size.width, height: 44.0)
)
childLayout.intrinsicInsets.bottom += 30.0
if let closeButtonView = closeButton.view {
if closeButtonView.superview == nil {
self.view.addSubview(closeButtonView)
}
transition.updateFrame(view: closeButtonView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - closeButtonSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - closeButtonSize.height - 10.0), size: closeButtonSize))
}
} else if let tabSelector = self.tabSelector {
self.tabSelector = nil
tabSelector.view?.removeFromSuperview()
}
for i in 0 ..< self.sources.count {
var itemFrame = CGRect(origin: CGPoint(), size: childLayout.size)
itemFrame.origin.x += CGFloat(i - self.activeIndex) * childLayout.size.width
if let panState = self.panState {
itemFrame.origin.x += panState.fraction * childLayout.size.width
}
let itemTransition = transition
itemTransition.updateFrame(node: self.sources[i].presentationNode, frame: itemFrame)
self.sources[i].update(
presentationData: presentationData,
layout: childLayout,
transition: itemTransition
)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let tabSelectorView = self.tabSelector?.view {
if let result = tabSelectorView.hitTest(self.view.convert(point, to: tabSelectorView), with: event) {
return result
}
}
if let closeButtonView = self.closeButton?.view {
if let result = closeButtonView.hitTest(self.view.convert(point, to: closeButtonView), with: event) {
return result
}
}
guard let activeSource = self.activeSource else {
return nil
}
return activeSource.presentationNode.view.hitTest(point, with: event)
}
}
private final class CloseButtonComponent: CombinedComponent {
let backgroundColor: UIColor
let text: String
init(
backgroundColor: UIColor,
text: String
) {
self.backgroundColor = backgroundColor
self.text = text
}
static func ==(lhs: CloseButtonComponent, rhs: CloseButtonComponent) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let text = Child(Text.self)
return { context in
let text = text.update(
component: Text(
text: "\(context.component.text)",
font: Font.regular(17.0),
color: .white
),
availableSize: CGSize(width: 200.0, height: 100.0),
transition: .immediate
)
let backgroundSize = CGSize(width: text.size.width + 34.0, height: 36.0)
let background = background.update(
component: RoundedRectangle(color: context.component.backgroundColor, cornerRadius: 18.0),
availableSize: backgroundSize,
transition: .immediate
)
context.add(background
.position(CGPoint(x: backgroundSize.width / 2.0, y: backgroundSize.height / 2.0))
)
context.add(text
.position(CGPoint(x: backgroundSize.width / 2.0, y: backgroundSize.height / 2.0))
)
return backgroundSize
}
}
}
@@ -0,0 +1,144 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
public final class PeekControllerTheme {
public let isDark: Bool
public let menuBackgroundColor: UIColor
public let menuItemHighligtedColor: UIColor
public let menuItemSeparatorColor: UIColor
public let accentColor: UIColor
public let destructiveColor: UIColor
public init(isDark: Bool, menuBackgroundColor: UIColor, menuItemHighligtedColor: UIColor, menuItemSeparatorColor: UIColor, accentColor: UIColor, destructiveColor: UIColor) {
self.isDark = isDark
self.menuBackgroundColor = menuBackgroundColor
self.menuItemHighligtedColor = menuItemHighligtedColor
self.menuItemSeparatorColor = menuItemSeparatorColor
self.accentColor = accentColor
self.destructiveColor = destructiveColor
}
}
extension PeekControllerTheme {
convenience public init(presentationTheme: PresentationTheme) {
let actionSheet = presentationTheme.actionSheet
self.init(isDark: actionSheet.backgroundType == .dark, menuBackgroundColor: actionSheet.opaqueItemBackgroundColor, menuItemHighligtedColor: actionSheet.opaqueItemHighlightedBackgroundColor, menuItemSeparatorColor: actionSheet.opaqueItemSeparatorColor, accentColor: actionSheet.controlAccentColor, destructiveColor: actionSheet.destructiveActionTextColor)
}
}
public final class PeekController: ViewController, ContextControllerProtocol {
public var useComplexItemsTransitionAnimation: Bool = false
public var immediateItemsTransitionAnimation = false
public func getActionsMinHeight() -> ContextController.ActionsHeight? {
return nil
}
public func setItems(_ items: Signal<ContextController.Items, NoError>, minHeight: ContextController.ActionsHeight?, animated: Bool) {
}
public func setItems(_ items: Signal<ContextController.Items, NoError>, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) {
}
public func pushItems(items: Signal<ContextController.Items, NoError>) {
self.controllerNode.pushItems(items: items)
}
public func popItems() {
self.controllerNode.popItems()
}
private var controllerNode: PeekControllerNode {
return self.displayNode as! PeekControllerNode
}
public var contentNode: PeekControllerContentNode & ASDisplayNode {
return self.controllerNode.contentNode
}
private let presentationData: PresentationData
private let content: PeekControllerContent
var sourceView: () -> (UIView, CGRect)?
private let activateImmediately: Bool
public var visibilityUpdated: ((Bool) -> Void)?
public var getOverlayViews: (() -> [UIView])?
public var appeared: (() -> Void)?
public var disappeared: (() -> Void)?
private var animatedIn = false
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
public init(presentationData: PresentationData, content: PeekControllerContent, sourceView: @escaping () -> (UIView, CGRect)?, activateImmediately: Bool = false) {
self.presentationData = presentationData
self.content = content
self.sourceView = sourceView
self.activateImmediately = activateImmediately
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
self.displayNode = PeekControllerNode(presentationData: self.presentationData, controller: self, content: self.content, requestDismiss: { [weak self] in
self?.dismiss()
})
self.displayNodeDidLoad()
}
private func getSourceRect() -> CGRect {
if let (sourceView, sourceRect) = self.sourceView() {
return sourceView.convert(sourceRect, to: self.view)
} else {
let size = self.displayNode.bounds.size
return CGRect(origin: CGPoint(x: floor((size.width - 10.0) / 2.0), y: floor((size.height - 10.0) / 2.0)), size: CGSize(width: 10.0, height: 10.0))
}
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn(from: self.getSourceRect())
self.visibilityUpdated?(true)
if self.activateImmediately {
self.controllerNode.activateMenu(immediately: true)
}
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, transition: transition)
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.visibilityUpdated?(false)
self.controllerNode.animateOut(to: self.getSourceRect(), completion: { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
})
}
public func dismiss(result: ContextMenuActionResult, completion: (() -> Void)?) {
self.dismiss(completion: completion)
}
}
@@ -0,0 +1,38 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
public enum PeekControllerContentPresentation {
case contained
case freeform
}
public enum PeerControllerMenuActivation {
case drag
case press
}
public protocol PeekControllerContent {
func presentation() -> PeekControllerContentPresentation
func menuActivation() -> PeerControllerMenuActivation
func menuItems() -> [ContextMenuItem]
func node() -> PeekControllerContentNode & ASDisplayNode
func topAccessoryNode() -> ASDisplayNode?
func fullScreenAccessoryNode(blurView: UIVisualEffectView) -> (PeekControllerAccessoryNode & ASDisplayNode)?
func isEqual(to: PeekControllerContent) -> Bool
}
public protocol PeekControllerContentNode {
func ready() -> Signal<Bool, NoError>
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize
}
public protocol PeekControllerAccessoryNode {
var dismiss: () -> Void { get set }
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
}
@@ -0,0 +1,321 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
private func traceDeceleratingScrollView(_ view: UIView, at point: CGPoint) -> Bool {
if view.bounds.contains(point), let view = view as? UIScrollView, view.isDecelerating {
return true
}
for subview in view.subviews {
let subviewPoint = view.convert(point, to: subview)
if traceDeceleratingScrollView(subview, at: subviewPoint) {
return true
}
}
return false
}
public final class PeekControllerGestureRecognizer: UIPanGestureRecognizer {
private let contentAtPoint: (CGPoint) -> Signal<(UIView, CGRect, PeekControllerContent)?, NoError>?
private let present: (PeekControllerContent, UIView, CGRect) -> ViewController?
private let updateContent: (PeekControllerContent?) -> Void
private let activateBySingleTap: Bool
public var longPressEnabled = true
public var checkSingleTapActivationAtPoint: ((CGPoint) -> Bool)?
private var tapLocation: CGPoint?
private var longTapTimer: SwiftSignalKit.Timer?
private var pressTimer: SwiftSignalKit.Timer?
private let candidateContentDisposable = MetaDisposable()
private var candidateContent: (UIView, CGRect, PeekControllerContent)? {
didSet {
self.updateContent(self.candidateContent?.2)
}
}
private var menuActivation: PeerControllerMenuActivation?
private weak var presentedController: PeekController?
public init(contentAtPoint: @escaping (CGPoint) -> Signal<(UIView, CGRect, PeekControllerContent)?, NoError>?, present: @escaping (PeekControllerContent, UIView, CGRect) -> ViewController?, updateContent: @escaping (PeekControllerContent?) -> Void = { _ in }, activateBySingleTap: Bool = false) {
self.contentAtPoint = contentAtPoint
self.present = present
self.updateContent = updateContent
self.activateBySingleTap = activateBySingleTap
super.init(target: nil, action: nil)
}
deinit {
self.longTapTimer?.invalidate()
self.pressTimer?.invalidate()
self.candidateContentDisposable.dispose()
}
private func startLongTapTimer() {
guard self.longPressEnabled else {
return
}
self.longTapTimer?.invalidate()
let longTapTimer = SwiftSignalKit.Timer(timeout: 0.4, repeat: false, completion: { [weak self] in
self?.longTapTimerFired()
}, queue: Queue.mainQueue())
self.longTapTimer = longTapTimer
longTapTimer.start()
}
private func startPressTimer() {
self.pressTimer?.invalidate()
let pressTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in
self?.pressTimerFired()
}, queue: Queue.mainQueue())
self.pressTimer = pressTimer
pressTimer.start()
}
private func stopLongTapTimer() {
self.longTapTimer?.invalidate()
self.longTapTimer = nil
}
private func stopPressTimer() {
self.pressTimer?.invalidate()
self.pressTimer = nil
}
override public func reset() {
super.reset()
self.stopLongTapTimer()
self.stopPressTimer()
self.tapLocation = nil
self.candidateContent = nil
self.menuActivation = nil
self.presentedController = nil
}
private func longTapTimerFired() {
guard let tapLocation = self.tapLocation else {
return
}
self.checkCandidateContent(at: tapLocation)
}
private func pressTimerFired() {
if let _ = self.tapLocation, let menuActivation = self.menuActivation, case .press = menuActivation {
if let presentedController = self.presentedController {
if presentedController.isNodeLoaded {
(presentedController.displayNode as? PeekControllerNode)?.activateMenu()
}
self.menuActivation = nil
// self.presentedController = nil
// self.state = .ended
}
}
}
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if let view = self.view, let tapLocation = touches.first?.location(in: view) {
if traceDeceleratingScrollView(view, at: tapLocation) {
self.candidateContent = nil
self.state = .failed
} else {
self.tapLocation = tapLocation
self.startLongTapTimer()
}
}
}
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
var activateBySingleTap = self.activateBySingleTap
if !activateBySingleTap, let checkSingleTapActivationAtPoint = self.checkSingleTapActivationAtPoint, let tapLocation = self.tapLocation {
activateBySingleTap = checkSingleTapActivationAtPoint(tapLocation)
}
if activateBySingleTap, self.presentedController == nil {
self.longTapTimer?.invalidate()
self.pressTimer?.invalidate()
if let tapLocation = self.tapLocation {
self.checkCandidateContent(at: tapLocation, forceActivate: true)
}
self.state = .ended
} else {
if let presentedController = self.presentedController, presentedController.isNodeLoaded, let location = touches.first?.location(in: presentedController.view) {
(presentedController.displayNode as? PeekControllerNode)?.endDragging(location)
self.presentedController = nil
self.menuActivation = nil
}
self.tapLocation = nil
self.candidateContent = nil
self.longTapTimer?.invalidate()
self.pressTimer?.invalidate()
self.candidateContentDisposable.set(nil)
self.state = .failed
}
}
override public func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.tapLocation = nil
self.candidateContent = nil
self.state = .failed
if let presentedController = self.presentedController {
self.menuActivation = nil
self.presentedController = nil
presentedController.dismiss()
}
}
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
if let touch = touches.first, let initialTapLocation = self.tapLocation {
let touchLocation = touch.location(in: self.view)
if let presentedController = self.presentedController, self.menuActivation == nil {
if presentedController.isNodeLoaded {
let touchLocation = touch.location(in: presentedController.view)
(presentedController.displayNode as? PeekControllerNode)?.applyDraggingOffset(touchLocation)
}
} else if let menuActivation = self.menuActivation, let presentedController = self.presentedController {
switch menuActivation {
case .drag:
var offset = touchLocation.y - initialTapLocation.y
let delta = abs(offset)
let factor: CGFloat = 60.0
offset = (-((1.0 - (1.0 / (((delta) * 0.55 / (factor)) + 1.0))) * factor)) * (offset < 0.0 ? 1.0 : -1.0)
if presentedController.isNodeLoaded {
// (presentedController.displayNode as? PeekControllerNode)?.applyDraggingOffset(offset)
}
case .press:
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
if touch.force >= 2.5 {
if presentedController.isNodeLoaded {
(presentedController.displayNode as? PeekControllerNode)?.activateMenu()
self.menuActivation = nil
self.presentedController = nil
self.candidateContent = nil
self.state = .ended
self.candidateContentDisposable.set(nil)
return
}
}
}
if self.pressTimer != nil {
let dX = touchLocation.x - initialTapLocation.x
let dY = touchLocation.y - initialTapLocation.y
if dX * dX + dY * dY > 3.0 * 3.0 {
self.startPressTimer()
}
}
if self.presentedController != nil {
self.checkCandidateContent(at: touchLocation)
}
}
} else {
let dX = touchLocation.x - initialTapLocation.x
let dY = touchLocation.y - initialTapLocation.y
if dX * dX + dY * dY > 3.0 * 3.0 {
self.stopLongTapTimer()
self.tapLocation = nil
self.candidateContent = nil
self.state = .failed
}
}
}
}
private func checkCandidateContent(at touchLocation: CGPoint, forceActivate: Bool = false) {
//print("check begin")
if let contentSignal = self.contentAtPoint(touchLocation) {
self.candidateContentDisposable.set((contentSignal
|> deliverOnMainQueue).start(next: { [weak self] result in
if let strongSelf = self {
let processResult: Bool
if forceActivate {
processResult = true
} else {
switch strongSelf.state {
case .possible, .changed:
processResult = true
default:
processResult = false
}
}
//print("check received, will process: \(processResult), force: \(forceActivate), state: \(strongSelf.state)")
if processResult {
if let (sourceView, sourceRect, content) = result {
if let currentContent = strongSelf.candidateContent {
if !currentContent.2.isEqual(to: content) {
strongSelf.tapLocation = touchLocation
strongSelf.candidateContent = (sourceView, sourceRect, content)
strongSelf.menuActivation = content.menuActivation()
if let presentedController = strongSelf.presentedController, presentedController.isNodeLoaded {
presentedController.sourceView = {
return (sourceView, sourceRect)
}
(presentedController.displayNode as? PeekControllerNode)?.updateContent(content: content)
}
}
} else {
if let presentedController = strongSelf.present(content, sourceView, sourceRect) {
if let presentedController = presentedController as? PeekController {
if forceActivate {
strongSelf.candidateContent = nil
if case .press = content.menuActivation() {
(presentedController.displayNode as? PeekControllerNode)?.activateMenu()
}
} else {
strongSelf.candidateContent = (sourceView, sourceRect, content)
strongSelf.menuActivation = content.menuActivation()
strongSelf.presentedController = presentedController
strongSelf.state = .began
switch content.menuActivation() {
case .drag:
break
case .press:
if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
if presentedController.traitCollection.forceTouchCapability != .available {
strongSelf.startPressTimer()
}
} else {
strongSelf.startPressTimer()
}
}
}
} else {
if strongSelf.state != .ended {
strongSelf.state = .ended
}
}
}
}
} else if strongSelf.presentedController == nil {
if strongSelf.state != .possible && strongSelf.state != .ended {
strongSelf.state = .failed
}
}
}
}
}))
} else if self.presentedController == nil {
self.state = .failed
}
}
}
@@ -0,0 +1,482 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
private let animationDurationFactor: Double = 1.0
final class PeekControllerNode: ViewControllerTracingNode {
private let requestDismiss: () -> Void
private let presentationData: PresentationData
private let theme: PeekControllerTheme
private weak var controller: PeekController?
private let blurView: UIView
private let dimNode: ASDisplayNode
private let containerBackgroundNode: ASImageNode
private let containerNode: ASDisplayNode
private let darkDimNode: ASDisplayNode
private var validLayout: ContainerViewLayout?
private var content: PeekControllerContent
var contentNode: PeekControllerContentNode & ASDisplayNode
private var contentNodeHasValidLayout = false
private var topAccessoryNode: ASDisplayNode?
private var fullScreenAccessoryNode: (PeekControllerAccessoryNode & ASDisplayNode)?
private var actionsStackNode: ContextControllerActionsStackNode
private var hapticFeedback = HapticFeedback()
private var initialContinueGesturePoint: CGPoint?
private var didMoveFromInitialGesturePoint = false
private var highlightedActionNode: ContextActionNodeProtocol?
init(presentationData: PresentationData, controller: PeekController, content: PeekControllerContent, requestDismiss: @escaping () -> Void) {
self.presentationData = presentationData
self.requestDismiss = requestDismiss
self.theme = PeekControllerTheme(presentationTheme: presentationData.theme)
self.controller = controller
self.dimNode = ASDisplayNode()
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: self.theme.isDark ? .dark : .light))
blurView.isUserInteractionEnabled = false
self.blurView = blurView
self.darkDimNode = ASDisplayNode()
self.darkDimNode.alpha = 0.0
self.darkDimNode.backgroundColor = presentationData.theme.contextMenu.dimColor
self.darkDimNode.isUserInteractionEnabled = false
switch content.menuActivation() {
case .drag:
self.dimNode.backgroundColor = nil
self.blurView.alpha = 1.0
case .press:
self.dimNode.backgroundColor = UIColor(white: self.theme.isDark ? 0.0 : 1.0, alpha: 0.5)
self.blurView.alpha = 0.0
}
self.containerBackgroundNode = ASImageNode()
self.containerBackgroundNode.isLayerBacked = true
self.containerBackgroundNode.displaysAsynchronously = false
self.containerNode = ASDisplayNode()
self.content = content
self.contentNode = content.node()
self.topAccessoryNode = content.topAccessoryNode()
self.fullScreenAccessoryNode = content.fullScreenAccessoryNode(blurView: blurView)
self.fullScreenAccessoryNode?.alpha = 0.0
var activatedActionImpl: (() -> Void)?
var requestLayoutImpl: ((ContainedViewLayoutTransition) -> Void)?
self.actionsStackNode = ContextControllerActionsStackNode(
context: nil,
getController: { [weak controller] in
return controller
},
requestDismiss: { result in
activatedActionImpl?()
},
requestUpdate: { transition in
requestLayoutImpl?(transition)
}
)
self.actionsStackNode.alpha = 0.0
let items = ContextController.Items(
id: 0,
content: .list(content.menuItems()),
context: nil,
reactionItems: [],
selectedReactionItems: Set(),
reactionsTitle: nil,
reactionsLocked: false,
animationCache: nil,
alwaysAllowPremiumReactions: false,
allPresetReactionsAreAvailable: false,
getEmojiContent: nil,
disablePositionLock: false,
tip: nil,
tipSignal: nil,
dismissed: nil
)
if let item = makeContextControllerActionsStackItem(items: items).first {
self.actionsStackNode.replace(
item: item,
animated: false
)
}
super.init()
requestLayoutImpl = { [weak self] transition in
self?.updateLayout(transition: transition)
}
if content.presentation() == .freeform {
self.containerNode.isUserInteractionEnabled = false
} else {
self.containerNode.clipsToBounds = true
self.containerNode.cornerRadius = 16.0
}
self.addSubnode(self.dimNode)
self.view.addSubview(self.blurView)
self.addSubnode(self.darkDimNode)
self.containerNode.addSubnode(self.contentNode)
self.addSubnode(self.actionsStackNode)
self.addSubnode(self.containerNode)
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode {
self.fullScreenAccessoryNode?.dismiss = { [weak self] in
self?.requestDismiss()
}
self.addSubnode(fullScreenAccessoryNode)
}
activatedActionImpl = { [weak self] in
self?.requestDismiss()
}
self.hapticFeedback.prepareTap()
controller.ready.set(self.contentNode.ready())
}
deinit {
}
override func didLoad() {
super.didLoad()
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimNodeTap(_:))))
self.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))))
}
func updateLayout(transition: ContainedViewLayoutTransition = .immediate) {
if let layout = self.validLayout {
self.containerLayoutUpdated(layout, transition: transition)
}
}
func replaceItem(items: Signal<ContextController.Items, NoError>) {
let _ = (items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
if let item = makeContextControllerActionsStackItem(items: items).first {
self.actionsStackNode.replace(item: item, animated: false)
}
})
}
func pushItems(items: Signal<ContextController.Items, NoError>) {
let _ = (items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
if let item = makeContextControllerActionsStackItem(items: items).first {
self.actionsStackNode.push(item: item, currentScrollingState: nil, positionLock: nil, animated: true)
}
})
}
func popItems() {
self.actionsStackNode.pop()
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.darkDimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(view: self.blurView, frame: CGRect(origin: CGPoint(), size: layout.size))
var layoutInsets = layout.insets(options: [])
let containerWidth = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: layout.safeInsets.left)
layoutInsets.left = floor((layout.size.width - containerWidth) / 2.0)
layoutInsets.right = layoutInsets.left
if !layoutInsets.bottom.isZero {
layoutInsets.bottom -= 12.0
}
let maxContainerSize = CGSize(width: layout.size.width - 14.0 * 2.0, height: layout.size.height - layoutInsets.top - layoutInsets.bottom - 90.0)
let contentSize = self.contentNode.updateLayout(size: maxContainerSize, transition: self.contentNodeHasValidLayout ? transition : .immediate)
if self.contentNodeHasValidLayout {
transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize))
} else {
self.contentNode.frame = CGRect(origin: CGPoint(), size: contentSize)
}
let actionsSideInset: CGFloat = layout.safeInsets.left + 11.0
let actionsSize = self.actionsStackNode.update(
presentationData: self.presentationData,
constrainedSize: CGSize(width: layout.size.width - actionsSideInset * 2.0, height: layout.size.height),
presentation: .inline,
transition: transition
)
let containerFrame: CGRect
let actionsFrame: CGRect
if layout.size.width > layout.size.height {
if self.actionsStackNode.alpha.isZero {
containerFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - contentSize.width) / 2.0), y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize)
} else {
containerFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - contentSize.width) / 3.0), y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize)
}
actionsFrame = CGRect(origin: CGPoint(x: containerFrame.maxX + 32.0, y: floor((layout.size.height - actionsSize.height) / 2.0)), size: actionsSize)
} else {
switch self.content.presentation() {
case .contained:
containerFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - contentSize.width) / 2.0), y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize)
case .freeform:
var fraction: CGFloat = 1.0 / 3.0
if let _ = self.controller?.appeared {
fraction *= 1.33
}
containerFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - contentSize.width) / 2.0), y: floor((layout.size.height - contentSize.height) * fraction)), size: contentSize)
}
actionsFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - actionsSize.width) / 2.0), y: containerFrame.maxY + 64.0), size: actionsSize)
}
transition.updateFrame(node: self.containerNode, frame: containerFrame)
transition.updateFrame(node: self.actionsStackNode, frame: actionsFrame)
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode {
transition.updateFrame(node: fullScreenAccessoryNode, frame: CGRect(origin: .zero, size: layout.size))
fullScreenAccessoryNode.updateLayout(size: layout.size, transition: transition)
}
self.contentNodeHasValidLayout = true
}
func animateIn(from rect: CGRect) {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.blurView.layer.animateAlpha(from: 0.0, to: self.blurView.alpha, duration: 0.3)
let offset = CGPoint(x: rect.midX - self.containerNode.position.x, y: rect.midY - self.containerNode.position.y)
self.containerNode.layer.animateSpring(from: NSValue(cgPoint: offset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.4, initialVelocity: 0.0, damping: 110.0, additive: true)
if let appeared = self.controller?.appeared {
appeared()
let scale = rect.width / self.contentNode.frame.width
self.containerNode.layer.animateSpring(from: scale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, initialVelocity: 0.0, damping: 110.0)
} else {
self.containerNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, initialVelocity: 0.0, damping: 110.0)
self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
if let topAccessoryNode = self.topAccessoryNode {
topAccessoryNode.layer.animateSpring(from: NSValue(cgPoint: offset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.4, initialVelocity: 0.0, damping: 110.0, additive: true)
topAccessoryNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, initialVelocity: 0.0, damping: 110.0)
topAccessoryNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
if case .press = self.content.menuActivation() {
self.hapticFeedback.tap()
} else {
self.hapticFeedback.impact()
}
}
func animateOut(to rect: CGRect, completion: @escaping () -> Void) {
self.isUserInteractionEnabled = false
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
self.blurView.layer.animateAlpha(from: self.blurView.alpha, to: 0.0, duration: 0.25, removeOnCompletion: false)
self.darkDimNode.layer.animateAlpha(from: self.darkDimNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false)
let springDuration: Double = 0.42 * animationDurationFactor
let springDamping: CGFloat = 104.0
var scaleCompleted = false
var positionCompleted = false
let outCompletion = {
if scaleCompleted && positionCompleted {
}
}
let offset = CGPoint(x: rect.midX - self.containerNode.position.x, y: rect.midY - self.containerNode.position.y)
self.containerNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint()), to: NSValue(cgPoint: offset), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, removeOnCompletion: false, additive: true, completion: { _ in
positionCompleted = true
outCompletion()
completion()
})
if let _ = self.controller?.disappeared {
self.controller?.disappeared?()
let scale = rect.width / self.contentNode.frame.width
self.containerNode.layer.animateScale(from: 1.0, to: scale, duration: 0.25, removeOnCompletion: false, completion: { _ in
scaleCompleted = true
outCompletion()
})
} else {
self.containerNode.layer.animateScale(from: 1.0, to: 0.1, duration: 0.25, removeOnCompletion: false, completion: { _ in
scaleCompleted = true
outCompletion()
})
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
if !self.actionsStackNode.alpha.isZero {
let actionsOffset = CGPoint(x: rect.midX - self.actionsStackNode.position.x, y: rect.midY - self.actionsStackNode.position.y)
self.actionsStackNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2 * animationDurationFactor, removeOnCompletion: false)
self.actionsStackNode.layer.animateSpring(from: 1.0 as NSNumber, to: 0.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping, removeOnCompletion: false)
self.actionsStackNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint()), to: NSValue(cgPoint: actionsOffset), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, additive: true)
}
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode, !fullScreenAccessoryNode.alpha.isZero {
fullScreenAccessoryNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2 * animationDurationFactor, removeOnCompletion: false)
}
}
@objc func dimNodeTap(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.requestDismiss()
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
guard case .drag = self.content.menuActivation() else {
return
}
let location = recognizer.location(in: self.view)
switch recognizer.state {
case .began:
break
case .changed:
self.applyDraggingOffset(location)
case .cancelled, .ended:
self.endDragging(location)
default:
break
}
}
func applyDraggingOffset(_ offset: CGPoint) {
let localPoint = offset
let initialPoint: CGPoint
if let current = self.initialContinueGesturePoint {
initialPoint = current
} else {
initialPoint = localPoint
self.initialContinueGesturePoint = localPoint
}
if !self.actionsStackNode.alpha.isZero {
if !self.didMoveFromInitialGesturePoint {
let distance = abs(localPoint.y - initialPoint.y)
if distance > 12.0 {
self.didMoveFromInitialGesturePoint = true
}
}
if self.didMoveFromInitialGesturePoint {
let actionPoint = self.view.convert(localPoint, to: self.actionsStackNode.view)
self.actionsStackNode.highlightGestureMoved(location: actionPoint)
}
}
}
func endDragging(_ location: CGPoint) {
if self.didMoveFromInitialGesturePoint {
self.actionsStackNode.highlightGestureFinished(performAction: true)
} else if self.actionsStackNode.alpha.isZero {
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode, !fullScreenAccessoryNode.alpha.isZero {
} else {
self.requestDismiss()
}
}
}
func activateMenu(immediately: Bool = false) {
if self.content.menuItems().isEmpty {
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode {
fullScreenAccessoryNode.alpha = 1.0
fullScreenAccessoryNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
let previousBlurAlpha = self.blurView.alpha
self.blurView.alpha = 1.0
self.blurView.layer.animateAlpha(from: previousBlurAlpha, to: self.blurView.alpha, duration: 0.3)
}
return
} else {
if let fullScreenAccessoryNode = self.fullScreenAccessoryNode {
fullScreenAccessoryNode.alpha = 1.0
fullScreenAccessoryNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
if case .press = self.content.menuActivation() {
self.hapticFeedback.impact()
}
let springDuration: Double = 0.42 * animationDurationFactor
let springDamping: CGFloat = 104.0
let previousBlurAlpha = self.blurView.alpha
self.blurView.alpha = 1.0
self.blurView.layer.animateAlpha(from: previousBlurAlpha, to: self.blurView.alpha, duration: 0.25)
let previousDarkDimAlpha = self.darkDimNode.alpha
self.darkDimNode.alpha = 1.0
self.darkDimNode.layer.animateAlpha(from: previousDarkDimAlpha, to: 1.0, duration: 0.3)
if let layout = self.validLayout {
self.containerLayoutUpdated(layout, transition: .animated(duration: springDuration, curve: .spring))
}
let animateIn = {
self.actionsStackNode.alpha = 1.0
self.actionsStackNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2 * animationDurationFactor)
self.actionsStackNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
let localContentSourceFrame = self.containerNode.frame
self.actionsStackNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: localContentSourceFrame.center.x - self.actionsStackNode.position.x, y: localContentSourceFrame.center.y - self.actionsStackNode.position.y)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, additive: true)
}
if immediately {
animateIn()
} else {
Queue.mainQueue().after(0.02, animateIn)
}
}
func updateContent(content: PeekControllerContent) {
let contentNode = self.contentNode
contentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak contentNode] _ in
contentNode?.removeFromSupernode()
})
contentNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.15, removeOnCompletion: false)
self.content = content
self.contentNode = content.node()
self.containerNode.addSubnode(self.contentNode)
self.contentNodeHasValidLayout = false
self.replaceItem(items: .single(ContextController.Items(content: .list(content.menuItems()))))
self.contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
self.contentNode.layer.animateSpring(from: 0.35 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5)
if let layout = self.validLayout {
self.containerLayoutUpdated(layout, transition: .animated(duration: 0.15, curve: .easeInOut))
}
self.hapticFeedback.tap()
}
}
@@ -0,0 +1,482 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import TextSelectionNode
import TelegramCore
import SwiftSignalKit
import UIKitRuntimeUtils
final class PinchSourceGesture: UIPinchGestureRecognizer {
private final class Target {
var updated: (() -> Void)?
@objc func onGesture(_ gesture: UIPinchGestureRecognizer) {
self.updated?()
}
}
private let target: Target
private(set) var currentTransform: (CGFloat, CGPoint, CGPoint)?
var began: (() -> Void)?
var updated: ((CGFloat, CGPoint, CGPoint) -> Void)?
var ended: (() -> Void)?
private var initialLocation: CGPoint?
private var pinchLocation = CGPoint()
private var currentOffset = CGPoint()
private var currentNumberOfTouches = 0
init() {
self.target = Target()
super.init(target: self.target, action: #selector(self.target.onGesture(_:)))
self.target.updated = { [weak self] in
self?.gestureUpdated()
}
}
override func reset() {
super.reset()
self.currentNumberOfTouches = 0
self.initialLocation = nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
//self.currentTouches.formUnion(touches)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
}
private func gestureUpdated() {
switch self.state {
case .began:
self.currentOffset = CGPoint()
let pinchLocation = self.location(in: self.view)
self.pinchLocation = pinchLocation
self.initialLocation = pinchLocation
let scale = max(1.0, self.scale)
self.currentTransform = (scale, self.pinchLocation, self.currentOffset)
self.currentNumberOfTouches = self.numberOfTouches
self.began?()
case .changed:
let locationSum = self.location(in: self.view)
if self.numberOfTouches < 2 && self.currentNumberOfTouches >= 2 {
self.initialLocation = CGPoint(x: locationSum.x - self.currentOffset.x, y: locationSum.y - self.currentOffset.y)
}
self.currentNumberOfTouches = self.numberOfTouches
if let initialLocation = self.initialLocation {
self.currentOffset = CGPoint(x: locationSum.x - initialLocation.x, y: locationSum.y - initialLocation.y)
}
if let (scale, pinchLocation, _) = self.currentTransform {
self.currentTransform = (scale, pinchLocation, self.currentOffset)
self.updated?(scale, pinchLocation, self.currentOffset)
}
let scale = max(1.0, self.scale)
self.currentTransform = (scale, self.pinchLocation, self.currentOffset)
self.updated?(scale, self.pinchLocation, self.currentOffset)
case .ended, .cancelled:
self.ended?()
default:
break
}
}
}
private func cancelContextGestures(node: ASDisplayNode) {
if let node = node as? ContextControllerSourceNode {
node.cancelGesture()
}
if let supernode = node.supernode {
cancelContextGestures(node: supernode)
}
}
private func cancelContextGestures(view: UIView) {
if let gestureRecognizers = view.gestureRecognizers {
for recognizer in gestureRecognizers {
if let recognizer = recognizer as? InteractiveTransitionGestureRecognizer {
recognizer.cancel()
} else if let recognizer = recognizer as? WindowPanRecognizer {
recognizer.cancel()
}
}
}
if let superview = view.superview {
cancelContextGestures(view: superview)
}
}
public final class PinchSourceContainerNode: ASDisplayNode, ASGestureRecognizerDelegate {
public let contentNode: ASDisplayNode
public var contentRect: CGRect = CGRect()
private(set) var naturalContentFrame: CGRect?
fileprivate let gesture: PinchSourceGesture
public var isPinchGestureEnabled: Bool = true {
didSet {
if self.isPinchGestureEnabled != oldValue {
self.gesture.isEnabled = self.isPinchGestureEnabled
}
}
}
public var maxPinchScale: CGFloat = 10.0
private var isActive: Bool = false
public var activate: ((PinchSourceContainerNode) -> Void)?
public var scaleUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)?
public var animatedOut: (() -> Void)?
var deactivate: (() -> Void)?
public var deactivated: (() -> Void)?
var updated: ((CGFloat, CGPoint, CGPoint) -> Void)?
override public init() {
self.gesture = PinchSourceGesture()
self.contentNode = ASDisplayNode()
super.init()
self.addSubnode(self.contentNode)
self.gesture.began = { [weak self] in
guard let strongSelf = self else {
return
}
cancelContextGestures(node: strongSelf)
cancelContextGestures(view: strongSelf.view)
strongSelf.isActive = true
strongSelf.activate?(strongSelf)
}
self.gesture.ended = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isActive = false
strongSelf.deactivate?()
strongSelf.deactivated?()
}
self.gesture.updated = { [weak self] scale, pinchLocation, offset in
guard let strongSelf = self else {
return
}
strongSelf.updated?(min(scale, strongSelf.maxPinchScale), pinchLocation, offset)
strongSelf.scaleUpdated?(min(scale, strongSelf.maxPinchScale), .immediate)
}
}
override public func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(self.gesture)
self.view.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
guard let strongSelf = self else {
return false
}
return strongSelf.isActive
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
public func update(size: CGSize, transition: ContainedViewLayoutTransition) {
let contentFrame = CGRect(origin: CGPoint(), size: size)
self.naturalContentFrame = contentFrame
if !self.isActive {
transition.updateFrame(node: self.contentNode, frame: contentFrame)
}
}
func restoreToNaturalSize() {
guard let naturalContentFrame = self.naturalContentFrame else {
return
}
self.contentNode.frame = naturalContentFrame
}
}
private final class PinchControllerNode: ViewControllerTracingNode {
private weak var controller: PinchController?
private var initialSourceFrame: CGRect?
private let clippingNode: ASDisplayNode
private let scrollingContainer: ASDisplayNode
private let sourceNode: PinchSourceContainerNode
private let disableScreenshots: Bool
private let getContentAreaInScreenSpace: () -> CGRect
private let dimNode: ASDisplayNode
private var validLayout: ContainerViewLayout?
private var isAnimatingOut: Bool = false
private var hapticFeedback: HapticFeedback?
init(controller: PinchController, sourceNode: PinchSourceContainerNode, disableScreenshots: Bool, getContentAreaInScreenSpace: @escaping () -> CGRect) {
self.controller = controller
self.sourceNode = sourceNode
self.disableScreenshots = disableScreenshots
self.getContentAreaInScreenSpace = getContentAreaInScreenSpace
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.dimNode.alpha = 0.0
self.clippingNode = ASDisplayNode()
self.clippingNode.clipsToBounds = true
self.scrollingContainer = ASDisplayNode()
super.init()
self.addSubnode(self.dimNode)
self.addSubnode(self.clippingNode)
self.clippingNode.addSubnode(self.scrollingContainer)
self.sourceNode.deactivate = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.controller?.dismiss()
}
self.sourceNode.updated = { [weak self] scale, pinchLocation, offset in
guard let strongSelf = self, let initialSourceFrame = strongSelf.initialSourceFrame else {
return
}
strongSelf.dimNode.alpha = max(0.0, min(1.0, scale - 1.0))
let pinchOffset = CGPoint(
x: pinchLocation.x - initialSourceFrame.width / 2.0,
y: pinchLocation.y - initialSourceFrame.height / 2.0
)
var transform = CATransform3DIdentity
transform = CATransform3DTranslate(transform, offset.x - pinchOffset.x * (scale - 1.0), offset.y - pinchOffset.y * (scale - 1.0), 0.0)
transform = CATransform3DScale(transform, scale, scale, 0.0)
strongSelf.sourceNode.contentNode.transform = transform
}
if self.disableScreenshots {
setLayerDisableScreenshots(self.layer, true)
}
}
deinit {
}
override func didLoad() {
super.didLoad()
}
func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition, previousActionsContainerNode: ContextActionsContainerNode?) {
if self.isAnimatingOut {
return
}
self.validLayout = layout
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.clippingNode, frame: CGRect(origin: CGPoint(), size: layout.size))
}
func animateIn() {
let convertedFrame = convertFrame(self.sourceNode.bounds, from: self.sourceNode.view, to: self.view)
self.sourceNode.contentNode.frame = convertedFrame
self.initialSourceFrame = convertedFrame
self.scrollingContainer.addSubnode(self.sourceNode.contentNode)
var updatedContentAreaInScreenSpace = self.getContentAreaInScreenSpace()
updatedContentAreaInScreenSpace.origin.x = 0.0
updatedContentAreaInScreenSpace.size.width = self.bounds.width
self.clippingNode.layer.animateFrame(from: updatedContentAreaInScreenSpace, to: self.clippingNode.frame, duration: 0.18 * 1.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
self.clippingNode.layer.animateBoundsOriginYAdditive(from: updatedContentAreaInScreenSpace.minY, to: 0.0, duration: 0.18 * 1.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
}
func animateOut(completion: @escaping () -> Void) {
self.isAnimatingOut = true
let performCompletion: () -> Void = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.isAnimatingOut = false
strongSelf.sourceNode.restoreToNaturalSize()
strongSelf.sourceNode.addSubnode(strongSelf.sourceNode.contentNode)
strongSelf.sourceNode.animatedOut?()
completion()
}
let convertedFrame = convertFrame(self.sourceNode.bounds, from: self.sourceNode.view, to: self.view)
self.sourceNode.contentNode.frame = convertedFrame
self.initialSourceFrame = convertedFrame
if let (scale, pinchLocation, offset) = self.sourceNode.gesture.currentTransform, let initialSourceFrame = self.initialSourceFrame {
let duration = 0.3
let transitionCurve: ContainedViewLayoutTransitionCurve = .easeInOut
var updatedContentAreaInScreenSpace = self.getContentAreaInScreenSpace()
updatedContentAreaInScreenSpace.origin.x = 0.0
updatedContentAreaInScreenSpace.size.width = self.bounds.width
self.clippingNode.layer.animateFrame(from: self.clippingNode.frame, to: updatedContentAreaInScreenSpace, duration: duration * 1.0, timingFunction: transitionCurve.timingFunction, removeOnCompletion: false)
self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: updatedContentAreaInScreenSpace.minY, duration: duration * 1.0, timingFunction: transitionCurve.timingFunction, removeOnCompletion: false)
let transition: ContainedViewLayoutTransition = .animated(duration: duration, curve: .spring)
if self.hapticFeedback == nil {
self.hapticFeedback = HapticFeedback()
}
self.hapticFeedback?.prepareImpact(.light)
self.hapticFeedback?.impact(.light)
self.sourceNode.scaleUpdated?(1.0, transition)
let pinchOffset = CGPoint(
x: pinchLocation.x - initialSourceFrame.width / 2.0,
y: pinchLocation.y - initialSourceFrame.height / 2.0
)
var transform = CATransform3DIdentity
transform = CATransform3DScale(transform, scale, scale, 0.0)
self.sourceNode.contentNode.transform = CATransform3DIdentity
self.sourceNode.contentNode.position = CGPoint(x: initialSourceFrame.midX, y: initialSourceFrame.midY)
self.sourceNode.contentNode.layer.animateSpring(from: scale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration * 1.2, damping: 110.0)
self.sourceNode.contentNode.layer.animatePosition(from: CGPoint(x: offset.x - pinchOffset.x * (scale - 1.0), y: offset.y - pinchOffset.y * (scale - 1.0)), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true, force: true, completion: { _ in
performCompletion()
})
let dimNodeTransition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: transitionCurve)
dimNodeTransition.updateAlpha(node: self.dimNode, alpha: 0.0)
} else {
performCompletion()
}
}
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
if self.isAnimatingOut {
self.scrollingContainer.bounds = self.scrollingContainer.bounds.offsetBy(dx: 0.0, dy: offset.y)
transition.animateOffsetAdditive(node: self.scrollingContainer, offset: -offset.y)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return nil
}
}
public final class PinchController: ViewController, StandalonePresentableController {
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private let sourceNode: PinchSourceContainerNode
private let disableScreenshots: Bool
private let getContentAreaInScreenSpace: () -> CGRect
private var wasDismissed = false
private var controllerNode: PinchControllerNode {
return self.displayNode as! PinchControllerNode
}
public init(sourceNode: PinchSourceContainerNode, disableScreenshots: Bool = false, getContentAreaInScreenSpace: @escaping () -> CGRect) {
self.sourceNode = sourceNode
self.disableScreenshots = disableScreenshots
self.getContentAreaInScreenSpace = getContentAreaInScreenSpace
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.lockOrientation = true
self.blocksBackgroundWhenInOverlay = true
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func loadDisplayNode() {
self.displayNode = PinchControllerNode(controller: self, sourceNode: self.sourceNode, disableScreenshots: self.disableScreenshots, getContentAreaInScreenSpace: self.getContentAreaInScreenSpace)
self.displayNodeDidLoad()
self._ready.set(.single(true))
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.updateLayout(layout: layout, transition: transition, previousActionsContainerNode: nil)
}
override public func viewDidAppear(_ animated: Bool) {
if self.ignoreAppearanceMethodInvocations() {
return
}
super.viewDidAppear(animated)
self.controllerNode.animateIn()
}
override public func dismiss(completion: (() -> Void)? = nil) {
if !self.wasDismissed {
self.wasDismissed = true
self.controllerNode.animateOut(completion: { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
completion?()
})
}
}
public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
self.controllerNode.addRelativeContentOffset(offset, transition: transition)
}
}
@@ -0,0 +1,55 @@
import Foundation
import UIKit
import SwiftSignalKit
import Display
import ComponentFlow
import TelegramCore
import AccountContext
import EmojiStatusComponent
final class ReactionPreviewView: UIView {
private let context: AccountContext
private let file: TelegramMediaFile
private let icon = ComponentView<Empty>()
init(context: AccountContext, file: TelegramMediaFile) {
self.context = context
self.file = file
super.init(frame: CGRect())
}
required init(coder: NSCoder) {
preconditionFailure()
}
func update(size: CGSize) {
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(EmojiStatusComponent(
context: self.context,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
content: .animation(
content: .file(file: self.file),
size: size,
placeholderColor: .clear,
themeColor: .white,
loopMode: .forever
),
isVisibleForAnimations: true,
action: nil
)),
environment: {},
containerSize: size
)
let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
iconView.frame = iconFrame
}
}
}