Home telegram-context menu controller
Post
Cancel

telegram-context menu controller

ContextMenuController

class ContextMenuControllerPresentationArguments

1
2
fileprivate let sourceNodeAndRect: () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?
fileprivate let bounce: Bool

enum ContextMenuActionContent

1
2
case text(title: String, accessibilityLabel: String)
case icon(UIImage)

struct ContextMenuAction

1
2
public let content: ContextMenuActionContent
public let action: () -> Void

struct CachedMaskParams

1
2
3
let size: CGSize
let relativeArrowPosition: CGFloat
let arrowOnBottom: Bool

class ContextMenuContainerMaskView

1
2
3
override class var layerClass: AnyClass {
    return CAShapeLayer.self
}

class ContextMenuContainerNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
private var cachedMaskParams: CachedMaskParams?
// self.view.mask
private let maskView = ContextMenuContainerMaskView()
public var relativeArrowPosition: (CGFloat, Bool)?

override public init() {
    //self.effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
    
    super.init()
    
    self.backgroundColor = UIColor(rgb: 0x8c8e8e)
    //self.view.addSubview(self.effectView)
    //self.effectView.mask = self.maskView
    self.view.mask = self.maskView
}

override public func didLoad() {
    super.didLoad()
    
  // allows group opacity
    self.layer.allowsGroupOpacity = true
}

override public func layout() {
    super.layout()
    
  // update layout
    self.updateLayout(transition: .immediate)
}

// update layout
public func updateLayout(transition: ContainedViewLayoutTransition) {
    //self.effectView.frame = self.bounds

    let maskParams = CachedMaskParams(size: self.bounds.size, relativeArrowPosition: self.relativeArrowPosition?.0 ?? self.bounds.size.width / 2.0, arrowOnBottom: self.relativeArrowPosition?.1 ?? true)
    if self.cachedMaskParams != maskParams {
        let path = UIBezierPath()
        let cornerRadius: CGFloat = 10.0
        let verticalInset: CGFloat = 9.0
        let arrowWidth: CGFloat = 18.0
        let requestedArrowPosition = maskParams.relativeArrowPosition
        let arrowPosition = max(cornerRadius + arrowWidth / 2.0, min(maskParams.size.width - cornerRadius - arrowWidth / 2.0, requestedArrowPosition))
        let arrowOnBottom = maskParams.arrowOnBottom

        path.move(to: CGPoint(x: 0.0, y: verticalInset + cornerRadius))
        path.addArc(withCenter: CGPoint(x: cornerRadius, y: verticalInset + cornerRadius), radius: cornerRadius, startAngle: CGFloat.pi, endAngle: CGFloat(3.0 * CGFloat.pi / 2.0), clockwise: true)
        if !arrowOnBottom {
            path.addLine(to: CGPoint(x: arrowPosition - arrowWidth / 2.0, y: verticalInset))
            path.addLine(to: CGPoint(x: arrowPosition, y: 0.0))
            path.addLine(to: CGPoint(x: arrowPosition + arrowWidth / 2.0, y: verticalInset))
        }
        path.addLine(to: CGPoint(x: maskParams.size.width - cornerRadius, y: verticalInset))
        path.addArc(withCenter: CGPoint(x: maskParams.size.width - cornerRadius, y: verticalInset + cornerRadius), radius: cornerRadius, startAngle: CGFloat(3.0 * CGFloat.pi / 2.0), endAngle: 0.0, clockwise: true)
        path.addLine(to: CGPoint(x: maskParams.size.width, y: maskParams.size.height - cornerRadius - verticalInset))
        path.addArc(withCenter: CGPoint(x: maskParams.size.width - cornerRadius, y: maskParams.size.height - cornerRadius - verticalInset), radius: cornerRadius, startAngle: 0.0, endAngle: CGFloat(CGFloat.pi / 2.0), clockwise: true)
        if arrowOnBottom {
            path.addLine(to: CGPoint(x: arrowPosition + arrowWidth / 2.0, y: maskParams.size.height - verticalInset))
            path.addLine(to: CGPoint(x: arrowPosition, y: maskParams.size.height))
            path.addLine(to: CGPoint(x: arrowPosition - arrowWidth / 2.0, y: maskParams.size.height - verticalInset))
        }
        path.addLine(to: CGPoint(x: cornerRadius, y: maskParams.size.height - verticalInset))
        path.addArc(withCenter: CGPoint(x: cornerRadius, y: maskParams.size.height - cornerRadius - verticalInset), radius: cornerRadius, startAngle: CGFloat(CGFloat.pi / 2.0), endAngle: CGFloat.pi, clockwise: true)
        path.close()

        self.cachedMaskParams = maskParams
        if let layer = self.maskView.layer as? CAShapeLayer {
            if case let .animated(duration, curve) = transition, let previousPath = layer.path {
              // animate mask path
                layer.animate(from: previousPath, to: path.cgPath, keyPath: "path", timingFunction: curve.timingFunction, duration: duration)
            }
            layer.path = path.cgPath
        }
    }
}

class ContextMenuContentScrollNode

