@react-native-ama/core 2.0.0-beta.1 → 2.0.0-beta.2
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/ama.config.json +17 -0
- package/android/build.gradle +43 -0
- package/android/src/debug/AndroidManifest.xml +2 -0
- package/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt +544 -0
- package/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt +30 -0
- package/android/src/debug/java/expo/modules/ama/highlight.kt +189 -0
- package/android/src/debug/java/expo/modules/ama/logger.kt +25 -0
- package/android/src/debug/java/expo/modules/ama/nodesGrabber.kt +664 -0
- package/expo-module.config.json +9 -0
- package/ios/Highlight.swift +264 -0
- package/ios/Logger.swift +31 -0
- package/ios/NodesGrabber.swift +733 -0
- package/ios/ReactNativeAma.podspec +29 -0
- package/ios/ReactNativeAmaModule.swift +568 -0
- package/ios/ReactNativeAmaView.swift +38 -0
- package/ios/TapGesture.swift +107 -0
- package/package.json +8 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ReactNativeAma'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = {
|
|
14
|
+
:ios => '15.1',
|
|
15
|
+
:tvos => '15.1'
|
|
16
|
+
}
|
|
17
|
+
s.swift_version = '5.4'
|
|
18
|
+
s.source = { git: 'https://github.com/FormidableLabs/react-native-ama/' }
|
|
19
|
+
s.static_framework = true
|
|
20
|
+
|
|
21
|
+
s.dependency 'ExpoModulesCore'
|
|
22
|
+
|
|
23
|
+
# Swift/Objective-C compatibility
|
|
24
|
+
s.pod_target_xcconfig = {
|
|
25
|
+
'DEFINES_MODULE' => 'YES',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
29
|
+
end
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
#if DEBUG
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
struct Constants {
|
|
6
|
+
static let debounce: TimeInterval = 2.0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
public class ReactNativeAmaModule: Module {
|
|
10
|
+
private var isMonitoring = false
|
|
11
|
+
private var currentDecorView: UIView?
|
|
12
|
+
private var displayLink: CADisplayLink?
|
|
13
|
+
|
|
14
|
+
private var a11yChecker: NodesGrabber?
|
|
15
|
+
private var highlighter: Highlight?
|
|
16
|
+
private var isCheckScheduled = false
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* We need to wait for the navigation transition to complete!
|
|
20
|
+
*/
|
|
21
|
+
private var uiCheckDelay = 1000
|
|
22
|
+
|
|
23
|
+
private var windowTapRecognizer: WindowTapGestureRecognizer?
|
|
24
|
+
private var tapDelegate: AmaTapGestureDelegate?
|
|
25
|
+
private var gap: CGFloat = 0
|
|
26
|
+
private var borderWidth: CGFloat = 3
|
|
27
|
+
|
|
28
|
+
public func definition() -> ModuleDefinition {
|
|
29
|
+
Name("ReactNativeAma")
|
|
30
|
+
|
|
31
|
+
Events("onAmaNodes", "onUIInteraction")
|
|
32
|
+
|
|
33
|
+
Function("start") { (options: [String: Any]?) in
|
|
34
|
+
let uiCheck = options?["ui"] as? Bool ?? false
|
|
35
|
+
uiCheckDelay = options?["delay"] as? Int ?? uiCheckDelay
|
|
36
|
+
gap = options?["gap"] as? CGFloat ?? gap
|
|
37
|
+
borderWidth = options?["borderWidth"] as? CGFloat ?? borderWidth
|
|
38
|
+
|
|
39
|
+
guard !isMonitoring else { return }
|
|
40
|
+
|
|
41
|
+
Logger.info("start", "👀 Start Monitoring 👀")
|
|
42
|
+
|
|
43
|
+
self.a11yChecker = NodesGrabber(appContext: self.appContext!)
|
|
44
|
+
self.highlighter = Highlight()
|
|
45
|
+
|
|
46
|
+
isMonitoring = true
|
|
47
|
+
|
|
48
|
+
DispatchQueue.main.async {
|
|
49
|
+
guard
|
|
50
|
+
let viewController = self.appContext?.utilities?
|
|
51
|
+
.currentViewController(),
|
|
52
|
+
let decorView = viewController.view,
|
|
53
|
+
let currentView = viewController.view,
|
|
54
|
+
let window = currentView.window
|
|
55
|
+
else {
|
|
56
|
+
Logger.info("start", "DEBUG guard failed — no viewController/decorView/window yet")
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
self.currentDecorView = decorView
|
|
61
|
+
self.setupDisplayLink()
|
|
62
|
+
|
|
63
|
+
if uiCheck {
|
|
64
|
+
self.attachWindowTapProbe()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Function("stop") {
|
|
70
|
+
guard isMonitoring else { return }
|
|
71
|
+
|
|
72
|
+
if let recognizer = self.windowTapRecognizer,
|
|
73
|
+
let window = UIApplication.shared.currentKeyWindow
|
|
74
|
+
{
|
|
75
|
+
window.removeGestureRecognizer(recognizer)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
self.windowTapRecognizer = nil
|
|
79
|
+
self.isMonitoring = false
|
|
80
|
+
|
|
81
|
+
DispatchQueue.main.async {
|
|
82
|
+
self.displayLink?.invalidate()
|
|
83
|
+
self.displayLink = nil
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
AsyncFunction("highlight") {
|
|
88
|
+
(viewId: Int, mode: String, hexColor: String, issueCount: Int) async -> [Double]? in
|
|
89
|
+
guard
|
|
90
|
+
let root = self.currentDecorView,
|
|
91
|
+
let target = await root.viewWithTag(viewId)
|
|
92
|
+
else {
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await MainActor.run {
|
|
97
|
+
if let scroll = target.enclosingScrollView {
|
|
98
|
+
var frameInScroll = target.convert(
|
|
99
|
+
target.bounds,
|
|
100
|
+
to: scroll
|
|
101
|
+
)
|
|
102
|
+
let m = CGFloat(10)
|
|
103
|
+
|
|
104
|
+
frameInScroll.origin.y = max(0, frameInScroll.origin.y - m)
|
|
105
|
+
scroll.scrollRectToVisible(frameInScroll, animated: false)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
self.highlighter?.highlight(
|
|
109
|
+
view: target,
|
|
110
|
+
mode: mode,
|
|
111
|
+
hexColor: hexColor,
|
|
112
|
+
gap: gap ?? 0,
|
|
113
|
+
lineWidth: borderWidth ?? 3,
|
|
114
|
+
issueCount: issueCount
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let bounds: CGRect = await MainActor.run {
|
|
119
|
+
target.convert(target.bounds, to: nil)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
bounds.origin.x,
|
|
124
|
+
bounds.origin.y,
|
|
125
|
+
bounds.width,
|
|
126
|
+
bounds.height,
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
AsyncFunction("clearHighlight") { (viewId: Int) in
|
|
131
|
+
await MainActor.run {
|
|
132
|
+
self.highlighter?.clearHighlight(viewId: viewId)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func setupDisplayLink() {
|
|
138
|
+
displayLink = CADisplayLink(
|
|
139
|
+
target: self,
|
|
140
|
+
selector: #selector(scheduleA11yCheck)
|
|
141
|
+
)
|
|
142
|
+
displayLink?.add(to: .main, forMode: .default)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@objc private func scheduleA11yCheck() {
|
|
146
|
+
guard isMonitoring, !isCheckScheduled else { return }
|
|
147
|
+
|
|
148
|
+
isCheckScheduled = true
|
|
149
|
+
|
|
150
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.debounce) {
|
|
151
|
+
[weak self] in
|
|
152
|
+
guard let self = self, self.currentDecorView != nil else {
|
|
153
|
+
self?.isCheckScheduled = false
|
|
154
|
+
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
self.displayLink?.isPaused = true
|
|
159
|
+
|
|
160
|
+
// Clean up any orphaned highlights (for unmounted views)
|
|
161
|
+
self.highlighter?.cleanupOrphanedHighlights()
|
|
162
|
+
|
|
163
|
+
self.getNodesToCheck()
|
|
164
|
+
|
|
165
|
+
DispatchQueue.main.async {
|
|
166
|
+
self.displayLink?.isPaused = false
|
|
167
|
+
self.isCheckScheduled = false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private func getNodesToCheck() {
|
|
173
|
+
guard isMonitoring else { return }
|
|
174
|
+
guard let result = a11yChecker?.getNodesToCheck(on: currentDecorView)
|
|
175
|
+
else { return }
|
|
176
|
+
|
|
177
|
+
if let shouldSend = result.send as? Bool, shouldSend {
|
|
178
|
+
// Since result.nodes is not optional, we can access it directly here.
|
|
179
|
+
let nodes = result.nodes
|
|
180
|
+
let nodesWithStringKeys = Dictionary(
|
|
181
|
+
uniqueKeysWithValues: nodes.map { (key, value) in
|
|
182
|
+
(String(key), value.toDictionary())
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
sendEvent(
|
|
187
|
+
"onAmaNodes",
|
|
188
|
+
nodesWithStringKeys
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
extension UIView {
|
|
195
|
+
fileprivate var enclosingScrollView: UIScrollView? {
|
|
196
|
+
var v: UIView? = self
|
|
197
|
+
while let view = v {
|
|
198
|
+
if let scroll = view as? UIScrollView {
|
|
199
|
+
return scroll
|
|
200
|
+
}
|
|
201
|
+
v = view.superview
|
|
202
|
+
}
|
|
203
|
+
return nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func getGlobalBounds(relativeTo rootView: UIView) -> CGRect {
|
|
207
|
+
return self.convert(self.bounds, to: rootView)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func isChecked() -> Bool {
|
|
211
|
+
if let control = self as? UIControl {
|
|
212
|
+
if let uiSwitch = control as? UISwitch {
|
|
213
|
+
return uiSwitch.isOn
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return control.isSelected
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return self.accessibilityTraits.contains(.selected)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func isModal() -> Bool {
|
|
223
|
+
let names = [
|
|
224
|
+
"RCTModalHostView",
|
|
225
|
+
"RCTModalHostViewComponentView",
|
|
226
|
+
]
|
|
227
|
+
for name in names {
|
|
228
|
+
if let cls = NSClassFromString(name), self.isKind(of: cls) {
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
extension UIApplication {
|
|
238
|
+
var currentKeyWindow: UIWindow? {
|
|
239
|
+
connectedScenes
|
|
240
|
+
.compactMap { $0 as? UIWindowScene }
|
|
241
|
+
.flatMap { $0.windows }
|
|
242
|
+
.first { $0.isKeyWindow }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
class AmaTapGestureDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
247
|
+
weak var module: ReactNativeAmaModule?
|
|
248
|
+
|
|
249
|
+
init(module: ReactNativeAmaModule) {
|
|
250
|
+
self.module = module
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func gestureRecognizer(
|
|
254
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
255
|
+
shouldRecognizeSimultaneouslyWith otherGestureRecognizer:
|
|
256
|
+
UIGestureRecognizer
|
|
257
|
+
)
|
|
258
|
+
-> Bool
|
|
259
|
+
{
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
extension ReactNativeAmaModule {
|
|
265
|
+
func attachWindowTapProbe() {
|
|
266
|
+
guard let window = UIApplication.shared.currentKeyWindow else { return }
|
|
267
|
+
if windowTapRecognizer != nil { return }
|
|
268
|
+
|
|
269
|
+
let delegate = AmaTapGestureDelegate(module: self)
|
|
270
|
+
tapDelegate = delegate // keep it alive
|
|
271
|
+
|
|
272
|
+
let recognizer = WindowTapGestureRecognizer(
|
|
273
|
+
target: self,
|
|
274
|
+
action: #selector(handleWindowTap(_:))
|
|
275
|
+
)
|
|
276
|
+
recognizer.cancelsTouchesInView = false
|
|
277
|
+
recognizer.delaysTouchesBegan = false
|
|
278
|
+
recognizer.delaysTouchesEnded = false
|
|
279
|
+
recognizer.delegate = delegate
|
|
280
|
+
|
|
281
|
+
window.addGestureRecognizer(recognizer)
|
|
282
|
+
windowTapRecognizer = recognizer
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public func gestureRecognizer(
|
|
286
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
287
|
+
shouldRecognizeSimultaneouslyWith otherGestureRecognizer:
|
|
288
|
+
UIGestureRecognizer
|
|
289
|
+
) -> Bool {
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
extension ReactNativeAmaModule {
|
|
295
|
+
private func findPressableAncestor(
|
|
296
|
+
from view: UIView,
|
|
297
|
+
root: UIView? = nil,
|
|
298
|
+
maxLevels: Int = 8
|
|
299
|
+
) -> UIView? {
|
|
300
|
+
var current: UIView? = view
|
|
301
|
+
var level = 0
|
|
302
|
+
|
|
303
|
+
while let v = current, level <= maxLevels {
|
|
304
|
+
if let root = root, v === root {
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if v.isPressable {
|
|
309
|
+
return v
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
level += 1
|
|
313
|
+
current = v.superview
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return nil
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@objc
|
|
320
|
+
func handleWindowTap(_ recognizer: WindowTapGestureRecognizer) {
|
|
321
|
+
guard recognizer.state == .ended else { return }
|
|
322
|
+
guard let window = UIApplication.shared.currentKeyWindow else { return }
|
|
323
|
+
// Hit-test from the RN root view rather than the window: on-device the
|
|
324
|
+
// window's own hitTest stops at the SwiftUI `UIViewControllerWrapperView`
|
|
325
|
+
// bridge (e.g. Expo Router native tabs) and never recurses into the
|
|
326
|
+
// Fabric view tree mounted inside it. Starting from `currentDecorView`
|
|
327
|
+
// (the actual RN root, already resolved via appContext.utilities) skips
|
|
328
|
+
// that boundary entirely.
|
|
329
|
+
guard let decorView = currentDecorView else { return }
|
|
330
|
+
|
|
331
|
+
// Use the location captured eagerly in touchesEnded, not
|
|
332
|
+
// recognizer.location(in:) queried here — by the time this action
|
|
333
|
+
// fires, UIKit's touch bookkeeping for this recognizer can already be
|
|
334
|
+
// reset (observed as a stuck (0, 0) on device), since it shares the
|
|
335
|
+
// window with RN's own touch handler.
|
|
336
|
+
let windowLocation = recognizer.capturedLocation
|
|
337
|
+
let location = window.convert(windowLocation, to: decorView)
|
|
338
|
+
|
|
339
|
+
guard let hitView = decorView.hitTest(location, with: nil) else { return }
|
|
340
|
+
|
|
341
|
+
// Find the closest pressable ancestor
|
|
342
|
+
let pressable = findPressableAncestor(
|
|
343
|
+
from: hitView,
|
|
344
|
+
root: decorView,
|
|
345
|
+
maxLevels: 8
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
guard let targetView = pressable else { return }
|
|
349
|
+
|
|
350
|
+
runMyChecks(tappedView: targetView, rootView: decorView)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
extension ReactNativeAmaModule {
|
|
355
|
+
private func runMyChecks(tappedView: UIView, rootView: UIView) {
|
|
356
|
+
let beforeSnapshot = takeSnapshotOfTappedView(
|
|
357
|
+
view: tappedView,
|
|
358
|
+
root: rootView
|
|
359
|
+
)
|
|
360
|
+
let beforeModalVisible = isModalVisible(in: rootView)
|
|
361
|
+
|
|
362
|
+
let delay = DispatchTime.now() + .milliseconds(60)
|
|
363
|
+
DispatchQueue.main.asyncAfter(deadline: delay) {
|
|
364
|
+
[weak self, weak tappedView, weak rootView] in
|
|
365
|
+
guard let self = self,
|
|
366
|
+
let tappedView = tappedView,
|
|
367
|
+
let rootView = rootView,
|
|
368
|
+
tappedView.window != nil,
|
|
369
|
+
rootView.window != nil
|
|
370
|
+
else {
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let afterSnapshot = self.takeSnapshotOfTappedView(
|
|
375
|
+
view: tappedView,
|
|
376
|
+
root: rootView
|
|
377
|
+
)
|
|
378
|
+
let afterModalVisible = self.isModalVisible(in: rootView)
|
|
379
|
+
|
|
380
|
+
isCheckScheduled = true
|
|
381
|
+
self.getNodesToCheck()
|
|
382
|
+
|
|
383
|
+
let settleDelay = DispatchTime.now() + .milliseconds(Int(uiCheckDelay))
|
|
384
|
+
DispatchQueue.main.asyncAfter(deadline: settleDelay) {
|
|
385
|
+
[weak self, weak tappedView, weak rootView] in
|
|
386
|
+
guard let self = self else { return }
|
|
387
|
+
guard let tappedView = tappedView,
|
|
388
|
+
let rootView = rootView,
|
|
389
|
+
tappedView.window != nil,
|
|
390
|
+
rootView.window != nil
|
|
391
|
+
else {
|
|
392
|
+
isCheckScheduled = false
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let settledSnapshot = self.takeSnapshotOfTappedView(
|
|
397
|
+
view: tappedView,
|
|
398
|
+
root: rootView
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
let payload: [String: Any] = [
|
|
402
|
+
"rootTag": tappedView.tag,
|
|
403
|
+
"before": self.convertSnapshotMapToDict(
|
|
404
|
+
snapshotMap: beforeSnapshot
|
|
405
|
+
),
|
|
406
|
+
"after": self.convertSnapshotMapToDict(
|
|
407
|
+
snapshotMap: afterSnapshot
|
|
408
|
+
),
|
|
409
|
+
"afterSettled": self.convertSnapshotMapToDict(
|
|
410
|
+
snapshotMap: settledSnapshot
|
|
411
|
+
),
|
|
412
|
+
"beforeModalVisible": beforeModalVisible,
|
|
413
|
+
"afterModalVisible": afterModalVisible,
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
isCheckScheduled = false
|
|
417
|
+
self.sendEvent("onUIInteraction", payload)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private func isModalVisible(in rootView: UIView) -> Bool {
|
|
423
|
+
return findModalView(in: rootView) != nil
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private func findModalView(in view: UIView) -> UIView? {
|
|
427
|
+
if view.isModal() {
|
|
428
|
+
return view
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for subview in view.subviews {
|
|
432
|
+
if let modal = findModalView(in: subview) {
|
|
433
|
+
return modal
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return nil
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
struct Snapshot {
|
|
442
|
+
let fgColor: String?
|
|
443
|
+
let bgColor: String?
|
|
444
|
+
let x: Int
|
|
445
|
+
let y: Int
|
|
446
|
+
let width: Int
|
|
447
|
+
let height: Int
|
|
448
|
+
let parentId: Int
|
|
449
|
+
let isPressable: Bool
|
|
450
|
+
let isChecked: Bool
|
|
451
|
+
let isBusy: Bool
|
|
452
|
+
let isExpanded: Bool
|
|
453
|
+
let isDisabled: Bool
|
|
454
|
+
let isSelected: Bool
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
extension ReactNativeAmaModule {
|
|
458
|
+
func takeSnapshotOfTappedView(
|
|
459
|
+
view: UIView,
|
|
460
|
+
root: UIView,
|
|
461
|
+
snapshots: inout [Int: Snapshot]
|
|
462
|
+
) {
|
|
463
|
+
let id = view.tag
|
|
464
|
+
let frame = view.convert(view.bounds, to: root)
|
|
465
|
+
let position = globalDpBounds(rect: frame, root: root)
|
|
466
|
+
|
|
467
|
+
let fgColor = view.contentColor?.hexString
|
|
468
|
+
let bgColor = view.contentBackgroundColor?.hexString
|
|
469
|
+
|
|
470
|
+
let a11yIsPressable = view.isPressable
|
|
471
|
+
let isChecked = view.accessibilityTraits.contains(.selected)
|
|
472
|
+
let isBusy = view.isBusy()
|
|
473
|
+
let a11yStates = view.a11yStates()
|
|
474
|
+
|
|
475
|
+
let parentId = (view.superview?.tag).map { $0 } ?? -1
|
|
476
|
+
|
|
477
|
+
snapshots[id] = Snapshot(
|
|
478
|
+
fgColor: fgColor,
|
|
479
|
+
bgColor: bgColor,
|
|
480
|
+
x: position[0],
|
|
481
|
+
y: position[1],
|
|
482
|
+
width: position[2],
|
|
483
|
+
height: position[3],
|
|
484
|
+
parentId: parentId,
|
|
485
|
+
isPressable: a11yIsPressable,
|
|
486
|
+
isChecked: isChecked,
|
|
487
|
+
isBusy: isBusy,
|
|
488
|
+
isExpanded: a11yStates.isExpanded,
|
|
489
|
+
isDisabled: a11yStates.isDisabled,
|
|
490
|
+
isSelected: a11yStates.isSelected,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if let group = view as? UIStackView {
|
|
494
|
+
for sub in group.arrangedSubviews {
|
|
495
|
+
takeSnapshotOfTappedView(
|
|
496
|
+
view: sub,
|
|
497
|
+
root: root,
|
|
498
|
+
snapshots: &snapshots
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
for sub in view.subviews {
|
|
503
|
+
takeSnapshotOfTappedView(
|
|
504
|
+
view: sub,
|
|
505
|
+
root: root,
|
|
506
|
+
snapshots: &snapshots
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
func takeSnapshotOfTappedView(view: UIView, root: UIView) -> [Int: Snapshot]
|
|
513
|
+
{
|
|
514
|
+
var map: [Int: Snapshot] = [:]
|
|
515
|
+
takeSnapshotOfTappedView(view: view, root: root, snapshots: &map)
|
|
516
|
+
return map
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
func globalDpBounds(rect: CGRect, root: UIView) -> [Int] {
|
|
520
|
+
let scale = UIScreen.main.scale
|
|
521
|
+
let leftDp = Int(rect.origin.x * scale / scale)
|
|
522
|
+
let topDp = Int(rect.origin.y * scale / scale)
|
|
523
|
+
let widthDp = Int(rect.size.width * scale / scale)
|
|
524
|
+
let heightDp = Int(rect.size.height * scale / scale)
|
|
525
|
+
return [leftDp, topDp, widthDp, heightDp]
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
func convertSnapshotMapToDict(snapshotMap: [Int: Snapshot]) -> [String: Any]
|
|
529
|
+
{
|
|
530
|
+
var dict: [String: Any] = [:]
|
|
531
|
+
|
|
532
|
+
for (key, snap) in snapshotMap {
|
|
533
|
+
dict["\(key)"] = convertSnapshotToDict(snapshot: snap)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return dict
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
func convertSnapshotToDict(snapshot: Snapshot) -> [String: Any] {
|
|
540
|
+
var result: [String: Any] = [:]
|
|
541
|
+
|
|
542
|
+
if let fg = snapshot.fgColor { result["fgColor"] = fg }
|
|
543
|
+
if let bg = snapshot.bgColor { result["bgColor"] = bg }
|
|
544
|
+
|
|
545
|
+
result["x"] = snapshot.x
|
|
546
|
+
result["y"] = snapshot.y
|
|
547
|
+
result["width"] = snapshot.width
|
|
548
|
+
result["height"] = snapshot.height
|
|
549
|
+
result["parentId"] = snapshot.parentId
|
|
550
|
+
result["isPressable"] = snapshot.isPressable
|
|
551
|
+
result["isChecked"] = snapshot.isChecked
|
|
552
|
+
result["isBusy"] = snapshot.isBusy
|
|
553
|
+
result["isDisabled"] = snapshot.isDisabled
|
|
554
|
+
result["isExpanded"] = snapshot.isExpanded
|
|
555
|
+
result["isSelected"] = snapshot.isSelected
|
|
556
|
+
|
|
557
|
+
return result
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
#else
|
|
561
|
+
import ExpoModulesCore
|
|
562
|
+
|
|
563
|
+
public class ReactNativeAmaModule: Module {
|
|
564
|
+
public func definition() -> ModuleDefinition {
|
|
565
|
+
Name("ReactNativeAma")
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
#endif
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
|
5
|
+
// to apply the proper styling (e.g. border radius and shadows).
|
|
6
|
+
class ReactNativeAmaView: ExpoView {
|
|
7
|
+
let webView = WKWebView()
|
|
8
|
+
let onLoad = EventDispatcher()
|
|
9
|
+
var delegate: WebViewDelegate?
|
|
10
|
+
|
|
11
|
+
required init(appContext: AppContext? = nil) {
|
|
12
|
+
super.init(appContext: appContext)
|
|
13
|
+
clipsToBounds = true
|
|
14
|
+
delegate = WebViewDelegate { url in
|
|
15
|
+
self.onLoad(["url": url])
|
|
16
|
+
}
|
|
17
|
+
webView.navigationDelegate = delegate
|
|
18
|
+
addSubview(webView)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override func layoutSubviews() {
|
|
22
|
+
webView.frame = bounds
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class WebViewDelegate: NSObject, WKNavigationDelegate {
|
|
27
|
+
let onUrlChange: (String) -> Void
|
|
28
|
+
|
|
29
|
+
init(onUrlChange: @escaping (String) -> Void) {
|
|
30
|
+
self.onUrlChange = onUrlChange
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {
|
|
34
|
+
if let url = webView.url {
|
|
35
|
+
onUrlChange(url.absoluteString)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|