@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,2858 @@
1
+ import Foundation
2
+ import OneKeyImage
3
+ import UIKit
4
+
5
+ final class NativeListActionOrigin {
6
+ weak var sourceView: UIView?
7
+ weak var ownerCell: NativeListCell?
8
+ let bindingEpoch: Int
9
+ let source: String
10
+ let slot: Int?
11
+
12
+ init(
13
+ sourceView: UIView,
14
+ ownerCell: NativeListCell,
15
+ bindingEpoch: Int,
16
+ source: String,
17
+ slot: Int? = nil
18
+ ) {
19
+ self.sourceView = sourceView
20
+ self.ownerCell = ownerCell
21
+ self.bindingEpoch = bindingEpoch
22
+ self.source = source
23
+ self.slot = slot
24
+ }
25
+ }
26
+
27
+ private final class NativeListInsetLabel: UILabel {
28
+ var horizontalInset: CGFloat = 0
29
+ var topInset: CGFloat = 0
30
+ var bottomInset: CGFloat = 0
31
+
32
+ override var intrinsicContentSize: CGSize {
33
+ let size = super.intrinsicContentSize
34
+ return CGSize(
35
+ width: size.width + horizontalInset * 2,
36
+ height: size.height + topInset + bottomInset
37
+ )
38
+ }
39
+
40
+ override func drawText(in rect: CGRect) {
41
+ super.drawText(in: rect.inset(
42
+ by: UIEdgeInsets(
43
+ top: topInset,
44
+ left: horizontalInset,
45
+ bottom: bottomInset,
46
+ right: horizontalInset
47
+ )
48
+ ))
49
+ }
50
+ }
51
+
52
+ private final class NativeListDottedUnderlineLabel: UILabel {
53
+ var showsDottedUnderline = false {
54
+ didSet { setNeedsLayout() }
55
+ }
56
+
57
+ var dottedUnderlineColor: UIColor = .clear {
58
+ didSet {
59
+ dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor
60
+ setNeedsLayout()
61
+ }
62
+ }
63
+
64
+ var dottedUnderlineVerticalOffset: CGFloat = 0 {
65
+ didSet { setNeedsLayout() }
66
+ }
67
+
68
+ private let dottedUnderlineLayer = CAShapeLayer()
69
+
70
+ override init(frame: CGRect) {
71
+ super.init(frame: frame)
72
+ dottedUnderlineLayer.fillColor = UIColor.clear.cgColor
73
+ dottedUnderlineLayer.lineWidth = 1.5
74
+ dottedUnderlineLayer.lineCap = .round
75
+ dottedUnderlineLayer.lineDashPattern = [0, 4]
76
+ layer.addSublayer(dottedUnderlineLayer)
77
+ }
78
+
79
+ required init?(coder: NSCoder) {
80
+ fatalError("init(coder:) has not been implemented")
81
+ }
82
+
83
+ override func layoutSubviews() {
84
+ super.layoutSubviews()
85
+ CATransaction.begin()
86
+ CATransaction.setDisableActions(true)
87
+ defer { CATransaction.commit() }
88
+ guard showsDottedUnderline, let attributedText else {
89
+ dottedUnderlineLayer.path = nil
90
+ dottedUnderlineLayer.isHidden = true
91
+ return
92
+ }
93
+ let textWidth = ceil(attributedText.boundingRect(
94
+ with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 24),
95
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
96
+ context: nil
97
+ ).width)
98
+ dottedUnderlineLayer.frame = CGRect(
99
+ x: 0,
100
+ y: 0,
101
+ width: bounds.width,
102
+ height: bounds.height + 2 + dottedUnderlineVerticalOffset
103
+ )
104
+ let y = bounds.height + 1 + dottedUnderlineVerticalOffset
105
+ let path = UIBezierPath()
106
+ path.move(to: CGPoint(x: 1, y: y))
107
+ path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y))
108
+ dottedUnderlineLayer.path = path.cgPath
109
+ dottedUnderlineLayer.isHidden = false
110
+ }
111
+ }
112
+
113
+ private final class NativeListTableColumnView: UIStackView {
114
+ private let primaryLine = UIStackView()
115
+ private let primaryLabel = UILabel()
116
+ private let badgesStack = UIStackView()
117
+ private let secondaryLine = UIStackView()
118
+ private let secondaryLeadingLabel = UILabel()
119
+ private let secondaryLabel = UILabel()
120
+
121
+ override init(frame: CGRect) {
122
+ super.init(frame: frame)
123
+ axis = .vertical
124
+ alignment = .leading
125
+ distribution = .fill
126
+ spacing = 4
127
+
128
+ primaryLine.axis = .horizontal
129
+ primaryLine.alignment = .center
130
+ primaryLine.spacing = 6
131
+ primaryLabel.numberOfLines = 1
132
+ primaryLabel.lineBreakMode = .byTruncatingTail
133
+ primaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
134
+ badgesStack.axis = .horizontal
135
+ badgesStack.alignment = .center
136
+ badgesStack.spacing = 4
137
+ badgesStack.setContentCompressionResistancePriority(.required, for: .horizontal)
138
+ primaryLine.addArrangedSubview(primaryLabel)
139
+ primaryLine.addArrangedSubview(badgesStack)
140
+
141
+ secondaryLine.axis = .horizontal
142
+ secondaryLine.alignment = .center
143
+ secondaryLine.spacing = 4
144
+ for label in [secondaryLeadingLabel, secondaryLabel] {
145
+ label.numberOfLines = 1
146
+ label.lineBreakMode = .byTruncatingTail
147
+ secondaryLine.addArrangedSubview(label)
148
+ }
149
+ secondaryLeadingLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
150
+ secondaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
151
+ secondaryLeadingLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true
152
+ addArrangedSubview(primaryLine)
153
+ addArrangedSubview(secondaryLine)
154
+ }
155
+
156
+ required init(coder: NSCoder) {
157
+ fatalError("init(coder:) has not been implemented")
158
+ }
159
+
160
+ func reset() {
161
+ primaryLabel.attributedText = nil
162
+ secondaryLeadingLabel.attributedText = nil
163
+ secondaryLabel.attributedText = nil
164
+ secondaryLeadingLabel.isHidden = true
165
+ secondaryLabel.isHidden = true
166
+ secondaryLine.isHidden = true
167
+ badgesStack.arrangedSubviews.forEach {
168
+ badgesStack.removeArrangedSubview($0)
169
+ $0.removeFromSuperview()
170
+ }
171
+ }
172
+
173
+ func bind(
174
+ column: [String: Any],
175
+ badges: [[String: Any]],
176
+ theme: [String: Any]?
177
+ ) {
178
+ reset()
179
+ let textAlignment: NSTextAlignment
180
+ switch column.string("alignment") {
181
+ case "end":
182
+ alignment = .trailing
183
+ textAlignment = .right
184
+ case "center":
185
+ alignment = .center
186
+ textAlignment = .center
187
+ default:
188
+ alignment = .leading
189
+ textAlignment = .left
190
+ }
191
+ primaryLabel.textAlignment = textAlignment
192
+ secondaryLeadingLabel.textAlignment = textAlignment
193
+ secondaryLabel.textAlignment = textAlignment
194
+ primaryLabel.attributedText = line(
195
+ column.string("text"),
196
+ font: nativeListFont(ofSize: 14, weight: .medium),
197
+ color: textColor(column.string("tone"), theme: theme),
198
+ height: 20
199
+ )
200
+
201
+ for badge in badges.prefix(2) {
202
+ let label = NativeListInsetLabel()
203
+ label.horizontalInset = 6
204
+ label.text = badge.string("text")
205
+ label.font = nativeListFont(ofSize: 10)
206
+ label.textColor = nativeListColor(theme, "info", "#0D74CE")
207
+ label.textAlignment = .center
208
+ label.backgroundColor = UIColor(nativeListHex: "#008FF519", fallback: .systemBlue)
209
+ label.layer.cornerRadius = 4
210
+ label.clipsToBounds = true
211
+ label.translatesAutoresizingMaskIntoConstraints = false
212
+ label.heightAnchor.constraint(equalToConstant: 16).isActive = true
213
+ badgesStack.addArrangedSubview(label)
214
+ }
215
+ badgesStack.isHidden = badges.isEmpty
216
+
217
+ let secondaryLeading = column.string("secondaryLeadingText")
218
+ if !secondaryLeading.isEmpty {
219
+ secondaryLeadingLabel.attributedText = line(
220
+ secondaryLeading,
221
+ font: nativeListFont(ofSize: 12),
222
+ color: nativeListColor(theme, "secondaryText", "#646464"),
223
+ height: 16
224
+ )
225
+ secondaryLeadingLabel.isHidden = false
226
+ secondaryLine.isHidden = false
227
+ }
228
+ let secondary = column.string("secondaryText")
229
+ if !secondary.isEmpty {
230
+ secondaryLabel.attributedText = line(
231
+ secondary,
232
+ font: nativeListFont(ofSize: 12),
233
+ color: textColor(
234
+ column.string("secondaryTone", default: "secondary"),
235
+ theme: theme
236
+ ),
237
+ height: 16
238
+ )
239
+ secondaryLabel.isHidden = false
240
+ secondaryLine.isHidden = false
241
+ }
242
+ }
243
+
244
+ private func line(_ text: String, font: UIFont, color: UIColor, height: CGFloat) -> NSAttributedString {
245
+ let paragraph = NSMutableParagraphStyle()
246
+ paragraph.minimumLineHeight = height
247
+ paragraph.maximumLineHeight = height
248
+ return NSAttributedString(
249
+ string: text,
250
+ attributes: [
251
+ .font: font,
252
+ .foregroundColor: color,
253
+ .paragraphStyle: paragraph,
254
+ ]
255
+ )
256
+ }
257
+
258
+ private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor {
259
+ switch tone {
260
+ case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
261
+ case "positive": return nativeListColor(theme, "positive", "#218358")
262
+ case "negative": return nativeListColor(theme, "negative", "#CE2C31")
263
+ default: return nativeListColor(theme, "primaryText", "#202020")
264
+ }
265
+ }
266
+ }
267
+
268
+ final class NativeListCell: UICollectionViewCell {
269
+ static let reuseIdentifier = "NativeListCell"
270
+
271
+ private let rootStack = UIStackView()
272
+ private let leadingImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) }
273
+ private let leadingOverlayBackground = UIView()
274
+ private let leadingCornerIconBackground = UIView()
275
+ private let leadingCornerIconImageView = UIImageView()
276
+ private let secondaryImage = OneKeyImageReusableView(frame: .zero)
277
+ private let mediaNetworkImage = OneKeyImageReusableView(frame: .zero)
278
+ private let fallbackLabel = UILabel()
279
+ private let leadingIconImageView = UIImageView()
280
+ private let favoriteIconImageView = UIImageView()
281
+ private let headerTitleIconImageView = UIImageView()
282
+ private let headerValueIconImageView = UIImageView()
283
+ private let leadingActionButton = UIButton(type: .system)
284
+ private let leadingContainer = UIView()
285
+ private let unreadDot = UIView()
286
+ private let mainStack = UIStackView()
287
+ private let mediaMetadataStack = UIStackView()
288
+ private let titleRowStack = UIStackView()
289
+ private let titleLabel = NativeListDottedUnderlineLabel()
290
+ private let subtitleLabel = UILabel()
291
+ private let tertiaryLabel = UILabel()
292
+ private let statusLabel = NativeListInsetLabel()
293
+ private let metricSubtitleLabel = UILabel()
294
+ private let metricCompositeStack = UIStackView()
295
+ private let badgeLabel = NativeListInsetLabel()
296
+ private let actionStack = UIStackView()
297
+ private let actionButtons = (0..<3).map { _ in UIButton(type: .system) }
298
+ private let trailingStack = UIStackView()
299
+ private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) }
300
+ private let checkboxButton = UIButton(type: .system)
301
+ private let spinner = UIActivityIndicatorView(style: .medium)
302
+ private let dataStack = UIStackView()
303
+ private let dataLabels = (0..<4).map { _ in UILabel() }
304
+ private let tableDataStack = UIStackView()
305
+ private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() }
306
+ private let mediaBadgeLabel = UILabel()
307
+ private let skeletonPrimary = UIView()
308
+ private let skeletonSecondary = UIView()
309
+ private var walletGroupCells: [NativeListCell] = []
310
+ private var walletGroupMembers: [NativeListItem] = []
311
+ private let walletGroupCompactContainer = UIView()
312
+ private var walletGroupCompactCell: NativeListCell?
313
+ private let walletGroupDragBadge = NativeListInsetLabel()
314
+ private var walletGroupCompactAppearanceActive = false
315
+ private lazy var walletGroupTap = UITapGestureRecognizer(
316
+ target: self,
317
+ action: #selector(walletGroupPressed(_:))
318
+ )
319
+ private let separatorView = UIView()
320
+ private var separatorLeadingConstraint: NSLayoutConstraint!
321
+ private var leadingWidth: NSLayoutConstraint!
322
+ private var leadingHeight: NSLayoutConstraint!
323
+ private var leadingIconWidth: NSLayoutConstraint!
324
+ private var leadingIconHeight: NSLayoutConstraint!
325
+ private var mediaHeight: NSLayoutConstraint!
326
+ private var secondaryWidth: NSLayoutConstraint!
327
+ private var secondaryHeight: NSLayoutConstraint!
328
+ private var rootLeadingConstraint: NSLayoutConstraint!
329
+ private var rootTrailingConstraint: NSLayoutConstraint!
330
+ private var rootTopConstraint: NSLayoutConstraint!
331
+ private var rootBottomConstraint: NSLayoutConstraint!
332
+ private var leadingSlotConstraints: [NSLayoutConstraint] = []
333
+ private var dataWeightConstraints: [NSLayoutConstraint] = []
334
+ private var accessorySizeConstraints: [NSLayoutConstraint] = []
335
+ private var currentItem: NativeListItem?
336
+ private var accessoryActions: [(String, NativeSelectionTarget?)] = []
337
+ private var footerActionKeys: [String] = []
338
+ private var checkboxAction: (String, NativeSelectionTarget?)?
339
+ private var boundCheckboxData: [String: Any]?
340
+ private var boundCheckboxTarget: NativeSelectionTarget?
341
+ private var leadingActionKey: String?
342
+ private var restingBackgroundColor: UIColor = .clear
343
+ private var pressedBackgroundColor = UIColor(
344
+ nativeListHex: "#E8E8E8",
345
+ fallback: .lightGray
346
+ )
347
+ private var checkboxCheckedColor = UIColor(nativeListHex: "#202020", fallback: .black)
348
+ private var checkboxUncheckedColor = UIColor(nativeListHex: "#FCFCFC", fallback: .white)
349
+ private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray)
350
+ private var visualBackdropColor = UIColor.white
351
+ private var currentLayout = "linear"
352
+ private var currentTheme: [String: Any]?
353
+ private var currentItemIndex: Int?
354
+ private(set) var bindingEpoch = 0
355
+
356
+ var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)?
357
+ var onBindingInvalidated: ((NativeListCell, Int) -> Void)?
358
+
359
+ override var isHighlighted: Bool {
360
+ didSet { updateBackgroundColor() }
361
+ }
362
+
363
+ override init(frame: CGRect) {
364
+ super.init(frame: frame)
365
+ contentView.addSubview(rootStack)
366
+ contentView.addSubview(separatorView)
367
+ contentView.addSubview(walletGroupCompactContainer)
368
+ rootStack.translatesAutoresizingMaskIntoConstraints = false
369
+ separatorView.translatesAutoresizingMaskIntoConstraints = false
370
+ walletGroupCompactContainer.translatesAutoresizingMaskIntoConstraints = false
371
+ rootLeadingConstraint = rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12)
372
+ rootTrailingConstraint = rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)
373
+ rootTopConstraint = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
374
+ rootBottomConstraint = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
375
+ leadingIconWidth = leadingIconImageView.widthAnchor.constraint(equalToConstant: 18)
376
+ leadingIconHeight = leadingIconImageView.heightAnchor.constraint(equalToConstant: 18)
377
+ separatorLeadingConstraint = separatorView.leadingAnchor.constraint(
378
+ equalTo: contentView.leadingAnchor
379
+ )
380
+ NSLayoutConstraint.activate([
381
+ rootLeadingConstraint,
382
+ rootTrailingConstraint,
383
+ rootTopConstraint,
384
+ rootBottomConstraint,
385
+ separatorLeadingConstraint,
386
+ separatorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
387
+ separatorView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
388
+ separatorView.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
389
+ walletGroupCompactContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
390
+ walletGroupCompactContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
391
+ walletGroupCompactContainer.topAnchor.constraint(equalTo: contentView.topAnchor),
392
+ walletGroupCompactContainer.heightAnchor.constraint(equalToConstant: 68),
393
+ ])
394
+ walletGroupCompactContainer.isHidden = true
395
+ walletGroupCompactContainer.isUserInteractionEnabled = false
396
+ favoriteIconImageView.translatesAutoresizingMaskIntoConstraints = false
397
+ favoriteIconImageView.contentMode = .scaleAspectFit
398
+ favoriteIconImageView.image = nativeListIcon(named: "StarOutline")
399
+ headerTitleIconImageView.translatesAutoresizingMaskIntoConstraints = false
400
+ headerTitleIconImageView.contentMode = .scaleAspectFit
401
+ headerValueIconImageView.translatesAutoresizingMaskIntoConstraints = false
402
+ headerValueIconImageView.contentMode = .scaleAspectFit
403
+ leadingActionButton.translatesAutoresizingMaskIntoConstraints = false
404
+ leadingActionButton.adjustsImageWhenDisabled = false
405
+ leadingActionButton.tintAdjustmentMode = .normal
406
+ leadingActionButton.addTarget(self, action: #selector(leadingActionPressed), for: .touchUpInside)
407
+ NSLayoutConstraint.activate([
408
+ favoriteIconImageView.widthAnchor.constraint(equalToConstant: 20),
409
+ favoriteIconImageView.heightAnchor.constraint(equalToConstant: 20),
410
+ headerTitleIconImageView.widthAnchor.constraint(equalToConstant: 12),
411
+ headerTitleIconImageView.heightAnchor.constraint(equalToConstant: 12),
412
+ headerValueIconImageView.widthAnchor.constraint(equalToConstant: 12),
413
+ headerValueIconImageView.heightAnchor.constraint(equalToConstant: 12),
414
+ leadingActionButton.widthAnchor.constraint(equalToConstant: 36),
415
+ leadingActionButton.heightAnchor.constraint(equalToConstant: 36),
416
+ ])
417
+ separatorView.isHidden = true
418
+ walletGroupTap.isEnabled = false
419
+ addGestureRecognizer(walletGroupTap)
420
+ rootStack.axis = .horizontal
421
+ rootStack.alignment = .center
422
+ rootStack.spacing = 12
423
+ rootStack.setCustomSpacing(12, after: leadingActionButton)
424
+ rootStack.distribution = .fill
425
+ rootLeadingConstraint.constant = 12
426
+ rootTrailingConstraint.constant = -12
427
+ rootTopConstraint.constant = 8
428
+ rootBottomConstraint.constant = -8
429
+ mainStack.alignment = .fill
430
+
431
+ leadingContainer.addSubview(fallbackLabel)
432
+ fallbackLabel.translatesAutoresizingMaskIntoConstraints = false
433
+ leadingContainer.addSubview(leadingIconImageView)
434
+ leadingIconImageView.translatesAutoresizingMaskIntoConstraints = false
435
+ leadingImages.enumerated().forEach { index, image in
436
+ image.translatesAutoresizingMaskIntoConstraints = false
437
+ if index == 1 {
438
+ leadingOverlayBackground.translatesAutoresizingMaskIntoConstraints = false
439
+ leadingContainer.addSubview(leadingOverlayBackground)
440
+ }
441
+ leadingContainer.addSubview(image)
442
+ }
443
+ leadingCornerIconBackground.translatesAutoresizingMaskIntoConstraints = false
444
+ leadingCornerIconImageView.translatesAutoresizingMaskIntoConstraints = false
445
+ leadingCornerIconBackground.addSubview(leadingCornerIconImageView)
446
+ leadingContainer.addSubview(leadingCornerIconBackground)
447
+ leadingCornerIconBackground.layer.cornerRadius = 10
448
+ leadingCornerIconBackground.isHidden = true
449
+ NSLayoutConstraint.activate([
450
+ leadingCornerIconBackground.widthAnchor.constraint(equalToConstant: 20),
451
+ leadingCornerIconBackground.heightAnchor.constraint(equalToConstant: 20),
452
+ leadingCornerIconBackground.trailingAnchor.constraint(
453
+ equalTo: leadingContainer.trailingAnchor,
454
+ constant: 4
455
+ ),
456
+ leadingCornerIconBackground.bottomAnchor.constraint(
457
+ equalTo: leadingContainer.bottomAnchor,
458
+ constant: 4
459
+ ),
460
+ leadingCornerIconImageView.widthAnchor.constraint(equalToConstant: 18),
461
+ leadingCornerIconImageView.heightAnchor.constraint(equalToConstant: 18),
462
+ leadingCornerIconImageView.centerXAnchor.constraint(
463
+ equalTo: leadingCornerIconBackground.centerXAnchor
464
+ ),
465
+ leadingCornerIconImageView.centerYAnchor.constraint(
466
+ equalTo: leadingCornerIconBackground.centerYAnchor
467
+ ),
468
+ ])
469
+ leadingOverlayBackground.layer.cornerRadius = 10
470
+ leadingOverlayBackground.isHidden = true
471
+ leadingCornerIconBackground.isHidden = true
472
+ leadingCornerIconImageView.image = nil
473
+ NSLayoutConstraint.activate([
474
+ fallbackLabel.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor),
475
+ fallbackLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
476
+ fallbackLabel.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
477
+ fallbackLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
478
+ leadingIconWidth,
479
+ leadingIconHeight,
480
+ leadingIconImageView.centerXAnchor.constraint(equalTo: leadingContainer.centerXAnchor),
481
+ leadingIconImageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor),
482
+ ])
483
+ leadingWidth = leadingContainer.widthAnchor.constraint(equalToConstant: 40)
484
+ leadingHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 40)
485
+ mediaHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 120)
486
+ leadingWidth.isActive = true
487
+ leadingHeight.isActive = true
488
+ fallbackLabel.textAlignment = .center
489
+ fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold)
490
+ fallbackLabel.textColor = UIColor(nativeListHex: "#8D8D8D", fallback: .gray)
491
+ secondaryImage.clipsToBounds = true
492
+ secondaryImage.layer.cornerRadius = 6
493
+ secondaryImage.translatesAutoresizingMaskIntoConstraints = false
494
+ secondaryWidth = secondaryImage.widthAnchor.constraint(equalToConstant: 56)
495
+ secondaryHeight = secondaryImage.heightAnchor.constraint(equalToConstant: 56)
496
+ mediaNetworkImage.translatesAutoresizingMaskIntoConstraints = false
497
+ NSLayoutConstraint.activate([
498
+ mediaNetworkImage.widthAnchor.constraint(equalToConstant: 14),
499
+ mediaNetworkImage.heightAnchor.constraint(equalToConstant: 14),
500
+ ])
501
+ mediaNetworkImage.layer.cornerRadius = 7
502
+ mediaNetworkImage.clipsToBounds = true
503
+
504
+ leadingContainer.addSubview(unreadDot)
505
+ unreadDot.translatesAutoresizingMaskIntoConstraints = false
506
+ unreadDot.layer.cornerRadius = 4
507
+ NSLayoutConstraint.activate([
508
+ unreadDot.widthAnchor.constraint(equalToConstant: 8),
509
+ unreadDot.heightAnchor.constraint(equalToConstant: 8),
510
+ unreadDot.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
511
+ unreadDot.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
512
+ ])
513
+
514
+ leadingContainer.addSubview(mediaBadgeLabel)
515
+ mediaBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
516
+ mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium)
517
+ mediaBadgeLabel.textAlignment = .center
518
+ mediaBadgeLabel.layer.cornerRadius = 10
519
+ mediaBadgeLabel.layer.borderWidth = 2
520
+ mediaBadgeLabel.clipsToBounds = true
521
+ NSLayoutConstraint.activate([
522
+ mediaBadgeLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
523
+ mediaBadgeLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
524
+ mediaBadgeLabel.heightAnchor.constraint(equalToConstant: 24),
525
+ ])
526
+
527
+ mainStack.axis = .vertical
528
+ mainStack.alignment = .fill
529
+ mainStack.spacing = 2
530
+ mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
531
+ mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
532
+ titleRowStack.axis = .horizontal
533
+ titleRowStack.alignment = .center
534
+ titleRowStack.spacing = 8
535
+ titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
536
+ titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
537
+ titleRowStack.addArrangedSubview(titleLabel)
538
+ titleRowStack.addArrangedSubview(badgeLabel)
539
+ titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
540
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
541
+ badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
542
+ badgeLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
543
+ titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
544
+ subtitleLabel.font = nativeListFont(ofSize: 14)
545
+ tertiaryLabel.font = nativeListFont(ofSize: 14)
546
+ statusLabel.font = nativeListFont(ofSize: 12)
547
+ metricSubtitleLabel.font = nativeListFont(ofSize: 12)
548
+ metricCompositeStack.axis = .vertical
549
+ metricCompositeStack.alignment = .fill
550
+ metricCompositeStack.spacing = 12
551
+ badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium)
552
+ [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel]
553
+ .forEach(mainStack.addArrangedSubview)
554
+
555
+ actionStack.axis = .horizontal
556
+ actionStack.alignment = .center
557
+ actionStack.spacing = 8
558
+ actionButtons.enumerated().forEach { index, button in
559
+ button.titleLabel?.font = nativeListFont(ofSize: 12, weight: .medium)
560
+ button.layer.cornerRadius = 8
561
+ button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
562
+ button.tag = index
563
+ button.addTarget(self, action: #selector(footerActionPressed(_:)), for: .touchUpInside)
564
+ actionStack.addArrangedSubview(button)
565
+ }
566
+ mainStack.addArrangedSubview(actionStack)
567
+
568
+ trailingStack.axis = .vertical
569
+ trailingStack.alignment = .trailing
570
+ trailingStack.spacing = 2
571
+ trailingStack.setContentHuggingPriority(.required, for: .horizontal)
572
+ trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal)
573
+ accessoryButtons.enumerated().forEach { index, button in
574
+ button.adjustsImageWhenDisabled = false
575
+ button.tintAdjustmentMode = .normal
576
+ button.titleLabel?.font = nativeListFont(
577
+ ofSize: index == 0 ? 16 : 14,
578
+ weight: index == 0 ? .medium : .regular
579
+ )
580
+ button.tag = index
581
+ button.addTarget(self, action: #selector(accessoryPressed(_:)), for: .touchUpInside)
582
+ trailingStack.addArrangedSubview(button)
583
+ }
584
+ checkboxButton.layer.borderWidth = 2
585
+ checkboxButton.layer.cornerRadius = 4
586
+ checkboxButton.tintColor = checkboxUncheckedColor
587
+ checkboxButton.imageView?.contentMode = .center
588
+ checkboxButton.addTarget(self, action: #selector(checkboxPressed), for: .touchUpInside)
589
+ NSLayoutConstraint.activate([
590
+ checkboxButton.widthAnchor.constraint(equalToConstant: 20),
591
+ checkboxButton.heightAnchor.constraint(equalToConstant: 20),
592
+ ])
593
+ trailingStack.addArrangedSubview(checkboxButton)
594
+ trailingStack.addArrangedSubview(spinner)
595
+
596
+ dataStack.axis = .horizontal
597
+ dataStack.alignment = .center
598
+ dataStack.distribution = .fillProportionally
599
+ dataStack.spacing = 8
600
+ dataLabels.forEach { label in
601
+ label.font = nativeListFont(ofSize: 12)
602
+ label.lineBreakMode = .byTruncatingTail
603
+ dataStack.addArrangedSubview(label)
604
+ }
605
+ tableDataStack.axis = .horizontal
606
+ tableDataStack.alignment = .center
607
+ tableDataStack.distribution = .fill
608
+ tableDataStack.spacing = 8
609
+ tableDataColumns.forEach(tableDataStack.addArrangedSubview)
610
+ }
611
+
612
+ required init?(coder: NSCoder) {
613
+ fatalError("init(coder:) has not been implemented")
614
+ }
615
+
616
+ override func layoutSubviews() {
617
+ super.layoutSubviews()
618
+ if currentItem?.type == "mediaTile" {
619
+ mediaHeight.constant = max(0, contentView.bounds.width - 20)
620
+ }
621
+ }
622
+
623
+ override func prepareForReuse() {
624
+ super.prepareForReuse()
625
+ invalidateCurrentBinding()
626
+ isHighlighted = false
627
+ restingBackgroundColor = .clear
628
+ contentView.backgroundColor = restingBackgroundColor
629
+ currentItem = nil
630
+ walletGroupCompactAppearanceActive = false
631
+ walletGroupCompactContainer.isHidden = true
632
+ walletGroupCompactContainer.alpha = 1
633
+ walletGroupCompactCell?.prepareForReuse()
634
+ walletGroupCells.forEach { $0.prepareForReuse() }
635
+ leadingImages.forEach { $0.prepareForReuse() }
636
+ secondaryImage.prepareForReuse()
637
+ mediaNetworkImage.prepareForReuse()
638
+ }
639
+
640
+ func bind(
641
+ item: NativeListItem,
642
+ theme: [String: Any]?,
643
+ layout: String,
644
+ itemIndex: Int?,
645
+ selected: Bool,
646
+ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
647
+ ) {
648
+ invalidateCurrentBinding()
649
+ bindingEpoch &+= 1
650
+ currentLayout = layout
651
+ currentTheme = theme
652
+ currentItemIndex = itemIndex
653
+ currentItem = item
654
+ leadingImages.forEach { $0.prepareForReuse() }
655
+ secondaryImage.prepareForReuse()
656
+ mediaNetworkImage.prepareForReuse()
657
+ reset()
658
+
659
+ let primary = nativeListColor(theme, "primaryText", "#202020")
660
+ let secondary = nativeListColor(theme, "secondaryText", "#646464")
661
+ let accent = nativeListColor(theme, "accent", "#108303")
662
+ checkboxCheckedColor = primary
663
+ checkboxUncheckedColor = nativeListColor(theme, "inverseText", "#FCFCFC")
664
+ // Checkbox uses the literal neutral7 alpha token. Applying opacity to the
665
+ // opaque primary text color produces a different RGB result.
666
+ checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray)
667
+ visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF")
668
+ titleLabel.textColor = primary
669
+ subtitleLabel.textColor = secondary
670
+ tertiaryLabel.textColor = secondary
671
+ statusLabel.textColor = secondary
672
+ metricSubtitleLabel.textColor = secondary
673
+ badgeLabel.textColor = accent
674
+ accessoryButtons.forEach { $0.setTitleColor(primary, for: .normal) }
675
+ unreadDot.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .systemRed)
676
+ mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020")
677
+ mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
678
+ mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor
679
+ separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0")
680
+ separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12
681
+ separatorView.isHidden = !item.data.bool("separator")
682
+ restingBackgroundColor = selectionBackgroundColor(
683
+ item: item,
684
+ theme: theme,
685
+ layout: layout,
686
+ itemIndex: itemIndex,
687
+ selected: selected
688
+ )
689
+ pressedBackgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8")
690
+ if item.type == "rail" {
691
+ pressedBackgroundColor = UIColor(nativeListHex: "#F0F0F0", fallback: .lightGray)
692
+ } else if item.type == "mediaTile" {
693
+ pressedBackgroundColor = restingBackgroundColor
694
+ }
695
+ updateBackgroundColor()
696
+ if layout == "table" {
697
+ if item.type == "dataRow" {
698
+ rootLeadingConstraint.constant = 20
699
+ rootTrailingConstraint.constant = -20
700
+ rootTopConstraint.constant = 10
701
+ rootBottomConstraint.constant = -10
702
+ rootStack.spacing = 10
703
+ } else {
704
+ rootLeadingConstraint.constant = 16
705
+ rootTrailingConstraint.constant = -16
706
+ }
707
+ }
708
+ applyGroupPosition(item.data.string("groupPosition"))
709
+ isUserInteractionEnabled = !item.data.bool("disabled")
710
+ contentView.alpha = isUserInteractionEnabled ? 1 : 0.5
711
+ accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title"))
712
+
713
+ switch item.type {
714
+ case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState)
715
+ case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState)
716
+ case "rail": bindRail(item, theme: theme)
717
+ case "activity": bindActivity(item, theme: theme)
718
+ case "message": bindMessage(item, theme: theme)
719
+ case "dataRow": bindDataRow(item, theme: theme, checkboxState)
720
+ case "mediaTile": bindMediaTile(item, theme: theme)
721
+ case "metricCard": bindMetricCard(item, theme: theme)
722
+ case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState)
723
+ case "action": bindAction(item, theme: theme, checkboxState)
724
+ case "system": bindSystem(item, theme: theme)
725
+ default: break
726
+ }
727
+ }
728
+
729
+ func updateSelection(
730
+ item: NativeListItem,
731
+ selected: Bool,
732
+ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
733
+ ) {
734
+ guard currentItem?.key == item.key else { return }
735
+ if item.type == "walletGroup" {
736
+ currentItem = item
737
+ let memberData = [item.data.dictionary("parent")].compactMap { $0 }
738
+ + item.data.dictionaries("children")
739
+ walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) }
740
+ walletGroupMembers.enumerated().forEach { index, member in
741
+ if walletGroupCells.indices.contains(index) {
742
+ walletGroupCells[index].updateSelection(
743
+ item: member,
744
+ selected: member.data.bool("selected"),
745
+ checkboxState: checkboxState
746
+ )
747
+ }
748
+ }
749
+ if let parent = walletGroupMembers.first {
750
+ walletGroupCompactCell?.updateSelection(
751
+ item: parent,
752
+ selected: parent.data.bool("selected"),
753
+ checkboxState: checkboxState
754
+ )
755
+ }
756
+ restingBackgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9")
757
+ pressedBackgroundColor = restingBackgroundColor
758
+ updateBackgroundColor()
759
+ return
760
+ }
761
+ if item.type == "sectionHeader", item.data.string("variant") == "summary" {
762
+ updateSummaryText(item)
763
+ }
764
+ currentItem = item
765
+ restingBackgroundColor = selectionBackgroundColor(
766
+ item: item,
767
+ theme: currentTheme,
768
+ layout: currentLayout,
769
+ itemIndex: currentItemIndex,
770
+ selected: selected
771
+ )
772
+ updateBackgroundColor()
773
+ if item.type == "identity", item.data.string("presentation") == "walletSidebar" {
774
+ titleLabel.textColor = nativeListColor(
775
+ currentTheme,
776
+ selected ? "primaryText" : "secondaryText",
777
+ selected ? "#FFFFFFED" : "#FFFFFFAF"
778
+ )
779
+ }
780
+ guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return }
781
+ updateCheckboxPresentation(
782
+ item,
783
+ data,
784
+ target: target,
785
+ state: checkboxState(item, target, data.string("state", default: "unchecked"))
786
+ )
787
+ }
788
+
789
+ private func updateSummaryText(_ item: NativeListItem) {
790
+ let title = item.data.string("title")
791
+ titleLabel.isHidden = title.isEmpty
792
+ setLineHeight(titleLabel, text: title, lineHeight: 24)
793
+
794
+ let value = item.data.string("value")
795
+ let valueButton = accessoryButtons[0]
796
+ valueButton.isHidden = value.isEmpty
797
+ if value.isEmpty {
798
+ valueButton.setTitle(nil, for: .normal)
799
+ valueButton.setAttributedTitle(nil, for: .normal)
800
+ } else {
801
+ setButtonLine(
802
+ valueButton,
803
+ text: value,
804
+ font: nativeListFont(ofSize: 16),
805
+ color: nativeListColor(currentTheme, "secondaryText", "#646464"),
806
+ lineHeight: 24
807
+ )
808
+ }
809
+ }
810
+
811
+ private func selectionBackgroundColor(
812
+ item: NativeListItem,
813
+ theme: [String: Any]?,
814
+ layout: String,
815
+ itemIndex: Int?,
816
+ selected: Bool
817
+ ) -> UIColor {
818
+ var color = nativeListColor(
819
+ theme,
820
+ selected ? "rowSelectedBackground" : "rowBackground",
821
+ selected ? "#F0F0F0" : "#FFFFFF"
822
+ )
823
+ if item.type == "metricCard", !selected {
824
+ color = nativeListColor(theme, "subduedBackground", "#F9F9F9")
825
+ } else if item.type == "rail" || item.type == "mediaTile" {
826
+ // Selection is communicated by the destination state for these source
827
+ // components; neither has a persistent selected tile background.
828
+ color = nativeListColor(theme, "rowBackground", "#FFFFFF")
829
+ } else if layout == "sectioned" {
830
+ // Checkbox-backed section lists in app-monorepo keep rows on $bg;
831
+ // selection is represented by the checkbox itself.
832
+ color = nativeListColor(theme, "rowBackground", "#FFFFFF")
833
+ } else if layout == "table",
834
+ item.type == "dataRow",
835
+ (item.data["index"] == nil ? itemIndex : item.data.int("index")).map({ $0 % 2 == 0 }) == true,
836
+ !selected {
837
+ color = nativeListColor(theme, "subduedBackground", "#F9F9F9")
838
+ }
839
+ return color
840
+ }
841
+
842
+ private func reset() {
843
+ walletGroupCompactCell?.invalidateCurrentBinding()
844
+ walletGroupCells.forEach { $0.invalidateCurrentBinding() }
845
+ isHighlighted = false
846
+ rootStack.arrangedSubviews.forEach {
847
+ rootStack.removeArrangedSubview($0)
848
+ $0.removeFromSuperview()
849
+ }
850
+ walletGroupMembers.removeAll()
851
+ walletGroupCompactAppearanceActive = false
852
+ walletGroupCompactContainer.isHidden = true
853
+ walletGroupCompactContainer.alpha = 1
854
+ walletGroupDragBadge.text = nil
855
+ rootStack.isHidden = false
856
+ rootStack.alpha = 1
857
+ walletGroupTap.isEnabled = false
858
+ contentView.layer.borderWidth = 0
859
+ contentView.layer.borderColor = nil
860
+ rootStack.axis = .horizontal
861
+ rootStack.alignment = .center
862
+ rootStack.spacing = 12
863
+ rootStack.setCustomSpacing(12, after: leadingActionButton)
864
+ rootStack.distribution = .fill
865
+ rootLeadingConstraint.constant = 12
866
+ rootTrailingConstraint.constant = -12
867
+ rootTopConstraint.constant = 8
868
+ rootBottomConstraint.constant = -8
869
+ mainStack.axis = .vertical
870
+ mainStack.alignment = .fill
871
+ mainStack.spacing = 2
872
+ mainStack.isHidden = false
873
+ mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
874
+ mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
875
+ titleRowStack.isHidden = false
876
+ titleRowStack.spacing = 8
877
+ trailingStack.isHidden = false
878
+ trailingStack.axis = .vertical
879
+ trailingStack.alignment = .trailing
880
+ trailingStack.spacing = 2
881
+ headerTitleIconImageView.removeFromSuperview()
882
+ headerTitleIconImageView.image = nil
883
+ headerTitleIconImageView.isHidden = true
884
+ headerValueIconImageView.removeFromSuperview()
885
+ headerValueIconImageView.image = nil
886
+ headerValueIconImageView.isHidden = true
887
+ mediaMetadataStack.removeArrangedSubview(subtitleLabel)
888
+ mediaMetadataStack.removeArrangedSubview(mediaNetworkImage)
889
+ mediaMetadataStack.removeFromSuperview()
890
+ mainStack.removeArrangedSubview(titleRowStack)
891
+ titleRowStack.removeFromSuperview()
892
+ mainStack.removeArrangedSubview(subtitleLabel)
893
+ subtitleLabel.removeFromSuperview()
894
+ mainStack.insertArrangedSubview(titleRowStack, at: 0)
895
+ mainStack.insertArrangedSubview(subtitleLabel, at: 1)
896
+ leadingWidth.constant = 40
897
+ leadingHeight.constant = 40
898
+ leadingIconWidth.constant = 18
899
+ leadingIconHeight.constant = 18
900
+ leadingWidth.isActive = true
901
+ leadingHeight.isActive = true
902
+ mediaHeight.isActive = false
903
+ secondaryWidth.isActive = false
904
+ secondaryHeight.isActive = false
905
+ NSLayoutConstraint.deactivate(leadingSlotConstraints)
906
+ leadingSlotConstraints.removeAll()
907
+ NSLayoutConstraint.deactivate(dataWeightConstraints)
908
+ dataWeightConstraints.removeAll()
909
+ NSLayoutConstraint.deactivate(accessorySizeConstraints)
910
+ accessorySizeConstraints.removeAll()
911
+ dataStack.distribution = .fill
912
+ leadingImages.forEach {
913
+ $0.isHidden = true
914
+ $0.alpha = 1
915
+ $0.layer.cornerRadius = 0
916
+ }
917
+ leadingContainer.clipsToBounds = true
918
+ leadingContainer.layer.borderWidth = 0
919
+ leadingContainer.layer.borderColor = nil
920
+ leadingOverlayBackground.isHidden = true
921
+ leadingIconImageView.isHidden = true
922
+ leadingIconImageView.image = nil
923
+ leadingCornerIconBackground.isHidden = true
924
+ leadingCornerIconImageView.image = nil
925
+ favoriteIconImageView.isHidden = true
926
+ favoriteIconImageView.image = nativeListIcon(named: "StarOutline")
927
+ leadingActionButton.isHidden = true
928
+ leadingActionButton.setImage(nil, for: .normal)
929
+ leadingActionButton.setImage(nil, for: .disabled)
930
+ unreadDot.isHidden = true
931
+ mediaBadgeLabel.isHidden = true
932
+ mediaBadgeLabel.text = nil
933
+ fallbackLabel.isHidden = false
934
+ fallbackLabel.text = nil
935
+ fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold)
936
+ secondaryImage.isHidden = false
937
+ secondaryImage.layer.borderWidth = 0
938
+ secondaryImage.layer.borderColor = nil
939
+ mediaNetworkImage.isHidden = true
940
+ titleLabel.text = nil
941
+ titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
942
+ titleLabel.lineBreakMode = .byTruncatingTail
943
+ titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
944
+ titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
945
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
946
+ titleLabel.textAlignment = .natural
947
+ subtitleLabel.text = nil
948
+ subtitleLabel.lineBreakMode = .byTruncatingTail
949
+ tertiaryLabel.text = nil
950
+ statusLabel.text = nil
951
+ statusLabel.topInset = 0
952
+ statusLabel.bottomInset = 0
953
+ metricSubtitleLabel.text = nil
954
+ metricCompositeStack.arrangedSubviews.forEach {
955
+ metricCompositeStack.removeArrangedSubview($0)
956
+ $0.removeFromSuperview()
957
+ }
958
+ mainStack.removeArrangedSubview(metricCompositeStack)
959
+ metricCompositeStack.removeFromSuperview()
960
+ badgeLabel.text = nil
961
+ badgeLabel.horizontalInset = 0
962
+ badgeLabel.topInset = 0
963
+ badgeLabel.bottomInset = 0
964
+ subtitleLabel.font = nativeListFont(ofSize: 14)
965
+ tertiaryLabel.font = nativeListFont(ofSize: 14)
966
+ statusLabel.font = nativeListFont(ofSize: 12)
967
+ badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium)
968
+ badgeLabel.backgroundColor = .clear
969
+ badgeLabel.layer.cornerRadius = 0
970
+ badgeLabel.clipsToBounds = false
971
+ [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel, badgeLabel, actionStack]
972
+ .forEach { $0.isHidden = true }
973
+ separatorView.isHidden = true
974
+ separatorLeadingConstraint.constant = 0
975
+ actionButtons.forEach {
976
+ $0.isHidden = true
977
+ $0.setTitle(nil, for: .normal)
978
+ $0.backgroundColor = .clear
979
+ }
980
+ accessoryButtons.enumerated().forEach { index, button in
981
+ button.titleLabel?.font = nativeListFont(
982
+ ofSize: index == 0 ? 16 : 14,
983
+ weight: index == 0 ? .medium : .regular
984
+ )
985
+ button.isHidden = true
986
+ button.setTitle(nil, for: .normal)
987
+ button.setAttributedTitle(nil, for: .normal)
988
+ button.setImage(nil, for: .normal)
989
+ button.setImage(nil, for: .disabled)
990
+ button.isEnabled = true
991
+ button.alpha = 1
992
+ button.titleLabel?.numberOfLines = 1
993
+ button.contentHorizontalAlignment = .center
994
+ button.backgroundColor = .clear
995
+ button.layer.cornerRadius = 0
996
+ button.contentEdgeInsets = .zero
997
+ }
998
+ checkboxButton.isHidden = true
999
+ checkboxButton.alpha = 1
1000
+ checkboxButton.backgroundColor = .clear
1001
+ checkboxButton.isEnabled = true
1002
+ checkboxButton.setImage(nil, for: .normal)
1003
+ spinner.stopAnimating()
1004
+ spinner.alpha = 1
1005
+ dataLabels.forEach {
1006
+ $0.isHidden = true
1007
+ $0.text = nil
1008
+ $0.attributedText = nil
1009
+ $0.numberOfLines = 1
1010
+ }
1011
+ tableDataColumns.forEach {
1012
+ $0.reset()
1013
+ $0.isHidden = true
1014
+ }
1015
+ skeletonPrimary.removeFromSuperview()
1016
+ skeletonSecondary.removeFromSuperview()
1017
+ accessoryActions = []
1018
+ footerActionKeys = []
1019
+ checkboxAction = nil
1020
+ boundCheckboxData = nil
1021
+ boundCheckboxTarget = nil
1022
+ leadingActionKey = nil
1023
+ layer.maskedCorners = []
1024
+ layer.cornerRadius = 0
1025
+ layer.cornerCurve = .circular
1026
+ contentView.layer.cornerRadius = 0
1027
+ contentView.layer.cornerCurve = .circular
1028
+ contentView.clipsToBounds = false
1029
+ leadingContainer.alpha = 1
1030
+ titleLabel.showsDottedUnderline = false
1031
+ titleLabel.dottedUnderlineVerticalOffset = 0
1032
+ }
1033
+
1034
+ private func bindWalletGroup(
1035
+ _ item: NativeListItem,
1036
+ theme: [String: Any]?,
1037
+ layout: String,
1038
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1039
+ ) {
1040
+ let memberData = [item.data.dictionary("parent")].compactMap { $0 }
1041
+ + item.data.dictionaries("children")
1042
+ walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) }
1043
+ if walletGroupCompactCell == nil {
1044
+ let compactCell = NativeListCell(frame: .zero)
1045
+ compactCell.translatesAutoresizingMaskIntoConstraints = false
1046
+ walletGroupCompactContainer.addSubview(compactCell)
1047
+ walletGroupCompactContainer.addSubview(walletGroupDragBadge)
1048
+ walletGroupDragBadge.translatesAutoresizingMaskIntoConstraints = false
1049
+ walletGroupDragBadge.horizontalInset = 6
1050
+ walletGroupDragBadge.textAlignment = .center
1051
+ walletGroupDragBadge.font = nativeListTabularFont(ofSize: 12, weight: .semibold)
1052
+ walletGroupDragBadge.layer.cornerRadius = 12
1053
+ walletGroupDragBadge.layer.masksToBounds = true
1054
+ walletGroupDragBadge.layer.borderWidth = 1
1055
+ NSLayoutConstraint.activate([
1056
+ compactCell.leadingAnchor.constraint(equalTo: walletGroupCompactContainer.leadingAnchor),
1057
+ compactCell.trailingAnchor.constraint(equalTo: walletGroupCompactContainer.trailingAnchor),
1058
+ compactCell.topAnchor.constraint(equalTo: walletGroupCompactContainer.topAnchor),
1059
+ compactCell.bottomAnchor.constraint(equalTo: walletGroupCompactContainer.bottomAnchor),
1060
+ walletGroupDragBadge.trailingAnchor.constraint(
1061
+ equalTo: walletGroupCompactContainer.trailingAnchor,
1062
+ constant: -4
1063
+ ),
1064
+ walletGroupDragBadge.bottomAnchor.constraint(
1065
+ equalTo: walletGroupCompactContainer.bottomAnchor,
1066
+ constant: -4
1067
+ ),
1068
+ walletGroupDragBadge.heightAnchor.constraint(equalToConstant: 24),
1069
+ walletGroupDragBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 24),
1070
+ ])
1071
+ walletGroupCompactCell = compactCell
1072
+ }
1073
+ while walletGroupCells.count < walletGroupMembers.count {
1074
+ let memberCell = NativeListCell(frame: .zero)
1075
+ memberCell.translatesAutoresizingMaskIntoConstraints = false
1076
+ memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true
1077
+ walletGroupCells.append(memberCell)
1078
+ }
1079
+ rootStack.axis = .vertical
1080
+ rootStack.alignment = .fill
1081
+ rootStack.spacing = 12
1082
+ rootLeadingConstraint.constant = 0
1083
+ rootTrailingConstraint.constant = 0
1084
+ rootTopConstraint.constant = 0
1085
+ rootBottomConstraint.constant = 0
1086
+ walletGroupMembers.enumerated().forEach { index, member in
1087
+ let memberCell = walletGroupCells[index]
1088
+ memberCell.onAction = { [weak self] source, action, target, origin in
1089
+ self?.onAction?(source, action, target, origin)
1090
+ }
1091
+ memberCell.onBindingInvalidated = { [weak self] cell, epoch in
1092
+ self?.onBindingInvalidated?(cell, epoch)
1093
+ }
1094
+ memberCell.bind(
1095
+ item: member,
1096
+ theme: theme,
1097
+ layout: layout,
1098
+ itemIndex: nil,
1099
+ selected: member.data.bool("selected"),
1100
+ checkboxState: checkboxState
1101
+ )
1102
+ rootStack.addArrangedSubview(memberCell)
1103
+ }
1104
+ if let parent = walletGroupMembers.first {
1105
+ walletGroupCompactCell?.bind(
1106
+ item: parent,
1107
+ theme: theme,
1108
+ layout: layout,
1109
+ itemIndex: nil,
1110
+ selected: parent.data.bool("selected"),
1111
+ checkboxState: checkboxState
1112
+ )
1113
+ }
1114
+ let childCount = item.data.dictionaries("children").count
1115
+ walletGroupDragBadge.text = childCount > 0 ? "+\(childCount)" : nil
1116
+ walletGroupDragBadge.isHidden = childCount == 0
1117
+ walletGroupDragBadge.backgroundColor = nativeListColor(
1118
+ theme,
1119
+ "inverseBackground",
1120
+ "#202020"
1121
+ )
1122
+ walletGroupDragBadge.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
1123
+ walletGroupDragBadge.layer.borderColor = nativeListColor(
1124
+ theme,
1125
+ "rowBackground",
1126
+ "#FFFFFF"
1127
+ ).cgColor
1128
+ restingBackgroundColor = nativeListColor(theme, "subduedBackground", "#F9F9F9")
1129
+ pressedBackgroundColor = restingBackgroundColor
1130
+ contentView.backgroundColor = restingBackgroundColor
1131
+ contentView.layer.borderWidth = 1
1132
+ contentView.layer.borderColor = nativeListColor(theme, "separator", "#E0E0E0").cgColor
1133
+ contentView.layer.cornerRadius = 20
1134
+ contentView.layer.cornerCurve = .continuous
1135
+ contentView.clipsToBounds = true
1136
+ walletGroupTap.isEnabled = true
1137
+ }
1138
+
1139
+ @objc private func walletGroupPressed(_ gesture: UITapGestureRecognizer) {
1140
+ let point = gesture.location(in: rootStack)
1141
+ for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated()
1142
+ where cell.frame.contains(point) {
1143
+ onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin())
1144
+ return
1145
+ }
1146
+ }
1147
+
1148
+ func setPressed(_ pressed: Bool) {
1149
+ if currentItem?.type == "walletGroup", walletGroupCompactAppearanceActive {
1150
+ walletGroupCompactCell?.setPressed(pressed)
1151
+ isHighlighted = false
1152
+ return
1153
+ }
1154
+ isHighlighted = pressed && isUserInteractionEnabled
1155
+ }
1156
+
1157
+ func setWalletGroupReorderCompact(_ compact: Bool) {
1158
+ guard currentItem?.type == "walletGroup" else { return }
1159
+ walletGroupCompactAppearanceActive = compact
1160
+ rootStack.isHidden = compact
1161
+ rootStack.alpha = compact ? 0 : 1
1162
+ walletGroupCompactContainer.isHidden = !compact
1163
+ walletGroupCompactContainer.alpha = compact ? 1 : 0
1164
+ if compact {
1165
+ contentView.backgroundColor = .clear
1166
+ contentView.layer.borderWidth = 0
1167
+ contentView.layer.borderColor = nil
1168
+ } else {
1169
+ restoreWalletGroupOuterAppearance()
1170
+ }
1171
+ }
1172
+
1173
+ func prepareWalletGroupReorderExpansion() {
1174
+ guard currentItem?.type == "walletGroup" else { return }
1175
+ walletGroupCompactAppearanceActive = false
1176
+ rootStack.isHidden = false
1177
+ rootStack.alpha = 0
1178
+ walletGroupCompactContainer.isHidden = false
1179
+ walletGroupCompactContainer.alpha = 1
1180
+ restoreWalletGroupOuterAppearance()
1181
+ }
1182
+
1183
+ func animateWalletGroupReorderExpansion() {
1184
+ guard currentItem?.type == "walletGroup" else { return }
1185
+ rootStack.alpha = 1
1186
+ walletGroupCompactContainer.alpha = 0
1187
+ }
1188
+
1189
+ func finishWalletGroupReorderExpansion() {
1190
+ guard currentItem?.type == "walletGroup" else { return }
1191
+ rootStack.isHidden = false
1192
+ rootStack.alpha = 1
1193
+ walletGroupCompactContainer.isHidden = true
1194
+ walletGroupCompactContainer.alpha = 1
1195
+ restoreWalletGroupOuterAppearance()
1196
+ }
1197
+
1198
+ private func restoreWalletGroupOuterAppearance() {
1199
+ contentView.backgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9")
1200
+ contentView.layer.borderWidth = 1
1201
+ contentView.layer.borderColor = nativeListColor(
1202
+ currentTheme,
1203
+ "separator",
1204
+ "#E0E0E0"
1205
+ ).cgColor
1206
+ }
1207
+
1208
+ private func updateBackgroundColor() {
1209
+ let pressed = isHighlighted && isUserInteractionEnabled
1210
+ contentView.backgroundColor = pressed ? pressedBackgroundColor : restingBackgroundColor
1211
+ if currentItem?.type == "walletGroup" {
1212
+ if walletGroupCompactAppearanceActive {
1213
+ contentView.backgroundColor = .clear
1214
+ contentView.layer.borderWidth = 0
1215
+ contentView.layer.borderColor = nil
1216
+ } else {
1217
+ restoreWalletGroupOuterAppearance()
1218
+ }
1219
+ layer.maskedCorners = [
1220
+ .layerMinXMinYCorner,
1221
+ .layerMaxXMinYCorner,
1222
+ .layerMinXMaxYCorner,
1223
+ .layerMaxXMaxYCorner,
1224
+ ]
1225
+ layer.cornerRadius = 20
1226
+ layer.cornerCurve = .continuous
1227
+ layer.masksToBounds = true
1228
+ contentView.layer.cornerRadius = 20
1229
+ contentView.layer.cornerCurve = .continuous
1230
+ contentView.clipsToBounds = true
1231
+ return
1232
+ }
1233
+ if currentItem?.type == "mediaTile" {
1234
+ // NFTListItem's group hover/press style belongs to the image wrapper,
1235
+ // not to the complete card.
1236
+ leadingContainer.alpha = pressed ? 0.8 : 1
1237
+ layer.maskedCorners = [
1238
+ .layerMinXMinYCorner,
1239
+ .layerMaxXMinYCorner,
1240
+ .layerMinXMaxYCorner,
1241
+ .layerMaxXMaxYCorner,
1242
+ ]
1243
+ layer.cornerRadius = 16
1244
+ layer.masksToBounds = true
1245
+ contentView.layer.cornerRadius = 16
1246
+ contentView.clipsToBounds = true
1247
+ return
1248
+ }
1249
+ if pressed {
1250
+ let isWalletSidebar = currentItem?.type == "identity"
1251
+ && currentItem?.data.string("presentation") == "walletSidebar"
1252
+ let radius: CGFloat
1253
+ if isWalletSidebar {
1254
+ radius = 20
1255
+ } else {
1256
+ radius = currentItem?.type == "rail" ? 8 : 12
1257
+ }
1258
+ layer.maskedCorners = [
1259
+ .layerMinXMinYCorner,
1260
+ .layerMaxXMinYCorner,
1261
+ .layerMinXMaxYCorner,
1262
+ .layerMaxXMaxYCorner,
1263
+ ]
1264
+ layer.cornerRadius = radius
1265
+ layer.cornerCurve = isWalletSidebar ? .continuous : .circular
1266
+ layer.masksToBounds = true
1267
+ contentView.layer.cornerRadius = radius
1268
+ contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular
1269
+ contentView.clipsToBounds = true
1270
+ } else {
1271
+ applyGroupPosition(currentItem?.data.string("groupPosition") ?? "")
1272
+ let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0
1273
+ contentView.layer.cornerRadius = restingRadius
1274
+ contentView.clipsToBounds = restingRadius > 0
1275
+ }
1276
+ }
1277
+
1278
+ private func bindIdentity(
1279
+ _ item: NativeListItem,
1280
+ theme: [String: Any]?,
1281
+ selected: Bool,
1282
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1283
+ ) {
1284
+ if item.data.string("presentation") == "walletSidebar" {
1285
+ rootStack.axis = .vertical
1286
+ rootStack.alignment = .center
1287
+ rootStack.spacing = 4
1288
+ rootLeadingConstraint.constant = 4
1289
+ rootTrailingConstraint.constant = -4
1290
+ rootTopConstraint.constant = 4
1291
+ rootBottomConstraint.constant = -4
1292
+ mainStack.alignment = .center
1293
+ mainStack.spacing = 0
1294
+ titleRowStack.setContentHuggingPriority(.required, for: .horizontal)
1295
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
1296
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1297
+ titleLabel.font = nativeListFont(ofSize: 12)
1298
+ titleLabel.textAlignment = .center
1299
+ fallbackLabel.font = nativeListFont(ofSize: 28)
1300
+ addLeading(item.data.dictionary("leading"), key: item.key)
1301
+ rootStack.addArrangedSubview(mainStack)
1302
+ show(titleLabel, item.data.string("title"), lines: 1)
1303
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16)
1304
+ titleLabel.textColor = nativeListColor(
1305
+ theme,
1306
+ selected ? "primaryText" : "secondaryText",
1307
+ selected ? "#FFFFFFED" : "#FFFFFFAF"
1308
+ )
1309
+ return
1310
+ }
1311
+ if item.data.string("presentation") == "accountSelector" {
1312
+ leadingWidth.constant = 32
1313
+ leadingHeight.constant = 32
1314
+ titleLabel.font = nativeListFont(ofSize: 16)
1315
+ }
1316
+ if item.data.dictionary("leading")?.string("kind") == "network" {
1317
+ leadingWidth.constant = 32
1318
+ leadingHeight.constant = 32
1319
+ }
1320
+ if let leadingAction = item.data.dictionary("leadingAction") {
1321
+ leadingActionButton.isHidden = false
1322
+ let tintColor = UIColor(
1323
+ nativeListHex: leadingAction.string("tintColor", default: "#646464"),
1324
+ fallback: .darkGray
1325
+ )
1326
+ leadingActionButton.tintColor = tintColor
1327
+ if let image = nativeListIcon(named: leadingAction.string("name")) {
1328
+ leadingActionButton.setImage(image, for: .normal)
1329
+ leadingActionButton.setImage(
1330
+ image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
1331
+ for: .disabled
1332
+ )
1333
+ }
1334
+ leadingActionButton.isEnabled = !leadingAction.bool("disabled")
1335
+ leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4
1336
+ leadingActionKey = leadingAction.string("actionKey")
1337
+ rootStack.addArrangedSubview(leadingActionButton)
1338
+ // ListItem.IconButton is a medium tertiary button: its 36-point frame
1339
+ // carries m=-7. The collection already contributes ListItem's outer
1340
+ // mx=8, so move the frame 7 points into that inset and reduce only the
1341
+ // following gap by 7. This preserves the source button, avatar and text
1342
+ // positions without shrinking the 24-point SVG glyph or its hit target.
1343
+ rootLeadingConstraint.constant = 5
1344
+ rootStack.setCustomSpacing(5, after: leadingActionButton)
1345
+ }
1346
+ addLeading(item.data.dictionary("leading"), key: item.key)
1347
+ rootStack.addArrangedSubview(mainStack)
1348
+ show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1))
1349
+ show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1))
1350
+ show(tertiaryLabel, item.data.string("tertiary"), lines: 1)
1351
+ if !item.data.string("subtitle").isEmpty, item.data.string("tertiary").isEmpty {
1352
+ mainStack.spacing = 0
1353
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
1354
+ setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20)
1355
+ }
1356
+ tertiaryLabel.textColor = nativeListColor(
1357
+ theme,
1358
+ item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText",
1359
+ item.data.string("tertiaryTone") == "info" ? "#0D74CE" : "#646464"
1360
+ )
1361
+ let badges = item.data.dictionaries("badges").prefix(2).map { $0.string("text") }
1362
+ if !badges.isEmpty {
1363
+ let badgeText = badges.joined(separator: " · ")
1364
+ show(badgeLabel, badgeText, lines: 1)
1365
+ badgeLabel.horizontalInset = 8
1366
+ badgeLabel.topInset = 2
1367
+ badgeLabel.bottomInset = 2
1368
+ setLineHeight(badgeLabel, text: badgeText, lineHeight: 16)
1369
+ badgeLabel.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
1370
+ badgeLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
1371
+ badgeLabel.layer.cornerRadius = 4
1372
+ badgeLabel.clipsToBounds = true
1373
+ }
1374
+ rootStack.addArrangedSubview(trailingStack)
1375
+ let accessories = item.data.dictionaries("trailing")
1376
+ if accessories.contains(where: { $0.string("kind") == "checkbox" }) &&
1377
+ accessories.contains(where: { $0.string("kind") == "value" }) {
1378
+ trailingStack.axis = .horizontal
1379
+ trailingStack.alignment = .center
1380
+ trailingStack.spacing = 12
1381
+ } else if accessories.count == 2,
1382
+ accessories[0].string("kind") == "valuePair",
1383
+ accessories[1].string("kind") == "menu" {
1384
+ trailingStack.axis = .horizontal
1385
+ trailingStack.alignment = .center
1386
+ trailingStack.spacing = 8
1387
+ } else if accessories.count == 2,
1388
+ accessories[0].string("kind") == "icon",
1389
+ accessories[0].string("name") == "PencilOutline",
1390
+ accessories[1].string("kind") == "icon",
1391
+ accessories[1].string("name") == "DragOutline" {
1392
+ trailingStack.axis = .horizontal
1393
+ trailingStack.alignment = .center
1394
+ // Each source IconButton has a 36-point frame and m=-7. XStack gap=$6
1395
+ // therefore places the physical frames 10 points apart (46-point
1396
+ // center distance), not 24 points apart.
1397
+ trailingStack.spacing = 10
1398
+ }
1399
+ bindAccessories(item, accessories, theme, checkboxState)
1400
+ }
1401
+
1402
+ private func bindRail(_ item: NativeListItem, theme: [String: Any]?) {
1403
+ rootStack.spacing = 6
1404
+ rootLeadingConstraint.constant = 4
1405
+ rootTrailingConstraint.constant = -4
1406
+ rootTopConstraint.constant = 4
1407
+ rootBottomConstraint.constant = -4
1408
+ leadingWidth.constant = 20
1409
+ leadingHeight.constant = 20
1410
+ addLeading(item.data.dictionary("visual"), key: item.key)
1411
+ show(titleLabel, item.data.string("title"), lines: 1)
1412
+ titleLabel.font = nativeListFont(ofSize: 12, weight: .medium)
1413
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16)
1414
+ mainStack.axis = .horizontal
1415
+ mainStack.alignment = .center
1416
+ mainStack.spacing = 6
1417
+ mainStack.setContentHuggingPriority(.required, for: .horizontal)
1418
+ mainStack.setContentCompressionResistancePriority(.required, for: .horizontal)
1419
+ titleRowStack.setContentHuggingPriority(.required, for: .horizontal)
1420
+ titleRowStack.setContentCompressionResistancePriority(.required, for: .horizontal)
1421
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1422
+ if let badge = item.data.dictionary("badge") {
1423
+ show(badgeLabel, badge.string("text"), lines: 1)
1424
+ badgeLabel.font = nativeListTabularFont(ofSize: 12, weight: .medium)
1425
+ badgeLabel.textColor = badge.string("tone") == "success"
1426
+ ? nativeListColor(theme, "positive", "#218358")
1427
+ : badge.string("tone") == "danger"
1428
+ ? nativeListColor(theme, "negative", "#CE2C31")
1429
+ : nativeListColor(theme, "secondaryText", "#646464")
1430
+ setLineHeight(badgeLabel, text: badge.string("text"), lineHeight: 16)
1431
+ }
1432
+ let status = item.data.string("status")
1433
+ if !status.isEmpty, status != "none" {
1434
+ show(statusLabel, status, lines: 1)
1435
+ statusLabel.font = nativeListTabularFont(ofSize: 12)
1436
+ setLineHeight(statusLabel, text: status, lineHeight: 16)
1437
+ }
1438
+ rootStack.addArrangedSubview(mainStack)
1439
+ contentView.layer.cornerRadius = 8
1440
+ contentView.clipsToBounds = true
1441
+ }
1442
+
1443
+ private func bindActivity(_ item: NativeListItem, theme: [String: Any]?) {
1444
+ addLeading(
1445
+ item.data.dictionary("leading"),
1446
+ secondaryVisual: item.data.dictionary("secondaryLeading"),
1447
+ key: item.key
1448
+ )
1449
+ rootStack.addArrangedSubview(mainStack)
1450
+ show(titleLabel, item.data.string("title"), lines: 1)
1451
+ show(subtitleLabel, item.data.string("description"), lines: 2)
1452
+ mainStack.spacing = 0
1453
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
1454
+ setLineHeight(subtitleLabel, text: item.data.string("description"), lineHeight: 20)
1455
+ if item.data.string("status") == "Failed" {
1456
+ show(badgeLabel, " Failed ", lines: 1)
1457
+ badgeLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
1458
+ badgeLabel.backgroundColor = nativeListColor(
1459
+ theme,
1460
+ "criticalBackground",
1461
+ "#F3000D14"
1462
+ )
1463
+ badgeLabel.layer.cornerRadius = 4
1464
+ badgeLabel.clipsToBounds = true
1465
+ } else {
1466
+ show(statusLabel, item.data.string("status"), lines: 1)
1467
+ }
1468
+ rootStack.addArrangedSubview(trailingStack)
1469
+ showAccessory(0, item.data.string("primaryAmount"))
1470
+ showAccessory(1, item.data.string("secondaryAmount"))
1471
+ let primaryAmountColor = item.data.string("primaryAmount").hasPrefix("+")
1472
+ ? nativeListColor(theme, "positive", "#218358")
1473
+ : nativeListColor(theme, "primaryText", "#202020")
1474
+ setButtonLine(
1475
+ accessoryButtons[0],
1476
+ text: item.data.string("primaryAmount"),
1477
+ font: nativeListTabularFont(ofSize: 16, weight: .medium),
1478
+ color: primaryAmountColor,
1479
+ lineHeight: 24
1480
+ )
1481
+ setButtonLine(
1482
+ accessoryButtons[1],
1483
+ text: item.data.string("secondaryAmount"),
1484
+ font: nativeListTabularFont(ofSize: 14),
1485
+ color: nativeListColor(theme, "secondaryText", "#646464"),
1486
+ lineHeight: 20
1487
+ )
1488
+ let actions = item.data.dictionaries("footerActions").prefix(3)
1489
+ if !actions.isEmpty {
1490
+ actionStack.isHidden = false
1491
+ for (index, action) in actions.enumerated() {
1492
+ let button = actionButtons[index]
1493
+ button.isHidden = false
1494
+ button.isEnabled = !action.bool("disabled")
1495
+ button.setTitle(action.string("label"), for: .normal)
1496
+ button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
1497
+ button.setTitleColor(
1498
+ action.string("tone") == "danger"
1499
+ ? nativeListColor(theme, "negative", "#CE2C31")
1500
+ : nativeListColor(theme, "primaryText", "#202020"),
1501
+ for: .normal
1502
+ )
1503
+ footerActionKeys.append(action.string("key"))
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ private func bindMessage(_ item: NativeListItem, theme: [String: Any]?) {
1509
+ rootStack.alignment = .top
1510
+ rootTopConstraint.constant = 16
1511
+ rootBottomConstraint.constant = -16
1512
+ leadingWidth.constant = 28
1513
+ leadingHeight.constant = 28
1514
+ if let leading = item.data.dictionary("leading") { addLeading(leading, key: item.key) }
1515
+ unreadDot.isHidden = !item.data.bool("unread")
1516
+ rootStack.addArrangedSubview(mainStack)
1517
+ show(titleLabel, item.data.string("title"), lines: 2)
1518
+ titleLabel.font = nativeListFont(ofSize: 14, weight: .semibold)
1519
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
1520
+ show(
1521
+ subtitleLabel,
1522
+ item.data.string("body"),
1523
+ lines: min(3, max(1, item.data.int("bodyLines", default: 3)))
1524
+ )
1525
+ subtitleLabel.font = nativeListFont(ofSize: 14)
1526
+ setLineHeight(subtitleLabel, text: item.data.string("body"), lineHeight: 20)
1527
+ show(statusLabel, item.data.string("time"), lines: 1)
1528
+ statusLabel.font = nativeListFont(ofSize: 12)
1529
+ statusLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
1530
+ statusLabel.topInset = 2
1531
+ setLineHeight(statusLabel, text: item.data.string("time"), lineHeight: 16)
1532
+ if let thumbnail = item.data.dictionary("thumbnail") {
1533
+ rootStack.addArrangedSubview(secondaryImage)
1534
+ secondaryWidth.constant = 64
1535
+ secondaryHeight.constant = 64
1536
+ secondaryWidth.isActive = true
1537
+ secondaryHeight.isActive = true
1538
+ secondaryImage.layer.borderWidth = 1 / UIScreen.main.scale
1539
+ secondaryImage.layer.borderColor = UIColor(
1540
+ nativeListHex: "#0000000F",
1541
+ fallback: .lightGray
1542
+ ).cgColor
1543
+ bindImage(thumbnail, into: secondaryImage, token: item.key, slot: 0, variant: "generic")
1544
+ }
1545
+ }
1546
+
1547
+ private func bindDataRow(
1548
+ _ item: NativeListItem,
1549
+ theme: [String: Any]?,
1550
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1551
+ ) {
1552
+ if item.data.bool("favorite") || item.data.bool("favoriteActive") {
1553
+ favoriteIconImageView.isHidden = false
1554
+ let favoriteActive = item.data.bool("favoriteActive")
1555
+ favoriteIconImageView.image = nativeListIcon(
1556
+ named: favoriteActive ? "StarSolid" : "StarOutline"
1557
+ )
1558
+ favoriteIconImageView.tintColor = nativeListColor(
1559
+ theme,
1560
+ favoriteActive ? "icon" : "iconSubdued",
1561
+ favoriteActive ? "#646464" : "#8D8D8D"
1562
+ )
1563
+ rootStack.addArrangedSubview(favoriteIconImageView)
1564
+ if currentLayout == "table" {
1565
+ rootStack.setCustomSpacing(8, after: favoriteIconImageView)
1566
+ }
1567
+ }
1568
+ rootStack.spacing = currentLayout == "table" ? 10 : 8
1569
+ if let leading = item.data.dictionary("leading") {
1570
+ leadingWidth.constant = 40
1571
+ leadingHeight.constant = 40
1572
+ addLeading(leading, key: item.key)
1573
+ }
1574
+ var hasLeadingAccessory = false
1575
+ if let checkbox = item.data.dictionary("checkbox") {
1576
+ bindCheckbox(item, checkbox, checkboxState)
1577
+ hasLeadingAccessory = true
1578
+ }
1579
+ if item.data["index"] != nil {
1580
+ showAccessory(0, String(item.data.int("index")))
1581
+ hasLeadingAccessory = true
1582
+ }
1583
+ if hasLeadingAccessory { rootStack.addArrangedSubview(trailingStack) }
1584
+ let columns = Array(item.data.dictionaries("columns").prefix(4))
1585
+ let rowBadges = item.data.dictionaries("badges")
1586
+ if currentLayout == "table" {
1587
+ rootStack.addArrangedSubview(tableDataStack)
1588
+ for (index, column) in columns.enumerated() {
1589
+ tableDataColumns[index].isHidden = false
1590
+ tableDataColumns[index].bind(
1591
+ column: column,
1592
+ badges: index == 0 ? rowBadges : [],
1593
+ theme: theme
1594
+ )
1595
+ }
1596
+ if let firstColumn = columns.first {
1597
+ let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1)))
1598
+ for index in 1..<columns.count {
1599
+ let weight = CGFloat(max(1, columns[index].int("weight", default: 1)))
1600
+ dataWeightConstraints.append(
1601
+ tableDataColumns[index].widthAnchor.constraint(
1602
+ equalTo: tableDataColumns[0].widthAnchor,
1603
+ multiplier: weight / firstWeight
1604
+ )
1605
+ )
1606
+ }
1607
+ NSLayoutConstraint.activate(dataWeightConstraints)
1608
+ }
1609
+ return
1610
+ }
1611
+
1612
+ rootStack.addArrangedSubview(dataStack)
1613
+ for (index, column) in columns.enumerated() {
1614
+ let label = dataLabels[index]
1615
+ label.isHidden = false
1616
+ let primaryColor = dataTextColor(column.string("tone"), theme: theme)
1617
+ let attributed = NSMutableAttributedString(
1618
+ string: column.string("text"),
1619
+ attributes: [
1620
+ .font: nativeListFont(ofSize: 14, weight: .medium),
1621
+ .foregroundColor: primaryColor,
1622
+ ]
1623
+ )
1624
+ if index == 0 {
1625
+ for badge in rowBadges.prefix(2) {
1626
+ attributed.append(NSAttributedString(
1627
+ string: " \(badge.string("text")) ",
1628
+ attributes: [
1629
+ .font: nativeListFont(ofSize: 12, weight: .medium),
1630
+ .foregroundColor: nativeListColor(theme, "info", "#0D74CE"),
1631
+ .backgroundColor: UIColor(nativeListHex: "#008FF519", fallback: .systemBlue),
1632
+ ]
1633
+ ))
1634
+ }
1635
+ }
1636
+ let secondaryText = column.string("secondaryText")
1637
+ if !secondaryText.isEmpty {
1638
+ attributed.append(NSAttributedString(
1639
+ string: "\n\(secondaryText)",
1640
+ attributes: [
1641
+ .font: nativeListFont(ofSize: 12),
1642
+ .foregroundColor: dataTextColor(
1643
+ column.string("secondaryTone", default: "secondary"),
1644
+ theme: theme
1645
+ ),
1646
+ ]
1647
+ ))
1648
+ label.numberOfLines = 2
1649
+ }
1650
+ label.attributedText = attributed
1651
+ label.textAlignment = column.string("alignment") == "end" ? .right : column.string("alignment") == "center" ? .center : .left
1652
+ label.setContentHuggingPriority(.defaultLow, for: .horizontal)
1653
+ }
1654
+ if let firstColumn = columns.first {
1655
+ let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1)))
1656
+ for index in 1..<columns.count {
1657
+ let weight = CGFloat(max(1, columns[index].int("weight", default: 1)))
1658
+ dataWeightConstraints.append(
1659
+ dataLabels[index].widthAnchor.constraint(
1660
+ equalTo: dataLabels[0].widthAnchor,
1661
+ multiplier: weight / firstWeight
1662
+ )
1663
+ )
1664
+ }
1665
+ NSLayoutConstraint.activate(dataWeightConstraints)
1666
+ }
1667
+ }
1668
+
1669
+ private func bindMediaTile(_ item: NativeListItem, theme: [String: Any]?) {
1670
+ rootStack.axis = .vertical
1671
+ rootStack.alignment = .fill
1672
+ rootStack.spacing = 8
1673
+ rootLeadingConstraint.constant = 10
1674
+ rootTrailingConstraint.constant = -10
1675
+ rootTopConstraint.constant = 10
1676
+ rootBottomConstraint.constant = -10
1677
+ leadingWidth.isActive = false
1678
+ leadingHeight.isActive = false
1679
+ mediaHeight.isActive = true
1680
+ let imageState = item.data.string("imageState")
1681
+ let imageVisual = imageState == "empty" || imageState == "error"
1682
+ ? nil
1683
+ : item.data.dictionary("image").map { ["kind": "image", "image": $0] }
1684
+ addLeading(imageVisual, key: item.key, to: rootStack)
1685
+ leadingContainer.layer.cornerRadius = 10
1686
+ leadingContainer.clipsToBounds = true
1687
+ if imageState == "empty" {
1688
+ leadingContainer.backgroundColor = .clear
1689
+ fallbackLabel.isHidden = true
1690
+ } else if imageState == "error" {
1691
+ leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#0000000F")
1692
+ fallbackLabel.isHidden = true
1693
+ leadingIconWidth.constant = 24
1694
+ leadingIconHeight.constant = 24
1695
+ leadingIconImageView.image = nativeListIcon(named: "ImageSquareWavesOutline")
1696
+ leadingIconImageView.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray)
1697
+ leadingIconImageView.isHidden = false
1698
+ }
1699
+ mainStack.removeArrangedSubview(titleRowStack)
1700
+ titleRowStack.removeFromSuperview()
1701
+ mainStack.removeArrangedSubview(subtitleLabel)
1702
+ subtitleLabel.removeFromSuperview()
1703
+ mediaMetadataStack.axis = .horizontal
1704
+ mediaMetadataStack.alignment = .center
1705
+ mediaMetadataStack.spacing = 8
1706
+ mediaMetadataStack.addArrangedSubview(subtitleLabel)
1707
+ subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1708
+ if let networkImage = item.data.dictionary("networkImage") {
1709
+ mediaMetadataStack.addArrangedSubview(mediaNetworkImage)
1710
+ mediaNetworkImage.isHidden = false
1711
+ bindImage(
1712
+ networkImage,
1713
+ into: mediaNetworkImage,
1714
+ token: item.key,
1715
+ slot: 2,
1716
+ variant: "network"
1717
+ )
1718
+ }
1719
+ mainStack.insertArrangedSubview(mediaMetadataStack, at: 0)
1720
+ mainStack.insertArrangedSubview(titleRowStack, at: 1)
1721
+ show(subtitleLabel, item.data.string("subtitle"), lines: 1)
1722
+ subtitleLabel.font = nativeListFont(ofSize: 12)
1723
+ show(titleLabel, item.data.string("title"), lines: 1)
1724
+ titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
1725
+ if let badge = item.data.dictionary("badge") {
1726
+ mediaBadgeLabel.text = " \(badge.string("text")) "
1727
+ mediaBadgeLabel.isHidden = false
1728
+ }
1729
+ rootStack.addArrangedSubview(mainStack)
1730
+ let closeAction = item.data.string("closeActionKey")
1731
+ if !closeAction.isEmpty {
1732
+ showAccessory(0, "×", action: (closeAction, nil))
1733
+ rootStack.addArrangedSubview(trailingStack)
1734
+ }
1735
+ }
1736
+
1737
+ private func bindMetricCard(_ item: NativeListItem, theme: [String: Any]?) {
1738
+ rootStack.axis = .vertical
1739
+ rootStack.alignment = .leading
1740
+ rootStack.spacing = 4
1741
+ rootLeadingConstraint.constant = 14
1742
+ rootTrailingConstraint.constant = -14
1743
+ rootTopConstraint.constant = 14
1744
+ rootBottomConstraint.constant = -14
1745
+ let variant = item.data.string("variant", default: "standard")
1746
+ if variant == "activity" || variant == "performance" {
1747
+ bindCompositeMetricCard(item, theme: theme, variant: variant)
1748
+ return
1749
+ }
1750
+ if let visual = item.data.dictionary("visual") {
1751
+ leadingWidth.constant = 32
1752
+ leadingHeight.constant = 32
1753
+ addLeading(visual, key: item.key, to: rootStack)
1754
+ leadingContainer.clipsToBounds = true
1755
+ }
1756
+ show(subtitleLabel, item.data.string("title"), lines: 1)
1757
+ subtitleLabel.font = nativeListFont(ofSize: 11)
1758
+ subtitleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
1759
+ show(titleLabel, item.data.string("value"), lines: 1)
1760
+ titleLabel.font = nativeListFont(
1761
+ ofSize: item.data.string("size") == "large" ? 24 : 18,
1762
+ weight: .semibold
1763
+ )
1764
+ show(statusLabel, item.data.string("trend"), lines: 1)
1765
+ let tone = item.data.string("trendTone", default: "neutral")
1766
+ if tone == "positive" {
1767
+ statusLabel.textColor = nativeListColor(theme, "positive", "#218358")
1768
+ } else if tone == "negative" {
1769
+ statusLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
1770
+ }
1771
+ show(metricSubtitleLabel, item.data.string("subtitle"), lines: 1)
1772
+ if let badge = item.data.dictionary("badge") {
1773
+ show(badgeLabel, badge.string("text"), lines: 1)
1774
+ }
1775
+ rootStack.addArrangedSubview(mainStack)
1776
+ contentView.layer.cornerRadius = 12
1777
+ contentView.clipsToBounds = true
1778
+ }
1779
+
1780
+ private func bindCompositeMetricCard(
1781
+ _ item: NativeListItem,
1782
+ theme: [String: Any]?,
1783
+ variant: String
1784
+ ) {
1785
+ rootStack.alignment = .fill
1786
+ show(titleLabel, item.data.string("title"), lines: 1)
1787
+ titleLabel.font = nativeListFont(ofSize: 11)
1788
+ titleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
1789
+ setLineHeight(
1790
+ titleLabel,
1791
+ text: item.data.string("title").uppercased(),
1792
+ lineHeight: 14,
1793
+ letterSpacing: 1.2
1794
+ )
1795
+ mainStack.spacing = 14
1796
+ metricCompositeStack.spacing = 14
1797
+ mainStack.addArrangedSubview(metricCompositeStack)
1798
+ rootStack.addArrangedSubview(mainStack)
1799
+
1800
+ let metrics = item.data.dictionaries("metrics")
1801
+ let topCount = min(2, metrics.count)
1802
+ if variant == "activity" {
1803
+ metricCompositeStack.addArrangedSubview(
1804
+ makeMetricRow(
1805
+ Array(metrics.prefix(topCount)),
1806
+ theme: theme,
1807
+ style: "activityHero"
1808
+ )
1809
+ )
1810
+ let divider = UIView()
1811
+ divider.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0")
1812
+ divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
1813
+ metricCompositeStack.addArrangedSubview(divider)
1814
+ metricCompositeStack.addArrangedSubview(
1815
+ makeMetricRow(
1816
+ Array(metrics.dropFirst(topCount)),
1817
+ theme: theme,
1818
+ style: "compact"
1819
+ )
1820
+ )
1821
+ } else {
1822
+ let performanceSummary = UIStackView()
1823
+ performanceSummary.axis = .vertical
1824
+ performanceSummary.alignment = .fill
1825
+ performanceSummary.spacing = 8
1826
+ performanceSummary.addArrangedSubview(
1827
+ makeMetricRow(
1828
+ Array(metrics.prefix(topCount)),
1829
+ theme: theme,
1830
+ style: "performanceHero"
1831
+ )
1832
+ )
1833
+ let progress = min(1, max(0, item.data.double("progress")))
1834
+ let progressRow = UIStackView()
1835
+ progressRow.axis = .horizontal
1836
+ progressRow.spacing = 0
1837
+ progressRow.layer.cornerRadius = 2
1838
+ progressRow.clipsToBounds = true
1839
+ let wins = UIView()
1840
+ wins.backgroundColor = nativeListColor(theme, "positive", "#218358")
1841
+ let losses = UIView()
1842
+ losses.backgroundColor = nativeListColor(theme, "negative", "#CE2C31")
1843
+ progressRow.addArrangedSubview(wins)
1844
+ progressRow.addArrangedSubview(losses)
1845
+ progressRow.heightAnchor.constraint(equalToConstant: 4).isActive = true
1846
+ if progress <= 0 {
1847
+ wins.isHidden = true
1848
+ } else if progress >= 1 {
1849
+ losses.isHidden = true
1850
+ } else {
1851
+ wins.widthAnchor.constraint(
1852
+ equalTo: progressRow.widthAnchor,
1853
+ multiplier: CGFloat(progress)
1854
+ ).isActive = true
1855
+ }
1856
+ performanceSummary.addArrangedSubview(progressRow)
1857
+ metricCompositeStack.addArrangedSubview(performanceSummary)
1858
+ metricCompositeStack.addArrangedSubview(
1859
+ makeMetricRow(
1860
+ Array(metrics.dropFirst(topCount)),
1861
+ theme: theme,
1862
+ style: "compactShaded"
1863
+ )
1864
+ )
1865
+ }
1866
+ contentView.layer.cornerRadius = 12
1867
+ contentView.clipsToBounds = true
1868
+ }
1869
+
1870
+ private func makeMetricRow(
1871
+ _ metrics: [[String: Any]],
1872
+ theme: [String: Any]?,
1873
+ style: String
1874
+ ) -> UIStackView {
1875
+ let shaded = style == "compactShaded"
1876
+ let row = UIStackView()
1877
+ row.axis = .horizontal
1878
+ row.alignment = style.hasSuffix("Hero") ? .bottom : .fill
1879
+ row.distribution = .fillEqually
1880
+ row.spacing = shaded ? 8 : 12
1881
+ metrics.enumerated().forEach { index, metric in
1882
+ let column = UIStackView()
1883
+ column.axis = .vertical
1884
+ column.alignment = shaded
1885
+ ? .leading
1886
+ : index == 0 ? .leading : index == metrics.count - 1 ? .trailing : .center
1887
+ column.spacing = 2
1888
+ if shaded {
1889
+ column.isLayoutMarginsRelativeArrangement = true
1890
+ column.layoutMargins = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
1891
+ column.backgroundColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray)
1892
+ column.layer.cornerRadius = 8
1893
+ }
1894
+ let label = UILabel()
1895
+ label.font = nativeListFont(ofSize: 11)
1896
+ label.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
1897
+ setLineHeight(label, text: metric.string("label"), lineHeight: 14)
1898
+ let value = UILabel()
1899
+ let valueSize: CGFloat
1900
+ let valueLineHeight: CGFloat
1901
+ let valueWeight: NativeListFontWeight
1902
+ switch style {
1903
+ case "activityHero" where index == 0:
1904
+ valueSize = 16
1905
+ valueLineHeight = 24
1906
+ valueWeight = .semibold
1907
+ case "performanceHero" where index == 0:
1908
+ valueSize = 18
1909
+ valueLineHeight = 24
1910
+ valueWeight = .semibold
1911
+ case "activityHero", "performanceHero":
1912
+ valueSize = 14
1913
+ valueLineHeight = 20
1914
+ valueWeight = .semibold
1915
+ default:
1916
+ valueSize = 14
1917
+ valueLineHeight = 20
1918
+ valueWeight = .medium
1919
+ }
1920
+ value.font = nativeListTabularFont(ofSize: valueSize, weight: valueWeight)
1921
+ value.textColor = dataTextColor(metric.string("tone"), theme: theme)
1922
+ setLineHeight(value, text: metric.string("value"), lineHeight: valueLineHeight)
1923
+ column.addArrangedSubview(label)
1924
+ if let visual = metric.dictionary("visual") {
1925
+ let valueRow = UIStackView()
1926
+ valueRow.axis = .horizontal
1927
+ valueRow.alignment = .center
1928
+ valueRow.spacing = 6
1929
+ valueRow.addArrangedSubview(
1930
+ makeMetricVisual(visual, key: metric.string("key"), slot: index)
1931
+ )
1932
+ valueRow.addArrangedSubview(value)
1933
+ column.addArrangedSubview(valueRow)
1934
+ } else {
1935
+ column.addArrangedSubview(value)
1936
+ }
1937
+ row.addArrangedSubview(column)
1938
+ }
1939
+ return row
1940
+ }
1941
+
1942
+ private func makeMetricVisual(
1943
+ _ visual: [String: Any],
1944
+ key: String,
1945
+ slot: Int
1946
+ ) -> UIView {
1947
+ let container = UIView()
1948
+ container.translatesAutoresizingMaskIntoConstraints = false
1949
+ container.backgroundColor = UIColor(
1950
+ nativeListHex: visual.string("backgroundColor", default: "#0000000F"),
1951
+ fallback: .lightGray
1952
+ )
1953
+ container.layer.cornerRadius = visual.string("shape") == "square" ? 0 : 8
1954
+ container.clipsToBounds = true
1955
+ NSLayoutConstraint.activate([
1956
+ container.widthAnchor.constraint(equalToConstant: 16),
1957
+ container.heightAnchor.constraint(equalToConstant: 16),
1958
+ ])
1959
+
1960
+ if visual.string("kind") == "icon" {
1961
+ let imageView = UIImageView(image: nativeListIcon(named: visual.string("name")))
1962
+ imageView.translatesAutoresizingMaskIntoConstraints = false
1963
+ imageView.tintColor = UIColor(
1964
+ nativeListHex: visual.string("tintColor", default: "#00000072"),
1965
+ fallback: .darkGray
1966
+ )
1967
+ imageView.contentMode = .scaleAspectFit
1968
+ container.addSubview(imageView)
1969
+ NSLayoutConstraint.activate([
1970
+ imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
1971
+ imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
1972
+ imageView.topAnchor.constraint(equalTo: container.topAnchor),
1973
+ imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
1974
+ ])
1975
+ } else if let source = visualSources(visual).first {
1976
+ let imageView = OneKeyImageReusableView(frame: .zero)
1977
+ imageView.translatesAutoresizingMaskIntoConstraints = false
1978
+ imageView.clipsToBounds = true
1979
+ imageView.layer.cornerRadius = container.layer.cornerRadius
1980
+ container.addSubview(imageView)
1981
+ NSLayoutConstraint.activate([
1982
+ imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
1983
+ imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
1984
+ imageView.topAnchor.constraint(equalTo: container.topAnchor),
1985
+ imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
1986
+ ])
1987
+ bindImage(source.data, into: imageView, token: key, slot: slot, variant: source.variant)
1988
+ }
1989
+ return container
1990
+ }
1991
+
1992
+ private func bindSectionHeader(
1993
+ _ item: NativeListItem,
1994
+ theme: [String: Any]?,
1995
+ layout: String,
1996
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1997
+ ) {
1998
+ rootStack.addArrangedSubview(mainStack)
1999
+ let variant = item.data.string("variant")
2000
+ let isSummary = variant == "summary"
2001
+ let isGallery = variant == "gallery"
2002
+ let isTable = layout == "table"
2003
+ let isNetworkSelector = item.data.string("presentation") == "networkSelector"
2004
+ let isHistory = variant == "history" ||
2005
+ item.key.hasPrefix("history-") ||
2006
+ (item.sectionKey?.hasPrefix("history-") ?? false)
2007
+ let headerWeight: NativeListFontWeight = isSummary
2008
+ ? .medium
2009
+ : isNetworkSelector ? .medium
2010
+ : isGallery || layout == "sectioned" ? .semibold : .regular
2011
+ titleLabel.font = nativeListFont(
2012
+ ofSize: isGallery ? 18 : isSummary ? 16 : isTable ? 11 : isHistory ? 12 : 14,
2013
+ weight: isHistory ? .semibold : headerWeight
2014
+ )
2015
+ titleLabel.textColor = nativeListColor(
2016
+ theme,
2017
+ isSummary || isGallery ? "primaryText" : "secondaryText",
2018
+ isSummary || isGallery ? "#202020" : "#646464"
2019
+ )
2020
+ show(titleLabel, item.data.string("title"), lines: 1)
2021
+ if isNetworkSelector {
2022
+ titleLabel.showsDottedUnderline = true
2023
+ titleLabel.dottedUnderlineVerticalOffset = 2
2024
+ titleLabel.dottedUnderlineColor = nativeListColor(
2025
+ theme,
2026
+ "secondaryText",
2027
+ "#646464"
2028
+ )
2029
+ rootLeadingConstraint.constant = 12
2030
+ rootTrailingConstraint.constant = -12
2031
+ rootTopConstraint.constant = 12
2032
+ rootBottomConstraint.constant = -15
2033
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
2034
+ } else if isHistory {
2035
+ rootLeadingConstraint.constant = 0
2036
+ rootTrailingConstraint.constant = 0
2037
+ rootTopConstraint.constant = 0
2038
+ rootBottomConstraint.constant = 0
2039
+ setLineHeight(
2040
+ titleLabel,
2041
+ text: item.data.string("title").uppercased(),
2042
+ lineHeight: 16,
2043
+ letterSpacing: 0.8
2044
+ )
2045
+ } else if !isTable && !isGallery && !isSummary {
2046
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
2047
+ if layout == "linear" {
2048
+ // TokenManager's section title is a plain body label with mt=10,
2049
+ // unlike the reusable 36-point SectionHeader used by sectioned lists.
2050
+ titleLabel.font = nativeListFont(ofSize: 14)
2051
+ rootTopConstraint.constant = 10
2052
+ rootBottomConstraint.constant = 0
2053
+ }
2054
+ }
2055
+ if isTable {
2056
+ mainStack.alignment = .leading
2057
+ titleRowStack.spacing = 4
2058
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
2059
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14)
2060
+ rootTopConstraint.constant = 12
2061
+ rootBottomConstraint.constant = -2
2062
+ if let icon = item.data.dictionary("titleIcon") {
2063
+ headerTitleIconImageView.image = nativeListIcon(named: icon.string("name"))
2064
+ headerTitleIconImageView.tintColor = UIColor(
2065
+ nativeListHex: icon.string("tintColor", default: "#646464"),
2066
+ fallback: .darkGray
2067
+ )
2068
+ headerTitleIconImageView.isHidden = false
2069
+ titleRowStack.insertArrangedSubview(headerTitleIconImageView, at: 1)
2070
+ if icon.string("name") != "ChevronGrabberVerOutline" {
2071
+ titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020")
2072
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14)
2073
+ }
2074
+ }
2075
+ }
2076
+ if isGallery {
2077
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
2078
+ rootTopConstraint.constant = 0
2079
+ rootBottomConstraint.constant = -8
2080
+ }
2081
+ show(subtitleLabel, item.data.string("subtitle"), lines: 1)
2082
+ if isSummary {
2083
+ titleLabel.showsDottedUnderline = true
2084
+ titleLabel.dottedUnderlineColor = nativeListColor(
2085
+ theme,
2086
+ "secondaryText",
2087
+ "#646464"
2088
+ )
2089
+ rootStack.addArrangedSubview(trailingStack)
2090
+ // The surrounding Stack contributes mt=16/pb=12 while its XStack uses
2091
+ // px=20/py=8. The collection's 8-point inset supplies the outer 8 points
2092
+ // of horizontal padding, so the cell supplies the remaining 12.
2093
+ rootLeadingConstraint.constant = 12
2094
+ rootTrailingConstraint.constant = -12
2095
+ rootTopConstraint.constant = 24
2096
+ rootBottomConstraint.constant = -20
2097
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
2098
+ let valueActionKey = item.data.string("valueActionKey")
2099
+ let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty
2100
+ ? nil
2101
+ : (valueActionKey, nil)
2102
+ showAccessory(
2103
+ 0,
2104
+ item.data.string("value"),
2105
+ action: action,
2106
+ color: nativeListColor(theme, "secondaryText", "#646464")
2107
+ )
2108
+ accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16)
2109
+ setButtonLine(
2110
+ accessoryButtons[0],
2111
+ text: item.data.string("value"),
2112
+ font: nativeListFont(ofSize: 16),
2113
+ color: nativeListColor(theme, "secondaryText", "#646464"),
2114
+ lineHeight: 24
2115
+ )
2116
+ } else if !isGallery && !isHistory {
2117
+ rootStack.addArrangedSubview(trailingStack)
2118
+ showAccessory(0, item.data.string("value"))
2119
+ if isTable {
2120
+ trailingStack.axis = .horizontal
2121
+ trailingStack.alignment = .center
2122
+ trailingStack.spacing = 4
2123
+ accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 11)
2124
+ accessoryButtons[0].setTitleColor(
2125
+ nativeListColor(theme, "secondaryText", "#646464"),
2126
+ for: .normal
2127
+ )
2128
+ if let icon = item.data.dictionary("valueIcon") {
2129
+ headerValueIconImageView.image = nativeListIcon(named: icon.string("name"))
2130
+ headerValueIconImageView.tintColor = UIColor(
2131
+ nativeListHex: icon.string("tintColor", default: "#646464"),
2132
+ fallback: .darkGray
2133
+ )
2134
+ headerValueIconImageView.isHidden = false
2135
+ trailingStack.addArrangedSubview(headerValueIconImageView)
2136
+ if icon.string("name") != "ChevronGrabberVerOutline" {
2137
+ accessoryButtons[0].setTitleColor(
2138
+ nativeListColor(theme, "primaryText", "#202020"),
2139
+ for: .normal
2140
+ )
2141
+ }
2142
+ }
2143
+ }
2144
+ if let checkbox = item.data.dictionary("checkbox") {
2145
+ bindCheckbox(item, checkbox, checkboxState)
2146
+ }
2147
+ if item.data.dictionary("checkbox") != nil && !item.data.string("value").isEmpty {
2148
+ titleLabel.showsDottedUnderline = true
2149
+ titleLabel.dottedUnderlineColor = nativeListColor(
2150
+ theme,
2151
+ "secondaryText",
2152
+ "#646464"
2153
+ )
2154
+ trailingStack.axis = .horizontal
2155
+ trailingStack.alignment = .center
2156
+ trailingStack.spacing = 12
2157
+ rootTopConstraint.constant = 8
2158
+ rootBottomConstraint.constant = -8
2159
+ accessoryButtons[0].titleLabel?.font = nativeListTabularFont(
2160
+ ofSize: 16,
2161
+ weight: .medium
2162
+ )
2163
+ setButtonLine(
2164
+ accessoryButtons[0],
2165
+ text: item.data.string("value"),
2166
+ font: nativeListTabularFont(ofSize: 16, weight: .medium),
2167
+ color: nativeListColor(theme, "primaryText", "#202020"),
2168
+ lineHeight: 24
2169
+ )
2170
+ }
2171
+ }
2172
+ }
2173
+
2174
+ private func bindAction(
2175
+ _ item: NativeListItem,
2176
+ theme: [String: Any]?,
2177
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
2178
+ ) {
2179
+ let isAccountSelector = item.data.string("presentation") == "accountSelector"
2180
+ if let icon = item.data.dictionary("icon") {
2181
+ leadingWidth.constant = isAccountSelector ? 32 : 40
2182
+ leadingHeight.constant = isAccountSelector ? 32 : 40
2183
+ leadingIconWidth.constant = 24
2184
+ leadingIconHeight.constant = 24
2185
+ addLeading(icon, key: item.key)
2186
+ if isAccountSelector {
2187
+ leadingContainer.layer.cornerCurve = .continuous
2188
+ }
2189
+ if icon["backgroundColor"] == nil {
2190
+ leadingContainer.backgroundColor = .clear
2191
+ leadingContainer.layer.borderWidth = 0
2192
+ leadingContainer.layer.borderColor = nil
2193
+ }
2194
+ }
2195
+ rootStack.addArrangedSubview(mainStack)
2196
+ show(titleLabel, item.data.string("title"), lines: 1)
2197
+ if isAccountSelector {
2198
+ titleLabel.font = nativeListFont(ofSize: 16)
2199
+ titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
2200
+ } else if item.data.string("tone") == "danger" {
2201
+ titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
2202
+ }
2203
+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
2204
+ if let checkbox = item.data.dictionary("checkbox") {
2205
+ bindCheckbox(item, checkbox, checkboxState)
2206
+ rootStack.addArrangedSubview(trailingStack)
2207
+ } else {
2208
+ let accessories = item.data.dictionaries("trailing")
2209
+ if !accessories.isEmpty {
2210
+ rootStack.addArrangedSubview(trailingStack)
2211
+ bindAccessories(item, accessories, theme, checkboxState)
2212
+ if accessories.count == 1, accessories[0].string("kind") == "chevron" {
2213
+ // ListItem.DrillIn uses mx=-6 around its 24-point icon.
2214
+ rootTrailingConstraint.constant = -6
2215
+ }
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor {
2221
+ switch tone.isEmpty ? "primary" : tone {
2222
+ case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
2223
+ case "positive": return nativeListColor(theme, "positive", "#218358")
2224
+ case "negative": return nativeListColor(theme, "negative", "#CE2C31")
2225
+ default: return nativeListColor(theme, "primaryText", "#202020")
2226
+ }
2227
+ }
2228
+
2229
+ private func bindSystem(_ item: NativeListItem, theme: [String: Any]?) {
2230
+ rootStack.alignment = .center
2231
+ rootStack.distribution = .fill
2232
+ let variant = item.data.string("variant")
2233
+ if variant == "loading" {
2234
+ leadingWidth.constant = 40
2235
+ leadingHeight.constant = 40
2236
+ leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
2237
+ leadingContainer.layer.cornerRadius = 20
2238
+ rootStack.addArrangedSubview(leadingContainer)
2239
+ configureSkeleton(skeletonPrimary, width: 120, height: 12, theme: theme)
2240
+ configureSkeleton(skeletonSecondary, width: 80, height: 12, theme: theme)
2241
+ mainStack.spacing = 8
2242
+ mainStack.addArrangedSubview(skeletonPrimary)
2243
+ mainStack.addArrangedSubview(skeletonSecondary)
2244
+ }
2245
+ show(
2246
+ titleLabel,
2247
+ item.data.string("message", default: variant == "end" ? "End" : ""),
2248
+ lines: 2
2249
+ )
2250
+ let alignsToStart = variant == "retry" || variant == "noMatch" || variant == "end"
2251
+ titleLabel.textAlignment = alignsToStart ? .natural : .center
2252
+ mainStack.alignment = variant == "loading" || alignsToStart ? .leading : .center
2253
+ if alignsToStart {
2254
+ mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
2255
+ titleLabel.font = nativeListFont(ofSize: 14)
2256
+ titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
2257
+ setLineHeight(titleLabel, text: titleLabel.text ?? "", lineHeight: 20)
2258
+ }
2259
+ rootStack.addArrangedSubview(mainStack)
2260
+ if variant == "retry" {
2261
+ showAccessory(0, "Retry", action: (item.data.string("actionKey"), nil))
2262
+ accessoryButtons[0].backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
2263
+ accessoryButtons[0].layer.cornerRadius = 14
2264
+ accessoryButtons[0].contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
2265
+ setButtonLine(
2266
+ accessoryButtons[0],
2267
+ text: "Retry",
2268
+ font: nativeListFont(ofSize: 14, weight: .medium),
2269
+ color: nativeListColor(theme, "primaryText", "#202020"),
2270
+ lineHeight: 20
2271
+ )
2272
+ rootStack.addArrangedSubview(trailingStack)
2273
+ }
2274
+ }
2275
+
2276
+ private func addLeading(
2277
+ _ visual: [String: Any]?,
2278
+ secondaryVisual: [String: Any]? = nil,
2279
+ key: String,
2280
+ to stack: UIStackView? = nil
2281
+ ) {
2282
+ let targetStack = stack ?? rootStack
2283
+ targetStack.addArrangedSubview(leadingContainer)
2284
+ guard let visual else { return }
2285
+ let kind = visual.string("kind")
2286
+ var sources = visualSources(visual)
2287
+ if let secondaryVisual, let secondarySource = visualSources(secondaryVisual).first {
2288
+ sources.append(secondarySource)
2289
+ }
2290
+ let isIcon = kind == "icon"
2291
+ let shape = visual.string(
2292
+ "shape",
2293
+ default: kind == "image" || mediaHeight.isActive ? "rounded" : "circle"
2294
+ )
2295
+ let cornerIcon = visual.dictionary("cornerIcon")
2296
+ fallbackLabel.text = String(visual.string("fallbackText").prefix(2))
2297
+ leadingContainer.backgroundColor = UIColor(
2298
+ nativeListHex: visual.string("backgroundColor", default: isIcon ? "#F0F0F0" : "#E0E0E0"),
2299
+ fallback: .gray
2300
+ )
2301
+ leadingContainer.layer.cornerRadius = leadingCornerRadius(shape: shape)
2302
+ leadingContainer.clipsToBounds = true
2303
+ fallbackLabel.isHidden = isIcon || !sources.isEmpty
2304
+ if isIcon {
2305
+ leadingContainer.layer.borderWidth = 1 / UIScreen.main.scale
2306
+ leadingContainer.layer.borderColor = UIColor(
2307
+ nativeListHex: "#0000001F",
2308
+ fallback: .lightGray
2309
+ ).cgColor
2310
+ leadingIconImageView.isHidden = false
2311
+ leadingIconImageView.tintColor = UIColor(
2312
+ nativeListHex: visual.string("tintColor", default: "#646464"),
2313
+ fallback: .darkGray
2314
+ )
2315
+ leadingIconImageView.image = nativeListIcon(named: visual.string("name"))
2316
+ leadingIconImageView.contentMode = .scaleAspectFit
2317
+ }
2318
+ let visibleSources = Array(sources.prefix(leadingImages.count))
2319
+ let tokenPair = kind == "token" && visibleSources.count > 1
2320
+ leadingContainer.clipsToBounds = !tokenPair && cornerIcon == nil
2321
+ if let cornerIcon {
2322
+ leadingCornerIconBackground.isHidden = false
2323
+ leadingCornerIconBackground.backgroundColor = UIColor(
2324
+ nativeListHex: cornerIcon.string("backgroundColor", default: "#FFFFFF"),
2325
+ fallback: .white
2326
+ )
2327
+ leadingCornerIconImageView.image = nativeListIcon(named: cornerIcon.string("name"))
2328
+ leadingCornerIconImageView.tintColor = UIColor(
2329
+ nativeListHex: cornerIcon.string("tintColor", default: "#646464"),
2330
+ fallback: .darkGray
2331
+ )
2332
+ }
2333
+ if tokenPair {
2334
+ leadingOverlayBackground.isHidden = false
2335
+ leadingOverlayBackground.backgroundColor = visualBackdropColor
2336
+ leadingSlotConstraints.append(contentsOf: [
2337
+ leadingOverlayBackground.widthAnchor.constraint(equalToConstant: 20),
2338
+ leadingOverlayBackground.heightAnchor.constraint(equalToConstant: 20),
2339
+ leadingOverlayBackground.trailingAnchor.constraint(
2340
+ equalTo: leadingContainer.trailingAnchor,
2341
+ constant: 4
2342
+ ),
2343
+ leadingOverlayBackground.bottomAnchor.constraint(
2344
+ equalTo: leadingContainer.bottomAnchor,
2345
+ constant: 4
2346
+ ),
2347
+ ])
2348
+ }
2349
+ for (index, source) in visibleSources.enumerated() {
2350
+ let imageView = leadingImages[index]
2351
+ imageView.isHidden = false
2352
+ imageView.clipsToBounds = true
2353
+ leadingSlotConstraints.append(contentsOf: leadingConstraints(
2354
+ imageView,
2355
+ index: index,
2356
+ count: visibleSources.count,
2357
+ tokenPair: tokenPair,
2358
+ shape: shape
2359
+ ))
2360
+ bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant)
2361
+ }
2362
+ NSLayoutConstraint.activate(leadingSlotConstraints)
2363
+ }
2364
+
2365
+ private func visualSources(_ visual: [String: Any]) -> [(data: [String: Any], variant: String)] {
2366
+ let kind = visual.string("kind")
2367
+ if kind == "stackedImages" {
2368
+ return visual.dictionaries("images").prefix(3).map { ($0, "generic") }
2369
+ }
2370
+ guard kind != "icon", let image = visual.dictionary("image") else { return [] }
2371
+ let variant: String
2372
+ switch kind {
2373
+ case "token": variant = "token"
2374
+ case "network": variant = "network"
2375
+ case "account", "wallet": variant = "avatar"
2376
+ default: variant = "generic"
2377
+ }
2378
+ var sources: [(data: [String: Any], variant: String)] = [(image, variant)]
2379
+ if kind == "token", let networkImage = visual.dictionary("networkImage") {
2380
+ sources.append((networkImage, "network"))
2381
+ }
2382
+ return sources
2383
+ }
2384
+
2385
+ private func leadingConstraints(
2386
+ _ imageView: UIView,
2387
+ index: Int,
2388
+ count: Int,
2389
+ tokenPair: Bool,
2390
+ shape: String
2391
+ ) -> [NSLayoutConstraint] {
2392
+ if count == 1 || tokenPair && index == 0 {
2393
+ imageView.layer.cornerRadius = leadingCornerRadius(shape: shape)
2394
+ return [
2395
+ imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor),
2396
+ imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
2397
+ imageView.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
2398
+ imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
2399
+ ]
2400
+ }
2401
+ if tokenPair && index == 1 {
2402
+ let overlaySize: CGFloat = 16
2403
+ imageView.layer.cornerRadius = overlaySize / 2
2404
+ return [
2405
+ imageView.widthAnchor.constraint(equalToConstant: overlaySize),
2406
+ imageView.heightAnchor.constraint(equalToConstant: overlaySize),
2407
+ imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: 2),
2408
+ imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: 2),
2409
+ ]
2410
+ }
2411
+ let imageSize = leadingHeight.constant * 0.72
2412
+ let availableOffset = leadingWidth.constant - imageSize
2413
+ let offset = CGFloat(index) * availableOffset / CGFloat(max(1, count - 1))
2414
+ imageView.layer.cornerRadius = imageSize / 2
2415
+ return [
2416
+ imageView.widthAnchor.constraint(equalToConstant: imageSize),
2417
+ imageView.heightAnchor.constraint(equalToConstant: imageSize),
2418
+ imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: offset),
2419
+ imageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor),
2420
+ ]
2421
+ }
2422
+
2423
+ private func leadingCornerRadius(shape: String) -> CGFloat {
2424
+ switch shape {
2425
+ case "square": return 0
2426
+ case "rounded": return min(10, leadingHeight.constant / 4)
2427
+ default: return leadingHeight.constant / 2
2428
+ }
2429
+ }
2430
+
2431
+ private func bindAccessories(
2432
+ _ item: NativeListItem,
2433
+ _ accessories: [[String: Any]],
2434
+ _ theme: [String: Any]?,
2435
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
2436
+ ) {
2437
+ var textIndex = 0
2438
+ for accessory in accessories.prefix(2) {
2439
+ switch accessory.string("kind") {
2440
+ case "value":
2441
+ showAccessory(textIndex, accessory.string("text"))
2442
+ textIndex += 1
2443
+ case "valuePair":
2444
+ showValuePairAccessory(textIndex, accessory, theme: theme)
2445
+ textIndex += 1
2446
+ case "checkbox": bindCheckbox(item, accessory, checkboxState)
2447
+ case "radio": showAccessory(textIndex, accessory.bool("checked") ? "●" : "○", action: accessoryAction(accessory)); textIndex += 1
2448
+ case "switch": showAccessory(textIndex, accessory.bool("value") ? "ON" : "OFF", action: accessoryAction(accessory)); textIndex += 1
2449
+ case "chevron":
2450
+ var icon = accessory
2451
+ icon["name"] = "ChevronRightSmallOutline"
2452
+ if icon["tintColor"] == nil {
2453
+ icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
2454
+ }
2455
+ if icon.string("actionKey").isEmpty { icon["actionKey"] = "press" }
2456
+ showAccessoryIcon(textIndex, icon)
2457
+ textIndex += 1
2458
+ case "menu": showMenuAccessory(textIndex, action: accessoryAction(accessory)); textIndex += 1
2459
+ case "drag":
2460
+ var icon = accessory
2461
+ icon["name"] = "DragOutline"
2462
+ if icon["tintColor"] == nil {
2463
+ icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
2464
+ }
2465
+ showAccessoryIcon(textIndex, icon)
2466
+ textIndex += 1
2467
+ case "icon":
2468
+ var icon = accessory
2469
+ if icon["tintColor"] == nil {
2470
+ icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
2471
+ }
2472
+ showAccessoryIcon(textIndex, icon)
2473
+ textIndex += 1
2474
+ case "spinner": spinner.startAnimating()
2475
+ case "progress": showAccessory(textIndex, "\(Int(accessory.double("value") * 100))%"); textIndex += 1
2476
+ default: break
2477
+ }
2478
+ }
2479
+ }
2480
+
2481
+ private func bindCheckbox(
2482
+ _ item: NativeListItem,
2483
+ _ data: [String: Any],
2484
+ _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
2485
+ ) {
2486
+ let targetData = data.dictionary("target")
2487
+ let scope = targetData?.string("scope", default: "row") ?? "row"
2488
+ let key = scope == "section" ? targetData?.string("sectionKey") : scope == "row" ? item.key : nil
2489
+ let target = NativeSelectionTarget(scope: scope, key: key)
2490
+ let state = checkboxState(item, target, data.string("state", default: "unchecked"))
2491
+ boundCheckboxData = data
2492
+ boundCheckboxTarget = target
2493
+ updateCheckboxPresentation(item, data, target: target, state: state)
2494
+ }
2495
+
2496
+ private func updateCheckboxPresentation(
2497
+ _ item: NativeListItem,
2498
+ _ data: [String: Any],
2499
+ target: NativeSelectionTarget,
2500
+ state: String
2501
+ ) {
2502
+ let accessoryDisabled = data.bool("disabled")
2503
+ if data.bool("loading") {
2504
+ checkboxButton.isHidden = true
2505
+ spinner.startAnimating()
2506
+ spinner.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1
2507
+ checkboxAction = nil
2508
+ return
2509
+ }
2510
+ checkboxButton.isHidden = false
2511
+ checkboxButton.backgroundColor = state == "unchecked" ? checkboxUncheckedColor : checkboxCheckedColor
2512
+ checkboxButton.layer.borderColor = state == "unchecked"
2513
+ ? checkboxBorderColor.cgColor
2514
+ : UIColor.clear.cgColor
2515
+ let glyphName = state == "indeterminate"
2516
+ ? "CheckboxIndeterminateCustom"
2517
+ : "CheckboxCheckedCustom"
2518
+ checkboxButton.setImage(
2519
+ state == "unchecked" ? nil : nativeListIcon(named: glyphName),
2520
+ for: .normal
2521
+ )
2522
+ checkboxButton.tintColor = checkboxUncheckedColor
2523
+ // A disabled ListItem already applies 0.5 to its complete content. Avoid
2524
+ // multiplying that opacity on the nested control a second time.
2525
+ checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1
2526
+ checkboxButton.isEnabled = !item.data.bool("disabled") && !accessoryDisabled && !data.bool("loading")
2527
+ checkboxAction = (data.string("actionKey", default: "selection"), target)
2528
+ }
2529
+
2530
+ private func show(_ label: UILabel, _ value: String, lines: Int) {
2531
+ guard !value.isEmpty else { return }
2532
+ label.text = value
2533
+ label.numberOfLines = lines
2534
+ label.isHidden = false
2535
+ }
2536
+
2537
+ private func setLineHeight(
2538
+ _ label: UILabel,
2539
+ text: String,
2540
+ lineHeight: CGFloat,
2541
+ letterSpacing: CGFloat = 0
2542
+ ) {
2543
+ let paragraphStyle = NSMutableParagraphStyle()
2544
+ paragraphStyle.minimumLineHeight = lineHeight
2545
+ paragraphStyle.maximumLineHeight = lineHeight
2546
+ var attributes: [NSAttributedString.Key: Any] = [
2547
+ .font: label.font as Any,
2548
+ .foregroundColor: label.textColor as Any,
2549
+ .paragraphStyle: paragraphStyle,
2550
+ ]
2551
+ if letterSpacing != 0 { attributes[.kern] = letterSpacing }
2552
+ label.attributedText = NSAttributedString(string: text, attributes: attributes)
2553
+ }
2554
+
2555
+ private func setButtonLine(
2556
+ _ button: UIButton,
2557
+ text: String,
2558
+ font: UIFont,
2559
+ color: UIColor,
2560
+ lineHeight: CGFloat
2561
+ ) {
2562
+ guard !text.isEmpty else { return }
2563
+ let paragraphStyle = NSMutableParagraphStyle()
2564
+ paragraphStyle.minimumLineHeight = lineHeight
2565
+ paragraphStyle.maximumLineHeight = lineHeight
2566
+ paragraphStyle.alignment = .center
2567
+ button.setAttributedTitle(
2568
+ NSAttributedString(
2569
+ string: text,
2570
+ attributes: [
2571
+ .font: font,
2572
+ .foregroundColor: color,
2573
+ .paragraphStyle: paragraphStyle,
2574
+ ]
2575
+ ),
2576
+ for: .normal
2577
+ )
2578
+ }
2579
+
2580
+ private func showAccessory(
2581
+ _ index: Int,
2582
+ _ value: String,
2583
+ action: (String, NativeSelectionTarget?)? = nil,
2584
+ color: UIColor? = nil
2585
+ ) {
2586
+ guard accessoryButtons.indices.contains(index), !value.isEmpty else { return }
2587
+ let button = accessoryButtons[index]
2588
+ button.isHidden = false
2589
+ button.setTitle(value, for: .normal)
2590
+ button.setContentHuggingPriority(.required, for: .horizontal)
2591
+ button.setContentCompressionResistancePriority(.required, for: .horizontal)
2592
+ if let color { button.setTitleColor(color, for: .normal) }
2593
+ while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
2594
+ accessoryActions[index] = action ?? ("", nil)
2595
+ }
2596
+
2597
+ private func showValuePairAccessory(
2598
+ _ index: Int,
2599
+ _ accessory: [String: Any],
2600
+ theme: [String: Any]?
2601
+ ) {
2602
+ guard accessoryButtons.indices.contains(index) else { return }
2603
+ let primary = accessory.string("primary")
2604
+ let secondary = accessory.string("secondary")
2605
+ guard !primary.isEmpty || !secondary.isEmpty else { return }
2606
+ let button = accessoryButtons[index]
2607
+ let text = NSMutableAttributedString()
2608
+ let primaryParagraph = NSMutableParagraphStyle()
2609
+ primaryParagraph.alignment = .right
2610
+ primaryParagraph.minimumLineHeight = 20
2611
+ primaryParagraph.maximumLineHeight = 20
2612
+ text.append(NSAttributedString(
2613
+ string: primary,
2614
+ attributes: [
2615
+ .font: nativeListFont(ofSize: 16, weight: .medium),
2616
+ .foregroundColor: accessoryTextColor(
2617
+ accessory.string("primaryTone"),
2618
+ defaultTone: "primary",
2619
+ theme: theme
2620
+ ),
2621
+ .paragraphStyle: primaryParagraph,
2622
+ ]
2623
+ ))
2624
+ if !primary.isEmpty, !secondary.isEmpty { text.append(NSAttributedString(string: "\n")) }
2625
+ let secondaryParagraph = NSMutableParagraphStyle()
2626
+ secondaryParagraph.alignment = .right
2627
+ secondaryParagraph.minimumLineHeight = 20
2628
+ secondaryParagraph.maximumLineHeight = 20
2629
+ text.append(NSAttributedString(
2630
+ string: secondary,
2631
+ attributes: [
2632
+ .font: nativeListFont(ofSize: 14),
2633
+ .foregroundColor: accessoryTextColor(
2634
+ accessory.string("secondaryTone"),
2635
+ defaultTone: "secondary",
2636
+ theme: theme
2637
+ ),
2638
+ .paragraphStyle: secondaryParagraph,
2639
+ ]
2640
+ ))
2641
+ button.isHidden = false
2642
+ button.titleLabel?.numberOfLines = 2
2643
+ button.contentHorizontalAlignment = .trailing
2644
+ button.setAttributedTitle(text, for: .normal)
2645
+ button.setContentHuggingPriority(.required, for: .horizontal)
2646
+ button.setContentCompressionResistancePriority(.required, for: .horizontal)
2647
+ while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
2648
+ accessoryActions[index] = ("", nil)
2649
+ }
2650
+
2651
+ private func showMenuAccessory(
2652
+ _ index: Int,
2653
+ action: (String, NativeSelectionTarget?)?
2654
+ ) {
2655
+ showAccessory(index, "⋮", action: action)
2656
+ guard accessoryButtons.indices.contains(index) else { return }
2657
+ let button = accessoryButtons[index]
2658
+ button.titleLabel?.font = nativeListFont(ofSize: 20, weight: .medium)
2659
+ accessorySizeConstraints.append(contentsOf: [
2660
+ button.widthAnchor.constraint(equalToConstant: 24),
2661
+ button.heightAnchor.constraint(equalToConstant: 24),
2662
+ ])
2663
+ NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2)))
2664
+ }
2665
+
2666
+ private func accessoryTextColor(
2667
+ _ tone: String,
2668
+ defaultTone: String,
2669
+ theme: [String: Any]?
2670
+ ) -> UIColor {
2671
+ switch tone.isEmpty ? defaultTone : tone {
2672
+ case "positive": return nativeListColor(theme, "positive", "#218358")
2673
+ case "negative": return nativeListColor(theme, "negative", "#CE2C31")
2674
+ case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
2675
+ default: return nativeListColor(theme, "primaryText", "#202020")
2676
+ }
2677
+ }
2678
+
2679
+ private func showAccessoryIcon(_ index: Int, _ data: [String: Any]) {
2680
+ guard accessoryButtons.indices.contains(index) else { return }
2681
+ let button = accessoryButtons[index]
2682
+ button.isHidden = false
2683
+ button.isEnabled = !data.bool("disabled")
2684
+ button.alpha = button.isEnabled ? 1 : 0.4
2685
+ let tintColor = UIColor(
2686
+ nativeListHex: data.string("tintColor", default: "#646464"),
2687
+ fallback: .darkGray
2688
+ )
2689
+ button.tintColor = tintColor
2690
+ if let image = nativeListIcon(named: data.string("name")) {
2691
+ button.setImage(image, for: .normal)
2692
+ button.setImage(
2693
+ image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
2694
+ for: .disabled
2695
+ )
2696
+ }
2697
+ let isDrillIn = data.string("kind") == "chevron"
2698
+ let size: CGFloat = isDrillIn ? 24 : 36
2699
+ if !isDrillIn, !data.string("actionKey").isEmpty {
2700
+ // Reproduce the trailing edge of IconButton's m=-7 while keeping its
2701
+ // full 36-point frame for padding/highlight behavior.
2702
+ rootTrailingConstraint.constant = -5
2703
+ }
2704
+ accessorySizeConstraints.append(contentsOf: [
2705
+ button.widthAnchor.constraint(equalToConstant: size),
2706
+ button.heightAnchor.constraint(equalToConstant: size),
2707
+ ])
2708
+ NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2)))
2709
+ while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
2710
+ accessoryActions[index] = data.bool("disabled")
2711
+ ? ("", nil)
2712
+ : (data.string("actionKey"), nil)
2713
+ }
2714
+
2715
+ private func accessoryAction(
2716
+ _ accessory: [String: Any],
2717
+ defaultKey: String = ""
2718
+ ) -> (String, NativeSelectionTarget?)? {
2719
+ if accessory.bool("disabled") { return nil }
2720
+ let key = accessory.string("actionKey", default: defaultKey)
2721
+ return key.isEmpty ? nil : (key, nil)
2722
+ }
2723
+
2724
+ private func bindImage(
2725
+ _ source: [String: Any],
2726
+ into imageView: OneKeyImageReusableView,
2727
+ token: String,
2728
+ slot: Int,
2729
+ variant: String
2730
+ ) {
2731
+ let headersJson: String?
2732
+ if let headers = source.dictionary("headers"),
2733
+ JSONSerialization.isValidJSONObject(headers),
2734
+ let data = try? JSONSerialization.data(withJSONObject: headers) {
2735
+ headersJson = String(data: data, encoding: .utf8)
2736
+ } else {
2737
+ headersJson = nil
2738
+ }
2739
+ imageView.configure(
2740
+ sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines),
2741
+ sourceHeadersJson: headersJson,
2742
+ variant: variant,
2743
+ contentFit: source.string("contentFit", default: "cover"),
2744
+ cachePolicy: source.string("cachePolicy", default: "memory-disk"),
2745
+ autoplay: source.bool("autoplay"),
2746
+ recyclingKey: "\(token):\(slot)",
2747
+ optimizeTos: source["optimizeTos"] == nil || source.bool("optimizeTos"),
2748
+ overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"),
2749
+ loadingStrategy: source.string("loadingStrategy", default: "static")
2750
+ )
2751
+ }
2752
+
2753
+ private func applyGroupPosition(_ position: String) {
2754
+ let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
2755
+ let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
2756
+ switch position {
2757
+ case "first": layer.maskedCorners = top
2758
+ case "last": layer.maskedCorners = bottom
2759
+ case "single": layer.maskedCorners = top.union(bottom)
2760
+ default:
2761
+ layer.maskedCorners = []
2762
+ layer.cornerRadius = 0
2763
+ layer.masksToBounds = false
2764
+ return
2765
+ }
2766
+ let isWalletSidebar = currentItem?.type == "identity"
2767
+ && currentItem?.data.string("presentation") == "walletSidebar"
2768
+ layer.cornerRadius = isWalletSidebar ? 20 : 12
2769
+ layer.cornerCurve = isWalletSidebar ? .continuous : .circular
2770
+ layer.masksToBounds = true
2771
+ }
2772
+
2773
+ private func configureSkeleton(
2774
+ _ view: UIView,
2775
+ width: CGFloat,
2776
+ height: CGFloat,
2777
+ theme: [String: Any]?
2778
+ ) {
2779
+ view.backgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8")
2780
+ view.layer.cornerRadius = 4
2781
+ view.translatesAutoresizingMaskIntoConstraints = false
2782
+ if view.constraints.isEmpty {
2783
+ NSLayoutConstraint.activate([
2784
+ view.widthAnchor.constraint(equalToConstant: width),
2785
+ view.heightAnchor.constraint(equalToConstant: height),
2786
+ ])
2787
+ }
2788
+ }
2789
+
2790
+ @objc private func accessoryPressed(_ sender: UIButton) {
2791
+ guard let item = currentItem, accessoryActions.indices.contains(sender.tag) else { return }
2792
+ let action = accessoryActions[sender.tag]
2793
+ guard !action.0.isEmpty else { return }
2794
+ let source = item.type == "mediaTile" && item.data.string("closeActionKey") == action.0
2795
+ ? "mediaClose"
2796
+ : "trailingAccessory"
2797
+ onAction?(
2798
+ item,
2799
+ action.0,
2800
+ action.1,
2801
+ actionOrigin(sourceView: sender, source: source, slot: sender.tag)
2802
+ )
2803
+ }
2804
+
2805
+ @objc private func footerActionPressed(_ sender: UIButton) {
2806
+ guard let item = currentItem, footerActionKeys.indices.contains(sender.tag) else { return }
2807
+ onAction?(
2808
+ item,
2809
+ footerActionKeys[sender.tag],
2810
+ nil,
2811
+ actionOrigin(sourceView: sender, source: "footerAction", slot: sender.tag)
2812
+ )
2813
+ }
2814
+
2815
+ @objc private func checkboxPressed() {
2816
+ guard let item = currentItem, let action = checkboxAction else { return }
2817
+ onAction?(
2818
+ item,
2819
+ action.0,
2820
+ action.1,
2821
+ actionOrigin(sourceView: checkboxButton, source: "trailingAccessory")
2822
+ )
2823
+ }
2824
+
2825
+ @objc private func leadingActionPressed() {
2826
+ guard let item = currentItem, let actionKey = leadingActionKey, !actionKey.isEmpty else { return }
2827
+ onAction?(
2828
+ item,
2829
+ actionKey,
2830
+ nil,
2831
+ actionOrigin(sourceView: leadingActionButton, source: "leadingAction")
2832
+ )
2833
+ }
2834
+
2835
+ private func actionOrigin(
2836
+ sourceView: UIView,
2837
+ source: String,
2838
+ slot: Int? = nil
2839
+ ) -> NativeListActionOrigin {
2840
+ NativeListActionOrigin(
2841
+ sourceView: sourceView,
2842
+ ownerCell: self,
2843
+ bindingEpoch: bindingEpoch,
2844
+ source: source,
2845
+ slot: slot
2846
+ )
2847
+ }
2848
+
2849
+ func rowActionOrigin() -> NativeListActionOrigin {
2850
+ actionOrigin(sourceView: contentView, source: "row")
2851
+ }
2852
+
2853
+ private func invalidateCurrentBinding() {
2854
+ guard currentItem != nil else { return }
2855
+ onBindingInvalidated?(self, bindingEpoch)
2856
+ bindingEpoch &+= 1
2857
+ }
2858
+ }