@onekeyfe/react-native-native-list 3.0.105

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.
Files changed (181) hide show
  1. package/README.md +342 -0
  2. package/android/CMakeLists.txt +20 -0
  3. package/android/build.gradle +105 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  7. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +125 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +165 -0
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +147 -0
  10. package/android/src/main/java/com/onekey/nativelist/NativeListPackage.kt +25 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +2939 -0
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1794 -0
  13. package/common/fonts/Roobert-Bold.ttf +0 -0
  14. package/common/fonts/Roobert-Medium.ttf +0 -0
  15. package/common/fonts/Roobert-Regular.ttf +0 -0
  16. package/common/fonts/Roobert-SemiBold.ttf +0 -0
  17. package/docs/DESIGN.md +129 -0
  18. package/ios/HybridNativeList.swift +131 -0
  19. package/ios/NativeListCell.swift +2858 -0
  20. package/ios/NativeListDesignAssets.swift +110 -0
  21. package/ios/NativeListModels.swift +215 -0
  22. package/ios/RNCNativeListView.swift +1737 -0
  23. package/ios/Resources/NativeListIcons.xcassets/Contents.json +1 -0
  24. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/Contents.json +1 -0
  25. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_bottom.imageset/icon.svg +1 -0
  26. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/Contents.json +1 -0
  27. package/ios/Resources/NativeListIcons.xcassets/onekey_arrow_top.imageset/icon.svg +1 -0
  28. package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/Contents.json +1 -0
  29. package/ios/Resources/NativeListIcons.xcassets/onekey_chart_trending_up.imageset/icon.svg +1 -0
  30. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/Contents.json +1 -0
  31. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_checked.imageset/icon.svg +1 -0
  32. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/Contents.json +1 -0
  33. package/ios/Resources/NativeListIcons.xcassets/onekey_checkbox_indeterminate.imageset/icon.svg +1 -0
  34. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/Contents.json +1 -0
  35. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_bottom.imageset/icon.svg +1 -0
  36. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/Contents.json +1 -0
  37. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_grabber_ver.imageset/icon.svg +1 -0
  38. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/Contents.json +1 -0
  39. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_right_small.imageset/icon.svg +1 -0
  40. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/Contents.json +1 -0
  41. package/ios/Resources/NativeListIcons.xcassets/onekey_chevron_top.imageset/icon.svg +1 -0
  42. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/Contents.json +1 -0
  43. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_hor.imageset/icon.svg +1 -0
  44. package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/Contents.json +1 -0
  45. package/ios/Resources/NativeListIcons.xcassets/onekey_drag.imageset/icon.svg +1 -0
  46. package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/Contents.json +1 -0
  47. package/ios/Resources/NativeListIcons.xcassets/onekey_error_solid.imageset/icon.svg +1 -0
  48. package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/Contents.json +1 -0
  49. package/ios/Resources/NativeListIcons.xcassets/onekey_image_square_waves.imageset/icon.svg +1 -0
  50. package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/Contents.json +1 -0
  51. package/ios/Resources/NativeListIcons.xcassets/onekey_info_circle.imageset/icon.svg +1 -0
  52. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/Contents.json +1 -0
  53. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle.imageset/icon.svg +1 -0
  54. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/Contents.json +1 -0
  55. package/ios/Resources/NativeListIcons.xcassets/onekey_minus_circle_solid.imageset/icon.svg +1 -0
  56. package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/Contents.json +1 -0
  57. package/ios/Resources/NativeListIcons.xcassets/onekey_pencil.imageset/icon.svg +1 -0
  58. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/Contents.json +1 -0
  59. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_circle.imageset/icon.svg +1 -0
  60. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/Contents.json +1 -0
  61. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_small.imageset/icon.svg +1 -0
  62. package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/Contents.json +1 -0
  63. package/ios/Resources/NativeListIcons.xcassets/onekey_questionmark_solid.imageset/icon.svg +1 -0
  64. package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/Contents.json +1 -0
  65. package/ios/Resources/NativeListIcons.xcassets/onekey_shield_exclamation.imageset/icon.svg +1 -0
  66. package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/Contents.json +1 -0
  67. package/ios/Resources/NativeListIcons.xcassets/onekey_speaker_promote.imageset/icon.svg +1 -0
  68. package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/Contents.json +1 -0
  69. package/ios/Resources/NativeListIcons.xcassets/onekey_star.imageset/icon.svg +1 -0
  70. package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/Contents.json +1 -0
  71. package/ios/Resources/NativeListIcons.xcassets/onekey_star_solid.imageset/icon.svg +1 -0
  72. package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/Contents.json +1 -0
  73. package/ios/Resources/NativeListIcons.xcassets/onekey_swap_hor.imageset/icon.svg +1 -0
  74. package/lib/module/NativeList.js +183 -0
  75. package/lib/module/NativeList.nitro.js +4 -0
  76. package/lib/module/NativeList.types.js +4 -0
  77. package/lib/module/NativeList.web.js +167 -0
  78. package/lib/module/index.js +6 -0
  79. package/lib/module/models.js +2 -0
  80. package/lib/module/package.json +1 -0
  81. package/lib/module/scrolling.js +121 -0
  82. package/lib/module/selection.js +85 -0
  83. package/lib/module/validation.js +562 -0
  84. package/lib/module/web/NativeListWebEngine.js +2238 -0
  85. package/lib/nitrogen/generated/.gitattributes +1 -0
  86. package/lib/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  87. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
  88. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
  89. package/lib/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
  90. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
  91. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
  92. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
  93. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
  94. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
  95. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
  96. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
  97. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
  98. package/lib/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
  99. package/lib/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
  100. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
  101. package/lib/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
  102. package/lib/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
  103. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
  104. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
  105. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
  106. package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
  107. package/lib/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
  108. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
  109. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
  110. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
  111. package/lib/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  112. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
  113. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
  114. package/lib/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
  115. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
  116. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
  117. package/lib/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
  118. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
  119. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
  120. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
  121. package/lib/typescript/package.json +1 -0
  122. package/lib/typescript/src/NativeList.d.ts +18 -0
  123. package/lib/typescript/src/NativeList.nitro.d.ts +29 -0
  124. package/lib/typescript/src/NativeList.types.d.ts +87 -0
  125. package/lib/typescript/src/NativeList.web.d.ts +19 -0
  126. package/lib/typescript/src/index.d.ts +7 -0
  127. package/lib/typescript/src/models.d.ts +456 -0
  128. package/lib/typescript/src/scrolling.d.ts +32 -0
  129. package/lib/typescript/src/selection.d.ts +27 -0
  130. package/lib/typescript/src/validation.d.ts +7 -0
  131. package/lib/typescript/src/web/NativeListWebEngine.d.ts +203 -0
  132. package/nitro.json +23 -0
  133. package/nitrogen/generated/.gitattributes +1 -0
  134. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +201 -0
  136. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +84 -0
  137. package/nitrogen/generated/android/c++/JNativeListScrollAlignment.hpp +64 -0
  138. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +104 -0
  139. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.hpp +54 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_std__string.kt +78 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +177 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/NativeListScrollAlignment.kt +25 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/nativelistOnLoad.kt +35 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +96 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListStateUpdater.kt +23 -0
  146. package/nitrogen/generated/android/nativelist+autolinking.cmake +83 -0
  147. package/nitrogen/generated/android/nativelist+autolinking.gradle +27 -0
  148. package/nitrogen/generated/android/nativelistOnLoad.cpp +58 -0
  149. package/nitrogen/generated/android/nativelistOnLoad.hpp +34 -0
  150. package/nitrogen/generated/ios/NativeListModule+autolinking.rb +62 -0
  151. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +41 -0
  152. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +91 -0
  153. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Umbrella.hpp +49 -0
  154. package/nitrogen/generated/ios/NativeListModuleAutolinking.mm +33 -0
  155. package/nitrogen/generated/ios/NativeListModuleAutolinking.swift +26 -0
  156. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.cpp +11 -0
  157. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +180 -0
  158. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +180 -0
  159. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  160. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +69 -0
  161. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +447 -0
  162. package/nitrogen/generated/ios/swift/NativeListScrollAlignment.swift +48 -0
  163. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +43 -0
  164. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +87 -0
  165. package/nitrogen/generated/shared/c++/NativeListScrollAlignment.hpp +84 -0
  166. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +46 -0
  167. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +103 -0
  168. package/nitrogen/generated/shared/json/NativeListConfig.json +16 -0
  169. package/package.json +126 -0
  170. package/react-native-native-list.podspec +38 -0
  171. package/react-native.config.js +19 -0
  172. package/src/NativeList.nitro.ts +48 -0
  173. package/src/NativeList.tsx +298 -0
  174. package/src/NativeList.types.ts +125 -0
  175. package/src/NativeList.web.tsx +258 -0
  176. package/src/index.ts +35 -0
  177. package/src/models.ts +604 -0
  178. package/src/scrolling.ts +189 -0
  179. package/src/selection.ts +159 -0
  180. package/src/validation.ts +877 -0
  181. package/src/web/NativeListWebEngine.ts +3767 -0
