react-native-platform-components 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -18
- package/android/src/main/java/com/platformcomponents/PCActionSheetView.kt +309 -0
- package/android/src/main/java/com/platformcomponents/PCActionSheetViewManager.kt +131 -0
- package/android/src/main/java/com/platformcomponents/PCButtonGroupView.kt +5 -1
- package/android/src/main/java/com/platformcomponents/PCButtonView.kt +5 -1
- package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +82 -27
- package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +12 -13
- package/android/src/main/java/com/platformcomponents/PCMenuSupport.kt +66 -2
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/android/src/main/res/values/ids.xml +2 -0
- package/ios/PCActionSheet.h +12 -0
- package/ios/PCActionSheet.mm +160 -0
- package/ios/PCActionSheetView.swift +280 -0
- package/ios/PCContextMenu.mm +22 -1
- package/ios/PCContextMenu.swift +218 -15
- package/ios/PCMenuItems.h +2 -1
- package/ios/PCMenuSupport.swift +22 -3
- package/lib/commonjs/ActionSheet.js +85 -0
- package/lib/commonjs/ActionSheet.js.map +1 -0
- package/lib/commonjs/ActionSheetNativeComponent.ts +94 -0
- package/lib/commonjs/ButtonGroupNativeComponent.ts +2 -0
- package/lib/commonjs/ButtonNativeComponent.ts +2 -0
- package/lib/commonjs/ContextMenu.js +50 -4
- package/lib/commonjs/ContextMenu.js.map +1 -1
- package/lib/commonjs/ContextMenuNativeComponent.ts +29 -8
- package/lib/commonjs/index.js +11 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +7 -0
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/jest.js +92 -5
- package/lib/commonjs/jest.js.map +1 -1
- package/lib/commonjs/menuItems.js +2 -1
- package/lib/commonjs/menuItems.js.map +1 -1
- package/lib/commonjs/selectionIdentifiers.js.map +1 -1
- package/lib/commonjs/web/ActionSheet.js +131 -0
- package/lib/commonjs/web/ActionSheet.js.map +1 -0
- package/lib/commonjs/web/components.js +3 -1
- package/lib/commonjs/web/components.js.map +1 -1
- package/lib/commonjs/web/containers.js +1 -0
- package/lib/commonjs/web/containers.js.map +1 -1
- package/lib/module/ActionSheet.js +78 -0
- package/lib/module/ActionSheet.js.map +1 -0
- package/lib/module/ActionSheetNativeComponent.ts +94 -0
- package/lib/module/ButtonGroupNativeComponent.ts +2 -0
- package/lib/module/ButtonNativeComponent.ts +2 -0
- package/lib/module/ContextMenu.js +51 -4
- package/lib/module/ContextMenu.js.map +1 -1
- package/lib/module/ContextMenuNativeComponent.ts +29 -8
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/jest.js +90 -5
- package/lib/module/jest.js.map +1 -1
- package/lib/module/menuItems.js +2 -1
- package/lib/module/menuItems.js.map +1 -1
- package/lib/module/selectionIdentifiers.js.map +1 -1
- package/lib/module/web/ActionSheet.js +126 -0
- package/lib/module/web/ActionSheet.js.map +1 -0
- package/lib/module/web/components.js +2 -0
- package/lib/module/web/components.js.map +1 -1
- package/lib/module/web/containers.js +1 -0
- package/lib/module/web/containers.js.map +1 -1
- package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/ActionSheet.d.ts +98 -0
- package/lib/typescript/commonjs/src/ActionSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts +78 -0
- package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts +2 -0
- package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts +2 -0
- package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/ContextMenu.d.ts +22 -6
- package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +20 -7
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +1 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.web.d.ts +2 -1
- package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/jest.d.ts +17 -3
- package/lib/typescript/commonjs/src/jest.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/menuItems.d.ts +12 -0
- package/lib/typescript/commonjs/src/menuItems.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts +1 -1
- package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/web/ActionSheet.d.ts +11 -0
- package/lib/typescript/commonjs/src/web/ActionSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/web/components.d.ts +1 -0
- package/lib/typescript/commonjs/src/web/components.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/web/containers.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/webComponents.d.ts +2 -0
- package/lib/typescript/commonjs/src/webComponents.d.ts.map +1 -1
- package/lib/typescript/module/docs/sidebars.d.ts.map +1 -1
- package/lib/typescript/module/src/ActionSheet.d.ts +98 -0
- package/lib/typescript/module/src/ActionSheet.d.ts.map +1 -0
- package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts +78 -0
- package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts +2 -0
- package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts.map +1 -1
- package/lib/typescript/module/src/ButtonNativeComponent.d.ts +2 -0
- package/lib/typescript/module/src/ButtonNativeComponent.d.ts.map +1 -1
- package/lib/typescript/module/src/ContextMenu.d.ts +22 -6
- package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -1
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +20 -7
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +1 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/index.web.d.ts +2 -1
- package/lib/typescript/module/src/index.web.d.ts.map +1 -1
- package/lib/typescript/module/src/jest.d.ts +17 -3
- package/lib/typescript/module/src/jest.d.ts.map +1 -1
- package/lib/typescript/module/src/menuItems.d.ts +12 -0
- package/lib/typescript/module/src/menuItems.d.ts.map +1 -1
- package/lib/typescript/module/src/selectionIdentifiers.d.ts +1 -1
- package/lib/typescript/module/src/selectionIdentifiers.d.ts.map +1 -1
- package/lib/typescript/module/src/web/ActionSheet.d.ts +11 -0
- package/lib/typescript/module/src/web/ActionSheet.d.ts.map +1 -0
- package/lib/typescript/module/src/web/components.d.ts +1 -0
- package/lib/typescript/module/src/web/components.d.ts.map +1 -1
- package/lib/typescript/module/src/web/containers.d.ts.map +1 -1
- package/lib/typescript/module/src/webComponents.d.ts +2 -0
- package/lib/typescript/module/src/webComponents.d.ts.map +1 -1
- package/package.json +9 -2
- package/src/ActionSheet.tsx +193 -0
- package/src/ActionSheetNativeComponent.ts +94 -0
- package/src/ButtonGroupNativeComponent.ts +2 -0
- package/src/ButtonNativeComponent.ts +2 -0
- package/src/ContextMenu.tsx +71 -12
- package/src/ContextMenuNativeComponent.ts +29 -8
- package/src/index.tsx +1 -0
- package/src/index.web.tsx +2 -0
- package/src/jest.tsx +108 -4
- package/src/menuItems.ts +13 -0
- package/src/selectionIdentifiers.ts +1 -1
- package/src/web/ActionSheet.tsx +130 -0
- package/src/web/components.tsx +5 -0
- package/src/web/containers.tsx +1 -0
- package/src/webComponents.ts +2 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import os.log
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
private let logger = Logger(subsystem: "com.platformcomponents", category: "ActionSheet")
|
|
5
|
+
|
|
6
|
+
/// One action bridged from ObjC++ (src/ActionSheet.tsx). Icons aren't read:
|
|
7
|
+
/// UIAlertAction has no public image API.
|
|
8
|
+
struct PCActionSheetItem {
|
|
9
|
+
let id: String
|
|
10
|
+
let title: String
|
|
11
|
+
let destructive: Bool
|
|
12
|
+
let disabled: Bool
|
|
13
|
+
/// The action's own `haptics`; "" = the component's
|
|
14
|
+
let haptics: String
|
|
15
|
+
let testID: String
|
|
16
|
+
|
|
17
|
+
init(dictionary dict: [String: Any]) {
|
|
18
|
+
func string(_ key: String) -> String { (dict[key] as? String) ?? "" }
|
|
19
|
+
id = string("id")
|
|
20
|
+
title = string("title")
|
|
21
|
+
destructive = string("destructive") == "true"
|
|
22
|
+
disabled = string("disabled") == "true"
|
|
23
|
+
haptics = string("haptics")
|
|
24
|
+
testID = string("testID")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// The ActionSheet: a `UIAlertController` in the `.actionSheet` style,
|
|
29
|
+
/// presented from the nearest view controller while `visible` is "open".
|
|
30
|
+
///
|
|
31
|
+
/// The view covers the component's frame (the React children are mounted
|
|
32
|
+
/// next to it in the Fabric view) and takes no touches. Wherever UIKit shows
|
|
33
|
+
/// the sheet as a popover (iPad, and iPhone on iOS 26), the popover points
|
|
34
|
+
/// at the children; without children the sheet has no source.
|
|
35
|
+
@objcMembers
|
|
36
|
+
public final class PCActionSheetView: UIView {
|
|
37
|
+
// MARK: - Props (set from ObjC++)
|
|
38
|
+
|
|
39
|
+
public var sheetTitle: String = ""
|
|
40
|
+
public var message: String = ""
|
|
41
|
+
|
|
42
|
+
/// ObjC++ sets this as an array of dictionaries (see PCActionSheet.mm)
|
|
43
|
+
public var actions: [Any] = [] {
|
|
44
|
+
didSet {
|
|
45
|
+
items = actions.compactMap { any in
|
|
46
|
+
guard let dict = any as? [String: Any] else { return nil }
|
|
47
|
+
return PCActionSheetItem(dictionary: dict)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// "" = the system's localized "Cancel"
|
|
53
|
+
public var cancelTitle: String = ""
|
|
54
|
+
public var cancelTestID: String = ""
|
|
55
|
+
|
|
56
|
+
/// "open" | "closed"
|
|
57
|
+
public var visible: String = "closed" {
|
|
58
|
+
didSet {
|
|
59
|
+
guard visible != oldValue else { return }
|
|
60
|
+
updatePresentation()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The `haptics` prop, played when an action is picked (an action's own
|
|
65
|
+
/// `haptics` wins)
|
|
66
|
+
public let haptics = PCHaptics()
|
|
67
|
+
|
|
68
|
+
// MARK: - Events back to ObjC++
|
|
69
|
+
|
|
70
|
+
public var onSelect: ((String) -> Void)?
|
|
71
|
+
public var onCancel: (() -> Void)?
|
|
72
|
+
|
|
73
|
+
// MARK: - Internal
|
|
74
|
+
|
|
75
|
+
private var items: [PCActionSheetItem] = []
|
|
76
|
+
/// The sheet on screen (or on its way), until an action ends it or it is
|
|
77
|
+
/// dismissed through `visible`
|
|
78
|
+
private var alert: UIAlertController?
|
|
79
|
+
/// Bumped on every visibility change, so a deferred presentation that
|
|
80
|
+
/// has been overtaken does nothing
|
|
81
|
+
private var presentationToken = 0
|
|
82
|
+
|
|
83
|
+
// MARK: - Init
|
|
84
|
+
|
|
85
|
+
public override init(frame: CGRect) {
|
|
86
|
+
super.init(frame: frame)
|
|
87
|
+
setup()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public required init?(coder: NSCoder) {
|
|
91
|
+
super.init(coder: coder)
|
|
92
|
+
setup()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func setup() {
|
|
96
|
+
backgroundColor = .clear
|
|
97
|
+
// Only the popover's source: touches reach the children.
|
|
98
|
+
isUserInteractionEnabled = false
|
|
99
|
+
isAccessibilityElement = false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public override func didMoveToWindow() {
|
|
103
|
+
super.didMoveToWindow()
|
|
104
|
+
if window == nil {
|
|
105
|
+
// Unmounted, or its screen went away: the sheet goes too, without
|
|
106
|
+
// a callback.
|
|
107
|
+
dismissIfNeeded()
|
|
108
|
+
} else if visible == "open" {
|
|
109
|
+
// A sheet asked for before the view was on screen opens now.
|
|
110
|
+
updatePresentation()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Called by PCActionSheet.mm before Fabric reuses the view.
|
|
115
|
+
public func resetForRecycle() {
|
|
116
|
+
presentationToken += 1
|
|
117
|
+
dismissIfNeeded()
|
|
118
|
+
visible = "closed"
|
|
119
|
+
sheetTitle = ""
|
|
120
|
+
message = ""
|
|
121
|
+
actions = []
|
|
122
|
+
cancelTitle = ""
|
|
123
|
+
cancelTestID = ""
|
|
124
|
+
haptics.kind = ""
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// MARK: - Presentation
|
|
128
|
+
|
|
129
|
+
private func updatePresentation() {
|
|
130
|
+
presentationToken += 1
|
|
131
|
+
if visible == "open" {
|
|
132
|
+
presentIfNeeded(token: presentationToken)
|
|
133
|
+
} else {
|
|
134
|
+
dismissIfNeeded()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func dismissIfNeeded() {
|
|
139
|
+
guard let alert else { return }
|
|
140
|
+
self.alert = nil
|
|
141
|
+
// A programmatic dismissal calls no action handler, so neither
|
|
142
|
+
// onSelect nor onCancel fires.
|
|
143
|
+
if alert.presentingViewController != nil, !alert.isBeingDismissed {
|
|
144
|
+
alert.dismiss(animated: true)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Presents on the next run loop turn, so the frame the popover points
|
|
149
|
+
/// at is final and a sheet that is still closing has started to go.
|
|
150
|
+
private func presentIfNeeded(token: Int) {
|
|
151
|
+
DispatchQueue.main.async { [weak self] in
|
|
152
|
+
guard let self, self.presentationToken == token else { return }
|
|
153
|
+
guard self.visible == "open", self.window != nil else { return }
|
|
154
|
+
if let alert = self.alert {
|
|
155
|
+
// Still up (or presenting): nothing to do. A sheet dismissed
|
|
156
|
+
// behind our back (its presenter went away) is replaced.
|
|
157
|
+
if alert.presentingViewController != nil || alert.isBeingPresented { return }
|
|
158
|
+
self.alert = nil
|
|
159
|
+
}
|
|
160
|
+
guard let presenter = self.presenter() else {
|
|
161
|
+
logger.debug("presentIfNeeded: no view controller to present from")
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
// A sheet (ours or another controller) is still animating away:
|
|
165
|
+
// present once that transition ends.
|
|
166
|
+
if let coordinator = presenter.transitionCoordinator {
|
|
167
|
+
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
|
168
|
+
self?.presentIfNeeded(token: token)
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if presenter.presentedViewController != nil {
|
|
173
|
+
logger.debug("presentIfNeeded: the presenter is busy")
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
self.present(from: presenter)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func present(from presenter: UIViewController) {
|
|
181
|
+
let alert = UIAlertController(
|
|
182
|
+
title: sheetTitle.isEmpty ? nil : sheetTitle,
|
|
183
|
+
message: message.isEmpty ? nil : message,
|
|
184
|
+
preferredStyle: .actionSheet
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for item in items {
|
|
188
|
+
let action = UIAlertAction(
|
|
189
|
+
title: item.title,
|
|
190
|
+
style: item.destructive ? .destructive : .default
|
|
191
|
+
) { [weak self, weak alert] _ in
|
|
192
|
+
guard let self, let alert, self.alert === alert else { return }
|
|
193
|
+
self.alert = nil
|
|
194
|
+
self.haptics.perform(in: self, override: item.haptics)
|
|
195
|
+
self.onSelect?(item.id)
|
|
196
|
+
}
|
|
197
|
+
action.isEnabled = !item.disabled
|
|
198
|
+
action.accessibilityIdentifier = item.testID.isEmpty ? nil : item.testID
|
|
199
|
+
alert.addAction(action)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The cancel action: a row on iPhone, and on a popover (iPad) UIKit
|
|
203
|
+
// calls it for a tap outside instead of showing it.
|
|
204
|
+
let cancel = UIAlertAction(
|
|
205
|
+
title: cancelTitle.isEmpty ? Self.systemCancelTitle : cancelTitle,
|
|
206
|
+
style: .cancel
|
|
207
|
+
) { [weak self, weak alert] _ in
|
|
208
|
+
guard let self, let alert, self.alert === alert else { return }
|
|
209
|
+
self.alert = nil
|
|
210
|
+
self.onCancel?()
|
|
211
|
+
}
|
|
212
|
+
cancel.accessibilityIdentifier = cancelTestID.isEmpty ? nil : cancelTestID
|
|
213
|
+
alert.addAction(cancel)
|
|
214
|
+
|
|
215
|
+
// With a source, UIKit shows the sheet as a popover pointing at it: on
|
|
216
|
+
// iPad, and on iPhone from iOS 26. Without children there is nothing
|
|
217
|
+
// to point at; an iPhone then shows the sheet with its Cancel row (in
|
|
218
|
+
// the middle of the screen on iOS 26, at the bottom before), and an
|
|
219
|
+
// iPad (which needs a source) a centered popover.
|
|
220
|
+
if let popover = alert.popoverPresentationController {
|
|
221
|
+
if let (host, frame) = childrenFrame() {
|
|
222
|
+
// Point at the children
|
|
223
|
+
popover.sourceView = host
|
|
224
|
+
popover.sourceRect = frame
|
|
225
|
+
} else if traitCollection.userInterfaceIdiom == .pad {
|
|
226
|
+
// Nothing to point at: the middle of the screen, no arrow
|
|
227
|
+
popover.sourceView = presenter.view
|
|
228
|
+
popover.sourceRect = CGRect(
|
|
229
|
+
x: presenter.view.bounds.midX,
|
|
230
|
+
y: presenter.view.bounds.midY,
|
|
231
|
+
width: 0,
|
|
232
|
+
height: 0
|
|
233
|
+
)
|
|
234
|
+
popover.permittedArrowDirections = []
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
logger.debug("present: \(self.items.count) actions")
|
|
239
|
+
self.alert = alert
|
|
240
|
+
presenter.present(alert, animated: true)
|
|
241
|
+
haptics.prepare(in: self)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// The view the React children are mounted in and the frame they cover
|
|
245
|
+
/// there, or nil without children. Fabric mounts them next to this view
|
|
246
|
+
/// (its content view), so they are its siblings. The component's own
|
|
247
|
+
/// frame says nothing: a childless sheet in a column is full width and
|
|
248
|
+
/// 0 high, and must not become a popover pointing at that line.
|
|
249
|
+
private func childrenFrame() -> (UIView, CGRect)? {
|
|
250
|
+
guard let host = superview else { return nil }
|
|
251
|
+
let children = host.subviews.filter { $0 !== self }
|
|
252
|
+
guard let first = children.first else { return nil }
|
|
253
|
+
let frame = children.dropFirst().reduce(first.frame) { $0.union($1.frame) }
|
|
254
|
+
return (host, frame)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// The controller to present from: the nearest one, or whatever it is
|
|
258
|
+
/// presenting already (a modal screen).
|
|
259
|
+
private func presenter() -> UIViewController? {
|
|
260
|
+
var responder: UIResponder? = self
|
|
261
|
+
var nearest: UIViewController?
|
|
262
|
+
while let next = responder?.next {
|
|
263
|
+
if let vc = next as? UIViewController {
|
|
264
|
+
nearest = vc
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
responder = next
|
|
268
|
+
}
|
|
269
|
+
guard var vc = nearest ?? window?.rootViewController else { return nil }
|
|
270
|
+
while let presented = vc.presentedViewController, !presented.isBeingDismissed {
|
|
271
|
+
vc = presented
|
|
272
|
+
}
|
|
273
|
+
return vc
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// UIKit's own localized "Cancel".
|
|
277
|
+
private static var systemCancelTitle: String {
|
|
278
|
+
Bundle(for: UIApplication.self).localizedString(forKey: "Cancel", value: "Cancel", table: nil)
|
|
279
|
+
}
|
|
280
|
+
}
|
package/ios/PCContextMenu.mm
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
#import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
|
|
10
10
|
#import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
|
|
11
11
|
#import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
|
|
12
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/RCTComponentViewHelpers.h>
|
|
12
13
|
|
|
13
14
|
#if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
|
|
14
15
|
#import <PlatformComponents/PlatformComponents-Swift.h>
|
|
@@ -21,6 +22,9 @@
|
|
|
21
22
|
using namespace facebook::react;
|
|
22
23
|
using namespace platformcomponents;
|
|
23
24
|
|
|
25
|
+
@interface PCContextMenu () <RCTPCContextMenuViewProtocol>
|
|
26
|
+
@end
|
|
27
|
+
|
|
24
28
|
@implementation PCContextMenu {
|
|
25
29
|
PCContextMenuView *_view;
|
|
26
30
|
}
|
|
@@ -129,7 +133,7 @@ using namespace platformcomponents;
|
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
// trigger: "longPress" | "tap"
|
|
136
|
+
// trigger: "longPress" | "tap" | "none"
|
|
133
137
|
if (!prevProps || newProps.trigger != prevProps->trigger) {
|
|
134
138
|
if (!newProps.trigger.empty()) {
|
|
135
139
|
_view.trigger =
|
|
@@ -139,6 +143,12 @@ using namespace platformcomponents;
|
|
|
139
143
|
}
|
|
140
144
|
}
|
|
141
145
|
|
|
146
|
+
// visible: "open" | "closed" | "" (unset, treated as closed). Set only on
|
|
147
|
+
// a change, so a menu the user opened isn't dismissed by an unrelated update.
|
|
148
|
+
if (!prevProps || newProps.visible != prevProps->visible) {
|
|
149
|
+
_view.visible = newProps.visible == "open" ? @"open" : @"closed";
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
if (!prevProps || newProps.haptics != prevProps->haptics) {
|
|
143
153
|
_view.haptics.kind = [NSString stringWithUTF8String:newProps.haptics.c_str()];
|
|
144
154
|
}
|
|
@@ -158,6 +168,17 @@ using namespace platformcomponents;
|
|
|
158
168
|
[super updateProps:props oldProps:oldProps];
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
#pragma mark - Commands
|
|
172
|
+
|
|
173
|
+
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
|
|
174
|
+
RCTPCContextMenuHandleCommand(self, commandName, args);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// trigger "tap": a tap on the content that no inner pressable took
|
|
178
|
+
- (void)handleTap {
|
|
179
|
+
[_view handleTap];
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
@end
|
|
162
183
|
|
|
163
184
|
Class<RCTComponentViewProtocol> PCContextMenuCls(void) {
|
package/ios/PCContextMenu.swift
CHANGED
|
@@ -6,7 +6,7 @@ private let logger = Logger(subsystem: "com.platformcomponents", category: "Cont
|
|
|
6
6
|
// MARK: - Main View
|
|
7
7
|
|
|
8
8
|
@objcMembers
|
|
9
|
-
public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
9
|
+
public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate, UIGestureRecognizerDelegate {
|
|
10
10
|
// MARK: - Props (set from ObjC++)
|
|
11
11
|
|
|
12
12
|
/// Menu title (shown as header on iOS)
|
|
@@ -28,9 +28,13 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/// "longPress" | "tap"
|
|
31
|
+
/// "longPress" | "tap" | "none"
|
|
32
32
|
public var trigger: String = "longPress" { didSet { updateTrigger() } }
|
|
33
33
|
|
|
34
|
+
/// "open" | "closed". ObjC++ sets it only when the prop changes, so a
|
|
35
|
+
/// menu the user opened stays open while the prop stays "closed".
|
|
36
|
+
public var visible: String = "closed" { didSet { updatePresentation() } }
|
|
37
|
+
|
|
34
38
|
/// iOS-specific: enable preview
|
|
35
39
|
public var enablePreview: String = "false"
|
|
36
40
|
|
|
@@ -55,8 +59,22 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
55
59
|
/// The long-press menu last handed to UIKit, to find the showing root.
|
|
56
60
|
private var presentedMenu: UIMenu?
|
|
57
61
|
|
|
58
|
-
// Tap mode: UIButton with UIMenu for tap-to-show
|
|
62
|
+
// Tap mode: UIButton with UIMenu for tap-to-show. From iOS 17.4 it takes
|
|
63
|
+
// no touches (see hitTest), and JS opens it (see handleTap).
|
|
59
64
|
private var tapMenuButton: PCMenuButton?
|
|
65
|
+
private var touchRecorder: UIGestureRecognizer?
|
|
66
|
+
/// Where the last touch on the content went down (see installTouchRecorder)
|
|
67
|
+
private weak var lastTouchView: UIView?
|
|
68
|
+
|
|
69
|
+
/// Invisible anchor of a menu opened through `visible` in longPress and
|
|
70
|
+
/// none modes (tap mode opens its own button). It never takes a touch;
|
|
71
|
+
/// see hitTest.
|
|
72
|
+
private var anchorMenuButton: PCMenuButton?
|
|
73
|
+
private var presentationToken = 0
|
|
74
|
+
|
|
75
|
+
private var isAnyMenuVisible: Bool {
|
|
76
|
+
isMenuVisible || tapMenuButton?.isMenuVisible == true || anchorMenuButton?.isMenuVisible == true
|
|
77
|
+
}
|
|
60
78
|
|
|
61
79
|
// MARK: - Init
|
|
62
80
|
|
|
@@ -84,13 +102,41 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
84
102
|
}
|
|
85
103
|
}
|
|
86
104
|
|
|
105
|
+
/// This view lies over the React content and lets its touches through,
|
|
106
|
+
/// so the content's own pressables get them: the long-press interaction
|
|
107
|
+
/// sits on the parent component view, around the content, and JS opens
|
|
108
|
+
/// a tap menu for taps no pressable took. Only the tap button keeps its
|
|
109
|
+
/// touches, before iOS 17.4, where JS can't open its menu.
|
|
110
|
+
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
111
|
+
guard let button = tapMenuButton, !PCMenuButton.canPresentMenuProgrammatically else { return nil }
|
|
112
|
+
let view = super.hitTest(point, with: event)
|
|
113
|
+
return view?.isDescendant(of: button) == true ? view : nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public override func willMove(toSuperview newSuperview: UIView?) {
|
|
117
|
+
super.willMove(toSuperview: newSuperview)
|
|
118
|
+
removeContextMenuInteraction()
|
|
119
|
+
removeTouchRecorder()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public override func didMoveToSuperview() {
|
|
123
|
+
super.didMoveToSuperview()
|
|
124
|
+
// The long-press interaction goes on the new parent
|
|
125
|
+
updateTrigger()
|
|
126
|
+
}
|
|
87
127
|
|
|
128
|
+
public override func didMoveToWindow() {
|
|
129
|
+
super.didMoveToWindow()
|
|
130
|
+
// A menu asked for before the view was on screen opens now.
|
|
131
|
+
if window != nil, visible == "open" { updatePresentation() }
|
|
132
|
+
}
|
|
88
133
|
|
|
89
134
|
private func updateEnabled() {
|
|
90
135
|
let disabled = (interactivity == "disabled")
|
|
91
136
|
alpha = disabled ? 0.5 : 1.0
|
|
92
137
|
isUserInteractionEnabled = !disabled
|
|
93
138
|
accessibilityTraits = disabled ? [.notEnabled] : []
|
|
139
|
+
if disabled { dismissMenu() }
|
|
94
140
|
}
|
|
95
141
|
|
|
96
142
|
private func sync() {
|
|
@@ -100,6 +146,9 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
100
146
|
// Update tap menu content if in tap mode
|
|
101
147
|
if trigger == "tap" {
|
|
102
148
|
updateTapMenuButton()
|
|
149
|
+
} else if let anchor = anchorMenuButton, anchor.isMenuVisible {
|
|
150
|
+
// A menu opened through `visible`; updates it in place.
|
|
151
|
+
anchor.setMenu(buildMenu())
|
|
103
152
|
} else if isMenuVisible, let interaction = contextMenuInteraction {
|
|
104
153
|
// An action that keeps the menu presented changed a title or state.
|
|
105
154
|
let menu = buildMenu()
|
|
@@ -117,36 +166,63 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
117
166
|
// MARK: - Trigger Mode
|
|
118
167
|
|
|
119
168
|
private func updateTrigger() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
installContextMenuInteraction()
|
|
123
|
-
removeTapMenuButton()
|
|
124
|
-
} else {
|
|
169
|
+
switch trigger {
|
|
170
|
+
case "tap":
|
|
125
171
|
// Tap mode: use UIButton with UIMenu for tap-to-show
|
|
126
172
|
removeContextMenuInteraction()
|
|
127
173
|
installTapMenuButton()
|
|
174
|
+
installTouchRecorder()
|
|
175
|
+
case "none":
|
|
176
|
+
// Only `visible` opens the menu, from the anchor
|
|
177
|
+
removeContextMenuInteraction()
|
|
178
|
+
removeTapMenuButton()
|
|
179
|
+
removeTouchRecorder()
|
|
180
|
+
default:
|
|
181
|
+
// Install context menu interaction for long-press
|
|
182
|
+
installContextMenuInteraction()
|
|
183
|
+
removeTapMenuButton()
|
|
184
|
+
removeTouchRecorder()
|
|
128
185
|
}
|
|
129
186
|
}
|
|
130
187
|
|
|
131
188
|
private func installContextMenuInteraction() {
|
|
132
|
-
guard contextMenuInteraction == nil else { return }
|
|
189
|
+
guard contextMenuInteraction == nil, let parentView = getParentComponentView() else { return }
|
|
133
190
|
|
|
134
|
-
// Install interaction on
|
|
191
|
+
// Install interaction on the parent component view, which holds the React
|
|
192
|
+
// content, so a long-press anywhere on it (an inner button too) triggers the
|
|
193
|
+
// menu while taps still reach the content.
|
|
135
194
|
// We use UITargetedPreview in the delegate methods to show the parent view
|
|
136
|
-
//
|
|
195
|
+
// as the preview.
|
|
137
196
|
let interaction = UIContextMenuInteraction(delegate: self)
|
|
138
|
-
addInteraction(interaction)
|
|
197
|
+
parentView.addInteraction(interaction)
|
|
139
198
|
contextMenuInteraction = interaction
|
|
140
|
-
logger.debug("Installed UIContextMenuInteraction on
|
|
199
|
+
logger.debug("Installed UIContextMenuInteraction on the parent component view")
|
|
141
200
|
}
|
|
142
201
|
|
|
143
202
|
private func removeContextMenuInteraction() {
|
|
144
203
|
guard let interaction = contextMenuInteraction else { return }
|
|
145
|
-
removeInteraction(interaction)
|
|
204
|
+
interaction.view?.removeInteraction(interaction)
|
|
146
205
|
contextMenuInteraction = nil
|
|
147
206
|
logger.debug("Removed UIContextMenuInteraction")
|
|
148
207
|
}
|
|
149
208
|
|
|
209
|
+
/// The long-press menu took the touch over, but React Native's touch
|
|
210
|
+
/// handler would still end it as a press of the pressable under the
|
|
211
|
+
/// finger. Cancels it the way RN does for its own touches: toggling
|
|
212
|
+
/// `isEnabled` cancels a gesture recognizer, and RN sends touchcancel.
|
|
213
|
+
private func cancelReactTouches() {
|
|
214
|
+
guard let handlerClass = NSClassFromString("RCTSurfaceTouchHandler") else { return }
|
|
215
|
+
var view = superview
|
|
216
|
+
while let current = view {
|
|
217
|
+
if let handler = current.gestureRecognizers?.first(where: { $0.isKind(of: handlerClass) }) {
|
|
218
|
+
handler.isEnabled = false
|
|
219
|
+
handler.isEnabled = true
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
view = current.superview
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
150
226
|
// MARK: - Tap Mode Button
|
|
151
227
|
|
|
152
228
|
private func installTapMenuButton() {
|
|
@@ -160,7 +236,13 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
160
236
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
161
237
|
// Make button invisible but still tappable
|
|
162
238
|
button.tintColor = .clear
|
|
163
|
-
|
|
239
|
+
// Still VoiceOver's element for the menu where it lets touches through
|
|
240
|
+
button.opensMenuOnAccessibilityActivate = true
|
|
241
|
+
button.onMenuOpen = { [weak self] in
|
|
242
|
+
guard let self else { return }
|
|
243
|
+
self.haptics.prepare(in: self)
|
|
244
|
+
self.onMenuOpen?()
|
|
245
|
+
}
|
|
164
246
|
button.onMenuClose = { [weak self] in self?.onMenuClose?() }
|
|
165
247
|
|
|
166
248
|
addSubview(button)
|
|
@@ -186,6 +268,126 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
186
268
|
button.setMenu(items.isEmpty ? nil : buildMenu())
|
|
187
269
|
}
|
|
188
270
|
|
|
271
|
+
/// Tap mode, iOS 17.4+: JS calls this for a tap on the content that no
|
|
272
|
+
/// inner pressable took (React Native's responder system knows that: the
|
|
273
|
+
/// deepest responder wins). Earlier versions open the menu from the tap
|
|
274
|
+
/// button's own touch, and do nothing here.
|
|
275
|
+
public func handleTap() {
|
|
276
|
+
guard trigger == "tap", interactivity != "disabled", !items.isEmpty else { return }
|
|
277
|
+
guard let button = tapMenuButton, !isAnyMenuVisible else { return }
|
|
278
|
+
// A native control (this library's Button, a switch) handles its own
|
|
279
|
+
// taps, outside the JS responder system
|
|
280
|
+
guard !isInsideControl(lastTouchView) else { return }
|
|
281
|
+
logger.debug("handleTap: presenting \(self.items.count) items")
|
|
282
|
+
button.presentMenu()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// MARK: - Tap Mode Touch Recorder
|
|
286
|
+
|
|
287
|
+
/// Notes the view each touch on the content lands on, for handleTap,
|
|
288
|
+
/// without taking part in the touch.
|
|
289
|
+
private func installTouchRecorder() {
|
|
290
|
+
guard touchRecorder == nil, let parentView = getParentComponentView() else { return }
|
|
291
|
+
let recorder = UIGestureRecognizer(target: nil, action: nil)
|
|
292
|
+
recorder.cancelsTouchesInView = false
|
|
293
|
+
recorder.delegate = self
|
|
294
|
+
parentView.addGestureRecognizer(recorder)
|
|
295
|
+
touchRecorder = recorder
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private func removeTouchRecorder() {
|
|
299
|
+
guard let recorder = touchRecorder else { return }
|
|
300
|
+
recorder.view?.removeGestureRecognizer(recorder)
|
|
301
|
+
touchRecorder = nil
|
|
302
|
+
lastTouchView = nil
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
306
|
+
lastTouchView = touch.view
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Whether the view is, or is inside, an enabled UIControl of the content.
|
|
311
|
+
private func isInsideControl(_ view: UIView?) -> Bool {
|
|
312
|
+
var current = view
|
|
313
|
+
while let candidate = current, candidate !== superview {
|
|
314
|
+
if let control = candidate as? UIControl, control !== tapMenuButton, control.isEnabled {
|
|
315
|
+
return true
|
|
316
|
+
}
|
|
317
|
+
current = candidate.superview
|
|
318
|
+
}
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// MARK: - Programmatic presentation (`visible`)
|
|
323
|
+
|
|
324
|
+
private func installAnchorMenuButton() -> PCMenuButton {
|
|
325
|
+
if let button = anchorMenuButton { return button }
|
|
326
|
+
|
|
327
|
+
// Over the view's bounds, so the system menu attaches to the content
|
|
328
|
+
let button = PCMenuButton(type: .custom)
|
|
329
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
330
|
+
button.backgroundColor = .clear
|
|
331
|
+
button.isAccessibilityElement = false
|
|
332
|
+
button.onMenuOpen = { [weak self] in
|
|
333
|
+
guard let self else { return }
|
|
334
|
+
self.haptics.prepare(in: self)
|
|
335
|
+
self.onMenuOpen?()
|
|
336
|
+
}
|
|
337
|
+
button.onMenuClose = { [weak self] in self?.onMenuClose?() }
|
|
338
|
+
|
|
339
|
+
addSubview(button)
|
|
340
|
+
NSLayoutConstraint.activate([
|
|
341
|
+
button.topAnchor.constraint(equalTo: topAnchor),
|
|
342
|
+
button.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
343
|
+
button.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
344
|
+
button.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
345
|
+
])
|
|
346
|
+
|
|
347
|
+
anchorMenuButton = button
|
|
348
|
+
return button
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private func updatePresentation() {
|
|
352
|
+
presentationToken += 1
|
|
353
|
+
if visible == "open" {
|
|
354
|
+
presentMenuIfNeeded(token: presentationToken)
|
|
355
|
+
} else {
|
|
356
|
+
dismissMenu()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// iOS 17.4+: opens the menu from the tap button or the anchor with
|
|
361
|
+
/// `UIControl.performPrimaryAction()`. Earlier versions have no public
|
|
362
|
+
/// API to open a menu without a touch, and do nothing.
|
|
363
|
+
private func presentMenuIfNeeded(token: Int) {
|
|
364
|
+
guard PCMenuButton.canPresentMenuProgrammatically else {
|
|
365
|
+
logger.debug("presentMenuIfNeeded: needs iOS 17.4")
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Next run loop turn, so the anchor has its final frame.
|
|
370
|
+
DispatchQueue.main.async { [weak self] in
|
|
371
|
+
guard let self else { return }
|
|
372
|
+
guard self.presentationToken == token, self.visible == "open" else { return }
|
|
373
|
+
guard self.interactivity != "disabled", !self.items.isEmpty else { return }
|
|
374
|
+
guard self.window != nil, !self.isAnyMenuVisible else { return }
|
|
375
|
+
|
|
376
|
+
let button = self.tapMenuButton ?? self.installAnchorMenuButton()
|
|
377
|
+
logger.debug("presentMenuIfNeeded: presenting \(self.items.count) items")
|
|
378
|
+
self.layoutIfNeeded()
|
|
379
|
+
button.setMenu(self.buildMenu())
|
|
380
|
+
button.presentMenu()
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Closes the open menu, whichever way it opened.
|
|
385
|
+
private func dismissMenu() {
|
|
386
|
+
tapMenuButton?.dismissMenu()
|
|
387
|
+
anchorMenuButton?.dismissMenu()
|
|
388
|
+
if isMenuVisible { contextMenuInteraction?.dismissMenu() }
|
|
389
|
+
}
|
|
390
|
+
|
|
189
391
|
// MARK: - UIContextMenuInteractionDelegate
|
|
190
392
|
|
|
191
393
|
public func contextMenuInteraction(
|
|
@@ -281,6 +483,7 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
281
483
|
) {
|
|
282
484
|
logger.debug("contextMenuInteraction: willDisplayMenu")
|
|
283
485
|
isMenuVisible = true
|
|
486
|
+
cancelReactTouches()
|
|
284
487
|
haptics.prepare(in: self)
|
|
285
488
|
onMenuOpen?()
|
|
286
489
|
}
|
package/ios/PCMenuItems.h
CHANGED
|
@@ -38,6 +38,7 @@ NSDictionary *PCMenuItemToDictionary(const Item &item) {
|
|
|
38
38
|
@"keepsMenuPresented" : PCMenuString(item.keepsMenuPresented),
|
|
39
39
|
@"state" : PCMenuString(item.state),
|
|
40
40
|
@"haptics" : PCMenuString(item.haptics),
|
|
41
|
+
@"testID" : PCMenuString(item.testID),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -59,7 +60,7 @@ bool PCMenuItemEqual(const Item &a, const Item &b) {
|
|
|
59
60
|
a.imageColor == b.imageColor && a.destructive == b.destructive &&
|
|
60
61
|
a.disabled == b.disabled &&
|
|
61
62
|
a.keepsMenuPresented == b.keepsMenuPresented && a.state == b.state &&
|
|
62
|
-
a.haptics == b.haptics;
|
|
63
|
+
a.haptics == b.haptics && a.testID == b.testID;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/// Compares every field, so a changed title or state (a stepper or toggle
|