@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.
@@ -0,0 +1,733 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ public struct NodePayload: Equatable {
5
+ let type: String
6
+ let viewId: Int
7
+ let bounds: [Double]?
8
+ let ariaLabel: String?
9
+ let content: String?
10
+ let ariaRole: String?
11
+ let traits: [String]?
12
+ let fg: String?
13
+ let bg: String?
14
+ let fontSize: CGFloat?
15
+ let isBold: Bool?
16
+ let isEnabled: Bool
17
+ let returnType: Int?
18
+ let isAccessible: Bool?
19
+
20
+ func toDictionary() -> [String: Any?] {
21
+ return [
22
+ "type": self.type,
23
+ "viewId": self.viewId,
24
+ "bounds": self.bounds,
25
+ "ariaLabel": self.ariaLabel,
26
+ "content": self.content,
27
+ "ariaRole": self.ariaRole,
28
+ "traits": self.traits,
29
+ "fg": self.fg,
30
+ "bg": self.bg,
31
+ "fontSize": self.fontSize,
32
+ "isBold": self.isBold,
33
+ "isEnabled": self.isEnabled,
34
+ "returnType": self.returnType,
35
+ "isAccessible": self.isAccessible
36
+ ]
37
+ }
38
+ }
39
+
40
+ public class NodesGrabber {
41
+ private let appContext: AppContext
42
+ private var nodesToCheck: [Int: NodePayload] = [:]
43
+
44
+ public init(appContext: AppContext) {
45
+ self.appContext = appContext
46
+ }
47
+
48
+ public func getNodesToCheck(on rootView: UIView?) -> (
49
+ nodes: [Int: NodePayload], send: Bool
50
+ ) {
51
+ guard let root = rootView else { return (nodesToCheck, false) }
52
+
53
+ let previous = nodesToCheck
54
+
55
+ nodesToCheck.removeAll()
56
+
57
+ traverseAndCheck(view: root)
58
+
59
+ let changed = previous != nodesToCheck
60
+
61
+ return (nodesToCheck, changed)
62
+ }
63
+
64
+ private func traverseAndCheck(view: UIView) {
65
+ checkView(view)
66
+
67
+ if view.isAccessibilityElement {
68
+ return
69
+ }
70
+
71
+ for subview in view.subviews {
72
+ traverseAndCheck(view: subview)
73
+ }
74
+ }
75
+
76
+ private func checkView(_ view: UIView) {
77
+ let supported = view.isPressable || view.isText() || view.isTextInput() || view.isModal() || view.isImage()
78
+
79
+ if !supported {
80
+ return
81
+ }
82
+
83
+ var font = view.contentFont
84
+ var tag = view.tag
85
+ var accessibilityLabel = view.accessibilityLabel
86
+ let info = view.extractRNTextInfo()
87
+
88
+ if info?.font != nil {
89
+ font = info?.font
90
+ }
91
+
92
+ if view.isTextInput() {
93
+ tag = view.superview?.tag ?? 0
94
+ // Use the TextInput's own accessibilityLabel first, fall back to superview's label
95
+ accessibilityLabel = view.accessibilityLabel ?? view.superview?.accessibilityLabel
96
+ }
97
+
98
+ addNode(
99
+ node: NodePayload(
100
+ type: view.getType(),
101
+ viewId: tag,
102
+ bounds: view.getTargetArea(),
103
+ ariaLabel: accessibilityLabel,
104
+ content: info?.text ?? view.content,
105
+ ariaRole: getDefaultAriaRole(view),
106
+ traits: view.accessibilityTraits.names,
107
+ fg: info?.fg?.hexString ?? view.contentColor?.hexString,
108
+ bg: view.getBackground(info),
109
+ fontSize: font?.pointSize,
110
+ isBold: font?.fontDescriptor.symbolicTraits.contains(
111
+ .traitBold
112
+ ),
113
+ isEnabled: !view.isDisabled(),
114
+ returnType: view.getReturnKeyType(),
115
+ isAccessible: (view.isText() || view.isImage()) ? !view.accessibilityElementsHidden : nil
116
+ )
117
+ )
118
+ }
119
+
120
+ private func getDefaultAriaRole(_ view: UIView) -> String? {
121
+ let defaultRole: String? = {
122
+ switch view {
123
+ case is UIButton:
124
+ return "button"
125
+ case is UISwitch:
126
+ return "switch"
127
+ case is UITextField:
128
+ return "text field"
129
+ case let iv as UIImageView where iv.isUserInteractionEnabled:
130
+ return "image button"
131
+ default:
132
+ return nil
133
+ }
134
+ }()
135
+
136
+ return defaultRole
137
+ }
138
+
139
+ private func addNode(node: NodePayload) {
140
+ guard node.viewId > 0 else { return }
141
+
142
+ nodesToCheck[node.viewId] = node
143
+ }
144
+ }
145
+
146
+ private struct RNTextInfo {
147
+ let text: String?
148
+ let fg: UIColor?
149
+ let bg: UIColor?
150
+ let font: UIFont?
151
+ }
152
+
153
+ private func previousSiblingsBackground(
154
+ in parent: UIView,
155
+ before view: UIView,
156
+ covering rect: CGRect
157
+ ) -> UIColor? {
158
+ guard let idx = parent.subviews.firstIndex(of: view) else { return nil }
159
+ if idx == 0 { return nil }
160
+
161
+ for i in stride(from: idx - 1, through: 0, by: -1) {
162
+ let sib = parent.subviews[i]
163
+ if sib.isHidden || sib.alpha <= 0.01 { continue }
164
+
165
+ // intersect with our area?
166
+ let sibRect = sib.convert(sib.bounds, to: parent)
167
+ if !sibRect.intersects(rect) { continue }
168
+
169
+ // direct background on the sibling?
170
+ if let c = ownBackground(of: sib, resolveFor: view) {
171
+ return c
172
+ }
173
+ }
174
+ return nil
175
+ }
176
+
177
+ /// Non-transparent background for a single view (either UIView.backgroundColor or layer.backgroundColor)
178
+ private func ownBackground(
179
+ of v: UIView,
180
+ resolveFor resolver: UITraitEnvironment
181
+ ) -> UIColor? {
182
+ if let c = v.backgroundColor, c.cgColor.alpha > 0.01 {
183
+ return c.resolvedColor(with: resolver.traitCollection)
184
+ }
185
+ if let cg = v.layer.backgroundColor, cg.alpha > 0.01 {
186
+ return UIColor(cgColor: cg).resolvedColor(
187
+ with: resolver.traitCollection
188
+ )
189
+ }
190
+ return nil
191
+ }
192
+
193
+ extension UIView {
194
+ struct A11yStates {
195
+ let isExpanded: Bool
196
+ let isDisabled: Bool
197
+ let isSelected: Bool
198
+ }
199
+
200
+ func getType() -> String {
201
+ if isTextInput() {
202
+ return "TextInput"
203
+ }
204
+
205
+ if isPressable {
206
+ return "Pressable"
207
+ }
208
+
209
+ if isText() {
210
+ return "Text"
211
+ }
212
+
213
+ if isImage() {
214
+ return "Image"
215
+ }
216
+
217
+ return ""
218
+ }
219
+
220
+ func getTargetArea() -> [Double]? {
221
+ if !isPressable && !isImage() {
222
+ return nil
223
+ }
224
+
225
+ let baseSize: CGRect = self.frame
226
+ let insets = isPressable ? self.getHitSlopRect() : .zero
227
+ let width = baseSize.width - insets.left - insets.right
228
+ let height = baseSize.height - insets.top - insets.bottom
229
+
230
+ return [width, height]
231
+ }
232
+
233
+ private func findColorIn(view: UIView) -> UIColor? {
234
+ if let imageView = view as? UIImageView,
235
+ let bgColor = imageView.backgroundColor
236
+ {
237
+ return bgColor
238
+ }
239
+
240
+ if let bgColor = view.backgroundColor, bgColor.cgColor.alpha > 0 {
241
+ return bgColor
242
+ }
243
+
244
+ for subview in view.subviews {
245
+ if let color = findColorIn(view: subview) {
246
+ return color
247
+ }
248
+ }
249
+
250
+ return nil
251
+ }
252
+
253
+ var content: String? {
254
+ let className = String(describing: type(of: self))
255
+
256
+ if className.contains("RCTParagraphComponentView") {
257
+ if let attrText = self.value(forKey: "attributedText")
258
+ as? NSAttributedString
259
+ {
260
+ return attrText.string
261
+ }
262
+ }
263
+
264
+ return subviews.compactMap { $0.content }.first
265
+ }
266
+
267
+ var textBackgroundColor: UIColor? {
268
+ var current: UIView? = self
269
+
270
+ while let superview = current?.superview {
271
+ for subview in superview.subviews {
272
+ let className = String(describing: type(of: subview))
273
+ if className.contains("_UIBarBackground") {
274
+ if let color = findColorIn(view: subview) {
275
+ return color
276
+ }
277
+ }
278
+ }
279
+ current = superview
280
+ }
281
+
282
+ if let parent = self.superview {
283
+ let rectInParent = self.convert(self.bounds, to: parent)
284
+ if let c = previousSiblingsBackground(
285
+ in: parent,
286
+ before: self,
287
+ covering: rectInParent
288
+ ) {
289
+ return c
290
+ }
291
+ if let c = ownBackground(of: parent, resolveFor: self) { return c }
292
+
293
+ return parent.textBackgroundColor
294
+ }
295
+ return nil
296
+ }
297
+
298
+ var contentBackgroundColor: UIColor? {
299
+ var current: UIView? = self
300
+
301
+ while let superview = current?.superview {
302
+ for subview in superview.subviews {
303
+ let className = String(describing: type(of: subview))
304
+ if className.contains("_UIBarBackground") {
305
+ if let color = findColorIn(view: subview) {
306
+ return color
307
+ }
308
+ }
309
+ }
310
+ current = superview
311
+ }
312
+
313
+ if let bg = self.backgroundColor { return bg }
314
+
315
+ var parent = self.superview
316
+ while let p = parent {
317
+ if let bg = p.backgroundColor, bg.cgColor.alpha > 0 {
318
+ return bg
319
+ }
320
+ parent = p.superview
321
+ }
322
+
323
+ return nil
324
+ }
325
+
326
+ var contentColor: UIColor? {
327
+ let className = String(describing: type(of: self))
328
+
329
+ if className.contains("SVG") {
330
+ return nil
331
+ }
332
+
333
+ if className.contains("RCTParagraphComponentView"),
334
+ let anyObj = self as AnyObject?,
335
+ let attrText = anyObj.value(forKey: "attributedText")
336
+ as? NSAttributedString,
337
+ attrText.length > 0,
338
+ let fgColor = attrText.attribute(
339
+ .foregroundColor,
340
+ at: 0,
341
+ effectiveRange: nil
342
+ )
343
+ as? UIColor
344
+ {
345
+ return fgColor
346
+ }
347
+
348
+ if let label = self as? UILabel {
349
+ return label.textColor
350
+ }
351
+ if let button = self as? UIButton {
352
+ if let tc = button.titleColor(for: .normal) { return tc }
353
+ return button.tintColor
354
+ }
355
+ if let iv = self as? UIImageView {
356
+ return iv.tintColor
357
+ }
358
+
359
+ for sub in subviews {
360
+ if let cc = sub.contentColor {
361
+ return cc
362
+ }
363
+ }
364
+
365
+ return self.tintColor
366
+ }
367
+
368
+ var contentFont: UIFont? {
369
+ if let label = self as? UILabel {
370
+ return label.font
371
+ }
372
+
373
+ if let button = self as? UIButton,
374
+ let f = button.titleLabel?.font
375
+ {
376
+ return f
377
+ }
378
+
379
+ let className = String(describing: type(of: self))
380
+
381
+ if className.contains("RCTParagraphComponentView"),
382
+ let anyObj = self as AnyObject?,
383
+ let attrText = anyObj.value(forKey: "attributedText")
384
+ as? NSAttributedString,
385
+ attrText.length > 0,
386
+ let font = attrText.attribute(.font, at: 0, effectiveRange: nil)
387
+ as? UIFont
388
+ {
389
+ return font
390
+ }
391
+
392
+ for sub in subviews {
393
+ if let f = sub.contentFont {
394
+ return f
395
+ }
396
+ }
397
+
398
+ return nil
399
+ }
400
+
401
+ fileprivate func getHitSlopRect() -> UIEdgeInsets {
402
+ let selector = NSSelectorFromString("hitTestEdgeInsets")
403
+
404
+ guard self.responds(to: selector) else {
405
+ return .zero
406
+ }
407
+
408
+ let anyObj = self as AnyObject
409
+ guard
410
+ let edgeValue = anyObj.value(forKey: "hitTestEdgeInsets")
411
+ as? UIEdgeInsets
412
+ else {
413
+ return .zero
414
+ }
415
+
416
+ return edgeValue
417
+ }
418
+
419
+ /**
420
+ * Returns true if the user can tap on the element
421
+ */
422
+ var isPressable: Bool {
423
+ return isUserInteractionEnabled && isAccessibilityElement
424
+ }
425
+
426
+ func isDisabled() -> Bool {
427
+ if accessibilityTraits.contains(.notEnabled) { return true }
428
+
429
+ if let control = self as? UIControl, control.isEnabled == false {
430
+ return true
431
+ }
432
+
433
+ if hasActivationRole
434
+ && (isHidden || alpha <= 0.01 || isUserInteractionEnabled == false)
435
+ {
436
+ return true
437
+ }
438
+
439
+ return false
440
+ }
441
+
442
+ private var hasActivationRole: Bool {
443
+ let t = accessibilityTraits
444
+ if t.contains(.button) || t.contains(.link) { return true }
445
+ if responds(to: NSSelectorFromString("accessibilityActivate")) {
446
+ return true
447
+ }
448
+ if let actions = accessibilityCustomActions, !actions.isEmpty {
449
+ return true
450
+ }
451
+
452
+ return false
453
+ }
454
+
455
+ func isText() -> Bool {
456
+ let names = [
457
+ "RCTParagraphComponentView",
458
+ "RCTParagraphTextView",
459
+ "RCTTextView",
460
+ "RCTText",
461
+ ]
462
+ for name in names {
463
+ if let cls = NSClassFromString(name), self.isKind(of: cls) {
464
+ return true
465
+ }
466
+ }
467
+
468
+ return self.responds(to: NSSelectorFromString("attributedText"))
469
+ }
470
+
471
+ func isTextInput() -> Bool {
472
+ if self is UITextField || self is UITextView {
473
+ return true
474
+ }
475
+
476
+ return false
477
+ }
478
+
479
+ func isImage() -> Bool {
480
+ return self is UIImageView
481
+ }
482
+
483
+ fileprivate func extractRNTextInfo() -> RNTextInfo? {
484
+ if !isText() {
485
+ return nil
486
+ }
487
+
488
+ let sel = NSSelectorFromString("attributedText")
489
+
490
+ guard self.responds(to: sel),
491
+ let obj = self as AnyObject?,
492
+ let attr = obj.value(forKey: "attributedText")
493
+ as? NSAttributedString,
494
+ attr.length > 0
495
+ else { return nil }
496
+
497
+ let fg =
498
+ attr.attribute(.foregroundColor, at: 0, effectiveRange: nil)
499
+ as? UIColor
500
+ let bg =
501
+ (attr.attribute(.backgroundColor, at: 0, effectiveRange: nil)
502
+ as? UIColor)
503
+ ?? (attr.attribute(
504
+ NSAttributedString.Key("NSBackgroundColor"),
505
+ at: 0,
506
+ effectiveRange: nil
507
+ ) as? UIColor)
508
+
509
+ let font = attr.attribute(.font, at: 0, effectiveRange: nil) as? UIFont
510
+
511
+ return RNTextInfo(text: attr.string, fg: fg, bg: bg, font: font)
512
+ }
513
+
514
+ func isBusy() -> Bool {
515
+ if matchesBusyToken(accessibilityValue) { return true }
516
+ if matchesBusyToken(accessibilityLabel) { return true }
517
+ if matchesBusyToken(accessibilityHint) { return true }
518
+
519
+ if let spinner = self as? UIActivityIndicatorView,
520
+ !isHidden, alpha > 0.01, spinner.isAnimating
521
+ {
522
+ return true
523
+ }
524
+
525
+ return false
526
+ }
527
+
528
+ private func matchesBusyToken(_ s: String?) -> Bool {
529
+ guard let s = s, !s.isEmpty else { return false }
530
+
531
+ if let rnBusy = localizedStringIfExists(key: "state_busy_description") {
532
+ if s.range(
533
+ of: rnBusy,
534
+ options: [.caseInsensitive, .diacriticInsensitive]
535
+ ) != nil {
536
+ return true
537
+ }
538
+ }
539
+
540
+ let fallbacks = ["busy", "loading", "in progress"]
541
+ return fallbacks.contains { token in
542
+ s.range(
543
+ of: token,
544
+ options: [.caseInsensitive, .diacriticInsensitive]
545
+ ) != nil
546
+ }
547
+ }
548
+
549
+ private func localizedStringIfExists(key: String, table: String? = nil)
550
+ -> String?
551
+ {
552
+ // Check main bundle first (RN often merges strings here)
553
+ let main = NSLocalizedString(
554
+ key,
555
+ tableName: table,
556
+ bundle: .main,
557
+ value: key,
558
+ comment: ""
559
+ )
560
+ if main != key { return main }
561
+
562
+ for b in Bundle.allBundles where b != .main {
563
+ let v = NSLocalizedString(
564
+ key,
565
+ tableName: table,
566
+ bundle: b,
567
+ value: key,
568
+ comment: ""
569
+ )
570
+ if v != key { return v }
571
+ }
572
+ return nil
573
+ }
574
+
575
+ func a11yStates() -> A11yStates {
576
+ let disabledByTrait = accessibilityTraits.contains(.notEnabled)
577
+ let disabledByControl = (self as? UIControl)?.isEnabled == false
578
+ let interactionBlocked =
579
+ isHidden || alpha <= 0.01 || isUserInteractionEnabled == false
580
+ let isDisabled =
581
+ disabledByTrait || disabledByControl || interactionBlocked
582
+
583
+ let selectedByTrait = accessibilityTraits.contains(.selected)
584
+ let selectedByControl = (self as? UIControl)?.isSelected == true
585
+ let isSelected = selectedByTrait || selectedByControl
586
+
587
+ let haystacks = [
588
+ accessibilityValue?.lowercased(),
589
+ accessibilityLabel?.lowercased(),
590
+ accessibilityHint?.lowercased(),
591
+ ].compactMap { $0 }
592
+
593
+ let expandedTokens = rnLocalizedTokens(
594
+ key: "state_expanded_description",
595
+ fallbacks: ["expanded"]
596
+ )
597
+ let collapsedTokens = rnLocalizedTokens(
598
+ key: "state_collapsed_description",
599
+ fallbacks: ["collapsed"]
600
+ )
601
+
602
+ let isExpanded: Bool? = {
603
+ if haystacks.contains(where: { s in
604
+ expandedTokens.contains(where: { s.contains($0) })
605
+ }) {
606
+ return true
607
+ }
608
+ if haystacks.contains(where: { s in
609
+ collapsedTokens.contains(where: { s.contains($0) })
610
+ }) {
611
+ return false
612
+ }
613
+ return nil // unknown
614
+ }()
615
+
616
+ return A11yStates(
617
+ isExpanded: isExpanded ?? false,
618
+ isDisabled: isDisabled,
619
+ isSelected: isSelected
620
+ )
621
+ }
622
+
623
+ private func rnLocalizedTokens(key: String, fallbacks: [String]) -> [String]
624
+ {
625
+ var out = [String]()
626
+ let vMain = NSLocalizedString(
627
+ key,
628
+ tableName: nil,
629
+ bundle: .main,
630
+ value: key,
631
+ comment: ""
632
+ )
633
+ if vMain != key { out.append(vMain.lowercased()) }
634
+
635
+ for b in Bundle.allBundles where b != .main {
636
+ let v = NSLocalizedString(
637
+ key,
638
+ tableName: nil,
639
+ bundle: b,
640
+ value: key,
641
+ comment: ""
642
+ )
643
+ if v != key { out.append(v.lowercased()) }
644
+ }
645
+ return (out + fallbacks.map { $0.lowercased() }).uniqued()
646
+ }
647
+
648
+ func getReturnKeyType() -> Int? {
649
+ if !isTextInput() {
650
+ return nil
651
+ }
652
+
653
+ if let field = self as? UITextField {
654
+ return field.returnKeyType.rawValue
655
+ }
656
+
657
+ if let view = self as? UITextView {
658
+ return view.returnKeyType.rawValue
659
+ }
660
+
661
+ return nil
662
+ }
663
+
664
+ fileprivate func getBackground(_ info: RNTextInfo?) -> String? {
665
+ if isText() {
666
+ return self.textBackgroundColor?.hexString ?? info?.bg?.hexString
667
+ }
668
+
669
+ return self.contentBackgroundColor?.hexString
670
+ }
671
+ }
672
+
673
+ extension Array where Element: Hashable {
674
+ fileprivate func uniqued() -> [Element] {
675
+ var seen = Set<Element>()
676
+ return filter { seen.insert($0).inserted }
677
+ }
678
+ }
679
+
680
+ extension UIAccessibilityTraits {
681
+ fileprivate var names: [String] {
682
+ let all: [(UIAccessibilityTraits, String)] = [
683
+ (.button, "button"),
684
+ (.link, "link"),
685
+ (.header, "header"),
686
+ (.searchField, "searchField"),
687
+ (.image, "image"),
688
+ (.selected, "selected"),
689
+ (.playsSound, "playsSound"),
690
+ (.keyboardKey, "keyboardKey"),
691
+ (.staticText, "staticText"),
692
+ (.summaryElement, "summaryElement"),
693
+ (.notEnabled, "notEnabled"),
694
+ (.updatesFrequently, "updatesFrequently"),
695
+ (.startsMediaSession, "startsMediaSession"),
696
+ (.adjustable, "adjustable"),
697
+ (.allowsDirectInteraction, "allowsDirectInteraction"),
698
+ (.causesPageTurn, "causesPageTurn"),
699
+ ]
700
+ return all.compactMap { (trait, name) in
701
+ (self.rawValue & trait.rawValue) != 0 ? name : nil
702
+ }
703
+ }
704
+ }
705
+
706
+ extension UIColor {
707
+ var rgbaComponents: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)? {
708
+ var r: CGFloat = 0
709
+ var g: CGFloat = 0
710
+ var b: CGFloat = 0
711
+ var a: CGFloat = 0
712
+ guard self.getRed(&r, green: &g, blue: &b, alpha: &a) else {
713
+ return nil
714
+ }
715
+ return (r, g, b, a)
716
+ }
717
+
718
+ var rgbaString: String {
719
+ guard let c = rgbaComponents else { return self.description }
720
+ let R = Int(c.r * 255)
721
+ let G = Int(c.g * 255)
722
+ let B = Int(c.b * 255)
723
+ return String(format: "rgba(%d,%d,%d,%.2f)", R, G, B, c.a)
724
+ }
725
+
726
+ var hexString: String {
727
+ guard let c = rgbaComponents else { return self.description }
728
+ let R = Int(c.r * 255)
729
+ let G = Int(c.g * 255)
730
+ let B = Int(c.b * 255)
731
+ return String(format: "#%02X%02X%02X", R, G, B)
732
+ }
733
+ }