@@ -0,0 +1,1737 @@
1
+ import Foundation
2
+ import UIKit
3
+ import UniformTypeIdentifiers
4
+
5
+ final class NativeListView: UIView {
6
+ private final class ActionAnchorRecord {
7
+ let token: String
8
+ weak var sourceView: UIView?
9
+ weak var ownerCell: NativeListCell?
10
+ let bindingEpoch: Int
11
+ var open = false
12
+ var invalidatedReason: String?
13
+
14
+ init(token: String, origin: NativeListActionOrigin) {
15
+ self.token = token
16
+ sourceView = origin.sourceView
17
+ ownerCell = origin.ownerCell
18
+ bindingEpoch = origin.bindingEpoch
19
+ }
20
+ }
21
+
22
+ private enum ReorderAnimation {
23
+ static let longPressDuration: TimeInterval = 0.2
24
+ static let allowableMovement: CGFloat = 10
25
+ static let duration: TimeInterval = 0.3
26
+ static let damping: CGFloat = 25
27
+ static let stiffness: CGFloat = 400
28
+ static let mass: CGFloat = 0.4
29
+ static let placeholderInset: CGFloat = 8
30
+ static let placeholderRadius: CGFloat = 12
31
+
32
+ static var timing: UISpringTimingParameters {
33
+ UISpringTimingParameters(
34
+ mass: mass,
35
+ stiffness: stiffness,
36
+ damping: damping,
37
+ initialVelocity: .zero
38
+ )
39
+ }
40
+ }
41
+
42
+ private enum ScrollRequest {
43
+ case key(String, Bool, String, Double, Double)
44
+ case index(Int, Bool, String, Double, Double)
45
+ case offset(Double, Bool)
46
+ case end(Bool)
47
+ }
48
+
49
+ var onRowAction: ((String) -> Void)?
50
+ var onActionAnchorInvalidated: ((String) -> Void)?
51
+ var onSelectionDelta: ((String) -> Void)?
52
+ var onReorder: ((String) -> Void)?
53
+ var onEndReached: ((String) -> Void)?
54
+ var onVisibleRangeChanged: ((String) -> Void)?
55
+
56
+ private let flowLayout = NativeListFlowLayout()
57
+ private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
58
+ private let footerContainer = UIView()
59
+ private let footerCell = NativeListCell(frame: .zero)
60
+ private let sectionIndexView = NativeListSectionIndexView()
61
+ private let sectionIndexPreview = UILabel()
62
+ private var footerHeightConstraint: NSLayoutConstraint!
63
+ private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
64
+ private var config: NativeListConfig?
65
+ private var itemsByKey: [String: NativeListItem] = [:]
66
+ private var endReachedGeneration: Int?
67
+ private var lastVisibleRange: (first: Int, last: Int, firstKey: String?, lastKey: String?)?
68
+ private var visibleEventScheduled = false
69
+ private var sectionIndexEntries: [NativeListSectionIndexEntry] = []
70
+ private var sectionIndexScrubbing = false
71
+ private var sectionIndexHapticsEnabled = true
72
+ private var lastLayoutDirection: UIUserInterfaceLayoutDirection?
73
+ private var lastLayoutSize: CGSize?
74
+ private var pendingScrollRequest: ScrollRequest?
75
+ private let sectionIndexFeedback = UISelectionFeedbackGenerator()
76
+ private lazy var reorderLongPress = UILongPressGestureRecognizer(
77
+ target: self,
78
+ action: #selector(reorderLongPressChanged(_:))
79
+ )
80
+ private var interactiveReorderSource: (key: String, index: Int)?
81
+ private weak var interactiveReorderCell: NativeListCell?
82
+ private var interactiveReorderCompactKey: String?
83
+ private var interactiveReorderUsesAtomicTargeting = false
84
+ private var interactiveReorderTargetIndex: Int?
85
+ private var interactiveReorderTransformedCells: [NativeListCell] = []
86
+ private let interactiveReorderPlaceholder = UIView()
87
+ private var interactiveReorderAnimator: UIViewPropertyAnimator?
88
+ private let reorderStartFeedback = UIImpactFeedbackGenerator(style: .medium)
89
+ private let reorderMoveFeedback = UISelectionFeedbackGenerator()
90
+ private var interactiveReorderFeedbackIndex: Int?
91
+ private var actionAnchor: ActionAnchorRecord?
92
+ private let actionAnchorInstanceID = UUID().uuidString
93
+ private var actionAnchorCounter = 0
94
+
95
+ private static let sectionIndexGutter: CGFloat = 44
96
+
97
+ override init(frame: CGRect) {
98
+ super.init(frame: frame)
99
+ collectionView.register(NativeListCell.self, forCellWithReuseIdentifier: NativeListCell.reuseIdentifier)
100
+ collectionView.backgroundColor = .clear
101
+ collectionView.delegate = self
102
+ collectionView.dragDelegate = self
103
+ collectionView.dropDelegate = self
104
+ collectionView.alwaysBounceVertical = true
105
+ reorderLongPress.minimumPressDuration = ReorderAnimation.longPressDuration
106
+ reorderLongPress.allowableMovement = ReorderAnimation.allowableMovement
107
+ reorderLongPress.delegate = self
108
+ collectionView.addGestureRecognizer(reorderLongPress)
109
+ interactiveReorderPlaceholder.isHidden = true
110
+ interactiveReorderPlaceholder.isUserInteractionEnabled = false
111
+ interactiveReorderPlaceholder.layer.cornerRadius = ReorderAnimation.placeholderRadius
112
+ interactiveReorderPlaceholder.layer.cornerCurve = .continuous
113
+ collectionView.insertSubview(interactiveReorderPlaceholder, at: 0)
114
+
115
+ addSubview(collectionView)
116
+ addSubview(footerContainer)
117
+ addSubview(sectionIndexView)
118
+ addSubview(sectionIndexPreview)
119
+ footerContainer.addSubview(footerCell)
120
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
121
+ footerContainer.translatesAutoresizingMaskIntoConstraints = false
122
+ sectionIndexView.translatesAutoresizingMaskIntoConstraints = false
123
+ sectionIndexPreview.translatesAutoresizingMaskIntoConstraints = false
124
+ footerCell.frame = footerContainer.bounds
125
+ footerCell.autoresizingMask = [.flexibleWidth, .flexibleHeight]
126
+ footerHeightConstraint = footerContainer.heightAnchor.constraint(equalToConstant: 0)
127
+ NSLayoutConstraint.activate([
128
+ collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
129
+ collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
130
+ collectionView.topAnchor.constraint(equalTo: topAnchor),
131
+ collectionView.bottomAnchor.constraint(equalTo: footerContainer.topAnchor),
132
+ footerContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
133
+ footerContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
134
+ footerContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
135
+ footerHeightConstraint,
136
+ sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
137
+ sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
138
+ sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor),
139
+ sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexGutter),
140
+ sectionIndexPreview.centerXAnchor.constraint(equalTo: collectionView.centerXAnchor),
141
+ sectionIndexPreview.centerYAnchor.constraint(equalTo: collectionView.centerYAnchor),
142
+ sectionIndexPreview.widthAnchor.constraint(equalToConstant: 72),
143
+ sectionIndexPreview.heightAnchor.constraint(equalToConstant: 72),
144
+ ])
145
+
146
+ sectionIndexView.isHidden = true
147
+ sectionIndexView.onSelect = { [weak self] index, interacting in
148
+ self?.selectSectionIndex(index, interacting: interacting)
149
+ }
150
+ sectionIndexView.onInteractionEnded = { [weak self] in
151
+ self?.finishSectionIndexInteraction()
152
+ }
153
+ sectionIndexPreview.isHidden = true
154
+ sectionIndexPreview.alpha = 0
155
+ sectionIndexPreview.layer.cornerRadius = 16
156
+ sectionIndexPreview.layer.masksToBounds = true
157
+ sectionIndexPreview.textAlignment = .center
158
+ sectionIndexPreview.adjustsFontForContentSizeCategory = true
159
+ sectionIndexPreview.font = nativeListFont(ofSize: 28, weight: .semibold)
160
+ sectionIndexPreview.isAccessibilityElement = false
161
+
162
+ footerCell.onAction = { [weak self] item, action, target, origin in
163
+ self?.handleAction(item: item, actionKey: action, target: target, origin: origin)
164
+ }
165
+ footerCell.onBindingInvalidated = { [weak self] cell, epoch in
166
+ self?.handleBindingInvalidated(cell: cell, epoch: epoch)
167
+ }
168
+ let footerTap = UITapGestureRecognizer(target: self, action: #selector(footerPressed))
169
+ footerTap.delegate = self
170
+ footerCell.addGestureRecognizer(footerTap)
171
+ let footerHighlight = UILongPressGestureRecognizer(
172
+ target: self,
173
+ action: #selector(footerHighlightChanged(_:))
174
+ )
175
+ footerHighlight.minimumPressDuration = 0
176
+ footerHighlight.cancelsTouchesInView = false
177
+ footerHighlight.delegate = self
178
+ footerCell.addGestureRecognizer(footerHighlight)
179
+ dataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: collectionView) {
180
+ [weak self] collectionView, indexPath, key in
181
+ guard let self,
182
+ let item = self.itemsByKey[key],
183
+ let cell = collectionView.dequeueReusableCell(
184
+ withReuseIdentifier: NativeListCell.reuseIdentifier,
185
+ for: indexPath
186
+ ) as? NativeListCell else { return nil }
187
+ cell.onAction = { [weak self] item, action, target, origin in
188
+ self?.handleAction(item: item, actionKey: action, target: target, origin: origin)
189
+ }
190
+ cell.onBindingInvalidated = { [weak self] cell, epoch in
191
+ self?.handleBindingInvalidated(cell: cell, epoch: epoch)
192
+ }
193
+ self.bind(cell: cell, item: item, itemIndex: indexPath.item)
194
+ return cell
195
+ }
196
+ dataSource.reorderingHandlers.canReorderItem = { [weak self] key in
197
+ guard let self else { return false }
198
+ return self.config?.reorderable == true && self.itemsByKey[key]?.isReorderable == true
199
+ }
200
+ dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
201
+ guard self?.interactiveReorderUsesAtomicTargeting != true else { return }
202
+ self?.completeInteractiveReorder(transaction.finalSnapshot)
203
+ }
204
+ }
205
+
206
+ required init?(coder: NSCoder) {
207
+ fatalError("init(coder:) has not been implemented")
208
+ }
209
+
210
+ override func layoutSubviews() {
211
+ super.layoutSubviews()
212
+ let direction = effectiveUserInterfaceLayoutDirection
213
+ if let lastLayoutSize,
214
+ lastLayoutSize != bounds.size || lastLayoutDirection != nil && lastLayoutDirection != direction {
215
+ invalidateActionAnchor(reason: "layout")
216
+ }
217
+ lastLayoutSize = bounds.size
218
+ if lastLayoutDirection != direction, let config {
219
+ configureLayout(config)
220
+ }
221
+ performPendingScrollIfNeeded()
222
+ }
223
+
224
+ func applySnapshotJson(_ json: String) {
225
+ guard let next = try? NativeListConfig.parse(json: json) else { return }
226
+ invalidateActionAnchor(reason: "snapshot")
227
+ if let current = config, isControlledSelectionSnapshotUpdate(from: current, to: next) {
228
+ config = next
229
+ itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
230
+ refreshVisibleSelection()
231
+ return
232
+ }
233
+ let oldItems = itemsByKey
234
+ config = next
235
+ itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
236
+ endReachedGeneration = nil
237
+ configureSectionIndex(next)
238
+ configureLayout(next)
239
+ configureRefresh(next)
240
+ configureFooter(next)
241
+
242
+ var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
243
+ snapshot.appendSections([0])
244
+ let keys = next.items.map(\.key)
245
+ snapshot.appendItems(keys, toSection: 0)
246
+ let changedKeys = keys.filter { key in
247
+ guard let old = oldItems[key], let new = itemsByKey[key] else { return false }
248
+ return old.revision != new.revision || old.content != new.content
249
+ }
250
+ snapshot.reconfigureItems(changedKeys)
251
+ dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
252
+ guard let self else { return }
253
+ self.collectionView.layoutIfNeeded()
254
+ self.performPendingScrollIfNeeded()
255
+ self.emitVisibleRangeIfNeeded()
256
+ self.checkEndReached()
257
+ self.syncSectionIndexToVisibleRows()
258
+ }
259
+ }
260
+
261
+ func applyPatchesJson(_ json: String) {
262
+ guard var current = config,
263
+ let bytes = json.data(using: .utf8),
264
+ let patches = try? JSONSerialization.jsonObject(with: bytes) as? [[String: Any]] else { return }
265
+ let indexByKey = Dictionary(uniqueKeysWithValues: current.items.enumerated().map { ($1.key, $0) })
266
+ var seen = Set<String>()
267
+ var pending: [(Int, [String: Any])] = []
268
+ for patch in patches {
269
+ let key = patch.string("key")
270
+ guard let index = indexByKey[key],
271
+ seen.insert(key).inserted,
272
+ patch.string("type") == current.items[index].type,
273
+ let changes = patch.dictionary("changes") else { return }
274
+ pending.append((index, changes))
275
+ }
276
+
277
+ var changedKeys: [String] = []
278
+ for (index, changes) in pending {
279
+ var merged = current.items[index].data
280
+ changes.forEach { key, value in
281
+ if key != "key" && key != "type" { merged[key] = value }
282
+ }
283
+ guard let item = try? NativeListItem(data: merged) else { return }
284
+ current.items[index] = item
285
+ changedKeys.append(item.key)
286
+ if let selected = changes["selected"] as? Bool {
287
+ if selected { current.selectedKeys.insert(item.key) } else { current.selectedKeys.remove(item.key) }
288
+ }
289
+ }
290
+ invalidateActionAnchor(reason: "snapshot")
291
+ config = current
292
+ itemsByKey = Dictionary(uniqueKeysWithValues: current.items.map { ($0.key, $0) })
293
+ var snapshot = dataSource.snapshot()
294
+ snapshot.reconfigureItems(changedKeys.filter { snapshot.indexOfItem($0) != nil })
295
+ dataSource.apply(snapshot, animatingDifferences: false)
296
+ configureFooter(current)
297
+ }
298
+
299
+ func reconcileSelectionJson(_ json: String) {
300
+ guard var current = config,
301
+ let bytes = json.data(using: .utf8),
302
+ let keys = try? JSONSerialization.jsonObject(with: bytes) as? [String] else { return }
303
+ let known = Set(current.items.filter(\.isSelectable).map(\.key))
304
+ let next = Set(keys)
305
+ guard next.isSubset(of: known), current.selectionMode != "single" || next.count <= 1 else { return }
306
+ current.selectedKeys = next
307
+ config = current
308
+ refreshVisibleSelection()
309
+ }
310
+
311
+ func scrollToKey(
312
+ _ key: String,
313
+ animated: Bool,
314
+ alignment: String,
315
+ viewPosition: Double = 0,
316
+ viewOffset: Double = 0
317
+ ) {
318
+ requestScroll(.key(key, animated, alignment, viewPosition, viewOffset))
319
+ }
320
+
321
+ func scrollToIndex(
322
+ _ index: Int,
323
+ animated: Bool,
324
+ alignment: String,
325
+ viewPosition: Double = 0,
326
+ viewOffset: Double = 0
327
+ ) {
328
+ requestScroll(.index(index, animated, alignment, viewPosition, viewOffset))
329
+ }
330
+
331
+ func scrollToOffset(_ offset: Double, animated: Bool) {
332
+ requestScroll(.offset(offset, animated))
333
+ }
334
+
335
+ func scrollToEnd(animated: Bool) {
336
+ requestScroll(.end(animated))
337
+ }
338
+
339
+ private func requestScroll(_ request: ScrollRequest) {
340
+ if performScroll(request) {
341
+ pendingScrollRequest = nil
342
+ } else {
343
+ pendingScrollRequest = request
344
+ }
345
+ }
346
+
347
+ private func performPendingScrollIfNeeded() {
348
+ guard let request = pendingScrollRequest, performScroll(request) else { return }
349
+ pendingScrollRequest = nil
350
+ }
351
+
352
+ private func performScroll(_ request: ScrollRequest) -> Bool {
353
+ guard let config else { return false }
354
+ guard dataSource.snapshot().numberOfItems == config.items.count else { return false }
355
+ let isHorizontal = config.orientation == "horizontal"
356
+ let viewportLength = isHorizontal
357
+ ? collectionView.bounds.width
358
+ : collectionView.bounds.height
359
+ guard viewportLength > 0 else { return false }
360
+ collectionView.layoutIfNeeded()
361
+
362
+ switch request {
363
+ case let .key(key, animated, alignment, viewPosition, viewOffset):
364
+ guard let index = config.items.firstIndex(where: { $0.key == key }) else { return true }
365
+ return performIndexScroll(
366
+ index,
367
+ animated: animated,
368
+ alignment: alignment,
369
+ viewPosition: viewPosition,
370
+ viewOffset: viewOffset
371
+ )
372
+ case let .index(index, animated, alignment, viewPosition, viewOffset):
373
+ guard index >= 0, index < config.items.count else { return true }
374
+ return performIndexScroll(
375
+ index,
376
+ animated: animated,
377
+ alignment: alignment,
378
+ viewPosition: viewPosition,
379
+ viewOffset: viewOffset
380
+ )
381
+ case let .offset(offset, animated):
382
+ let insets = collectionView.adjustedContentInset
383
+ let startInset = isHorizontal ? insets.left : insets.top
384
+ setScrollOffset(CGFloat(offset) - startInset, animated: animated)
385
+ return true
386
+ case let .end(animated):
387
+ let insets = collectionView.adjustedContentInset
388
+ let endInset = isHorizontal ? insets.right : insets.bottom
389
+ let contentLength = isHorizontal
390
+ ? collectionView.contentSize.width
391
+ : collectionView.contentSize.height
392
+ setScrollOffset(
393
+ max(0, contentLength - viewportLength + endInset),
394
+ animated: animated
395
+ )
396
+ return true
397
+ }
398
+ }
399
+
400
+ private func performIndexScroll(
401
+ _ index: Int,
402
+ animated: Bool,
403
+ alignment: String,
404
+ viewPosition: Double,
405
+ viewOffset: Double
406
+ ) -> Bool {
407
+ let indexPath = IndexPath(item: index, section: 0)
408
+ guard let attributes = collectionView.collectionViewLayout
409
+ .layoutAttributesForItem(at: indexPath) else { return false }
410
+ let isHorizontal = config?.orientation == "horizontal"
411
+ let insets = collectionView.adjustedContentInset
412
+ let viewportLength = isHorizontal
413
+ ? collectionView.bounds.width - insets.left - insets.right
414
+ : collectionView.bounds.height - insets.top - insets.bottom
415
+ let itemStart = isHorizontal ? attributes.frame.minX : attributes.frame.minY
416
+ let itemLength = isHorizontal ? attributes.frame.width : attributes.frame.height
417
+ let currentOffset = isHorizontal
418
+ ? collectionView.contentOffset.x
419
+ : collectionView.contentOffset.y
420
+ let startInset = isHorizontal ? insets.left : insets.top
421
+ let visibleStart = currentOffset + startInset
422
+ let visibleEnd = visibleStart + viewportLength
423
+ let resolvedPosition: CGFloat
424
+ if alignment == "nearest" {
425
+ if itemStart < visibleStart {
426
+ resolvedPosition = 0
427
+ } else if itemStart + itemLength > visibleEnd {
428
+ resolvedPosition = 1
429
+ } else {
430
+ return true
431
+ }
432
+ } else {
433
+ resolvedPosition = CGFloat(min(1, max(0, viewPosition)))
434
+ }
435
+ let target = itemStart
436
+ - startInset
437
+ - resolvedPosition * max(0, viewportLength - itemLength)
438
+ - CGFloat(viewOffset)
439
+ setScrollOffset(target, animated: animated)
440
+ return true
441
+ }
442
+
443
+ private func setScrollOffset(_ requestedOffset: CGFloat, animated: Bool) {
444
+ let isHorizontal = config?.orientation == "horizontal"
445
+ let insets = collectionView.adjustedContentInset
446
+ let viewportLength = isHorizontal ? collectionView.bounds.width : collectionView.bounds.height
447
+ let contentLength = isHorizontal
448
+ ? collectionView.contentSize.width
449
+ : collectionView.contentSize.height
450
+ let minimum = -(isHorizontal ? insets.left : insets.top)
451
+ let maximum = max(
452
+ minimum,
453
+ contentLength - viewportLength + (isHorizontal ? insets.right : insets.bottom)
454
+ )
455
+ let offset = min(maximum, max(minimum, requestedOffset))
456
+ let point = isHorizontal
457
+ ? CGPoint(x: offset, y: collectionView.contentOffset.y)
458
+ : CGPoint(x: collectionView.contentOffset.x, y: offset)
459
+ collectionView.setContentOffset(point, animated: animated)
460
+ }
461
+
462
+ func setRefreshing(_ refreshing: Bool) {
463
+ guard var current = config else { return }
464
+ current.refreshing = refreshing
465
+ config = current
466
+ if refreshing {
467
+ collectionView.refreshControl?.beginRefreshing()
468
+ } else {
469
+ collectionView.refreshControl?.endRefreshing()
470
+ }
471
+ }
472
+
473
+ private func configureLayout(_ config: NativeListConfig) {
474
+ let isHorizontal = config.orientation == "horizontal"
475
+ let indexGutter = sectionIndexEntries.isEmpty ? 0 : Self.sectionIndexGutter
476
+ let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft
477
+ flowLayout.scrollDirection = isHorizontal ? .horizontal : .vertical
478
+ flowLayout.minimumLineSpacing = config.itemSpacing
479
+ flowLayout.minimumInteritemSpacing = config.itemSpacing
480
+ flowLayout.sectionInset = UIEdgeInsets(
481
+ top: config.contentPaddingTop,
482
+ left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0),
483
+ bottom: config.contentPaddingBottom,
484
+ right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter)
485
+ )
486
+ flowLayout.stickyItemIndexes = config.stickyHeaders
487
+ ? Set(config.items.enumerated().compactMap {
488
+ $0.element.type == "sectionHeader" && $0.element.data.string("variant") != "summary"
489
+ ? $0.offset
490
+ : nil
491
+ })
492
+ : []
493
+ flowLayout.invalidateLayout()
494
+ collectionView.alwaysBounceHorizontal = isHorizontal
495
+ collectionView.alwaysBounceVertical = !isHorizontal
496
+ collectionView.showsVerticalScrollIndicator = sectionIndexEntries.isEmpty
497
+ collectionView.dragInteractionEnabled = false
498
+ lastLayoutDirection = effectiveUserInterfaceLayoutDirection
499
+ }
500
+
501
+ @objc private func reorderLongPressChanged(_ gesture: UILongPressGestureRecognizer) {
502
+ switch gesture.state {
503
+ case .began:
504
+ let point = gesture.location(in: collectionView)
505
+ guard let current = config,
506
+ current.reorderable,
507
+ let indexPath = collectionView.indexPathForItem(at: point),
508
+ let item = item(at: indexPath),
509
+ item.isReorderable,
510
+ let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else {
511
+ interactiveReorderSource = nil
512
+ interactiveReorderCell = nil
513
+ return
514
+ }
515
+ if item.type == "walletGroup", gesture.location(in: cell).y > 68 {
516
+ interactiveReorderSource = nil
517
+ interactiveReorderCell = nil
518
+ return
519
+ }
520
+ interactiveReorderSource = (item.key, indexPath.item)
521
+ interactiveReorderCell = cell
522
+ interactiveReorderUsesAtomicTargeting = item.type == "identity" &&
523
+ item.data.string("presentation") == "walletSidebar" &&
524
+ current.items.contains { $0.type == "walletGroup" }
525
+ interactiveReorderTargetIndex = indexPath.item
526
+ if item.type == "walletGroup" {
527
+ interactiveReorderCompactKey = item.key
528
+ cell.setWalletGroupReorderCompact(true)
529
+ flowLayout.invalidateLayout()
530
+ collectionView.layoutIfNeeded()
531
+ }
532
+ cell.setPressed(true)
533
+ guard collectionView.beginInteractiveMovementForItem(at: indexPath) else {
534
+ interactiveReorderCompactKey = nil
535
+ cell.setWalletGroupReorderCompact(false)
536
+ cell.setPressed(false)
537
+ interactiveReorderSource = nil
538
+ interactiveReorderCell = nil
539
+ interactiveReorderUsesAtomicTargeting = false
540
+ interactiveReorderTargetIndex = nil
541
+ interactiveReorderFeedbackIndex = nil
542
+ flowLayout.invalidateLayout()
543
+ collectionView.layoutIfNeeded()
544
+ return
545
+ }
546
+ interactiveReorderFeedbackIndex = indexPath.item
547
+ reorderStartFeedback.impactOccurred(intensity: 0.7)
548
+ reorderStartFeedback.prepare()
549
+ reorderMoveFeedback.prepare()
550
+ showInteractiveReorderPlaceholder(at: indexPath, item: item, config: current)
551
+ case .changed:
552
+ guard interactiveReorderSource != nil else { return }
553
+ let point = gesture.location(in: collectionView)
554
+ collectionView.updateInteractiveMovementTargetPosition(point)
555
+ if let indexPath = nearestReorderIndexPath(to: point),
556
+ let item = item(at: indexPath) {
557
+ let targetChanged = interactiveReorderFeedbackIndex != indexPath.item
558
+ if interactiveReorderUsesAtomicTargeting {
559
+ updateAtomicReorderTarget(to: indexPath.item, animated: targetChanged)
560
+ }
561
+ if targetChanged {
562
+ interactiveReorderFeedbackIndex = indexPath.item
563
+ reorderMoveFeedback.selectionChanged()
564
+ reorderMoveFeedback.prepare()
565
+ showInteractiveReorderPlaceholder(at: indexPath, item: item, config: config)
566
+ }
567
+ }
568
+ case .ended:
569
+ guard interactiveReorderSource != nil else { return }
570
+ interactiveReorderCell?.setPressed(false)
571
+ finishInteractiveReorder(cancelled: false)
572
+ case .cancelled, .failed:
573
+ interactiveReorderCell?.setPressed(false)
574
+ finishInteractiveReorder(cancelled: true)
575
+ default:
576
+ break
577
+ }
578
+ }
579
+
580
+ private func nearestReorderIndexPath(to point: CGPoint) -> IndexPath? {
581
+ collectionView.indexPathsForVisibleItems.min { lhs, rhs in
582
+ let lhsFrame = flowLayout.layoutAttributesForItem(at: lhs)?.frame ?? .zero
583
+ let rhsFrame = flowLayout.layoutAttributesForItem(at: rhs)?.frame ?? .zero
584
+ return abs(lhsFrame.midY - point.y) < abs(rhsFrame.midY - point.y)
585
+ }
586
+ }
587
+
588
+ private func showInteractiveReorderPlaceholder(
589
+ at indexPath: IndexPath,
590
+ item: NativeListItem,
591
+ config: NativeListConfig?
592
+ ) {
593
+ guard (
594
+ item.type == "walletGroup" ||
595
+ (item.type == "identity" && item.data.string("presentation") == "walletSidebar")
596
+ ),
597
+ var frame = flowLayout.layoutAttributesForItem(at: indexPath)?.frame else { return }
598
+ let targetMaxY = frame.maxY
599
+ frame.origin.x += ReorderAnimation.placeholderInset
600
+ frame.size.width = max(0, frame.width - ReorderAnimation.placeholderInset * 2)
601
+ frame.size.height = 68
602
+ if interactiveReorderUsesAtomicTargeting,
603
+ item.type == "walletGroup",
604
+ let source = interactiveReorderSource,
605
+ indexPath.item > source.index {
606
+ frame.origin.y = targetMaxY - frame.height
607
+ }
608
+ interactiveReorderPlaceholder.backgroundColor = nativeListColor(
609
+ config?.theme,
610
+ "rowPressedBackground",
611
+ "#00000017"
612
+ )
613
+ interactiveReorderAnimator?.stopAnimation(true)
614
+ if interactiveReorderPlaceholder.isHidden {
615
+ interactiveReorderPlaceholder.frame = frame
616
+ interactiveReorderPlaceholder.alpha = 1
617
+ interactiveReorderPlaceholder.isHidden = false
618
+ return
619
+ }
620
+ let animator = UIViewPropertyAnimator(
621
+ duration: ReorderAnimation.duration,
622
+ timingParameters: ReorderAnimation.timing
623
+ )
624
+ animator.addAnimations { [weak self] in self?.interactiveReorderPlaceholder.frame = frame }
625
+ interactiveReorderAnimator = animator
626
+ animator.startAnimation()
627
+ }
628
+
629
+ private func finishInteractiveReorder(cancelled: Bool) {
630
+ if interactiveReorderUsesAtomicTargeting {
631
+ finishAtomicInteractiveReorder(cancelled: cancelled)
632
+ return
633
+ }
634
+ interactiveReorderAnimator?.stopAnimation(true)
635
+ interactiveReorderFeedbackIndex = nil
636
+ if cancelled {
637
+ collectionView.cancelInteractiveMovement()
638
+ } else {
639
+ collectionView.endInteractiveMovement()
640
+ }
641
+ let animator = UIViewPropertyAnimator(
642
+ duration: ReorderAnimation.duration,
643
+ timingParameters: ReorderAnimation.timing
644
+ )
645
+ animator.addAnimations { [weak self] in
646
+ guard let self else { return }
647
+ self.collectionView.layoutIfNeeded()
648
+ self.interactiveReorderPlaceholder.alpha = 0
649
+ }
650
+ animator.addCompletion { [weak self] _ in
651
+ guard let self else { return }
652
+ self.interactiveReorderPlaceholder.isHidden = true
653
+ self.interactiveReorderPlaceholder.alpha = 1
654
+ self.interactiveReorderAnimator = nil
655
+ if self.interactiveReorderCompactKey != nil {
656
+ self.expandWalletGroupAfterInteractiveReorder()
657
+ } else {
658
+ self.interactiveReorderSource = nil
659
+ self.interactiveReorderCell = nil
660
+ }
661
+ }
662
+ interactiveReorderAnimator = animator
663
+ animator.startAnimation()
664
+ }
665
+
666
+ private func completeInteractiveReorder(
667
+ _ snapshot: NSDiffableDataSourceSnapshot<Int, String>
668
+ ) {
669
+ guard var current = config,
670
+ let source = interactiveReorderSource else { return }
671
+ defer {
672
+ interactiveReorderSource = nil
673
+ interactiveReorderCell = nil
674
+ }
675
+ let keys = snapshot.itemIdentifiers
676
+ let items = keys.compactMap { itemsByKey[$0] }
677
+ guard keys.count == current.items.count,
678
+ let toIndex = keys.firstIndex(of: source.key),
679
+ items.count == keys.count else { return }
680
+ current.items = items
681
+ config = current
682
+ guard source.index != toIndex else { return }
683
+ var payload: [String: Any] = [
684
+ "key": source.key,
685
+ "fromIndex": source.index,
686
+ "toIndex": toIndex,
687
+ ]
688
+ if let before = items[safe: toIndex - 1] { payload["beforeKey"] = before.key }
689
+ if let after = items[safe: toIndex + 1] { payload["afterKey"] = after.key }
690
+ emit(onReorder, payload)
691
+ }
692
+
693
+ private func expandWalletGroupAfterInteractiveReorder() {
694
+ guard let key = interactiveReorderCompactKey else { return }
695
+ collectionView.layoutIfNeeded()
696
+ walletGroupCell(for: key)?.prepareWalletGroupReorderExpansion()
697
+ interactiveReorderCompactKey = nil
698
+ flowLayout.invalidateLayout()
699
+ let animator = UIViewPropertyAnimator(
700
+ duration: ReorderAnimation.duration,
701
+ timingParameters: ReorderAnimation.timing
702
+ )
703
+ animator.addAnimations { [weak self] in
704
+ guard let self else { return }
705
+ self.collectionView.layoutIfNeeded()
706
+ self.walletGroupCell(for: key)?.animateWalletGroupReorderExpansion()
707
+ }
708
+ animator.addCompletion { [weak self] _ in
709
+ guard let self else { return }
710
+ self.walletGroupCell(for: key)?.finishWalletGroupReorderExpansion()
711
+ self.interactiveReorderSource = nil
712
+ self.interactiveReorderCell = nil
713
+ }
714
+ interactiveReorderAnimator = animator
715
+ animator.startAnimation()
716
+ }
717
+
718
+ private func walletGroupCell(for key: String) -> NativeListCell? {
719
+ guard let index = dataSource.snapshot().indexOfItem(key) else { return nil }
720
+ return collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? NativeListCell
721
+ }
722
+
723
+ private func item(at indexPath: IndexPath) -> NativeListItem? {
724
+ if let key = dataSource.itemIdentifier(for: indexPath), let item = itemsByKey[key] {
725
+ return item
726
+ }
727
+ return config?.items[safe: indexPath.item]
728
+ }
729
+
730
+ private func updateAtomicReorderTarget(to targetIndex: Int, animated: Bool) {
731
+ guard let source = interactiveReorderSource else { return }
732
+ interactiveReorderTargetIndex = targetIndex
733
+ let distance = CGFloat(68) + flowLayout.minimumLineSpacing
734
+ let updates = { [weak self] in
735
+ guard let self else { return }
736
+ for case let cell as NativeListCell in self.collectionView.visibleCells {
737
+ guard let indexPath = self.collectionView.indexPath(for: cell),
738
+ indexPath.item != source.index else { continue }
739
+ let offset: CGFloat
740
+ if targetIndex < source.index,
741
+ indexPath.item >= targetIndex,
742
+ indexPath.item < source.index {
743
+ offset = distance
744
+ } else if targetIndex > source.index,
745
+ indexPath.item > source.index,
746
+ indexPath.item <= targetIndex {
747
+ offset = -distance
748
+ } else {
749
+ offset = 0
750
+ }
751
+ if offset != 0,
752
+ !self.interactiveReorderTransformedCells.contains(where: { $0 === cell }) {
753
+ self.interactiveReorderTransformedCells.append(cell)
754
+ }
755
+ cell.transform = CGAffineTransform(translationX: 0, y: offset)
756
+ }
757
+ }
758
+ if animated {
759
+ UIView.animate(
760
+ withDuration: ReorderAnimation.duration,
761
+ delay: 0,
762
+ options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseInOut],
763
+ animations: updates
764
+ )
765
+ } else {
766
+ UIView.performWithoutAnimation(updates)
767
+ }
768
+ }
769
+
770
+ private func finishAtomicInteractiveReorder(cancelled: Bool) {
771
+ interactiveReorderAnimator?.stopAnimation(true)
772
+ interactiveReorderFeedbackIndex = nil
773
+ guard let source = interactiveReorderSource else { return }
774
+ let targetIndex = interactiveReorderTargetIndex ?? source.index
775
+ collectionView.cancelInteractiveMovement()
776
+
777
+ if !cancelled, targetIndex != source.index {
778
+ var snapshot = dataSource.snapshot()
779
+ let keys = snapshot.itemIdentifiers
780
+ if targetIndex < source.index, let targetKey = keys[safe: targetIndex] {
781
+ snapshot.moveItem(source.key, beforeItem: targetKey)
782
+ } else if let targetKey = keys[safe: targetIndex] {
783
+ snapshot.moveItem(source.key, afterItem: targetKey)
784
+ }
785
+ dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
786
+ guard let self else { return }
787
+ self.resetAtomicReorderTransforms()
788
+ self.collectionView.layoutIfNeeded()
789
+ self.completeInteractiveReorder(snapshot)
790
+ self.clearAtomicInteractiveReorderState()
791
+ }
792
+ } else {
793
+ resetAtomicReorderTransforms()
794
+ interactiveReorderSource = nil
795
+ interactiveReorderCell = nil
796
+ clearAtomicInteractiveReorderState()
797
+ }
798
+ }
799
+
800
+ private func clearAtomicInteractiveReorderState() {
801
+ interactiveReorderUsesAtomicTargeting = false
802
+ interactiveReorderTargetIndex = nil
803
+ interactiveReorderPlaceholder.isHidden = true
804
+ interactiveReorderPlaceholder.alpha = 1
805
+ }
806
+
807
+ private func resetAtomicReorderTransforms() {
808
+ UIView.performWithoutAnimation {
809
+ for cell in interactiveReorderTransformedCells {
810
+ cell.transform = .identity
811
+ }
812
+ for cell in collectionView.visibleCells {
813
+ cell.transform = .identity
814
+ }
815
+ interactiveReorderTransformedCells.removeAll()
816
+ collectionView.layoutIfNeeded()
817
+ }
818
+ }
819
+
820
+ private func configureSectionIndex(_ config: NativeListConfig) {
821
+ let previousKey = sectionIndexView.activeIndex.flatMap { sectionIndexEntries[safe: $0]?.key }
822
+ finishSectionIndexInteraction(immediately: true)
823
+ let enabled = config.sectionIndexEnabled &&
824
+ config.layout == "sectioned" &&
825
+ config.orientation != "horizontal"
826
+ sectionIndexEntries = enabled
827
+ ? config.items.enumerated().compactMap { position, item in
828
+ guard item.type == "sectionHeader" else { return nil }
829
+ let title = item.data.string("indexTitle")
830
+ return title.isEmpty ? nil : NativeListSectionIndexEntry(
831
+ key: item.key,
832
+ title: title,
833
+ position: position
834
+ )
835
+ }
836
+ : []
837
+ sectionIndexHapticsEnabled = config.sectionIndexHapticsEnabled
838
+ sectionIndexView.configure(
839
+ titles: sectionIndexEntries.map(\.title),
840
+ textColor: nativeListColor(config.theme, "secondaryText", "#646464"),
841
+ activeColor: nativeListColor(config.theme, "accent", "#108303")
842
+ )
843
+ sectionIndexView.isHidden = sectionIndexEntries.isEmpty
844
+ sectionIndexPreview.backgroundColor = nativeListColor(
845
+ config.theme,
846
+ "inverseBackground",
847
+ "#202020"
848
+ )
849
+ sectionIndexPreview.textColor = nativeListColor(config.theme, "inverseText", "#FCFCFC")
850
+ if let previousKey,
851
+ let index = sectionIndexEntries.firstIndex(where: { $0.key == previousKey }) {
852
+ sectionIndexView.setActiveIndex(index)
853
+ } else {
854
+ sectionIndexView.setActiveIndex(nil)
855
+ }
856
+ if sectionIndexEntries.isEmpty { finishSectionIndexInteraction(immediately: true) }
857
+ }
858
+
859
+ private func selectSectionIndex(_ index: Int, interacting: Bool) {
860
+ guard let entry = sectionIndexEntries[safe: index] else { return }
861
+ let changed = sectionIndexView.activeIndex != index
862
+ sectionIndexScrubbing = interacting
863
+ sectionIndexView.setActiveIndex(index)
864
+ scrollToIndex(entry.position, animated: false, alignment: "start")
865
+ if interacting {
866
+ sectionIndexPreview.layer.removeAllAnimations()
867
+ sectionIndexPreview.text = entry.title
868
+ sectionIndexPreview.isHidden = false
869
+ sectionIndexPreview.alpha = 1
870
+ if changed && sectionIndexHapticsEnabled {
871
+ sectionIndexFeedback.selectionChanged()
872
+ sectionIndexFeedback.prepare()
873
+ }
874
+ }
875
+ }
876
+
877
+ private func finishSectionIndexInteraction(immediately: Bool = false) {
878
+ sectionIndexScrubbing = false
879
+ let hide: () -> Void = { [weak self] in
880
+ guard let self else { return }
881
+ self.sectionIndexPreview.alpha = 0
882
+ }
883
+ let completion: (Bool) -> Void = { [weak self] _ in
884
+ guard let self else { return }
885
+ self.sectionIndexPreview.isHidden = true
886
+ }
887
+ sectionIndexPreview.layer.removeAllAnimations()
888
+ if immediately {
889
+ hide()
890
+ completion(true)
891
+ } else {
892
+ UIView.animate(withDuration: 0.15, animations: hide, completion: completion)
893
+ }
894
+ }
895
+
896
+ private func syncSectionIndexToVisibleRows() {
897
+ guard !sectionIndexScrubbing, !sectionIndexEntries.isEmpty else { return }
898
+ let firstVisible = collectionView.indexPathsForVisibleItems.map(\.item).min() ?? 0
899
+ let index = sectionIndexEntries.lastIndex { $0.position <= firstVisible }
900
+ sectionIndexView.setActiveIndex(index)
901
+ }
902
+
903
+ private func configureRefresh(_ config: NativeListConfig) {
904
+ if config.pullToRefresh {
905
+ if collectionView.refreshControl == nil {
906
+ let refreshControl = UIRefreshControl()
907
+ refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
908
+ collectionView.refreshControl = refreshControl
909
+ }
910
+ if config.refreshing { collectionView.refreshControl?.beginRefreshing() }
911
+ else { collectionView.refreshControl?.endRefreshing() }
912
+ } else {
913
+ collectionView.refreshControl = nil
914
+ }
915
+ }
916
+
917
+ private func configureFooter(_ config: NativeListConfig) {
918
+ guard let footer = config.fixedFooter else {
919
+ footerHeightConstraint.constant = 0
920
+ footerCell.isHidden = true
921
+ return
922
+ }
923
+ footerCell.isHidden = false
924
+ footerHeightConstraint.constant = rowHeight(footer)
925
+ bind(cell: footerCell, item: footer, itemIndex: nil)
926
+ }
927
+
928
+ private func bind(cell: NativeListCell, item: NativeListItem, itemIndex: Int? = nil) {
929
+ guard let config else { return }
930
+ cell.bind(
931
+ item: item,
932
+ theme: config.theme,
933
+ layout: config.layout,
934
+ itemIndex: itemIndex,
935
+ selected: config.selectedKeys.contains(item.key),
936
+ checkboxState: { [weak self] item, target, fallback in
937
+ self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback
938
+ }
939
+ )
940
+ if item.key == interactiveReorderCompactKey {
941
+ cell.setWalletGroupReorderCompact(true)
942
+ }
943
+ }
944
+
945
+ private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) {
946
+ guard let config, !item.data.bool("disabled") else { return }
947
+ if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" {
948
+ updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key)
949
+ return
950
+ }
951
+ if item.type == "action" {
952
+ emit(onRowAction, rowActionPayload(item: item, actionKey: item.data.string("actionKey"), origin: origin))
953
+ } else if item.type == "system", item.data.string("variant") == "retry" {
954
+ emit(onRowAction, rowActionPayload(item: item, actionKey: item.data.string("actionKey"), origin: origin))
955
+ } else {
956
+ emit(onRowAction, rowActionPayload(item: item, actionKey: "press", origin: origin))
957
+ }
958
+ }
959
+
960
+ private func handleAction(
961
+ item: NativeListItem,
962
+ actionKey: String,
963
+ target: NativeSelectionTarget?,
964
+ origin: NativeListActionOrigin?
965
+ ) {
966
+ guard !item.data.bool("disabled") else { return }
967
+ if let target, config?.selectionMode != "none" {
968
+ updateSelection(target: target, sourceKey: item.key)
969
+ return
970
+ }
971
+ emit(onRowAction, rowActionPayload(item: item, actionKey: actionKey, origin: origin))
972
+ }
973
+
974
+ private func updateSelection(target: NativeSelectionTarget, sourceKey: String) {
975
+ guard var current = config, current.selectionMode != "none" else { return }
976
+ let keys = selectionKeys(target: target, items: current.items)
977
+ guard !keys.isEmpty else { return }
978
+ let before = current.selectedKeys
979
+ var after = before
980
+ if current.selectionMode == "single" {
981
+ let key = keys[0]
982
+ after.removeAll()
983
+ if !before.contains(key) { after.insert(key) }
984
+ } else {
985
+ let allSelected = keys.allSatisfy(before.contains)
986
+ keys.forEach { if allSelected { after.remove($0) } else { after.insert($0) } }
987
+ }
988
+ guard before != after else { return }
989
+ current.selectedKeys = after
990
+ config = current
991
+ refreshVisibleSelection()
992
+ emit(onSelectionDelta, [
993
+ "addedKeys": Array(after.subtracting(before)),
994
+ "removedKeys": Array(before.subtracting(after)),
995
+ "source": target.scope,
996
+ "sourceKey": target.key ?? sourceKey,
997
+ ])
998
+ }
999
+
1000
+ private func selectionKeys(
1001
+ target: NativeSelectionTarget,
1002
+ items: [NativeListItem]
1003
+ ) -> [String] {
1004
+ switch target.scope {
1005
+ case "row": return items.filter { $0.key == target.key && $0.isSelectable }.map(\.key)
1006
+ case "section": return items.filter { $0.sectionKey == target.key && $0.isSelectable }.map(\.key)
1007
+ case "list": return items.filter(\.isSelectable).map(\.key)
1008
+ default: return []
1009
+ }
1010
+ }
1011
+
1012
+ private func resolveCheckboxState(
1013
+ item: NativeListItem,
1014
+ target: NativeSelectionTarget?,
1015
+ fallback: String
1016
+ ) -> String {
1017
+ guard let config else { return fallback }
1018
+ let keys = selectionKeys(
1019
+ target: target ?? NativeSelectionTarget(scope: "row", key: item.key),
1020
+ items: config.items
1021
+ )
1022
+ guard !keys.isEmpty else { return fallback }
1023
+ let count = keys.filter(config.selectedKeys.contains).count
1024
+ if count == 0 { return "unchecked" }
1025
+ if count == keys.count { return "checked" }
1026
+ return "indeterminate"
1027
+ }
1028
+
1029
+ private func refreshVisibleSelection() {
1030
+ guard let config else { return }
1031
+ let checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String = {
1032
+ [weak self] item, target, fallback in
1033
+ self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback
1034
+ }
1035
+ for indexPath in collectionView.indexPathsForVisibleItems {
1036
+ guard let item = config.items[safe: indexPath.item],
1037
+ let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue }
1038
+ cell.updateSelection(
1039
+ item: item,
1040
+ selected: config.selectedKeys.contains(item.key),
1041
+ checkboxState: checkboxState
1042
+ )
1043
+ }
1044
+ if let footer = config.fixedFooter, !footerCell.isHidden {
1045
+ footerCell.updateSelection(
1046
+ item: footer,
1047
+ selected: config.selectedKeys.contains(footer.key),
1048
+ checkboxState: checkboxState
1049
+ )
1050
+ }
1051
+ }
1052
+
1053
+ private func isControlledSelectionSnapshotUpdate(
1054
+ from current: NativeListConfig,
1055
+ to next: NativeListConfig
1056
+ ) -> Bool {
1057
+ guard current.generation == next.generation,
1058
+ current.layout == next.layout,
1059
+ current.orientation == next.orientation,
1060
+ current.gridColumns == next.gridColumns,
1061
+ current.stickyHeaders == next.stickyHeaders,
1062
+ current.contentPadding == next.contentPadding,
1063
+ current.contentPaddingHorizontal == next.contentPaddingHorizontal,
1064
+ current.contentPaddingTop == next.contentPaddingTop,
1065
+ current.contentPaddingBottom == next.contentPaddingBottom,
1066
+ current.itemSpacing == next.itemSpacing,
1067
+ current.selectionMode == next.selectionMode,
1068
+ current.rowPressToggles == next.rowPressToggles,
1069
+ current.reorderable == next.reorderable,
1070
+ current.pullToRefresh == next.pullToRefresh,
1071
+ current.refreshing == next.refreshing,
1072
+ current.loadMore == next.loadMore,
1073
+ current.endReachedThreshold == next.endReachedThreshold,
1074
+ current.sectionIndexEnabled == next.sectionIndexEnabled,
1075
+ current.sectionIndexHapticsEnabled == next.sectionIndexHapticsEnabled,
1076
+ dictionariesEqual(current.theme, next.theme),
1077
+ current.fixedFooter?.content == next.fixedFooter?.content,
1078
+ current.items.count == next.items.count else { return false }
1079
+
1080
+ return zip(current.items, next.items).allSatisfy { old, new in
1081
+ guard old.key == new.key, old.type == new.type else { return false }
1082
+ if old.content == new.content { return true }
1083
+ guard old.type == "sectionHeader",
1084
+ old.data.string("variant") == "summary",
1085
+ new.data.string("variant") == "summary" else { return false }
1086
+ var oldData = old.data
1087
+ var newData = new.data
1088
+ oldData.removeValue(forKey: "title")
1089
+ oldData.removeValue(forKey: "value")
1090
+ newData.removeValue(forKey: "title")
1091
+ newData.removeValue(forKey: "value")
1092
+ return jsonData(oldData) == jsonData(newData)
1093
+ }
1094
+ }
1095
+
1096
+ private func dictionariesEqual(_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool {
1097
+ switch (lhs, rhs) {
1098
+ case (nil, nil): return true
1099
+ case let (lhs?, rhs?): return jsonData(lhs) == jsonData(rhs)
1100
+ default: return false
1101
+ }
1102
+ }
1103
+
1104
+ private func jsonData(_ value: [String: Any]) -> Data? {
1105
+ guard JSONSerialization.isValidJSONObject(value) else { return nil }
1106
+ return try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
1107
+ }
1108
+
1109
+ private func rowHeight(_ item: NativeListItem) -> CGFloat {
1110
+ if item.type == "system", item.data.string("variant") == "spacer" {
1111
+ return CGFloat(item.data.int("height"))
1112
+ }
1113
+ if item.type == "walletGroup" {
1114
+ if item.key == interactiveReorderCompactKey { return 68 }
1115
+ let childCount = item.data.dictionaries("children").count
1116
+ return CGFloat((childCount + 1) * 68 + childCount * 12)
1117
+ }
1118
+ if item.type == "identity", item.data.string("presentation") == "walletSidebar" {
1119
+ return 68
1120
+ }
1121
+ if item.type == "identity", item.data.string("presentation") == "networkSelector" {
1122
+ return 47
1123
+ }
1124
+ let base: CGFloat
1125
+ switch item.type {
1126
+ case "rail": base = 40
1127
+ case "activity": base = item.data.dictionaries("footerActions").isEmpty ? 60 : 100
1128
+ case "message": base = messageHeight(item)
1129
+ case "mediaTile": base = 244
1130
+ case "metricCard":
1131
+ base = item.data.string("variant") == "activity"
1132
+ ? 160 + 1 / UIScreen.main.scale
1133
+ : item.data.string("variant") == "performance" ? 178 : 132
1134
+ case "sectionHeader":
1135
+ let variant = item.data.string("variant")
1136
+ let isNetworkSelector = item.data.string("presentation") == "networkSelector"
1137
+ let isHistory = variant == "history" ||
1138
+ item.key.hasPrefix("history-") ||
1139
+ (item.sectionKey?.hasPrefix("history-") ?? false)
1140
+ base = config?.layout == "table"
1141
+ ? 28
1142
+ : isNetworkSelector
1143
+ ? 47
1144
+ : isHistory
1145
+ ? 16
1146
+ : variant == "summary"
1147
+ ? 68
1148
+ : variant == "gallery"
1149
+ ? 32
1150
+ : item.data.dictionary("checkbox") != nil
1151
+ ? 56
1152
+ : config?.layout == "linear" ? 30 : 36
1153
+ case "system":
1154
+ switch item.data.string("variant") {
1155
+ case "noMatch", "end": base = 36
1156
+ case "retry": base = 44
1157
+ default: base = 56
1158
+ }
1159
+ case "action":
1160
+ base = item.data.string("presentation") == "accountSelector"
1161
+ ? 48
1162
+ : item.data.dictionary("icon") == nil ? 44 : 60
1163
+ case "dataRow":
1164
+ base = item.data.dictionaries("columns").contains {
1165
+ !$0.string("secondaryText").isEmpty
1166
+ } ? 60 : 56
1167
+ default:
1168
+ if item.type == "identity", !item.data.string("tertiary").isEmpty {
1169
+ base = 72
1170
+ } else if item.type == "identity", !item.data.string("subtitle").isEmpty {
1171
+ base = 60
1172
+ } else {
1173
+ base = 56
1174
+ }
1175
+ }
1176
+ let hasFixedHeaderHeight = item.type == "sectionHeader" &&
1177
+ ["summary", "gallery"].contains(item.data.string("variant"))
1178
+ let modifier: CGFloat = hasFixedHeaderHeight
1179
+ ? 0
1180
+ : item.data.string("size", default: "medium") == "small"
1181
+ ? -8
1182
+ : item.data.string("size") == "large" ? 12 : 0
1183
+ let sectionSpacing: CGFloat = 0
1184
+ let tableAdjustment: CGFloat = config?.layout == "table" &&
1185
+ item.type == "dataRow" &&
1186
+ !item.data.dictionaries("columns").contains(where: { !$0.string("secondaryText").isEmpty })
1187
+ ? -8
1188
+ : 0
1189
+ return max(0, base + modifier + sectionSpacing + tableAdjustment)
1190
+ }
1191
+
1192
+ private func messageHeight(_ item: NativeListItem) -> CGFloat {
1193
+ let maximumBodyLines = min(3, max(1, item.data.int("bodyLines", default: 3)))
1194
+ let horizontalInsets = (config?.contentPaddingHorizontal ?? 0) * 2 + 40
1195
+ let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : 40
1196
+ let thumbnailWidth: CGFloat = item.data.dictionary("thumbnail") == nil ? 0 : 76
1197
+ let textWidth = max(1, collectionView.bounds.width - horizontalInsets - leadingWidth - thumbnailWidth)
1198
+ let bodyBounds = (item.data.string("body") as NSString).boundingRect(
1199
+ with: CGSize(width: textWidth, height: .greatestFiniteMagnitude),
1200
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
1201
+ attributes: [.font: nativeListFont(ofSize: 14)],
1202
+ context: nil
1203
+ )
1204
+ let titleBounds = (item.data.string("title") as NSString).boundingRect(
1205
+ with: CGSize(width: textWidth, height: .greatestFiniteMagnitude),
1206
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
1207
+ attributes: [.font: nativeListFont(ofSize: 14, weight: .semibold)],
1208
+ context: nil
1209
+ )
1210
+ let bodyLines = min(maximumBodyLines, max(1, Int(ceil(bodyBounds.height / 20))))
1211
+ let titleLines = min(2, max(1, Int(ceil(titleBounds.height / 20))))
1212
+ return 32 + CGFloat(titleLines * 20 + bodyLines * 20) + 22
1213
+ }
1214
+
1215
+ private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) {
1216
+ guard JSONSerialization.isValidJSONObject(value),
1217
+ let data = try? JSONSerialization.data(withJSONObject: value),
1218
+ let json = String(data: data, encoding: .utf8) else { return }
1219
+ block?(json)
1220
+ }
1221
+
1222
+ private func rowActionPayload(
1223
+ item: NativeListItem,
1224
+ actionKey: String,
1225
+ origin: NativeListActionOrigin? = nil
1226
+ ) -> [String: Any] {
1227
+ var payload: [String: Any] = ["rowKey": item.key, "actionKey": actionKey]
1228
+ if let sectionKey = item.sectionKey { payload["sectionKey"] = sectionKey }
1229
+ if let origin, let anchor = createActionAnchor(origin: origin) { payload["anchor"] = anchor }
1230
+ return payload
1231
+ }
1232
+
1233
+ func setActionAnchorStateJson(_ json: String) {
1234
+ guard let bytes = json.data(using: .utf8),
1235
+ let state = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any],
1236
+ let token = state["token"] as? String,
1237
+ let open = state["open"] as? Bool,
1238
+ let anchor = actionAnchor,
1239
+ anchor.token == token else { return }
1240
+ if !open {
1241
+ if state.bool("restoreFocus"), isActionAnchorValid(anchor), let sourceView = anchor.sourceView {
1242
+ UIAccessibility.post(notification: .layoutChanged, argument: sourceView)
1243
+ }
1244
+ actionAnchor = nil
1245
+ return
1246
+ }
1247
+ if let reason = anchor.invalidatedReason {
1248
+ emitActionAnchorInvalidated(anchor: anchor, reason: reason)
1249
+ } else if isActionAnchorValid(anchor) {
1250
+ anchor.open = true
1251
+ } else {
1252
+ emitActionAnchorInvalidated(anchor: anchor, reason: "rebind")
1253
+ }
1254
+ }
1255
+
1256
+ func disposeActionAnchor() {
1257
+ invalidateActionAnchor(reason: "destroy")
1258
+ actionAnchor = nil
1259
+ }
1260
+
1261
+ private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? {
1262
+ guard let sourceView = origin.sourceView,
1263
+ let ownerCell = origin.ownerCell,
1264
+ ownerCell.bindingEpoch == origin.bindingEpoch,
1265
+ sourceView.window != nil,
1266
+ sourceView === ownerCell.contentView || sourceView.isDescendant(of: ownerCell.contentView) else { return nil }
1267
+ invalidateActionAnchor(reason: "rebind")
1268
+ actionAnchorCounter &+= 1
1269
+ let generation = config?.generation ?? 0
1270
+ let token = "\(actionAnchorInstanceID):\(generation):\(actionAnchorCounter):\(origin.bindingEpoch)"
1271
+ let rect = sourceView.convert(sourceView.bounds, to: window)
1272
+ let record = ActionAnchorRecord(token: token, origin: origin)
1273
+ actionAnchor = record
1274
+ var anchor: [String: Any] = [
1275
+ "token": token,
1276
+ "windowRect": [
1277
+ "x": rect.minX,
1278
+ "y": rect.minY,
1279
+ "width": rect.width,
1280
+ "height": rect.height,
1281
+ ],
1282
+ "source": origin.source,
1283
+ "generation": generation,
1284
+ "layoutDirection": sourceView.effectiveUserInterfaceLayoutDirection == .rightToLeft
1285
+ ? "rtl"
1286
+ : "ltr",
1287
+ ]
1288
+ if let slot = origin.slot { anchor["slot"] = slot }
1289
+ return anchor
1290
+ }
1291
+
1292
+ private func isActionAnchorValid(_ anchor: ActionAnchorRecord) -> Bool {
1293
+ guard anchor.invalidatedReason == nil,
1294
+ let sourceView = anchor.sourceView,
1295
+ let ownerCell = anchor.ownerCell else { return false }
1296
+ return ownerCell.bindingEpoch == anchor.bindingEpoch
1297
+ && sourceView.window != nil
1298
+ && (sourceView === ownerCell.contentView || sourceView.isDescendant(of: ownerCell.contentView))
1299
+ }
1300
+
1301
+ private func handleBindingInvalidated(cell: NativeListCell, epoch: Int) {
1302
+ guard let anchor = actionAnchor,
1303
+ anchor.ownerCell === cell,
1304
+ anchor.bindingEpoch == epoch else { return }
1305
+ invalidateActionAnchor(reason: "rebind")
1306
+ }
1307
+
1308
+ private func invalidateActionAnchor(reason: String) {
1309
+ guard let anchor = actionAnchor, anchor.invalidatedReason == nil else { return }
1310
+ anchor.invalidatedReason = reason
1311
+ if anchor.open { emitActionAnchorInvalidated(anchor: anchor, reason: reason) }
1312
+ }
1313
+
1314
+ private func emitActionAnchorInvalidated(anchor: ActionAnchorRecord, reason: String) {
1315
+ guard actionAnchor === anchor else { return }
1316
+ actionAnchor = nil
1317
+ emit(onActionAnchorInvalidated, ["token": anchor.token, "reason": reason])
1318
+ }
1319
+
1320
+ @objc private func refreshTriggered() {
1321
+ emit(onRowAction, ["actionKey": "nativeList.refresh"])
1322
+ }
1323
+
1324
+ @objc private func footerPressed() {
1325
+ guard let footer = config?.fixedFooter else { return }
1326
+ handleRowPress(footer, origin: footerCell.rowActionOrigin())
1327
+ }
1328
+
1329
+ @objc private func footerHighlightChanged(_ recognizer: UILongPressGestureRecognizer) {
1330
+ switch recognizer.state {
1331
+ case .began, .changed:
1332
+ footerCell.setPressed(footerCell.bounds.contains(recognizer.location(in: footerCell)))
1333
+ default:
1334
+ footerCell.setPressed(false)
1335
+ }
1336
+ }
1337
+
1338
+ private func emitVisibleRangeIfNeeded() {
1339
+ guard !visibleEventScheduled else { return }
1340
+ visibleEventScheduled = true
1341
+ DispatchQueue.main.async { [weak self] in
1342
+ guard let self else { return }
1343
+ self.visibleEventScheduled = false
1344
+ let indexes = self.collectionView.indexPathsForVisibleItems.map(\.item).sorted()
1345
+ let first = indexes.first ?? -1
1346
+ let last = indexes.last ?? -1
1347
+ let firstKey = self.config?.items[safe: first]?.key
1348
+ let lastKey = self.config?.items[safe: last]?.key
1349
+ guard self.lastVisibleRange?.first != first ||
1350
+ self.lastVisibleRange?.last != last ||
1351
+ self.lastVisibleRange?.firstKey != firstKey ||
1352
+ self.lastVisibleRange?.lastKey != lastKey else { return }
1353
+ self.lastVisibleRange = (first, last, firstKey, lastKey)
1354
+ var payload: [String: Any] = ["firstIndex": first, "lastIndex": last]
1355
+ if let firstKey { payload["firstKey"] = firstKey }
1356
+ if let lastKey { payload["lastKey"] = lastKey }
1357
+ self.emit(self.onVisibleRangeChanged, payload)
1358
+ }
1359
+ }
1360
+
1361
+ private func checkEndReached() {
1362
+ guard let config,
1363
+ config.loadMore,
1364
+ endReachedGeneration != config.generation,
1365
+ !config.items.isEmpty else { return }
1366
+ let lastVisible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? -1
1367
+ let threshold = max(1, Int(ceil(Double(config.items.count) * config.endReachedThreshold)))
1368
+ guard lastVisible >= config.items.count - threshold else { return }
1369
+ endReachedGeneration = config.generation
1370
+ var payload: [String: Any] = ["generation": config.generation]
1371
+ if let lastKey = config.items.last?.key { payload["lastKey"] = lastKey }
1372
+ emit(onEndReached, payload)
1373
+ }
1374
+ }
1375
+
1376
+ extension NativeListView: UIGestureRecognizerDelegate {
1377
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
1378
+ var view = touch.view
1379
+ while let current = view, current !== footerCell {
1380
+ if current is UIControl { return false }
1381
+ view = current.superview
1382
+ }
1383
+ return true
1384
+ }
1385
+
1386
+ func gestureRecognizer(
1387
+ _ gestureRecognizer: UIGestureRecognizer,
1388
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
1389
+ ) -> Bool {
1390
+ gestureRecognizer.view === footerCell || otherGestureRecognizer.view === footerCell
1391
+ }
1392
+ }
1393
+
1394
+ extension NativeListView: UICollectionViewDelegateFlowLayout {
1395
+ @available(iOS 15.0, *)
1396
+ func collectionView(
1397
+ _ collectionView: UICollectionView,
1398
+ targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath,
1399
+ atCurrentIndexPath currentIndexPath: IndexPath,
1400
+ toProposedIndexPath proposedIndexPath: IndexPath
1401
+ ) -> IndexPath {
1402
+ interactiveReorderUsesAtomicTargeting ? currentIndexPath : proposedIndexPath
1403
+ }
1404
+
1405
+ func collectionView(
1406
+ _ collectionView: UICollectionView,
1407
+ targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath,
1408
+ toProposedIndexPath proposedIndexPath: IndexPath
1409
+ ) -> IndexPath {
1410
+ interactiveReorderUsesAtomicTargeting ? originalIndexPath : proposedIndexPath
1411
+ }
1412
+
1413
+ func collectionView(
1414
+ _ collectionView: UICollectionView,
1415
+ layout collectionViewLayout: UICollectionViewLayout,
1416
+ sizeForItemAt indexPath: IndexPath
1417
+ ) -> CGSize {
1418
+ guard let config, let item = item(at: indexPath) else { return .zero }
1419
+ let insets = flowLayout.sectionInset
1420
+ if config.orientation == "horizontal" {
1421
+ let width: CGFloat = item.type == "rail" ? railWidth(item) : item.type == "mediaTile" ? 200 : 280
1422
+ let availableHeight = max(0, collectionView.bounds.height - insets.top - insets.bottom)
1423
+ let height = item.type == "rail" ? min(rowHeight(item), availableHeight) : availableHeight
1424
+ return CGSize(width: width, height: height)
1425
+ }
1426
+ let available = max(0, collectionView.bounds.width - insets.left - insets.right)
1427
+ let structural = item.type == "sectionHeader" || item.type == "system" || item.type == "action"
1428
+ if config.layout == "grid", !structural {
1429
+ let spacing = CGFloat(config.gridColumns - 1) * config.itemSpacing
1430
+ let width = floor((available - spacing) / CGFloat(config.gridColumns))
1431
+ let height = item.type == "mediaTile" ? width + 48 : rowHeight(item)
1432
+ return CGSize(width: width, height: height)
1433
+ }
1434
+ return CGSize(width: available, height: rowHeight(item))
1435
+ }
1436
+
1437
+ private func railWidth(_ item: NativeListItem) -> CGFloat {
1438
+ let titleWidth = (item.data.string("title") as NSString).size(
1439
+ withAttributes: [.font: nativeListFont(ofSize: 12, weight: .medium)]
1440
+ ).width
1441
+ let badge = item.data.dictionary("badge")?.string("text") ?? ""
1442
+ let badgeWidth = (badge as NSString).size(
1443
+ withAttributes: [.font: nativeListTabularFont(ofSize: 12, weight: .medium)]
1444
+ ).width
1445
+ let status = item.data.string("status")
1446
+ let statusWidth = status.isEmpty || status == "none" ? 0 : (status as NSString).size(
1447
+ withAttributes: [.font: nativeListTabularFont(ofSize: 12)]
1448
+ ).width
1449
+ let visibleTextCount = 1 + (badgeWidth > 0 ? 1 : 0) + (statusWidth > 0 ? 1 : 0)
1450
+ let width = 4 + 20 + 6 + titleWidth + badgeWidth + statusWidth
1451
+ + CGFloat(max(0, visibleTextCount - 1)) * 6 + 4
1452
+ return min(288, max(72, ceil(width + 8)))
1453
+ }
1454
+
1455
+ func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
1456
+ guard let item = config?.items[safe: indexPath.item] else { return }
1457
+ if item.type == "walletGroup" { return }
1458
+ let origin = (collectionView.cellForItem(at: indexPath) as? NativeListCell)?.rowActionOrigin()
1459
+ handleRowPress(item, origin: origin)
1460
+ }
1461
+
1462
+ func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
1463
+ guard let item = config?.items[safe: indexPath.item] else { return false }
1464
+ return !item.data.bool("disabled")
1465
+ }
1466
+
1467
+ func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
1468
+ guard let item = config?.items[safe: indexPath.item] else { return false }
1469
+ return !item.data.bool("disabled")
1470
+ }
1471
+
1472
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
1473
+ invalidateActionAnchor(reason: "scroll")
1474
+ syncSectionIndexToVisibleRows()
1475
+ emitVisibleRangeIfNeeded()
1476
+ checkEndReached()
1477
+ }
1478
+ }
1479
+
1480
+ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDelegate {
1481
+ func collectionView(
1482
+ _ collectionView: UICollectionView,
1483
+ itemsForBeginning session: UIDragSession,
1484
+ at indexPath: IndexPath
1485
+ ) -> [UIDragItem] {
1486
+ guard config?.reorderable == true,
1487
+ let item = config?.items[safe: indexPath.item],
1488
+ item.isReorderable else { return [] }
1489
+ let dragItem = UIDragItem(itemProvider: NSItemProvider(object: item.key as NSString))
1490
+ dragItem.localObject = indexPath
1491
+ return [dragItem]
1492
+ }
1493
+
1494
+ func collectionView(
1495
+ _ collectionView: UICollectionView,
1496
+ dropSessionDidUpdate session: UIDropSession,
1497
+ withDestinationIndexPath destinationIndexPath: IndexPath?
1498
+ ) -> UICollectionViewDropProposal {
1499
+ UICollectionViewDropProposal(operation: session.localDragSession == nil ? .forbidden : .move, intent: .insertAtDestinationIndexPath)
1500
+ }
1501
+
1502
+ func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
1503
+ guard var current = config,
1504
+ let drop = coordinator.items.first,
1505
+ let source = drop.dragItem.localObject as? IndexPath else { return }
1506
+ let destination = coordinator.destinationIndexPath ?? IndexPath(item: max(0, current.items.count - 1), section: 0)
1507
+ guard current.items.indices.contains(source.item), current.items.indices.contains(destination.item) else { return }
1508
+ let sourceItem = current.items[source.item]
1509
+ let targetItem = current.items[destination.item]
1510
+ guard sourceItem.isReorderable, targetItem.isReorderable, sourceItem.sectionKey == targetItem.sectionKey else { return }
1511
+ current.items.remove(at: source.item)
1512
+ current.items.insert(sourceItem, at: destination.item)
1513
+ config = current
1514
+ itemsByKey = Dictionary(uniqueKeysWithValues: current.items.map { ($0.key, $0) })
1515
+ var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
1516
+ snapshot.appendSections([0])
1517
+ snapshot.appendItems(current.items.map(\.key))
1518
+ dataSource.apply(snapshot, animatingDifferences: false)
1519
+ coordinator.drop(drop.dragItem, toItemAt: destination)
1520
+ var payload: [String: Any] = [
1521
+ "key": sourceItem.key,
1522
+ "fromIndex": source.item,
1523
+ "toIndex": destination.item,
1524
+ ]
1525
+ if let before = current.items[safe: destination.item - 1] { payload["beforeKey"] = before.key }
1526
+ if let after = current.items[safe: destination.item + 1] { payload["afterKey"] = after.key }
1527
+ emit(onReorder, payload)
1528
+ }
1529
+ }
1530
+
1531
+ final class NativeListFlowLayout: UICollectionViewFlowLayout {
1532
+ var stickyItemIndexes: Set<Int> = []
1533
+ private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
1534
+ private var horizontalContentSize: CGSize = .zero
1535
+
1536
+ override func prepare() {
1537
+ super.prepare()
1538
+ horizontalAttributes.removeAll(keepingCapacity: true)
1539
+ guard scrollDirection == .horizontal, let collectionView else { return }
1540
+
1541
+ var nextX = sectionInset.left
1542
+ let itemCount = collectionView.numberOfItems(inSection: 0)
1543
+ for item in 0..<itemCount {
1544
+ let indexPath = IndexPath(item: item, section: 0)
1545
+ guard let attributes = super.layoutAttributesForItem(at: indexPath)?.copy()
1546
+ as? UICollectionViewLayoutAttributes else { continue }
1547
+ attributes.frame.origin = CGPoint(x: nextX, y: sectionInset.top)
1548
+ horizontalAttributes[indexPath] = attributes
1549
+ nextX = attributes.frame.maxX + minimumLineSpacing
1550
+ }
1551
+ let contentWidth = itemCount == 0
1552
+ ? sectionInset.left + sectionInset.right
1553
+ : nextX - minimumLineSpacing + sectionInset.right
1554
+ horizontalContentSize = CGSize(
1555
+ width: max(collectionView.bounds.width, contentWidth),
1556
+ height: collectionView.bounds.height
1557
+ )
1558
+ }
1559
+
1560
+ override var collectionViewContentSize: CGSize {
1561
+ scrollDirection == .horizontal ? horizontalContentSize : super.collectionViewContentSize
1562
+ }
1563
+
1564
+ override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
1565
+ if scrollDirection == .horizontal, collectionView?.bounds.size != newBounds.size { return true }
1566
+ return !stickyItemIndexes.isEmpty || super.shouldInvalidateLayout(forBoundsChange: newBounds)
1567
+ }
1568
+
1569
+ override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
1570
+ if scrollDirection == .horizontal {
1571
+ return horizontalAttributes.values
1572
+ .filter { $0.frame.intersects(rect) }
1573
+ .sorted { $0.indexPath.item < $1.indexPath.item }
1574
+ }
1575
+ guard scrollDirection == .vertical,
1576
+ !stickyItemIndexes.isEmpty,
1577
+ let collectionView else { return super.layoutAttributesForElements(in: rect) }
1578
+ let base = super.layoutAttributesForElements(in: rect)?.compactMap { $0.copy() as? UICollectionViewLayoutAttributes } ?? []
1579
+ let firstVisible = collectionView.indexPathsForVisibleItems.map(\.item).min() ?? 0
1580
+ guard let stickyIndex = stickyItemIndexes.filter({ $0 <= firstVisible }).max(),
1581
+ let original = super.layoutAttributesForItem(at: IndexPath(item: stickyIndex, section: 0)),
1582
+ let sticky = original.copy() as? UICollectionViewLayoutAttributes else { return base }
1583
+ let pinY = collectionView.contentOffset.y + collectionView.adjustedContentInset.top
1584
+ let nextHeaderY = stickyItemIndexes
1585
+ .filter { $0 > stickyIndex }
1586
+ .min()
1587
+ .flatMap { super.layoutAttributesForItem(at: IndexPath(item: $0, section: 0))?.frame.minY }
1588
+ sticky.frame.origin.y = min(max(pinY, original.frame.minY), (nextHeaderY ?? .greatestFiniteMagnitude) - sticky.frame.height)
1589
+ sticky.zIndex = 10_000
1590
+ let filtered = base.filter { $0.indexPath != sticky.indexPath }
1591
+ return filtered + [sticky]
1592
+ }
1593
+
1594
+ override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
1595
+ if scrollDirection == .horizontal { return horizontalAttributes[indexPath] }
1596
+ return super.layoutAttributesForItem(at: indexPath)
1597
+ }
1598
+
1599
+ }
1600
+
1601
+ private struct NativeListSectionIndexEntry {
1602
+ let key: String
1603
+ let title: String
1604
+ let position: Int
1605
+ }
1606
+
1607
+ private final class NativeListSectionIndexView: UIControl {
1608
+ var onSelect: ((Int, Bool) -> Void)?
1609
+ var onInteractionEnded: (() -> Void)?
1610
+ private var titles: [String] = []
1611
+ private var labels: [UILabel] = []
1612
+ private var textColor: UIColor = .secondaryLabel
1613
+ private var activeColor: UIColor = .tintColor
1614
+ private var lastTouchIndex: Int?
1615
+ private(set) var activeIndex: Int?
1616
+
1617
+ override init(frame: CGRect) {
1618
+ super.init(frame: frame)
1619
+ isAccessibilityElement = true
1620
+ accessibilityLabel = "Section index"
1621
+ accessibilityTraits = [.adjustable]
1622
+ isExclusiveTouch = true
1623
+ }
1624
+
1625
+ required init?(coder: NSCoder) {
1626
+ fatalError("init(coder:) has not been implemented")
1627
+ }
1628
+
1629
+ func configure(titles: [String], textColor: UIColor, activeColor: UIColor) {
1630
+ self.titles = titles
1631
+ self.textColor = textColor
1632
+ self.activeColor = activeColor
1633
+ labels.forEach { $0.removeFromSuperview() }
1634
+ labels = titles.map { title in
1635
+ let label = UILabel()
1636
+ label.text = title
1637
+ label.textAlignment = .center
1638
+ label.adjustsFontForContentSizeCategory = true
1639
+ label.isAccessibilityElement = false
1640
+ addSubview(label)
1641
+ return label
1642
+ }
1643
+ activeIndex = nil
1644
+ accessibilityValue = nil
1645
+ setNeedsLayout()
1646
+ updateLabelStyles()
1647
+ }
1648
+
1649
+ func setActiveIndex(_ index: Int?) {
1650
+ guard activeIndex != index else { return }
1651
+ activeIndex = index
1652
+ accessibilityValue = index.flatMap { titles[safe: $0] }
1653
+ updateLabelStyles()
1654
+ }
1655
+
1656
+ override func layoutSubviews() {
1657
+ super.layoutSubviews()
1658
+ guard !labels.isEmpty else { return }
1659
+ let height = min(16, max(8, bounds.height / CGFloat(labels.count)))
1660
+ let originY = (bounds.height - height * CGFloat(labels.count)) / 2
1661
+ for (index, label) in labels.enumerated() {
1662
+ label.frame = CGRect(
1663
+ x: 0,
1664
+ y: originY + CGFloat(index) * height,
1665
+ width: bounds.width,
1666
+ height: height
1667
+ )
1668
+ }
1669
+ }
1670
+
1671
+ override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
1672
+ guard !titles.isEmpty else { return false }
1673
+ lastTouchIndex = nil
1674
+ select(at: touch.location(in: self).y, interacting: true)
1675
+ return true
1676
+ }
1677
+
1678
+ override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
1679
+ select(at: touch.location(in: self).y, interacting: true)
1680
+ return true
1681
+ }
1682
+
1683
+ override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
1684
+ if let touch { select(at: touch.location(in: self).y, interacting: true) }
1685
+ lastTouchIndex = nil
1686
+ onInteractionEnded?()
1687
+ }
1688
+
1689
+ override func cancelTracking(with event: UIEvent?) {
1690
+ lastTouchIndex = nil
1691
+ onInteractionEnded?()
1692
+ }
1693
+
1694
+ override func accessibilityIncrement() {
1695
+ guard !titles.isEmpty else { return }
1696
+ select(index: min(titles.count - 1, (activeIndex ?? -1) + 1), interacting: false)
1697
+ }
1698
+
1699
+ override func accessibilityDecrement() {
1700
+ guard !titles.isEmpty else { return }
1701
+ select(index: max(0, (activeIndex ?? 1) - 1), interacting: false)
1702
+ }
1703
+
1704
+ private func select(at y: CGFloat, interacting: Bool) {
1705
+ let height = min(16, max(8, bounds.height / CGFloat(titles.count)))
1706
+ let originY = (bounds.height - height * CGFloat(titles.count)) / 2
1707
+ let index = Int(floor((y - originY) / height)).clamped(to: 0...(titles.count - 1))
1708
+ if interacting && lastTouchIndex == index { return }
1709
+ lastTouchIndex = interacting ? index : nil
1710
+ select(index: index, interacting: interacting)
1711
+ }
1712
+
1713
+ private func select(index: Int, interacting: Bool) {
1714
+ onSelect?(index, interacting)
1715
+ setActiveIndex(index)
1716
+ }
1717
+
1718
+ private func updateLabelStyles() {
1719
+ for (index, label) in labels.enumerated() {
1720
+ let active = index == activeIndex
1721
+ label.textColor = active ? activeColor : textColor
1722
+ label.font = nativeListFont(ofSize: 10, weight: active ? .semibold : .medium)
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ private extension Comparable {
1728
+ func clamped(to range: ClosedRange<Self>) -> Self {
1729
+ min(max(self, range.lowerBound), range.upperBound)
1730
+ }
1731
+ }
1732
+
1733
+ private extension Array {
1734
+ subscript(safe index: Int) -> Element? {
1735
+ indices.contains(index) ? self[index] : nil
1736
+ }
1737
+ }