generate shadow image

1
2
3
4
5
6
7
8
private func generateShadowImage() -> UIImage? {
    return generateImage(CGSize(width: 30.0, height: 1.0), rotatedContext: { size, context in
        context.clear(CGRect(origin: CGPoint(), size: size))
        context.setShadow(offset: CGSize(), blur: 10.0, color: UIColor(white: 0.18, alpha: 1.0).cgColor)
        context.setFillColor(UIColor(white: 0.18, alpha: 1.0).cgColor)
        context.fill(CGRect(origin: CGPoint(x: -15.0, y: 0.0), size: CGSize(width: 30.0, height: 1.0)))
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var contentWidth: CGFloat = 0.0
private var initialOffset: CGFloat = 0.0

private let leftShadow: ASImageNode
private let rightShadow: ASImageNode
private let leftOverscrollNode: ASDisplayNode
private let rightOverscrollNode: ASDisplayNode
let contentNode: ASDisplayNode

override init() {
    self.contentNode = ASDisplayNode()
    
    let shadowImage = generateShadowImage()
    
    self.leftShadow = ASImageNode()
    self.leftShadow.displaysAsynchronously = false
    self.leftShadow.image = shadowImage
    self.rightShadow = ASImageNode()
    self.rightShadow.displaysAsynchronously = false
    self.rightShadow.image = shadowImage
    self.rightShadow.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
    
    self.leftOverscrollNode = ASDisplayNode()
    //self.leftOverscrollNode.backgroundColor = UIColor(white: 0.0, alpha: 0.8)
    self.rightOverscrollNode = ASDisplayNode()
    //self.rightOverscrollNode.backgroundColor = UIColor(white: 0.0, alpha: 0.8)
    
    super.init()
    
    self.contentNode.addSubnode(self.leftOverscrollNode)
    self.contentNode.addSubnode(self.rightOverscrollNode)
    self.addSubnode(self.contentNode)
    
    self.addSubnode(self.leftShadow)
    self.addSubnode(self.rightShadow)
}

class ContextMenuNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
private let actions: [ContextMenuAction]
private let dismiss: () -> Void

private let containerNode: ContextMenuContainerNode
private let scrollNode: ContextMenuContentScrollNode
private let actionNodes: [ContextMenuActionNode]

var sourceRect: CGRect?
var containerRect: CGRect?
var arrowOnBottom: Bool = true

private var dismissedByTouchOutside = false
private let catchTapsOutside: Bool

private let feedback: HapticFeedback?

init(actions: [ContextMenuAction], dismiss: @escaping () -> Void, catchTapsOutside: Bool, hasHapticFeedback: Bool = false) {
    self.actions = actions
    self.dismiss = dismiss
    self.catchTapsOutside = catchTapsOutside

    self.containerNode = ContextMenuContainerNode()
    self.scrollNode = ContextMenuContentScrollNode()

    self.actionNodes = actions.map { action in
        return ContextMenuActionNode(action: action)
    }

    if hasHapticFeedback {
        self.feedback = HapticFeedback()
        self.feedback?.prepareImpact(.light)
    } else {
        self.feedback = nil
    }

    super.init()

  // add scrollNode to containerNode
    self.containerNode.addSubnode(self.scrollNode)

  // add containerNode to self
    self.addSubnode(self.containerNode)
    let dismissNode = {
        dismiss()
    }
    for actionNode in self.actionNodes {
        actionNode.dismiss = dismissNode
      // add to scrollNode.contentNode
        self.scrollNode.contentNode.addSubnode(actionNode)
    }
}

// layout
func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
    var unboundActionsWidth: CGFloat = 0.0
    let actionSeparatorWidth: CGFloat = UIScreenPixel
    for actionNode in self.actionNodes {
        if !unboundActionsWidth.isZero {
            unboundActionsWidth += actionSeparatorWidth
        }
        let actionSize = actionNode.measure(CGSize(width: layout.size.width, height: 54.0))
        actionNode.frame = CGRect(origin: CGPoint(x: unboundActionsWidth, y: 0.0), size: actionSize)
        unboundActionsWidth += actionSize.width
    }

    let maxActionsWidth = layout.size.width - 20.0
    let actionsWidth = min(unboundActionsWidth, maxActionsWidth)

    let sourceRect: CGRect = self.sourceRect ?? CGRect(origin: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0), size: CGSize())
    let containerRect: CGRect = self.containerRect ?? self.bounds

    let insets = layout.insets(options: [.statusBar, .input])

    let verticalOrigin: CGFloat
    var arrowOnBottom = true
    if sourceRect.minY - 54.0 > containerRect.minY + insets.top {
        verticalOrigin = sourceRect.minY - 54.0
    } else {
        verticalOrigin = min(containerRect.maxY - insets.bottom - 54.0, sourceRect.maxY)
        arrowOnBottom = false
    }
    self.arrowOnBottom = arrowOnBottom

    let horizontalOrigin: CGFloat = floor(max(8.0, min(max(sourceRect.minX + 8.0, sourceRect.midX - actionsWidth / 2.0), layout.size.width - actionsWidth - 8.0)))

    self.containerNode.frame = CGRect(origin: CGPoint(x: horizontalOrigin, y: verticalOrigin), size: CGSize(width: actionsWidth, height: 54.0))
    self.containerNode.relativeArrowPosition = (sourceRect.midX - horizontalOrigin, arrowOnBottom)

    self.scrollNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: actionsWidth, height: 54.0))
    self.scrollNode.contentWidth = unboundActionsWidth

    self.containerNode.layout()
    self.scrollNode.layout()
}

