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)
}
}