// animate in
func animateIn(bounce: Bool) {
    if bounce {
        self.containerNode.layer.animateSpring(from: NSNumber(value: Float(0.2)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.4)
        let containerPosition = self.containerNode.layer.position
        self.containerNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: containerPosition.x, y: containerPosition.y + (self.arrowOnBottom ? 1.0 : -1.0) * self.containerNode.bounds.size.height / 2.0)), to: NSValue(cgPoint: containerPosition), keyPath: "position", duration: 0.4)
    }

    self.allowsGroupOpacity = true
    self.layer.rasterizationScale = UIScreen.main.scale
    self.layer.shouldRasterize = true
    self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, completion: { [weak self] _ in
        self?.allowsGroupOpacity = false
        self?.layer.shouldRasterize = false
    })

    if let feedback = self.feedback {
        feedback.impact(.light)
    }
}

// animate out
func animateOut(bounce: Bool, completion: @escaping () -> Void) {
    self.allowsGroupOpacity = true
    self.layer.rasterizationScale = UIScreen.main.scale
    self.layer.shouldRasterize = true
    self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
        self?.allowsGroupOpacity = false
        self?.layer.shouldRasterize = false
        completion()
    })
}

// hit test
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    if let event = event {
        var eventIsPresses = false
        if #available(iOSApplicationExtension 9.0, iOS 9.0, *) {
            eventIsPresses = event.type == .presses
        }
        if event.type == .touches || eventIsPresses {
            if !self.containerNode.frame.contains(point) {
                if !self.dismissedByTouchOutside {
                    self.dismissedByTouchOutside = true
                    self.dismiss()
                }
                if self.catchTapsOutside {
                    return self.view
                }
                return nil
            }
        }
    }
    return super.hitTest(point, with: event)
}

class ContextMenuController:ViewController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
private var contextMenuNode: ContextMenuNode {
    return self.displayNode as! ContextMenuNode
}

public var keyShortcuts: [KeyShortcut] {
    return [KeyShortcut(input: UIKeyCommand.inputEscape, action: { [weak self] in
        if let strongSelf = self {
            strongSelf.dismiss()
        }
    })]
}

private let actions: [ContextMenuAction]
private let catchTapsOutside: Bool
private let hasHapticFeedback: Bool

private var layout: ContainerViewLayout?

public var dismissed: (() -> Void)?

public init(actions: [ContextMenuAction], catchTapsOutside: Bool = false, hasHapticFeedback: Bool = false) {
    self.actions = actions
    self.catchTapsOutside = catchTapsOutside
    self.hasHapticFeedback = hasHapticFeedback
    
    super.init(navigationBarPresentationData: nil)
    
    self.statusBar.statusBarStyle = .Ignore
}

override public func loadDisplayNode() {
    self.displayNode = ContextMenuNode(actions: self.actions, dismiss: { [weak self] in
        self?.dismissed?()
        self?.contextMenuNode.animateOut(bounce: (self?.presentationArguments as? ContextMenuControllerPresentationArguments)?.bounce ?? true, completion: {
            self?.presentingViewController?.dismiss(animated: false)
        })
    }, catchTapsOutside: self.catchTapsOutside, hasHapticFeedback: self.hasHapticFeedback)
    self.displayNodeDidLoad()
}

override public func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    // animate in
    self.contextMenuNode.animateIn(bounce: (self.presentationArguments as? ContextMenuControllerPresentationArguments)?.bounce ?? true)
}

override public func dismiss(completion: (() -> Void)? = nil) {
    self.dismissed?()
  // animate out
    self.contextMenuNode.animateOut(bounce: (self.presentationArguments as? ContextMenuControllerPresentationArguments)?.bounce ?? true, completion: { [weak self] in
        self?.presentingViewController?.dismiss(animated: false)
    })
}

// layout
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
    super.containerLayoutUpdated(layout, transition: transition)

    if self.layout != nil && self.layout! != layout {
        self.dismissed?()
        self.contextMenuNode.animateOut(bounce: (self.presentationArguments as? ContextMenuControllerPresentationArguments)?.bounce ?? true, completion: { [weak self] in
            self?.presentingViewController?.dismiss(animated: false)
        })
    } else {
        self.layout = layout

        if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() {
            self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil)
            self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil)
        } else {
            self.contextMenuNode.sourceRect = nil
            self.contextMenuNode.containerRect = nil
        }

        self.contextMenuNode.containerLayoutUpdated(layout, transition: transition)
    }
}
This post is licensed under CC BY 4.0 by the author.