@onekeyfe/react-native-native-list 3.0.157-alpha.249 → 3.0.157-alpha.250

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 (130) hide show
  1. package/README.md +21 -5
  2. package/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +711 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt +169 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +243 -0
  5. package/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +20 -6
  6. package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +487 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +820 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListImageSlot.kt +147 -0
  9. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +453 -0
  10. package/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt +997 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +234 -0
  12. package/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +232 -0
  13. package/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt +551 -0
  14. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +24 -0
  15. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +475 -0
  16. package/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt +143 -0
  17. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +42 -0
  18. package/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +415 -0
  19. package/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +163 -0
  20. package/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt +70 -0
  21. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +748 -0
  22. package/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt +464 -0
  23. package/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +25 -0
  24. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +174 -104
  25. package/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt +407 -0
  26. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListSelectorBackgroundColorTest.kt +34 -0
  27. package/docs/DESIGN.md +518 -9
  28. package/docs/MIGRATION_BASELINE.md +117 -0
  29. package/docs/ROW_TEMPLATES.md +359 -0
  30. package/docs/SPEC.md +358 -0
  31. package/docs/STYLE_SPEC.md +978 -0
  32. package/docs/images/list-capabilities.svg +39 -0
  33. package/docs/images/row-action.svg +17 -0
  34. package/docs/images/row-activity.svg +24 -0
  35. package/docs/images/row-data.svg +24 -0
  36. package/docs/images/row-identity.svg +25 -0
  37. package/docs/images/row-market.svg +23 -0
  38. package/docs/images/row-media-tile.svg +26 -0
  39. package/docs/images/row-message.svg +20 -0
  40. package/docs/images/row-metric-card.svg +33 -0
  41. package/docs/images/row-rail.svg +18 -0
  42. package/docs/images/row-section-header.svg +19 -0
  43. package/docs/images/row-style-regions.svg +30 -0
  44. package/docs/images/row-system.svg +30 -0
  45. package/docs/images/row-wallet-group.svg +26 -0
  46. package/ios/NativeListAccessoryStack.swift +680 -0
  47. package/ios/NativeListActionRenderer.swift +108 -0
  48. package/ios/NativeListActivityRenderer.swift +179 -0
  49. package/ios/NativeListDataRowRenderer.swift +444 -0
  50. package/ios/NativeListIdentityRenderer.swift +685 -0
  51. package/ios/NativeListImageSlot.swift +140 -0
  52. package/ios/NativeListLeadingVisual.swift +372 -0
  53. package/ios/NativeListMarketRenderer.swift +698 -0
  54. package/ios/NativeListMediaTileRenderer.swift +184 -0
  55. package/ios/NativeListMessageRenderer.swift +168 -0
  56. package/ios/NativeListMetricCardRenderer.swift +420 -0
  57. package/ios/NativeListModels.swift +32 -0
  58. package/ios/NativeListRailRenderer.swift +152 -0
  59. package/ios/NativeListRendererCell.swift +311 -0
  60. package/ios/NativeListRendererRegistry.swift +50 -0
  61. package/ios/NativeListResolvedText.swift +122 -0
  62. package/ios/NativeListRowHost.swift +63 -0
  63. package/ios/NativeListSectionHeaderRenderer.swift +708 -0
  64. package/ios/NativeListSystemRenderer.swift +568 -0
  65. package/ios/NativeListTextLabel.swift +42 -0
  66. package/ios/NativeListTextStyles.swift +236 -0
  67. package/ios/NativeListWalletGroupRenderer.swift +255 -0
  68. package/ios/RNCNativeListView.swift +111 -180
  69. package/lib/module/models.js +54 -0
  70. package/lib/module/validation.js +324 -26
  71. package/lib/module/web/NativeListWebEngine.js +148 -936
  72. package/lib/module/web/templates/ActionRowRenderer.js +97 -0
  73. package/lib/module/web/templates/ActivityRowRenderer.js +95 -0
  74. package/lib/module/web/templates/DataRowRenderer.js +145 -0
  75. package/lib/module/web/templates/IdentityRowRenderer.js +289 -0
  76. package/lib/module/web/templates/MarketRowRenderer.js +310 -0
  77. package/lib/module/web/templates/MediaTileRowRenderer.js +132 -0
  78. package/lib/module/web/templates/MessageRowRenderer.js +165 -0
  79. package/lib/module/web/templates/MetricCardRowRenderer.js +172 -0
  80. package/lib/module/web/templates/RailRowRenderer.js +98 -0
  81. package/lib/module/web/templates/RowContainerStyle.js +100 -0
  82. package/lib/module/web/templates/RowElements.js +93 -0
  83. package/lib/module/web/templates/RowRendererRegistry.js +70 -0
  84. package/lib/module/web/templates/RowText.js +121 -0
  85. package/lib/module/web/templates/RowVisual.js +43 -0
  86. package/lib/module/web/templates/SectionHeaderRowRenderer.js +146 -0
  87. package/lib/module/web/templates/SystemRowRenderer.js +161 -0
  88. package/lib/module/web/templates/WalletGroupRowRenderer.js +96 -0
  89. package/lib/typescript/src/models.d.ts +166 -13
  90. package/lib/typescript/src/web/NativeListWebEngine.d.ts +18 -16
  91. package/lib/typescript/src/web/templates/ActionRowRenderer.d.ts +15 -0
  92. package/lib/typescript/src/web/templates/ActivityRowRenderer.d.ts +15 -0
  93. package/lib/typescript/src/web/templates/DataRowRenderer.d.ts +15 -0
  94. package/lib/typescript/src/web/templates/IdentityRowRenderer.d.ts +16 -0
  95. package/lib/typescript/src/web/templates/MarketRowRenderer.d.ts +30 -0
  96. package/lib/typescript/src/web/templates/MediaTileRowRenderer.d.ts +15 -0
  97. package/lib/typescript/src/web/templates/MessageRowRenderer.d.ts +44 -0
  98. package/lib/typescript/src/web/templates/MetricCardRowRenderer.d.ts +15 -0
  99. package/lib/typescript/src/web/templates/RailRowRenderer.d.ts +28 -0
  100. package/lib/typescript/src/web/templates/RowContainerStyle.d.ts +9 -0
  101. package/lib/typescript/src/web/templates/RowElements.d.ts +38 -0
  102. package/lib/typescript/src/web/templates/RowRendererRegistry.d.ts +83 -0
  103. package/lib/typescript/src/web/templates/RowText.d.ts +9 -0
  104. package/lib/typescript/src/web/templates/RowVisual.d.ts +20 -0
  105. package/lib/typescript/src/web/templates/SectionHeaderRowRenderer.d.ts +15 -0
  106. package/lib/typescript/src/web/templates/SystemRowRenderer.d.ts +16 -0
  107. package/lib/typescript/src/web/templates/WalletGroupRowRenderer.d.ts +20 -0
  108. package/package.json +3 -3
  109. package/src/models.ts +281 -19
  110. package/src/validation.ts +501 -46
  111. package/src/web/NativeListWebEngine.ts +361 -1932
  112. package/src/web/templates/ActionRowRenderer.ts +123 -0
  113. package/src/web/templates/ActivityRowRenderer.ts +159 -0
  114. package/src/web/templates/DataRowRenderer.ts +212 -0
  115. package/src/web/templates/IdentityRowRenderer.ts +527 -0
  116. package/src/web/templates/MarketRowRenderer.ts +467 -0
  117. package/src/web/templates/MediaTileRowRenderer.ts +182 -0
  118. package/src/web/templates/MessageRowRenderer.ts +306 -0
  119. package/src/web/templates/MetricCardRowRenderer.ts +337 -0
  120. package/src/web/templates/RailRowRenderer.ts +136 -0
  121. package/src/web/templates/RowContainerStyle.ts +131 -0
  122. package/src/web/templates/RowElements.ts +160 -0
  123. package/src/web/templates/RowRendererRegistry.ts +111 -0
  124. package/src/web/templates/RowText.ts +181 -0
  125. package/src/web/templates/RowVisual.ts +115 -0
  126. package/src/web/templates/SectionHeaderRowRenderer.ts +243 -0
  127. package/src/web/templates/SystemRowRenderer.ts +282 -0
  128. package/src/web/templates/WalletGroupRowRenderer.ts +131 -0
  129. package/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +0 -4538
  130. package/ios/NativeListCell.swift +0 -4300
@@ -1,4300 +0,0 @@
1
- import Foundation
2
- // OneKey patch: preserve native font faces while enabling tabular number features.
3
- import CoreText
4
- import CryptoKit
5
- import OneKeyImage
6
- import UIKit
7
-
8
- private enum NativeListSourceFallbackState {
9
- private static let sources: NSCache<NSString, NSNumber> = {
10
- let cache = NSCache<NSString, NSNumber>()
11
- cache.countLimit = 128
12
- return cache
13
- }()
14
-
15
- static func has(_ key: String) -> Bool {
16
- sources.object(forKey: key as NSString) != nil
17
- }
18
-
19
- static func remember(_ key: String) {
20
- sources.setObject(NSNumber(value: true), forKey: key as NSString)
21
- }
22
-
23
- static func forget(_ key: String) {
24
- sources.removeObject(forKey: key as NSString)
25
- }
26
- }
27
-
28
- // Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer.
29
- private final class NativeListMarketSkeleton: UIView {
30
- private let marks = (0..<5).map { _ in UIView() }
31
- private let gradients = (0..<5).map { _ in CAGradientLayer() }
32
-
33
- init(background: UIColor) {
34
- super.init(frame: .zero)
35
- var white: CGFloat = 1
36
- background.getWhite(&white, alpha: nil)
37
- let base = UIColor(nativeListHex: white < 0.5 ? "#111111" : "#FAFAFA", fallback: .white)
38
- let highlight = UIColor(nativeListHex: white < 0.5 ? "#333333" : "#CDCDCD", fallback: .lightGray)
39
- for (index, mark) in marks.enumerated() {
40
- mark.backgroundColor = base
41
- mark.clipsToBounds = true
42
- mark.layer.cornerRadius = index == 0 ? 16 : 8
43
- let gradient = gradients[index]
44
- gradient.cornerRadius = mark.layer.cornerRadius
45
- gradient.colors = [base.cgColor, highlight.cgColor, base.cgColor]
46
- gradient.locations = [0, 0.5, 1]
47
- gradient.startPoint = CGPoint(x: 0, y: 0.5)
48
- gradient.endPoint = CGPoint(x: 1, y: 0.5)
49
- mark.layer.addSublayer(gradient)
50
- addSubview(mark)
51
- }
52
- }
53
-
54
- required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
55
-
56
- override func layoutSubviews() {
57
- super.layoutSubviews()
58
- let frames = [
59
- CGRect(x: 0, y: 0, width: 32, height: 32),
60
- CGRect(x: 44, y: 0, width: 80, height: 16),
61
- CGRect(x: 44, y: 20, width: 60, height: 12),
62
- CGRect(x: bounds.width - 168, y: 7, width: 80, height: 18),
63
- CGRect(x: bounds.width - 80, y: 7, width: 80, height: 18),
64
- ]
65
- CATransaction.begin()
66
- CATransaction.setDisableActions(true)
67
- for (index, frame) in frames.enumerated() {
68
- marks[index].frame = frame
69
- gradients[index].frame = marks[index].bounds
70
- }
71
- CATransaction.commit()
72
- updateAnimation()
73
- }
74
-
75
- override func didMoveToWindow() {
76
- super.didMoveToWindow()
77
- updateAnimation()
78
- }
79
-
80
- private func updateAnimation() {
81
- for gradient in gradients {
82
- guard window != nil else { gradient.removeAllAnimations(); continue }
83
- guard gradient.bounds.width > 0, gradient.animation(forKey: "shimmer") == nil else { continue }
84
- let animation = CABasicAnimation(keyPath: "transform.translation.x")
85
- animation.fromValue = -gradient.bounds.width
86
- animation.toValue = gradient.bounds.width
87
- animation.duration = 3
88
- animation.repeatCount = .infinity
89
- animation.timingFunction = CAMediaTimingFunction(name: .linear)
90
- gradient.add(animation, forKey: "shimmer")
91
- }
92
- }
93
- }
94
-
95
- final class NativeListActionOrigin {
96
- weak var sourceView: UIView?
97
- weak var ownerCell: NativeListCell?
98
- let bindingEpoch: Int
99
- let source: String
100
- let slot: Int?
101
- // OneKey patch: expose the layout slot while preserving the larger hit target.
102
- let anchorInset: CGFloat
103
- var windowPoint: CGPoint?
104
-
105
- init(
106
- sourceView: UIView,
107
- ownerCell: NativeListCell,
108
- bindingEpoch: Int,
109
- source: String,
110
- slot: Int? = nil,
111
- anchorInset: CGFloat = 0
112
- ) {
113
- self.sourceView = sourceView
114
- self.ownerCell = ownerCell
115
- self.bindingEpoch = bindingEpoch
116
- self.source = source
117
- self.slot = slot
118
- self.anchorInset = anchorInset
119
- }
120
- }
121
-
122
- // OneKey patch: explicit summary actions use the source text's physical-pixel line box.
123
- private final class NativeListAccessoryButton: UIButton {
124
- var pressedBackgroundColor: UIColor?
125
-
126
- override var isHighlighted: Bool {
127
- didSet {
128
- guard let pressedBackgroundColor else { return }
129
- backgroundColor = isHighlighted ? pressedBackgroundColor : .clear
130
- }
131
- }
132
-
133
- var selectorSummaryLineHeight: CGFloat? {
134
- didSet { invalidateIntrinsicContentSize(); setNeedsLayout() }
135
- }
136
-
137
- var marketLineHeight: CGFloat? {
138
- didSet { invalidateIntrinsicContentSize(); setNeedsLayout() }
139
- }
140
-
141
- private var sourcePixelScale: CGFloat {
142
- max(1, window?.screen.scale ?? traitCollection.displayScale)
143
- }
144
-
145
- override var intrinsicContentSize: CGSize {
146
- var size = super.intrinsicContentSize
147
- guard selectorSummaryLineHeight != nil || marketLineHeight != nil, let title = attributedTitle(for: .normal) else { return size }
148
- let width = title.boundingRect(
149
- with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
150
- options: [.usesLineFragmentOrigin, .usesFontLeading],
151
- context: nil
152
- ).width
153
- size.width = ceil(width * sourcePixelScale) / sourcePixelScale
154
- return size
155
- }
156
-
157
- override func layoutSubviews() {
158
- super.layoutSubviews()
159
- if let lineHeight = marketLineHeight, let titleLabel, let title = attributedTitle(for: .normal) {
160
- let measured = title.boundingRect(
161
- with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
162
- options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil
163
- ).width
164
- let width = min(bounds.width, ceil(measured * sourcePixelScale) / sourcePixelScale)
165
- let x = contentHorizontalAlignment == .trailing ? bounds.width - width
166
- : contentHorizontalAlignment == .leading ? 0 : (bounds.width - width) / 2
167
- titleLabel.frame = CGRect(
168
- x: floor(x * sourcePixelScale) / sourcePixelScale,
169
- y: (bounds.height - lineHeight) / 2,
170
- width: width, height: lineHeight
171
- )
172
- return
173
- }
174
- guard let lineHeight = selectorSummaryLineHeight, let titleLabel else { return }
175
- // OneKey patch: position the final source line box after UIKit has measured the button.
176
- let top = ceil((bounds.height - lineHeight) / 2 * sourcePixelScale) / sourcePixelScale
177
- var frame = titleLabel.frame
178
- frame.origin.y = top
179
- titleLabel.frame = frame
180
- }
181
- }
182
-
183
- private final class NativeListInsetLabel: UILabel {
184
- var horizontalInset: CGFloat = 0
185
- var topInset: CGFloat = 0
186
- var bottomInset: CGFloat = 0
187
-
188
- override var intrinsicContentSize: CGSize {
189
- let size = super.intrinsicContentSize
190
- return CGSize(
191
- width: size.width + horizontalInset * 2,
192
- height: size.height + topInset + bottomInset
193
- )
194
- }
195
-
196
- override func drawText(in rect: CGRect) {
197
- super.drawText(in: rect.inset(
198
- by: UIEdgeInsets(
199
- top: topInset,
200
- left: horizontalInset,
201
- bottom: bottomInset,
202
- right: horizontalInset
203
- )
204
- ))
205
- }
206
- }
207
-
208
- private final class NativeListDottedUnderlineLabel: UILabel {
209
- var showsDottedUnderline = false {
210
- didSet { setNeedsLayout() }
211
- }
212
-
213
- // OneKey patch: migrated section titles include the source 3-point underline box.
214
- var reservesDottedUnderlineSpace = false {
215
- didSet { invalidateIntrinsicContentSize(); setNeedsLayout(); setNeedsDisplay() }
216
- }
217
-
218
- override var intrinsicContentSize: CGSize {
219
- var size = super.intrinsicContentSize
220
- if reservesDottedUnderlineSpace && showsDottedUnderline { size.height += 3 }
221
- return size
222
- }
223
-
224
- override func drawText(in rect: CGRect) {
225
- let textRect = reservesDottedUnderlineSpace && showsDottedUnderline
226
- ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - 3))
227
- : rect
228
- super.drawText(in: textRect)
229
- }
230
-
231
- var dottedUnderlineColor: UIColor = .clear {
232
- didSet {
233
- dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor
234
- setNeedsLayout()
235
- }
236
- }
237
-
238
- var dottedUnderlineVerticalOffset: CGFloat = 0 {
239
- didSet { setNeedsLayout() }
240
- }
241
-
242
- private let dottedUnderlineLayer = CAShapeLayer()
243
-
244
- override init(frame: CGRect) {
245
- super.init(frame: frame)
246
- dottedUnderlineLayer.fillColor = UIColor.clear.cgColor
247
- dottedUnderlineLayer.lineWidth = 1.5
248
- dottedUnderlineLayer.lineCap = .round
249
- dottedUnderlineLayer.lineDashPattern = [0, 4]
250
- layer.addSublayer(dottedUnderlineLayer)
251
- }
252
-
253
- required init?(coder: NSCoder) {
254
- fatalError("init(coder:) has not been implemented")
255
- }
256
-
257
- override func layoutSubviews() {
258
- super.layoutSubviews()
259
- CATransaction.begin()
260
- CATransaction.setDisableActions(true)
261
- defer { CATransaction.commit() }
262
- guard showsDottedUnderline, let attributedText else {
263
- dottedUnderlineLayer.path = nil
264
- dottedUnderlineLayer.isHidden = true
265
- return
266
- }
267
- let textWidth = ceil(attributedText.boundingRect(
268
- with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 24),
269
- options: [.usesLineFragmentOrigin, .usesFontLeading],
270
- context: nil
271
- ).width)
272
- dottedUnderlineLayer.frame = CGRect(
273
- x: 0,
274
- y: 0,
275
- width: bounds.width,
276
- height: bounds.height + 2 + dottedUnderlineVerticalOffset
277
- )
278
- // OneKey patch: explicit header underline occupies the reserved final two points.
279
- // let y = bounds.height + 1 + dottedUnderlineVerticalOffset
280
- let y = reservesDottedUnderlineSpace ? bounds.height - 1 : bounds.height + 1 + dottedUnderlineVerticalOffset
281
- let path = UIBezierPath()
282
- path.move(to: CGPoint(x: 1, y: y))
283
- path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y))
284
- dottedUnderlineLayer.path = path.cgPath
285
- dottedUnderlineLayer.isHidden = false
286
- }
287
- }
288
-
289
- private final class NativeListTableColumnView: UIStackView {
290
- private let primaryLine = UIStackView()
291
- private let primaryLabel = UILabel()
292
- private let badgesStack = UIStackView()
293
- private let secondaryLine = UIStackView()
294
- private let secondaryLeadingLabel = UILabel()
295
- private let secondaryLabel = UILabel()
296
-
297
- override init(frame: CGRect) {
298
- super.init(frame: frame)
299
- axis = .vertical
300
- alignment = .leading
301
- distribution = .fill
302
- spacing = 4
303
-
304
- primaryLine.axis = .horizontal
305
- primaryLine.alignment = .center
306
- primaryLine.spacing = 6
307
- primaryLabel.numberOfLines = 1
308
- primaryLabel.lineBreakMode = .byTruncatingTail
309
- primaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
310
- badgesStack.axis = .horizontal
311
- badgesStack.alignment = .center
312
- badgesStack.spacing = 4
313
- badgesStack.setContentCompressionResistancePriority(.required, for: .horizontal)
314
- primaryLine.addArrangedSubview(primaryLabel)
315
- primaryLine.addArrangedSubview(badgesStack)
316
-
317
- secondaryLine.axis = .horizontal
318
- secondaryLine.alignment = .center
319
- secondaryLine.spacing = 4
320
- for label in [secondaryLeadingLabel, secondaryLabel] {
321
- label.numberOfLines = 1
322
- label.lineBreakMode = .byTruncatingTail
323
- secondaryLine.addArrangedSubview(label)
324
- }
325
- secondaryLeadingLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
326
- secondaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
327
- secondaryLeadingLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true
328
- addArrangedSubview(primaryLine)
329
- addArrangedSubview(secondaryLine)
330
- }
331
-
332
- required init(coder: NSCoder) {
333
- fatalError("init(coder:) has not been implemented")
334
- }
335
-
336
- func reset() {
337
- primaryLabel.attributedText = nil
338
- secondaryLeadingLabel.attributedText = nil
339
- secondaryLabel.attributedText = nil
340
- secondaryLeadingLabel.isHidden = true
341
- secondaryLabel.isHidden = true
342
- secondaryLine.isHidden = true
343
- badgesStack.arrangedSubviews.forEach {
344
- badgesStack.removeArrangedSubview($0)
345
- $0.removeFromSuperview()
346
- }
347
- }
348
-
349
- func bind(
350
- column: [String: Any],
351
- badges: [[String: Any]],
352
- theme: [String: Any]?
353
- ) {
354
- reset()
355
- let textAlignment: NSTextAlignment
356
- switch column.string("alignment") {
357
- case "end":
358
- alignment = .trailing
359
- textAlignment = .right
360
- case "center":
361
- alignment = .center
362
- textAlignment = .center
363
- default:
364
- alignment = .leading
365
- textAlignment = .left
366
- }
367
- primaryLabel.textAlignment = textAlignment
368
- secondaryLeadingLabel.textAlignment = textAlignment
369
- secondaryLabel.textAlignment = textAlignment
370
- primaryLabel.attributedText = line(
371
- column.string("text"),
372
- font: nativeListFont(ofSize: 14, weight: .medium),
373
- color: textColor(column.string("tone"), theme: theme),
374
- height: 20
375
- )
376
-
377
- for badge in badges.prefix(2) {
378
- let label = NativeListInsetLabel()
379
- label.horizontalInset = 6
380
- label.text = badge.string("text")
381
- label.font = nativeListFont(ofSize: 10)
382
- label.textColor = nativeListColor(theme, "info", "#0D74CE")
383
- label.textAlignment = .center
384
- label.backgroundColor = UIColor(nativeListHex: "#008FF519", fallback: .systemBlue)
385
- label.layer.cornerRadius = 4
386
- label.clipsToBounds = true
387
- label.translatesAutoresizingMaskIntoConstraints = false
388
- label.heightAnchor.constraint(equalToConstant: 16).isActive = true
389
- badgesStack.addArrangedSubview(label)
390
- }
391
- badgesStack.isHidden = badges.isEmpty
392
-
393
- let secondaryLeading = column.string("secondaryLeadingText")
394
- if !secondaryLeading.isEmpty {
395
- secondaryLeadingLabel.attributedText = line(
396
- secondaryLeading,
397
- font: nativeListFont(ofSize: 12),
398
- color: nativeListColor(theme, "secondaryText", "#646464"),
399
- height: 16
400
- )
401
- secondaryLeadingLabel.isHidden = false
402
- secondaryLine.isHidden = false
403
- }
404
- let secondary = column.string("secondaryText")
405
- if !secondary.isEmpty {
406
- secondaryLabel.attributedText = line(
407
- secondary,
408
- font: nativeListFont(ofSize: 12),
409
- color: textColor(
410
- column.string("secondaryTone", default: "secondary"),
411
- theme: theme
412
- ),
413
- height: 16
414
- )
415
- secondaryLabel.isHidden = false
416
- secondaryLine.isHidden = false
417
- }
418
- }
419
-
420
- private func line(_ text: String, font: UIFont, color: UIColor, height: CGFloat) -> NSAttributedString {
421
- let paragraph = NSMutableParagraphStyle()
422
- paragraph.minimumLineHeight = height
423
- paragraph.maximumLineHeight = height
424
- return NSAttributedString(
425
- string: text,
426
- attributes: [
427
- .font: font,
428
- .foregroundColor: color,
429
- .paragraphStyle: paragraph,
430
- ]
431
- )
432
- }
433
-
434
- private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor {
435
- switch tone {
436
- // OneKey patch: account warnings and hidden balances use existing theme tokens.
437
- case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D")
438
- case "caution": return nativeListColor(theme, "caution", "#AB6400")
439
- case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
440
- case "positive": return nativeListColor(theme, "positive", "#218358")
441
- case "negative": return nativeListColor(theme, "negative", "#CE2C31")
442
- default: return nativeListColor(theme, "primaryText", "#202020")
443
- }
444
- }
445
- }
446
-
447
- final class NativeListCell: UICollectionViewCell {
448
- static let reuseIdentifier = "NativeListCell"
449
-
450
- private let rootStack = UIStackView()
451
- private let leadingImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) }
452
- private let leadingOverlayBackground = UIView()
453
- private let leadingCornerIconBackground = UIView()
454
- private let leadingCornerIconImageView = UIImageView()
455
- // OneKey patch: selector-only views are reset on every native cell binding.
456
- private var selectorViews: [UIView] = []
457
- private var selectorConstraints: [NSLayoutConstraint] = []
458
- private var selectorImages: [OneKeyImageReusableView] = []
459
- private var selectorBorder: CAShapeLayer?
460
- private let selectorFullWidthBackground = CALayer()
461
- private lazy var selectorTitleTap = UITapGestureRecognizer(target: self, action: #selector(selectorTitlePressed))
462
- private let secondaryImage = OneKeyImageReusableView(frame: .zero)
463
- private let mediaNetworkImage = OneKeyImageReusableView(frame: .zero)
464
- private let fallbackLabel = UILabel()
465
- private let leadingIconImageView = UIImageView()
466
- private let favoriteIconImageView = UIImageView()
467
- private let headerTitleIconImageView = UIImageView()
468
- private let headerValueIconImageView = UIImageView()
469
- private let leadingActionButton = UIButton(type: .system)
470
- private let leadingContainer = UIView()
471
- private let unreadDot = UIView()
472
- private let mainStack = UIStackView()
473
- private let mediaMetadataStack = UIStackView()
474
- private let titleRowStack = UIStackView()
475
- private let titleLabel = NativeListDottedUnderlineLabel()
476
- private let subtitleLabel = UILabel()
477
- // OneKey patch: keep Market name and volume in independent line boxes.
478
- private let marketSubtitleStack = UIStackView()
479
- private let marketSubtitleSpacer = UIView()
480
- private let tertiaryLabel = UILabel()
481
- private let statusLabel = NativeListInsetLabel()
482
- private let metricSubtitleLabel = UILabel()
483
- private let metricCompositeStack = UIStackView()
484
- private let badgeLabel = NativeListInsetLabel()
485
- // OneKey patch: reuse the existing explicit line-box layout for styled Market badges.
486
- // private let marketBadgeButtons = (0..<3).map { _ in UIButton(type: .system) }
487
- private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .system) }
488
- private let marketBadgeImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) }
489
- private let actionStack = UIStackView()
490
- private let actionButtons = (0..<3).map { _ in UIButton(type: .system) }
491
- private let trailingStack = UIStackView()
492
- // OneKey patch: summary actions opt into source typography while other buttons keep UIKit layout.
493
- // private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) }
494
- private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) }
495
- private let checkboxButton = UIButton(type: .system)
496
- private let spinner = UIActivityIndicatorView(style: .medium)
497
- private let dataStack = UIStackView()
498
- private let dataLabels = (0..<4).map { _ in UILabel() }
499
- private let tableDataStack = UIStackView()
500
- private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() }
501
- private let mediaBadgeLabel = UILabel()
502
- private let skeletonPrimary = UIView()
503
- private let skeletonSecondary = UIView()
504
- private var walletGroupCells: [NativeListCell] = []
505
- private var walletGroupMembers: [NativeListItem] = []
506
- private let walletGroupCompactContainer = UIView()
507
- private var walletGroupCompactCell: NativeListCell?
508
- private let walletGroupDragBadge = NativeListInsetLabel()
509
- private var walletGroupCompactAppearanceActive = false
510
- private lazy var walletGroupTap = UITapGestureRecognizer(
511
- target: self,
512
- action: #selector(walletGroupPressed(_:))
513
- )
514
- private let separatorView = UIView()
515
- private var separatorLeadingConstraint: NSLayoutConstraint!
516
- private var leadingWidth: NSLayoutConstraint!
517
- private var leadingHeight: NSLayoutConstraint!
518
- private var leadingIconWidth: NSLayoutConstraint!
519
- private var leadingIconHeight: NSLayoutConstraint!
520
- private var mediaHeight: NSLayoutConstraint!
521
- private var secondaryWidth: NSLayoutConstraint!
522
- private var secondaryHeight: NSLayoutConstraint!
523
- private var rootLeadingConstraint: NSLayoutConstraint!
524
- private var rootTrailingConstraint: NSLayoutConstraint!
525
- private var rootTopConstraint: NSLayoutConstraint!
526
- private var rootBottomConstraint: NSLayoutConstraint!
527
- private var leadingSlotConstraints: [NSLayoutConstraint] = []
528
- private var dataWeightConstraints: [NSLayoutConstraint] = []
529
- private var accessorySizeConstraints: [NSLayoutConstraint] = []
530
- // OneKey patch: restore selector-only font features before a cell is reused.
531
- private var selectorTypographyRestorers: [() -> Void] = []
532
- private var currentItem: NativeListItem?
533
- private var accessoryActions: [(String, NativeSelectionTarget?)] = []
534
- private var footerActionKeys: [String] = []
535
- private var checkboxAction: (String, NativeSelectionTarget?)?
536
- private var boundCheckboxData: [String: Any]?
537
- private var boundCheckboxTarget: NativeSelectionTarget?
538
- private var leadingActionKey: String?
539
- private var marketBadgeActionKeys: [String?] = []
540
- private var restingBackgroundColor: UIColor = .clear
541
- private var pressedBackgroundColor = UIColor(
542
- nativeListHex: "#E8E8E8",
543
- fallback: .lightGray
544
- )
545
- private var checkboxCheckedColor = UIColor(nativeListHex: "#202020", fallback: .black)
546
- private var checkboxIconColor = UIColor.white
547
- private var checkboxUncheckedColor = UIColor(nativeListHex: "#FCFCFC", fallback: .white)
548
- private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray)
549
- private var visualBackdropColor = UIColor.white
550
- private var currentLayout = "linear"
551
- private var currentTheme: [String: Any]?
552
- private var currentItemIndex: Int?
553
- // OneKey patch: delayed image retries belong to the current reusable cell binding.
554
- private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:]
555
- private(set) var bindingEpoch = 0
556
-
557
- var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)?
558
- var onBindingInvalidated: ((NativeListCell, Int) -> Void)?
559
-
560
- override var isHighlighted: Bool {
561
- didSet { updateBackgroundColor() }
562
- }
563
-
564
- override init(frame: CGRect) {
565
- super.init(frame: frame)
566
- contentView.addSubview(rootStack)
567
- contentView.addSubview(separatorView)
568
- contentView.addSubview(walletGroupCompactContainer)
569
- rootStack.translatesAutoresizingMaskIntoConstraints = false
570
- separatorView.translatesAutoresizingMaskIntoConstraints = false
571
- walletGroupCompactContainer.translatesAutoresizingMaskIntoConstraints = false
572
- rootLeadingConstraint = rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12)
573
- rootTrailingConstraint = rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)
574
- rootTopConstraint = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
575
- rootBottomConstraint = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
576
- leadingIconWidth = leadingIconImageView.widthAnchor.constraint(equalToConstant: 18)
577
- leadingIconHeight = leadingIconImageView.heightAnchor.constraint(equalToConstant: 18)
578
- separatorLeadingConstraint = separatorView.leadingAnchor.constraint(
579
- equalTo: contentView.leadingAnchor
580
- )
581
- NSLayoutConstraint.activate([
582
- rootLeadingConstraint,
583
- rootTrailingConstraint,
584
- rootTopConstraint,
585
- rootBottomConstraint,
586
- separatorLeadingConstraint,
587
- separatorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
588
- separatorView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
589
- separatorView.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
590
- walletGroupCompactContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
591
- walletGroupCompactContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
592
- walletGroupCompactContainer.topAnchor.constraint(equalTo: contentView.topAnchor),
593
- walletGroupCompactContainer.heightAnchor.constraint(equalToConstant: 68),
594
- ])
595
- walletGroupCompactContainer.isHidden = true
596
- walletGroupCompactContainer.isUserInteractionEnabled = false
597
- favoriteIconImageView.translatesAutoresizingMaskIntoConstraints = false
598
- favoriteIconImageView.contentMode = .scaleAspectFit
599
- favoriteIconImageView.image = nativeListIcon(named: "StarOutline")
600
- headerTitleIconImageView.translatesAutoresizingMaskIntoConstraints = false
601
- headerTitleIconImageView.contentMode = .scaleAspectFit
602
- headerValueIconImageView.translatesAutoresizingMaskIntoConstraints = false
603
- headerValueIconImageView.contentMode = .scaleAspectFit
604
- leadingActionButton.translatesAutoresizingMaskIntoConstraints = false
605
- leadingActionButton.adjustsImageWhenDisabled = false
606
- leadingActionButton.tintAdjustmentMode = .normal
607
- leadingActionButton.addTarget(self, action: #selector(leadingActionPressed), for: .touchUpInside)
608
- NSLayoutConstraint.activate([
609
- favoriteIconImageView.widthAnchor.constraint(equalToConstant: 20),
610
- favoriteIconImageView.heightAnchor.constraint(equalToConstant: 20),
611
- headerTitleIconImageView.widthAnchor.constraint(equalToConstant: 12),
612
- headerTitleIconImageView.heightAnchor.constraint(equalToConstant: 12),
613
- headerValueIconImageView.widthAnchor.constraint(equalToConstant: 12),
614
- headerValueIconImageView.heightAnchor.constraint(equalToConstant: 12),
615
- leadingActionButton.widthAnchor.constraint(equalToConstant: 36),
616
- leadingActionButton.heightAnchor.constraint(equalToConstant: 36),
617
- ])
618
- separatorView.isHidden = true
619
- walletGroupTap.isEnabled = false
620
- addGestureRecognizer(walletGroupTap)
621
- rootStack.axis = .horizontal
622
- rootStack.alignment = .center
623
- rootStack.spacing = 12
624
- rootStack.setCustomSpacing(12, after: leadingActionButton)
625
- rootStack.distribution = .fill
626
- rootLeadingConstraint.constant = 12
627
- rootTrailingConstraint.constant = -12
628
- rootTopConstraint.constant = 8
629
- rootBottomConstraint.constant = -8
630
- mainStack.alignment = .fill
631
-
632
- leadingContainer.addSubview(fallbackLabel)
633
- fallbackLabel.translatesAutoresizingMaskIntoConstraints = false
634
- leadingContainer.addSubview(leadingIconImageView)
635
- leadingIconImageView.translatesAutoresizingMaskIntoConstraints = false
636
- leadingImages.enumerated().forEach { index, image in
637
- image.translatesAutoresizingMaskIntoConstraints = false
638
- if index == 1 {
639
- leadingOverlayBackground.translatesAutoresizingMaskIntoConstraints = false
640
- leadingContainer.addSubview(leadingOverlayBackground)
641
- }
642
- leadingContainer.addSubview(image)
643
- }
644
- leadingCornerIconBackground.translatesAutoresizingMaskIntoConstraints = false
645
- leadingCornerIconImageView.translatesAutoresizingMaskIntoConstraints = false
646
- leadingCornerIconBackground.addSubview(leadingCornerIconImageView)
647
- leadingContainer.addSubview(leadingCornerIconBackground)
648
- leadingCornerIconBackground.layer.cornerRadius = 10
649
- leadingCornerIconBackground.isHidden = true
650
- NSLayoutConstraint.activate([
651
- leadingCornerIconBackground.widthAnchor.constraint(equalToConstant: 20),
652
- leadingCornerIconBackground.heightAnchor.constraint(equalToConstant: 20),
653
- leadingCornerIconBackground.trailingAnchor.constraint(
654
- equalTo: leadingContainer.trailingAnchor,
655
- constant: 4
656
- ),
657
- leadingCornerIconBackground.bottomAnchor.constraint(
658
- equalTo: leadingContainer.bottomAnchor,
659
- constant: 4
660
- ),
661
- leadingCornerIconImageView.widthAnchor.constraint(equalToConstant: 18),
662
- leadingCornerIconImageView.heightAnchor.constraint(equalToConstant: 18),
663
- leadingCornerIconImageView.centerXAnchor.constraint(
664
- equalTo: leadingCornerIconBackground.centerXAnchor
665
- ),
666
- leadingCornerIconImageView.centerYAnchor.constraint(
667
- equalTo: leadingCornerIconBackground.centerYAnchor
668
- ),
669
- ])
670
- leadingOverlayBackground.layer.cornerRadius = 10
671
- leadingOverlayBackground.isHidden = true
672
- leadingCornerIconBackground.isHidden = true
673
- leadingCornerIconImageView.image = nil
674
- NSLayoutConstraint.activate([
675
- fallbackLabel.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor),
676
- fallbackLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
677
- fallbackLabel.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
678
- fallbackLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
679
- leadingIconWidth,
680
- leadingIconHeight,
681
- leadingIconImageView.centerXAnchor.constraint(equalTo: leadingContainer.centerXAnchor),
682
- leadingIconImageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor),
683
- ])
684
- leadingWidth = leadingContainer.widthAnchor.constraint(equalToConstant: 40)
685
- leadingHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 40)
686
- mediaHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 120)
687
- leadingWidth.isActive = true
688
- leadingHeight.isActive = true
689
- fallbackLabel.textAlignment = .center
690
- fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold)
691
- fallbackLabel.textColor = UIColor(nativeListHex: "#8D8D8D", fallback: .gray)
692
- secondaryImage.clipsToBounds = true
693
- secondaryImage.layer.cornerRadius = 6
694
- secondaryImage.translatesAutoresizingMaskIntoConstraints = false
695
- secondaryWidth = secondaryImage.widthAnchor.constraint(equalToConstant: 56)
696
- secondaryHeight = secondaryImage.heightAnchor.constraint(equalToConstant: 56)
697
- mediaNetworkImage.translatesAutoresizingMaskIntoConstraints = false
698
- NSLayoutConstraint.activate([
699
- mediaNetworkImage.widthAnchor.constraint(equalToConstant: 14),
700
- mediaNetworkImage.heightAnchor.constraint(equalToConstant: 14),
701
- ])
702
- mediaNetworkImage.layer.cornerRadius = 7
703
- mediaNetworkImage.clipsToBounds = true
704
-
705
- leadingContainer.addSubview(unreadDot)
706
- unreadDot.translatesAutoresizingMaskIntoConstraints = false
707
- unreadDot.layer.cornerRadius = 4
708
- NSLayoutConstraint.activate([
709
- unreadDot.widthAnchor.constraint(equalToConstant: 8),
710
- unreadDot.heightAnchor.constraint(equalToConstant: 8),
711
- unreadDot.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
712
- unreadDot.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
713
- ])
714
-
715
- leadingContainer.addSubview(mediaBadgeLabel)
716
- mediaBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
717
- mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium)
718
- mediaBadgeLabel.textAlignment = .center
719
- mediaBadgeLabel.layer.cornerRadius = 10
720
- mediaBadgeLabel.layer.borderWidth = 2
721
- mediaBadgeLabel.clipsToBounds = true
722
- NSLayoutConstraint.activate([
723
- mediaBadgeLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
724
- mediaBadgeLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
725
- mediaBadgeLabel.heightAnchor.constraint(equalToConstant: 24),
726
- ])
727
-
728
- mainStack.axis = .vertical
729
- mainStack.alignment = .fill
730
- mainStack.spacing = 2
731
- mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
732
- mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
733
- titleRowStack.axis = .horizontal
734
- titleRowStack.alignment = .center
735
- titleRowStack.spacing = 8
736
- titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
737
- titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
738
- titleRowStack.addArrangedSubview(titleLabel)
739
- titleRowStack.addArrangedSubview(badgeLabel)
740
- marketBadgeButtons.enumerated().forEach { index, button in
741
- button.tag = index
742
- button.addTarget(self, action: #selector(marketBadgePressed(_:)), for: .touchUpInside)
743
- button.titleLabel?.font = nativeListFont(ofSize: 11, weight: .medium)
744
- button.layer.cornerRadius = 4
745
- button.clipsToBounds = true
746
- let image = marketBadgeImages[index]
747
- image.isUserInteractionEnabled = false
748
- image.translatesAutoresizingMaskIntoConstraints = false
749
- button.addSubview(image)
750
- NSLayoutConstraint.activate([
751
- image.centerYAnchor.constraint(equalTo: button.centerYAnchor),
752
- image.widthAnchor.constraint(equalToConstant: 14),
753
- image.heightAnchor.constraint(equalToConstant: 14),
754
- ])
755
- titleRowStack.addArrangedSubview(button)
756
- }
757
- titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
758
- titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
759
- badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
760
- badgeLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
761
- titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
762
- subtitleLabel.font = nativeListFont(ofSize: 14)
763
- tertiaryLabel.font = nativeListFont(ofSize: 14)
764
- statusLabel.font = nativeListFont(ofSize: 12)
765
- metricSubtitleLabel.font = nativeListFont(ofSize: 12)
766
- metricCompositeStack.axis = .vertical
767
- metricCompositeStack.alignment = .fill
768
- metricCompositeStack.spacing = 12
769
- badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium)
770
- [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel]
771
- .forEach(mainStack.addArrangedSubview)
772
-
773
- actionStack.axis = .horizontal
774
- actionStack.alignment = .center
775
- actionStack.spacing = 8
776
- actionButtons.enumerated().forEach { index, button in
777
- button.titleLabel?.font = nativeListFont(ofSize: 12, weight: .medium)
778
- button.layer.cornerRadius = 8
779
- button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
780
- button.tag = index
781
- button.addTarget(self, action: #selector(footerActionPressed(_:)), for: .touchUpInside)
782
- actionStack.addArrangedSubview(button)
783
- }
784
- mainStack.addArrangedSubview(actionStack)
785
-
786
- trailingStack.axis = .vertical
787
- trailingStack.alignment = .trailing
788
- trailingStack.spacing = 2
789
- trailingStack.setContentHuggingPriority(.required, for: .horizontal)
790
- trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal)
791
- accessoryButtons.enumerated().forEach { index, button in
792
- button.adjustsImageWhenDisabled = false
793
- button.tintAdjustmentMode = .normal
794
- button.titleLabel?.font = nativeListFont(
795
- ofSize: index == 0 ? 16 : 14,
796
- weight: index == 0 ? .medium : .regular
797
- )
798
- button.tag = index
799
- button.addTarget(self, action: #selector(accessoryPressed(_:)), for: .touchUpInside)
800
- trailingStack.addArrangedSubview(button)
801
- }
802
- checkboxButton.layer.borderWidth = 2
803
- checkboxButton.layer.cornerRadius = 4
804
- checkboxButton.tintColor = checkboxUncheckedColor
805
- checkboxButton.imageView?.contentMode = .center
806
- checkboxButton.addTarget(self, action: #selector(checkboxPressed), for: .touchUpInside)
807
- NSLayoutConstraint.activate([
808
- checkboxButton.widthAnchor.constraint(equalToConstant: 20),
809
- checkboxButton.heightAnchor.constraint(equalToConstant: 20),
810
- ])
811
- trailingStack.addArrangedSubview(checkboxButton)
812
- trailingStack.addArrangedSubview(spinner)
813
-
814
- dataStack.axis = .horizontal
815
- dataStack.alignment = .center
816
- dataStack.distribution = .fillProportionally
817
- dataStack.spacing = 8
818
- dataLabels.forEach { label in
819
- label.font = nativeListFont(ofSize: 12)
820
- label.lineBreakMode = .byTruncatingTail
821
- dataStack.addArrangedSubview(label)
822
- }
823
- tableDataStack.axis = .horizontal
824
- tableDataStack.alignment = .center
825
- tableDataStack.distribution = .fill
826
- tableDataStack.spacing = 8
827
- tableDataColumns.forEach(tableDataStack.addArrangedSubview)
828
- }
829
-
830
- required init?(coder: NSCoder) {
831
- fatalError("init(coder:) has not been implemented")
832
- }
833
-
834
- override func layoutSubviews() {
835
- super.layoutSubviews()
836
- if let item = currentItem, item.type == "system", item.data.string("presentation") == "market",
837
- ["noMatch", "retry"].contains(item.data.string("variant")), !titleLabel.isHidden {
838
- titleLabel.transform = .identity
839
- // Settle each nested stack before measuring; the content view alone can leave stale descendant frames.
840
- contentView.layoutIfNeeded()
841
- rootStack.layoutIfNeeded()
842
- mainStack.layoutIfNeeded()
843
- titleRowStack.layoutIfNeeded()
844
- // React Native floors text origins to physical pixels after centering the line box.
845
- let scale = max(1, window?.screen.scale ?? traitCollection.displayScale)
846
- let origin = titleLabel.convert(titleLabel.bounds, to: contentView).origin
847
- let x = floor((contentView.bounds.width - titleLabel.bounds.width) / 2 * scale) / scale
848
- let contentHeight: CGFloat = item.data.string("variant") == "retry" ? 56 : 24
849
- let y = floor(max(32, (contentView.bounds.height - contentHeight) / 2) * scale) / scale
850
- titleLabel.transform = CGAffineTransform(translationX: x - origin.x, y: y - origin.y)
851
- }
852
- // OneKey patch: extend only the background across the section list outer inset.
853
- if currentItem?.data.bool("backgroundFullWidth") == true {
854
- selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height)
855
- }
856
- if currentItem?.type == "mediaTile" {
857
- mediaHeight.constant = max(0, contentView.bounds.width - 20)
858
- }
859
- if currentItem?.type == "identity", currentItem?.data["height"] != nil,
860
- currentItem?.data.string("presentation") == "accountSelector",
861
- let accessory = currentItem?.data.dictionaries("trailing").first,
862
- accessory.string("kind") == "icon", accessory.string("name") == "PlusSmallOutline" {
863
- // OneKey patch: PlusButton's fixed top18 slot and negative7 margin place its frame at11.
864
- let button = accessoryButtons[0]
865
- button.transform = .identity
866
- let origin = button.convert(button.bounds, to: contentView).minY
867
- button.transform = CGAffineTransform(translationX: 0, y: 11 - origin)
868
- }
869
- }
870
-
871
- // OneKey patch: trim only detached/recycled members, never a live compact
872
- // drag proxy or an expanding group. Small groups retain eight reusable cells.
873
- private func trimWalletGroupCells(keeping required: Int) {
874
- let retained = max(8, required)
875
- while walletGroupCells.count > retained {
876
- let cell = walletGroupCells.removeLast()
877
- rootStack.removeArrangedSubview(cell)
878
- cell.removeFromSuperview()
879
- cell.prepareForReuse()
880
- cell.onAction = nil
881
- cell.onBindingInvalidated = nil
882
- }
883
- }
884
-
885
- override func prepareForReuse() {
886
- let canTrimMembers = !walletGroupCompactAppearanceActive && (rootStack.layer.animationKeys()?.isEmpty ?? true)
887
- super.prepareForReuse()
888
- invalidateCurrentBinding()
889
- isHighlighted = false
890
- restingBackgroundColor = .clear
891
- contentView.backgroundColor = restingBackgroundColor
892
- currentItem = nil
893
- walletGroupCompactAppearanceActive = false
894
- walletGroupCompactContainer.isHidden = true
895
- walletGroupCompactContainer.alpha = 1
896
- walletGroupCompactCell?.prepareForReuse()
897
- walletGroupCells.forEach { $0.prepareForReuse() }
898
- walletGroupMembers.removeAll()
899
- if canTrimMembers { trimWalletGroupCells(keeping: 0) }
900
- leadingImages.forEach { $0.prepareForReuse() }
901
- secondaryImage.prepareForReuse()
902
- mediaNetworkImage.prepareForReuse()
903
- }
904
-
905
- func bind(
906
- item: NativeListItem,
907
- theme: [String: Any]?,
908
- layout: String,
909
- itemIndex: Int?,
910
- selected: Bool,
911
- checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
912
- ) {
913
- // OneKey patch: a same-row snapshot refresh must not clear a touch that is still held.
914
- let shouldRestoreHighlight = isHighlighted && currentItem?.key == item.key
915
- invalidateCurrentBinding()
916
- bindingEpoch &+= 1
917
- currentLayout = layout
918
- currentTheme = theme
919
- currentItemIndex = itemIndex
920
- currentItem = item
921
- leadingImages.forEach { $0.prepareForReuse() }
922
- secondaryImage.prepareForReuse()
923
- mediaNetworkImage.prepareForReuse()
924
- reset()
925
-
926
- let primary = nativeListColor(theme, "primaryText", "#202020")
927
- let secondary = nativeListColor(theme, "secondaryText", "#646464")
928
- let accent = nativeListColor(theme, "accent", "#108303")
929
- checkboxCheckedColor = primary
930
- checkboxUncheckedColor = nativeListColor(theme, "inverseText", "#FCFCFC")
931
- // Checkbox uses the literal neutral7 alpha token. Applying opacity to the
932
- // opaque primary text color produces a different RGB result.
933
- checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray)
934
- checkboxIconColor = checkboxUncheckedColor
935
- if item.data.string("presentation") == "networkSelector" {
936
- checkboxCheckedColor = nativeListColor(theme, "checkboxBackground", "#202020")
937
- checkboxBorderColor = nativeListColor(theme, "checkboxBorder", "#00000031")
938
- checkboxIconColor = nativeListColor(theme, "checkboxIcon", "#FFFFFF")
939
- // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse.
940
- checkboxUncheckedColor = checkboxIconColor
941
- }
942
- visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF")
943
- titleLabel.textColor = primary
944
- subtitleLabel.textColor = secondary
945
- tertiaryLabel.textColor = secondary
946
- statusLabel.textColor = secondary
947
- metricSubtitleLabel.textColor = secondary
948
- badgeLabel.textColor = accent
949
- accessoryButtons.forEach { $0.setTitleColor(primary, for: .normal) }
950
- unreadDot.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .systemRed)
951
- mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020")
952
- mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
953
- mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor
954
- separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0")
955
- separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12
956
- separatorView.isHidden = !item.data.bool("separator")
957
- restingBackgroundColor = selectionBackgroundColor(
958
- item: item,
959
- theme: theme,
960
- layout: layout,
961
- itemIndex: itemIndex,
962
- selected: selected
963
- )
964
- pressedBackgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8")
965
- if item.type == "rail" {
966
- pressedBackgroundColor = UIColor(nativeListHex: "#F0F0F0", fallback: .lightGray)
967
- } else if item.type == "mediaTile" {
968
- pressedBackgroundColor = restingBackgroundColor
969
- }
970
- updateBackgroundColor()
971
- if item.data.bool("backgroundFullWidth"), let background = item.data["backgroundColor"] as? String {
972
- selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor
973
- contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0)
974
- clipsToBounds = false
975
- }
976
- if layout == "table" {
977
- if item.type == "dataRow" {
978
- rootLeadingConstraint.constant = 20
979
- rootTrailingConstraint.constant = -20
980
- rootTopConstraint.constant = 10
981
- rootBottomConstraint.constant = -10
982
- rootStack.spacing = 10
983
- } else {
984
- rootLeadingConstraint.constant = 16
985
- rootTrailingConstraint.constant = -16
986
- }
987
- }
988
- applyGroupPosition(item.data.string("groupPosition"))
989
- isUserInteractionEnabled = !item.data.bool("disabled")
990
- // OneKey patch: deprecated wallets remain interactive while dimmed.
991
- // contentView.alpha = isUserInteractionEnabled ? 1 : 0.5
992
- contentView.alpha = CGFloat(item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5)
993
- accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title"))
994
- // OneKey patch: keep existing selector automation identifiers.
995
- accessibilityIdentifier = item.data["testID"] as? String
996
-
997
- switch item.type {
998
- case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState)
999
- case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState)
1000
- case "rail": bindRail(item, theme: theme)
1001
- case "activity": bindActivity(item, theme: theme)
1002
- case "message": bindMessage(item, theme: theme)
1003
- case "dataRow": bindDataRow(item, theme: theme, checkboxState)
1004
- case "market": bindMarket(item, theme: theme)
1005
- case "mediaTile": bindMediaTile(item, theme: theme)
1006
- case "metricCard": bindMetricCard(item, theme: theme)
1007
- case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState)
1008
- case "action": bindAction(item, theme: theme, checkboxState)
1009
- case "system": bindSystem(item, theme: theme)
1010
- default: break
1011
- }
1012
- applySelectorTypography(item)
1013
- if shouldRestoreHighlight && isUserInteractionEnabled {
1014
- isHighlighted = true
1015
- }
1016
- if item.type == "sectionHeader", item.data.string("presentation") == "networkSelector", item.data["height"] != nil, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty {
1017
- // OneKey patch: UIKit must reserve only the total's intrinsic width before the checkbox.
1018
- let valueWidth = accessoryButtons[0].intrinsicContentSize.width
1019
- let width = trailingStack.widthAnchor.constraint(equalToConstant: valueWidth + 12 + 20)
1020
- width.isActive = true
1021
- selectorConstraints.append(width)
1022
- }
1023
- }
1024
-
1025
- func updateSelection(
1026
- item: NativeListItem,
1027
- selected: Bool,
1028
- checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1029
- ) {
1030
- guard currentItem?.key == item.key else { return }
1031
- restoreSelectorTypography()
1032
- defer { applySelectorTypography(item) }
1033
- if item.type == "walletGroup" {
1034
- currentItem = item
1035
- let memberData = [item.data.dictionary("parent")].compactMap { $0 }
1036
- + item.data.dictionaries("children")
1037
- walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) }
1038
- walletGroupMembers.enumerated().forEach { index, member in
1039
- if walletGroupCells.indices.contains(index) {
1040
- walletGroupCells[index].updateSelection(
1041
- item: member,
1042
- selected: member.data.bool("selected"),
1043
- checkboxState: checkboxState
1044
- )
1045
- }
1046
- }
1047
- if let parent = walletGroupMembers.first {
1048
- walletGroupCompactCell?.updateSelection(
1049
- item: parent,
1050
- selected: parent.data.bool("selected"),
1051
- checkboxState: checkboxState
1052
- )
1053
- }
1054
- restingBackgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9")
1055
- pressedBackgroundColor = restingBackgroundColor
1056
- updateBackgroundColor()
1057
- return
1058
- }
1059
- if item.type == "sectionHeader", item.data.string("variant") == "summary" {
1060
- updateSummaryText(item)
1061
- }
1062
- currentItem = item
1063
- restingBackgroundColor = selectionBackgroundColor(
1064
- item: item,
1065
- theme: currentTheme,
1066
- layout: currentLayout,
1067
- itemIndex: currentItemIndex,
1068
- selected: selected
1069
- )
1070
- updateBackgroundColor()
1071
- if item.type == "identity", item.data.string("presentation") == "walletSidebar" {
1072
- titleLabel.textColor = nativeListColor(
1073
- currentTheme,
1074
- selected ? "primaryText" : "secondaryText",
1075
- selected ? "#FFFFFFED" : "#FFFFFFAF"
1076
- )
1077
- }
1078
- // OneKey patch: retain the latest descriptor when a controlled echo avoids full binding.
1079
- if boundCheckboxData != nil {
1080
- let latestCheckbox = item.type == "identity"
1081
- ? item.data.dictionaries("trailing").last { $0.string("kind") == "checkbox" }
1082
- : item.data.dictionary("checkbox")
1083
- boundCheckboxData = latestCheckbox ?? boundCheckboxData
1084
- }
1085
- guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return }
1086
- updateCheckboxPresentation(
1087
- item,
1088
- data,
1089
- target: target,
1090
- state: checkboxState(item, target, data.string("state", default: "unchecked"))
1091
- )
1092
- }
1093
-
1094
- // OneKey patch: match SizableText TABULAR_NUMS on every selector text run, retaining its face and size.
1095
- private func selectorTabularFont(_ font: UIFont) -> UIFont {
1096
- var settings = font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] ?? []
1097
- settings.removeAll { $0[.type] == kNumberSpacingType }
1098
- settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector])
1099
- return UIFont(descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), size: font.pointSize)
1100
- }
1101
-
1102
- private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString {
1103
- let result = NSMutableAttributedString(attributedString: original)
1104
- // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui.
1105
- result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length))
1106
- original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { value, range, _ in
1107
- if let font = value as? UIFont { result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) }
1108
- }
1109
- return result
1110
- }
1111
-
1112
- private func restoreSelectorTypography() {
1113
- selectorTypographyRestorers.reversed().forEach { $0() }
1114
- selectorTypographyRestorers.removeAll()
1115
- }
1116
-
1117
- private func applySelectorTypography(_ item: NativeListItem) {
1118
- guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) || item.type == "system" && item.data.string("variant") == "warning" else { return }
1119
- func visit(_ view: UIView) {
1120
- if let button = view as? UIButton {
1121
- if let original = button.attributedTitle(for: .normal) {
1122
- selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) }
1123
- button.setAttributedTitle(selectorTabularText(original), for: .normal)
1124
- }
1125
- } else if let label = view as? UILabel, let font = label.font {
1126
- let original = label.attributedText
1127
- selectorTypographyRestorers.append { label.font = font; label.attributedText = original }
1128
- label.font = selectorTabularFont(font)
1129
- if let original { label.attributedText = selectorTabularText(original) }
1130
- }
1131
- for child in view.subviews { visit(child) }
1132
- }
1133
- visit(contentView)
1134
- }
1135
-
1136
- private func updateSummaryText(_ item: NativeListItem) {
1137
- let title = item.data.string("title")
1138
- titleLabel.isHidden = title.isEmpty
1139
- setLineHeight(titleLabel, text: title, lineHeight: 24)
1140
-
1141
- let value = item.data.string("value")
1142
- let isExplicitNetworkHeader = item.data.string("presentation") == "networkSelector" && item.data["height"] != nil
1143
- let valueButton = accessoryButtons[0]
1144
- valueButton.isHidden = value.isEmpty
1145
- if value.isEmpty {
1146
- valueButton.setTitle(nil, for: .normal)
1147
- valueButton.setAttributedTitle(nil, for: .normal)
1148
- } else {
1149
- setButtonLine(
1150
- valueButton,
1151
- text: value,
1152
- font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular),
1153
- color: nativeListColor(currentTheme, "secondaryText", "#646464"),
1154
- lineHeight: 24
1155
- )
1156
- }
1157
- }
1158
-
1159
- private func selectionBackgroundColor(
1160
- item: NativeListItem,
1161
- theme: [String: Any]?,
1162
- layout: String,
1163
- itemIndex: Int?,
1164
- selected: Bool
1165
- ) -> UIColor {
1166
- var color = nativeListColor(
1167
- theme,
1168
- selected ? "rowSelectedBackground" : "rowBackground",
1169
- selected ? "#F0F0F0" : "#FFFFFF"
1170
- )
1171
- if item.type == "metricCard", !selected {
1172
- color = nativeListColor(theme, "subduedBackground", "#F9F9F9")
1173
- } else if item.type == "rail" || item.type == "mediaTile" {
1174
- // Selection is communicated by the destination state for these source
1175
- // components; neither has a persistent selected tile background.
1176
- color = nativeListColor(theme, "rowBackground", "#FFFFFF")
1177
- } else if layout == "sectioned", !item.data.bool("selected") {
1178
- // Checkbox-backed section lists in app-monorepo keep rows on $bg;
1179
- // selection is represented by the checkbox itself.
1180
- color = nativeListColor(theme, "rowBackground", "#FFFFFF")
1181
- } else if layout == "table",
1182
- item.type == "dataRow",
1183
- (item.data["index"] == nil ? itemIndex : item.data.int("index")).map({ $0 % 2 == 0 }) == true,
1184
- !selected {
1185
- color = nativeListColor(theme, "subduedBackground", "#F9F9F9")
1186
- }
1187
- // OneKey patch: portfolio group headers retain their source background.
1188
- if let backgroundColor = item.data["backgroundColor"] as? String {
1189
- return UIColor(nativeListHex: backgroundColor, fallback: color)
1190
- }
1191
- return color
1192
- }
1193
-
1194
- private func reset() {
1195
- titleLabel.transform = .identity
1196
- restoreSelectorTypography()
1197
- // OneKey patch: remove selector decorations before rebinding recycled cells.
1198
- selectorViews.forEach { $0.removeFromSuperview() }
1199
- selectorViews.removeAll()
1200
- NSLayoutConstraint.deactivate(selectorConstraints)
1201
- selectorConstraints.removeAll()
1202
- selectorImages.forEach { $0.prepareForReuse() }
1203
- selectorImages.removeAll()
1204
- marketBadgeImages.forEach { $0.prepareForReuse(); $0.isHidden = true }
1205
- selectorFullWidthBackground.removeFromSuperlayer()
1206
- selectorBorder?.removeFromSuperlayer()
1207
- selectorBorder = nil
1208
- titleLabel.removeGestureRecognizer(selectorTitleTap)
1209
- titleLabel.isUserInteractionEnabled = false
1210
- walletGroupCompactCell?.invalidateCurrentBinding()
1211
- walletGroupCells.forEach { $0.invalidateCurrentBinding() }
1212
- isHighlighted = false
1213
- rootStack.arrangedSubviews.forEach {
1214
- rootStack.removeArrangedSubview($0)
1215
- $0.removeFromSuperview()
1216
- }
1217
- walletGroupMembers.removeAll()
1218
- // OneKey patch: reset has detached the old hierarchy, including on direct
1219
- // large-to-small binds that do not pass through UICollectionView reuse.
1220
- let requiredMembers = currentItem?.type == "walletGroup" ? (currentItem?.data.dictionaries("children").count ?? 0) + 1 : 0
1221
- trimWalletGroupCells(keeping: requiredMembers)
1222
- walletGroupCompactAppearanceActive = false
1223
- walletGroupCompactContainer.isHidden = true
1224
- walletGroupCompactContainer.alpha = 1
1225
- walletGroupDragBadge.text = nil
1226
- rootStack.isHidden = false
1227
- rootStack.alpha = 1
1228
- walletGroupTap.isEnabled = false
1229
- contentView.layer.borderWidth = 0
1230
- contentView.layer.borderColor = nil
1231
- rootStack.axis = .horizontal
1232
- rootStack.alignment = .center
1233
- rootStack.spacing = 12
1234
- rootStack.setCustomSpacing(12, after: leadingActionButton)
1235
- rootStack.distribution = .fill
1236
- rootLeadingConstraint.constant = 12
1237
- rootTrailingConstraint.constant = -12
1238
- rootTopConstraint.constant = 8
1239
- rootBottomConstraint.constant = -8
1240
- mainStack.axis = .vertical
1241
- mainStack.alignment = .fill
1242
- mainStack.spacing = 2
1243
- mainStack.isHidden = false
1244
- mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
1245
- mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
1246
- titleRowStack.isHidden = false
1247
- titleRowStack.spacing = 8
1248
- trailingStack.isHidden = false
1249
- trailingStack.axis = .vertical
1250
- trailingStack.alignment = .trailing
1251
- trailingStack.spacing = 2
1252
- headerTitleIconImageView.removeFromSuperview()
1253
- headerTitleIconImageView.image = nil
1254
- headerTitleIconImageView.isHidden = true
1255
- headerValueIconImageView.removeFromSuperview()
1256
- headerValueIconImageView.image = nil
1257
- headerValueIconImageView.isHidden = true
1258
- // OneKey patch: restore the shared labels before any recycled row binds.
1259
- marketSubtitleStack.arrangedSubviews.forEach {
1260
- marketSubtitleStack.removeArrangedSubview($0)
1261
- $0.removeFromSuperview()
1262
- }
1263
- mainStack.removeArrangedSubview(marketSubtitleStack)
1264
- marketSubtitleStack.removeFromSuperview()
1265
- mainStack.removeArrangedSubview(tertiaryLabel)
1266
- tertiaryLabel.removeFromSuperview()
1267
- mediaMetadataStack.removeArrangedSubview(subtitleLabel)
1268
- mediaMetadataStack.removeArrangedSubview(mediaNetworkImage)
1269
- mediaMetadataStack.removeFromSuperview()
1270
- mainStack.removeArrangedSubview(titleRowStack)
1271
- titleRowStack.removeFromSuperview()
1272
- mainStack.removeArrangedSubview(subtitleLabel)
1273
- subtitleLabel.removeFromSuperview()
1274
- mainStack.insertArrangedSubview(titleRowStack, at: 0)
1275
- mainStack.insertArrangedSubview(subtitleLabel, at: 1)
1276
- mainStack.insertArrangedSubview(tertiaryLabel, at: 2)
1277
- leadingWidth.constant = 40
1278
- leadingHeight.constant = 40
1279
- leadingIconWidth.constant = 18
1280
- leadingIconHeight.constant = 18
1281
- leadingWidth.isActive = true
1282
- leadingHeight.isActive = true
1283
- mediaHeight.isActive = false
1284
- secondaryWidth.isActive = false
1285
- secondaryHeight.isActive = false
1286
- NSLayoutConstraint.deactivate(leadingSlotConstraints)
1287
- leadingSlotConstraints.removeAll()
1288
- NSLayoutConstraint.deactivate(dataWeightConstraints)
1289
- dataWeightConstraints.removeAll()
1290
- NSLayoutConstraint.deactivate(accessorySizeConstraints)
1291
- accessorySizeConstraints.removeAll()
1292
- dataStack.distribution = .fill
1293
- leadingImages.forEach {
1294
- $0.isHidden = true
1295
- $0.alpha = 1
1296
- $0.layer.cornerRadius = 0
1297
- $0.layer.mask = nil
1298
- }
1299
- leadingContainer.clipsToBounds = true
1300
- leadingContainer.layer.borderWidth = 0
1301
- leadingContainer.layer.borderColor = nil
1302
- leadingOverlayBackground.isHidden = true
1303
- leadingIconImageView.isHidden = true
1304
- leadingIconImageView.image = nil
1305
- leadingCornerIconBackground.isHidden = true
1306
- leadingCornerIconImageView.image = nil
1307
- favoriteIconImageView.isHidden = true
1308
- favoriteIconImageView.image = nativeListIcon(named: "StarOutline")
1309
- leadingActionButton.isHidden = true
1310
- leadingActionButton.setImage(nil, for: .normal)
1311
- leadingActionButton.setImage(nil, for: .disabled)
1312
- leadingActionButton.accessibilityIdentifier = nil
1313
- leadingActionButton.accessibilityLabel = nil
1314
- unreadDot.isHidden = true
1315
- mediaBadgeLabel.isHidden = true
1316
- mediaBadgeLabel.text = nil
1317
- fallbackLabel.isHidden = false
1318
- fallbackLabel.text = nil
1319
- fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold)
1320
- secondaryImage.isHidden = false
1321
- secondaryImage.layer.borderWidth = 0
1322
- secondaryImage.layer.borderColor = nil
1323
- mediaNetworkImage.isHidden = true
1324
- titleLabel.text = nil
1325
- titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
1326
- titleLabel.lineBreakMode = .byTruncatingTail
1327
- titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
1328
- titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
1329
- titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1330
- titleLabel.textAlignment = .natural
1331
- subtitleLabel.text = nil
1332
- subtitleLabel.lineBreakMode = .byTruncatingTail
1333
- subtitleLabel.attributedText = nil
1334
- subtitleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
1335
- subtitleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
1336
- tertiaryLabel.attributedText = nil
1337
- tertiaryLabel.lineBreakMode = .byTruncatingTail
1338
- tertiaryLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
1339
- tertiaryLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
1340
- tertiaryLabel.text = nil
1341
- statusLabel.text = nil
1342
- statusLabel.topInset = 0
1343
- statusLabel.bottomInset = 0
1344
- metricSubtitleLabel.text = nil
1345
- metricCompositeStack.arrangedSubviews.forEach {
1346
- metricCompositeStack.removeArrangedSubview($0)
1347
- $0.removeFromSuperview()
1348
- }
1349
- mainStack.removeArrangedSubview(metricCompositeStack)
1350
- metricCompositeStack.removeFromSuperview()
1351
- badgeLabel.text = nil
1352
- badgeLabel.horizontalInset = 0
1353
- badgeLabel.topInset = 0
1354
- badgeLabel.bottomInset = 0
1355
- subtitleLabel.font = nativeListFont(ofSize: 14)
1356
- tertiaryLabel.font = nativeListFont(ofSize: 14)
1357
- statusLabel.font = nativeListFont(ofSize: 12)
1358
- badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium)
1359
- badgeLabel.backgroundColor = .clear
1360
- badgeLabel.layer.cornerRadius = 0
1361
- badgeLabel.clipsToBounds = false
1362
- marketBadgeButtons.forEach {
1363
- $0.isHidden = true
1364
- $0.isEnabled = true
1365
- $0.isUserInteractionEnabled = false
1366
- // OneKey patch: a recycled badge must not retain an attributed title or font.
1367
- $0.setAttributedTitle(nil, for: .normal)
1368
- $0.marketLineHeight = nil
1369
- $0.selectorSummaryLineHeight = nil
1370
- $0.titleLabel?.font = nativeListFont(ofSize: 11, weight: .medium)
1371
- $0.titleLabel?.numberOfLines = 1
1372
- $0.contentHorizontalAlignment = .center
1373
- $0.accessibilityLabel = nil
1374
- $0.accessibilityTraits = .staticText
1375
- $0.setTitle(nil, for: .normal)
1376
- $0.setImage(nil, for: .normal)
1377
- $0.setTitleColor(nil, for: .normal)
1378
- $0.tintColor = nil
1379
- $0.backgroundColor = .clear
1380
- $0.contentEdgeInsets = .zero
1381
- $0.imageEdgeInsets = .zero
1382
- $0.titleEdgeInsets = .zero
1383
- }
1384
- [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel, badgeLabel, actionStack]
1385
- .forEach { $0.isHidden = true }
1386
- separatorView.isHidden = true
1387
- separatorLeadingConstraint.constant = 0
1388
- actionButtons.forEach {
1389
- $0.isHidden = true
1390
- $0.setTitle(nil, for: .normal)
1391
- $0.backgroundColor = .clear
1392
- }
1393
- accessoryButtons.enumerated().forEach { index, button in
1394
- button.isUserInteractionEnabled = true
1395
- button.selectorSummaryLineHeight = nil
1396
- button.marketLineHeight = nil
1397
- button.titleLabel?.font = nativeListFont(
1398
- ofSize: index == 0 ? 16 : 14,
1399
- weight: index == 0 ? .medium : .regular
1400
- )
1401
- button.isHidden = true
1402
- button.accessibilityIdentifier = nil
1403
- button.setTitle(nil, for: .normal)
1404
- button.setAttributedTitle(nil, for: .normal)
1405
- button.setImage(nil, for: .normal)
1406
- button.setImage(nil, for: .disabled)
1407
- button.isEnabled = true
1408
- button.alpha = 1
1409
- button.pressedBackgroundColor = nil
1410
- button.titleLabel?.numberOfLines = 1
1411
- button.contentHorizontalAlignment = .center
1412
- button.backgroundColor = .clear
1413
- button.layer.cornerRadius = 0
1414
- button.contentEdgeInsets = .zero
1415
- button.transform = .identity
1416
- }
1417
- checkboxButton.isHidden = true
1418
- checkboxButton.alpha = 1
1419
- checkboxButton.backgroundColor = .clear
1420
- checkboxButton.isEnabled = true
1421
- checkboxButton.setImage(nil, for: .normal)
1422
- spinner.stopAnimating()
1423
- spinner.alpha = 1
1424
- dataLabels.forEach {
1425
- $0.isHidden = true
1426
- $0.text = nil
1427
- $0.attributedText = nil
1428
- $0.numberOfLines = 1
1429
- }
1430
- tableDataColumns.forEach {
1431
- $0.reset()
1432
- $0.isHidden = true
1433
- }
1434
- skeletonPrimary.removeFromSuperview()
1435
- skeletonSecondary.removeFromSuperview()
1436
- accessoryActions = []
1437
- footerActionKeys = []
1438
- checkboxAction = nil
1439
- boundCheckboxData = nil
1440
- boundCheckboxTarget = nil
1441
- leadingActionKey = nil
1442
- marketBadgeActionKeys = []
1443
- layer.maskedCorners = []
1444
- layer.cornerRadius = 0
1445
- layer.cornerCurve = .circular
1446
- contentView.layer.cornerRadius = 0
1447
- contentView.layer.cornerCurve = .circular
1448
- contentView.clipsToBounds = false
1449
- leadingContainer.alpha = 1
1450
- titleLabel.showsDottedUnderline = false
1451
- titleLabel.reservesDottedUnderlineSpace = false
1452
- titleLabel.dottedUnderlineVerticalOffset = 0
1453
- }
1454
-
1455
- private func bindWalletGroup(
1456
- _ item: NativeListItem,
1457
- theme: [String: Any]?,
1458
- layout: String,
1459
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1460
- ) {
1461
- let memberData = [item.data.dictionary("parent")].compactMap { $0 }
1462
- + item.data.dictionaries("children")
1463
- walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) }
1464
- if walletGroupCompactCell == nil {
1465
- let compactCell = NativeListCell(frame: .zero)
1466
- compactCell.translatesAutoresizingMaskIntoConstraints = false
1467
- walletGroupCompactContainer.addSubview(compactCell)
1468
- walletGroupCompactContainer.addSubview(walletGroupDragBadge)
1469
- walletGroupDragBadge.translatesAutoresizingMaskIntoConstraints = false
1470
- walletGroupDragBadge.horizontalInset = 6
1471
- walletGroupDragBadge.textAlignment = .center
1472
- walletGroupDragBadge.font = nativeListTabularFont(ofSize: 12, weight: .semibold)
1473
- walletGroupDragBadge.layer.cornerRadius = 12
1474
- walletGroupDragBadge.layer.masksToBounds = true
1475
- walletGroupDragBadge.layer.borderWidth = 1
1476
- NSLayoutConstraint.activate([
1477
- compactCell.leadingAnchor.constraint(equalTo: walletGroupCompactContainer.leadingAnchor),
1478
- compactCell.trailingAnchor.constraint(equalTo: walletGroupCompactContainer.trailingAnchor),
1479
- compactCell.topAnchor.constraint(equalTo: walletGroupCompactContainer.topAnchor),
1480
- compactCell.bottomAnchor.constraint(equalTo: walletGroupCompactContainer.bottomAnchor),
1481
- walletGroupDragBadge.trailingAnchor.constraint(
1482
- equalTo: walletGroupCompactContainer.trailingAnchor,
1483
- constant: -4
1484
- ),
1485
- walletGroupDragBadge.bottomAnchor.constraint(
1486
- equalTo: walletGroupCompactContainer.bottomAnchor,
1487
- constant: -4
1488
- ),
1489
- walletGroupDragBadge.heightAnchor.constraint(equalToConstant: 24),
1490
- walletGroupDragBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 24),
1491
- ])
1492
- walletGroupCompactCell = compactCell
1493
- }
1494
- while walletGroupCells.count < walletGroupMembers.count {
1495
- let memberCell = NativeListCell(frame: .zero)
1496
- memberCell.translatesAutoresizingMaskIntoConstraints = false
1497
- // OneKey patch: each member's current height is applied when bound.
1498
- // memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true
1499
- walletGroupCells.append(memberCell)
1500
- }
1501
- rootStack.axis = .vertical
1502
- rootStack.alignment = .fill
1503
- rootStack.spacing = 12
1504
- rootLeadingConstraint.constant = 0
1505
- rootTrailingConstraint.constant = 0
1506
- rootTopConstraint.constant = 0
1507
- rootBottomConstraint.constant = 0
1508
- if memberData.first?["height"] != nil {
1509
- // OneKey patch: the source group's one-point border occupies layout space.
1510
- rootLeadingConstraint.constant = 1
1511
- rootTrailingConstraint.constant = -1
1512
- rootTopConstraint.constant = 1
1513
- rootBottomConstraint.constant = -1
1514
- }
1515
- walletGroupMembers.enumerated().forEach { index, member in
1516
- let memberCell = walletGroupCells[index]
1517
- // OneKey patch: badges add a second line within their logical wallet group.
1518
- memberCell.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.isActive = false }
1519
- memberCell.heightAnchor.constraint(equalToConstant: CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true
1520
- memberCell.onAction = { [weak self] source, action, target, origin in
1521
- self?.onAction?(source, action, target, origin)
1522
- }
1523
- memberCell.onBindingInvalidated = { [weak self] cell, epoch in
1524
- self?.onBindingInvalidated?(cell, epoch)
1525
- }
1526
- memberCell.bind(
1527
- item: member,
1528
- theme: theme,
1529
- layout: layout,
1530
- itemIndex: nil,
1531
- selected: member.data.bool("selected"),
1532
- checkboxState: checkboxState
1533
- )
1534
- rootStack.addArrangedSubview(memberCell)
1535
- }
1536
- if let parent = walletGroupMembers.first {
1537
- walletGroupCompactCell?.bind(
1538
- item: parent,
1539
- theme: theme,
1540
- layout: layout,
1541
- itemIndex: nil,
1542
- selected: parent.data.bool("selected"),
1543
- checkboxState: checkboxState
1544
- )
1545
- }
1546
- let childCount = item.data.dictionaries("children").filter {
1547
- ($0["draggable"] as? Bool) != false
1548
- }.count
1549
- walletGroupDragBadge.text = childCount > 0 ? "+\(childCount)" : nil
1550
- walletGroupDragBadge.isHidden = childCount == 0
1551
- walletGroupDragBadge.backgroundColor = nativeListColor(
1552
- theme,
1553
- "inverseBackground",
1554
- "#202020"
1555
- )
1556
- walletGroupDragBadge.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
1557
- walletGroupDragBadge.layer.borderColor = nativeListColor(
1558
- theme,
1559
- "rowBackground",
1560
- "#FFFFFF"
1561
- ).cgColor
1562
- restingBackgroundColor = nativeListColor(theme, "subduedBackground", "#F9F9F9")
1563
- pressedBackgroundColor = restingBackgroundColor
1564
- contentView.backgroundColor = restingBackgroundColor
1565
- contentView.layer.borderWidth = 1
1566
- contentView.layer.borderColor = nativeListColor(theme, "separator", "#E0E0E0").cgColor
1567
- contentView.layer.cornerRadius = 20
1568
- contentView.layer.cornerCurve = .continuous
1569
- contentView.clipsToBounds = true
1570
- walletGroupTap.isEnabled = true
1571
- }
1572
-
1573
- @objc private func walletGroupPressed(_ gesture: UITapGestureRecognizer) {
1574
- let point = gesture.location(in: rootStack)
1575
- for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated()
1576
- where cell.frame.contains(point) {
1577
- // OneKey patch: group member press gating must not disable accessory controls.
1578
- if !walletGroupMembers[index].data.bool("pressDisabled") {
1579
- onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin())
1580
- }
1581
- return
1582
- }
1583
- }
1584
-
1585
- func setPressed(_ pressed: Bool) {
1586
- if currentItem?.type == "walletGroup", walletGroupCompactAppearanceActive {
1587
- walletGroupCompactCell?.setPressed(pressed)
1588
- isHighlighted = false
1589
- return
1590
- }
1591
- isHighlighted = pressed && isUserInteractionEnabled
1592
- }
1593
-
1594
- func canStartWalletGroupReorder(at point: CGPoint) -> Bool {
1595
- guard currentItem?.type == "walletGroup" else { return true }
1596
- let groupPoint = rootStack.convert(point, from: self)
1597
- for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated()
1598
- where cell.frame.contains(groupPoint) {
1599
- return (walletGroupMembers[index].data["draggable"] as? Bool) != false
1600
- }
1601
- return true
1602
- }
1603
-
1604
- func setWalletGroupReorderCompact(_ compact: Bool) {
1605
- guard currentItem?.type == "walletGroup" else { return }
1606
- walletGroupCompactAppearanceActive = compact
1607
- rootStack.isHidden = compact
1608
- rootStack.alpha = compact ? 0 : 1
1609
- walletGroupCompactContainer.isHidden = !compact
1610
- walletGroupCompactContainer.alpha = compact ? 1 : 0
1611
- if compact {
1612
- contentView.backgroundColor = .clear
1613
- contentView.layer.borderWidth = 0
1614
- contentView.layer.borderColor = nil
1615
- } else {
1616
- restoreWalletGroupOuterAppearance()
1617
- }
1618
- }
1619
-
1620
- func prepareWalletGroupReorderExpansion() {
1621
- guard currentItem?.type == "walletGroup" else { return }
1622
- walletGroupCompactAppearanceActive = false
1623
- rootStack.isHidden = false
1624
- rootStack.alpha = 0
1625
- walletGroupCompactContainer.isHidden = false
1626
- walletGroupCompactContainer.alpha = 1
1627
- restoreWalletGroupOuterAppearance()
1628
- }
1629
-
1630
- func animateWalletGroupReorderExpansion() {
1631
- guard currentItem?.type == "walletGroup" else { return }
1632
- rootStack.alpha = 1
1633
- walletGroupCompactContainer.alpha = 0
1634
- }
1635
-
1636
- func finishWalletGroupReorderExpansion() {
1637
- guard currentItem?.type == "walletGroup" else { return }
1638
- rootStack.isHidden = false
1639
- rootStack.alpha = 1
1640
- walletGroupCompactContainer.isHidden = true
1641
- walletGroupCompactContainer.alpha = 1
1642
- restoreWalletGroupOuterAppearance()
1643
- }
1644
-
1645
- private func restoreWalletGroupOuterAppearance() {
1646
- contentView.backgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9")
1647
- contentView.layer.borderWidth = 1
1648
- contentView.layer.borderColor = nativeListColor(
1649
- currentTheme,
1650
- "separator",
1651
- "#E0E0E0"
1652
- ).cgColor
1653
- }
1654
-
1655
- private func updateBackgroundColor() {
1656
- let pressed = isHighlighted && isUserInteractionEnabled
1657
- contentView.backgroundColor = pressed ? pressedBackgroundColor : restingBackgroundColor
1658
- if currentItem?.type == "walletGroup" {
1659
- if walletGroupCompactAppearanceActive {
1660
- contentView.backgroundColor = .clear
1661
- contentView.layer.borderWidth = 0
1662
- contentView.layer.borderColor = nil
1663
- } else {
1664
- restoreWalletGroupOuterAppearance()
1665
- }
1666
- layer.maskedCorners = [
1667
- .layerMinXMinYCorner,
1668
- .layerMaxXMinYCorner,
1669
- .layerMinXMaxYCorner,
1670
- .layerMaxXMaxYCorner,
1671
- ]
1672
- layer.cornerRadius = 20
1673
- layer.cornerCurve = .continuous
1674
- layer.masksToBounds = true
1675
- contentView.layer.cornerRadius = 20
1676
- contentView.layer.cornerCurve = .continuous
1677
- contentView.clipsToBounds = true
1678
- return
1679
- }
1680
- if currentItem?.type == "mediaTile" {
1681
- // NFTListItem's group hover/press style belongs to the image wrapper,
1682
- // not to the complete card.
1683
- leadingContainer.alpha = pressed ? 0.8 : 1
1684
- layer.maskedCorners = [
1685
- .layerMinXMinYCorner,
1686
- .layerMaxXMinYCorner,
1687
- .layerMinXMaxYCorner,
1688
- .layerMaxXMaxYCorner,
1689
- ]
1690
- layer.cornerRadius = 16
1691
- layer.masksToBounds = true
1692
- contentView.layer.cornerRadius = 16
1693
- contentView.clipsToBounds = true
1694
- return
1695
- }
1696
- if pressed {
1697
- let isWalletSidebar = currentItem?.type == "identity"
1698
- && currentItem?.data.string("presentation") == "walletSidebar"
1699
- let radius: CGFloat
1700
- if isWalletSidebar {
1701
- radius = 20
1702
- } else {
1703
- radius = currentItem?.type == "rail" ? 8 : 12
1704
- }
1705
- layer.maskedCorners = [
1706
- .layerMinXMinYCorner,
1707
- .layerMaxXMinYCorner,
1708
- .layerMinXMaxYCorner,
1709
- .layerMaxXMaxYCorner,
1710
- ]
1711
- layer.cornerRadius = radius
1712
- layer.cornerCurve = isWalletSidebar ? .continuous : .circular
1713
- layer.masksToBounds = true
1714
- contentView.layer.cornerRadius = radius
1715
- contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular
1716
- contentView.clipsToBounds = true
1717
- } else {
1718
- applyGroupPosition(currentItem?.data.string("groupPosition") ?? "")
1719
- // OneKey patch: explicit account and network selectors preserve ListItem radius while idle.
1720
- // let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0
1721
- let isSelectorIdentity = currentItem?.type == "identity" && currentItem?.data["height"] != nil
1722
- let isAccountSelector = isSelectorIdentity && ["accountSelector", "networkSelector"].contains(currentItem?.data.string("presentation") ?? "")
1723
- let isWalletSidebar = isSelectorIdentity && currentItem?.data.string("presentation") == "walletSidebar"
1724
- let restingRadius: CGFloat = isWalletSidebar ? 20 : currentItem?.type == "metricCard" || isAccountSelector ? 12 : 0
1725
- contentView.layer.cornerRadius = restingRadius
1726
- contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular
1727
- contentView.clipsToBounds = restingRadius > 0
1728
- }
1729
- }
1730
-
1731
- private func bindIdentity(
1732
- _ item: NativeListItem,
1733
- theme: [String: Any]?,
1734
- selected: Bool,
1735
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
1736
- ) {
1737
- if item.data.string("presentation") == "walletSidebar" {
1738
- rootStack.axis = .vertical
1739
- rootStack.alignment = .center
1740
- rootStack.spacing = 4
1741
- rootLeadingConstraint.constant = 4
1742
- rootTrailingConstraint.constant = -4
1743
- rootTopConstraint.constant = 4
1744
- rootBottomConstraint.constant = -4
1745
- mainStack.alignment = .center
1746
- mainStack.spacing = 0
1747
- titleRowStack.setContentHuggingPriority(.required, for: .horizontal)
1748
- titleLabel.setContentHuggingPriority(.required, for: .horizontal)
1749
- titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1750
- titleLabel.font = nativeListFont(ofSize: 12)
1751
- titleLabel.textAlignment = .center
1752
- fallbackLabel.font = nativeListFont(ofSize: 28)
1753
- addLeading(item.data.dictionary("leading"), key: item.key)
1754
- rootStack.addArrangedSubview(mainStack)
1755
- // OneKey patch: activate width constraints only after both stacks share an ancestor.
1756
- if item.data["height"] != nil {
1757
- titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1758
- titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1759
- let width = mainStack.widthAnchor.constraint(equalTo: rootStack.widthAnchor)
1760
- width.isActive = true
1761
- selectorConstraints.append(width)
1762
- let titleWidth = titleRowStack.widthAnchor.constraint(lessThanOrEqualTo: mainStack.widthAnchor)
1763
- titleWidth.isActive = true
1764
- selectorConstraints.append(titleWidth)
1765
- }
1766
- show(titleLabel, item.data.string("title"), lines: 1)
1767
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16)
1768
- titleLabel.textColor = nativeListColor(
1769
- theme,
1770
- selected ? "primaryText" : "secondaryText",
1771
- selected ? "#FFFFFFED" : "#FFFFFFAF"
1772
- )
1773
- // OneKey patch: wallet tags belong below the centered name.
1774
- let badges = item.data.dictionaries("badges")
1775
- if !badges.isEmpty {
1776
- let line = UIStackView()
1777
- line.axis = .horizontal
1778
- line.spacing = 4
1779
- line.alignment = .center
1780
- for badge in badges {
1781
- let label = NativeListInsetLabel()
1782
- let isSelector = item.data["height"] != nil
1783
- let isWarning = badge.string("tone") == "warning"
1784
- label.font = nativeListFont(ofSize: isSelector ? 11 : 12)
1785
- label.textColor = nativeListColor(theme, isSelector && isWarning ? "caution" : "secondaryText", isSelector && isWarning ? "#AB6400" : "#646464")
1786
- label.backgroundColor = nativeListColor(theme, isSelector ? (isWarning ? "cautionBackground" : "subduedBackground") : "strongBackground", isSelector && isWarning ? "#FFF8C5" : "#F0F0F0")
1787
- label.horizontalInset = isSelector ? 6 : 4
1788
- label.topInset = 2
1789
- label.bottomInset = 2
1790
- label.layer.cornerRadius = 4
1791
- label.clipsToBounds = true
1792
- setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16)
1793
- line.addArrangedSubview(label)
1794
- }
1795
- mainStack.spacing = 4
1796
- mainStack.addArrangedSubview(line)
1797
- selectorViews.append(line)
1798
- }
1799
- return
1800
- }
1801
- if item.data.string("presentation") == "accountSelector" {
1802
- leadingWidth.constant = 32
1803
- leadingHeight.constant = 32
1804
- titleLabel.font = nativeListFont(ofSize: 16)
1805
- }
1806
- if item.data.dictionary("leading")?.string("kind") == "network" {
1807
- leadingWidth.constant = 32
1808
- leadingHeight.constant = 32
1809
- }
1810
- if let leadingAction = item.data.dictionary("leadingAction") {
1811
- leadingActionButton.isHidden = false
1812
- let tintColor = UIColor(
1813
- nativeListHex: leadingAction.string("tintColor", default: "#646464"),
1814
- fallback: .darkGray
1815
- )
1816
- leadingActionButton.tintColor = tintColor
1817
- if let image = nativeListIcon(named: leadingAction.string("name")) {
1818
- leadingActionButton.setImage(image, for: .normal)
1819
- leadingActionButton.setImage(
1820
- image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
1821
- for: .disabled
1822
- )
1823
- }
1824
- leadingActionButton.isEnabled = !leadingAction.bool("disabled")
1825
- leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4
1826
- leadingActionKey = leadingAction.string("actionKey")
1827
- rootStack.addArrangedSubview(leadingActionButton)
1828
- // ListItem.IconButton is a medium tertiary button: its 36-point frame
1829
- // carries m=-7. The collection already contributes ListItem's outer
1830
- // mx=8, so move the frame 7 points into that inset and reduce only the
1831
- // following gap by 7. This preserves the source button, avatar and text
1832
- // positions without shrinking the 24-point SVG glyph or its hit target.
1833
- rootLeadingConstraint.constant = 5
1834
- rootStack.setCustomSpacing(5, after: leadingActionButton)
1835
- }
1836
- addLeading(item.data.dictionary("leading"), key: item.key)
1837
- // OneKey patch: custom network initials match LetterAvatar size 32.
1838
- if item.data.string("presentation") == "networkSelector", let leading = item.data.dictionary("leading"), leading.dictionary("image") == nil, leading.dictionary("fallbackIcon") == nil, !leading.string("fallbackText").isEmpty {
1839
- fallbackLabel.font = nativeListFont(ofSize: 19, weight: .semibold)
1840
- fallbackLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
1841
- setLineHeight(fallbackLabel, text: leading.string("fallbackText"), lineHeight: 27)
1842
- }
1843
- rootStack.addArrangedSubview(mainStack)
1844
- show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1))
1845
- show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1))
1846
- show(tertiaryLabel, item.data.string("tertiary"), lines: 1)
1847
- if !item.data.string("subtitle").isEmpty, item.data.string("tertiary").isEmpty {
1848
- mainStack.spacing = 0
1849
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
1850
- setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20)
1851
- }
1852
- // OneKey patch: preserve independent balance/address truncation and warning tones.
1853
- let segments = item.data.dictionaries("subtitleSegments")
1854
- if !segments.isEmpty {
1855
- subtitleLabel.isHidden = true
1856
- mainStack.spacing = 0
1857
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
1858
- let line = UIStackView()
1859
- line.axis = .horizontal
1860
- line.alignment = .center
1861
- line.spacing = 0
1862
- for segment in segments {
1863
- if segment.bool("separatorBefore") {
1864
- let gap = UIView()
1865
- gap.translatesAutoresizingMaskIntoConstraints = false
1866
- let dot = UIView()
1867
- dot.translatesAutoresizingMaskIntoConstraints = false
1868
- dot.backgroundColor = nativeListColor(theme, "disabledText", "#8D8D8D")
1869
- dot.layer.cornerRadius = 2
1870
- gap.addSubview(dot)
1871
- NSLayoutConstraint.activate([
1872
- gap.widthAnchor.constraint(equalToConstant: 16),
1873
- gap.heightAnchor.constraint(equalToConstant: 20),
1874
- dot.widthAnchor.constraint(equalToConstant: 4),
1875
- dot.heightAnchor.constraint(equalToConstant: 4),
1876
- dot.centerXAnchor.constraint(equalTo: gap.centerXAnchor),
1877
- dot.centerYAnchor.constraint(equalTo: gap.centerYAnchor),
1878
- ])
1879
- line.addArrangedSubview(gap)
1880
- }
1881
- let label = UILabel()
1882
- label.font = nativeListFont(ofSize: 14)
1883
- label.lineBreakMode = .byTruncatingTail
1884
- // Match V1: the balance may shrink, but keep the shortened address intact.
1885
- label.setContentCompressionResistancePriority(
1886
- item.data.string("presentation") == "accountSelector" && segment.bool("separatorBefore")
1887
- ? .defaultHigh
1888
- : .defaultLow,
1889
- for: .horizontal
1890
- )
1891
- label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme)
1892
- setLineHeight(label, text: segment.string("text"), lineHeight: 20)
1893
- let runs = segment.dictionaries("textSegments")
1894
- if !runs.isEmpty {
1895
- let value = NSMutableAttributedString(string: "")
1896
- let paragraph = NSMutableParagraphStyle()
1897
- paragraph.minimumLineHeight = 20
1898
- paragraph.maximumLineHeight = 20
1899
- for run in runs {
1900
- value.append(NSAttributedString(string: run.string("text"), attributes: [
1901
- .font: nativeListFont(ofSize: run.string("style") == "subscript" ? 9 : 14),
1902
- .foregroundColor: label.textColor as Any,
1903
- .paragraphStyle: paragraph,
1904
- .baselineOffset: max(0, (20 - nativeListFont(ofSize: 14).lineHeight) / 2),
1905
- ]))
1906
- }
1907
- label.attributedText = value
1908
- }
1909
- line.addArrangedSubview(label)
1910
- }
1911
- let filler = UIView()
1912
- filler.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal)
1913
- line.addArrangedSubview(filler)
1914
- mainStack.insertArrangedSubview(line, at: 2)
1915
- selectorViews.append(line)
1916
- }
1917
- let matches = item.data.dictionaries("titleMatch")
1918
- if !matches.isEmpty {
1919
- let text = NSMutableAttributedString(attributedString: titleLabel.attributedText ?? NSAttributedString(string: item.data.string("title")))
1920
- for match in matches {
1921
- let start = match.int("start")
1922
- let end = match.int("end")
1923
- if start >= 0 && end > start && end <= text.length {
1924
- text.addAttribute(.foregroundColor, value: nativeListColor(theme, "info", "#0D74CE"), range: NSRange(location: start, length: end - start))
1925
- }
1926
- }
1927
- titleLabel.attributedText = text
1928
- }
1929
- tertiaryLabel.textColor = nativeListColor(
1930
- theme,
1931
- item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText",
1932
- item.data.string("tertiaryTone") == "info" ? "#0D74CE" : "#646464"
1933
- )
1934
- let badges = item.data.dictionaries("badges").prefix(2).map { $0.string("text") }
1935
- if !badges.isEmpty {
1936
- let badgeText = badges.joined(separator: " · ")
1937
- show(badgeLabel, badgeText, lines: 1)
1938
- badgeLabel.horizontalInset = 8
1939
- badgeLabel.topInset = 2
1940
- badgeLabel.bottomInset = 2
1941
- setLineHeight(badgeLabel, text: badgeText, lineHeight: 16)
1942
- badgeLabel.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
1943
- badgeLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
1944
- badgeLabel.layer.cornerRadius = 4
1945
- badgeLabel.clipsToBounds = true
1946
- }
1947
- rootStack.addArrangedSubview(trailingStack)
1948
- let accessories = item.data.dictionaries("trailing")
1949
- if accessories.contains(where: { $0.string("kind") == "checkbox" }) &&
1950
- accessories.contains(where: { $0.string("kind") == "value" }) {
1951
- trailingStack.axis = .horizontal
1952
- trailingStack.alignment = .center
1953
- trailingStack.spacing = 12
1954
- } else if accessories.count == 2,
1955
- accessories[0].string("kind") == "valuePair",
1956
- accessories[1].string("kind") == "menu" {
1957
- trailingStack.axis = .horizontal
1958
- trailingStack.alignment = .center
1959
- trailingStack.spacing = 8
1960
- } else if accessories.count == 2,
1961
- accessories[0].string("kind") == "icon",
1962
- accessories[0].string("name") == "PencilOutline",
1963
- accessories[1].string("kind") == "icon",
1964
- accessories[1].string("name") == "DragOutline" {
1965
- trailingStack.axis = .horizontal
1966
- trailingStack.alignment = .center
1967
- // Each source IconButton has a 36-point frame and m=-7. XStack gap=$6
1968
- // therefore places the physical frames 10 points apart (46-point
1969
- // center distance), not 24 points apart.
1970
- trailingStack.spacing = 10
1971
- }
1972
- bindAccessories(item, accessories, theme, checkboxState)
1973
- if item.data.string("presentation") == "accountSelector",
1974
- accessories.count == 1,
1975
- let accessory = accessoryButtons.first(where: { !$0.isHidden }) {
1976
- // The vertical trailing stack has no stable intrinsic width, so keep its
1977
- // flexible space in the account title and subtitle column.
1978
- let width = trailingStack.widthAnchor.constraint(equalTo: accessory.widthAnchor)
1979
- width.isActive = true
1980
- selectorConstraints.append(width)
1981
- }
1982
- }
1983
-
1984
- private func bindRail(_ item: NativeListItem, theme: [String: Any]?) {
1985
- rootStack.spacing = 6
1986
- rootLeadingConstraint.constant = 4
1987
- rootTrailingConstraint.constant = -4
1988
- rootTopConstraint.constant = 4
1989
- rootBottomConstraint.constant = -4
1990
- leadingWidth.constant = 20
1991
- leadingHeight.constant = 20
1992
- addLeading(item.data.dictionary("visual"), key: item.key)
1993
- show(titleLabel, item.data.string("title"), lines: 1)
1994
- titleLabel.font = nativeListFont(ofSize: 12, weight: .medium)
1995
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16)
1996
- mainStack.axis = .horizontal
1997
- mainStack.alignment = .center
1998
- mainStack.spacing = 6
1999
- mainStack.setContentHuggingPriority(.required, for: .horizontal)
2000
- mainStack.setContentCompressionResistancePriority(.required, for: .horizontal)
2001
- titleRowStack.setContentHuggingPriority(.required, for: .horizontal)
2002
- titleRowStack.setContentCompressionResistancePriority(.required, for: .horizontal)
2003
- titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
2004
- if let badge = item.data.dictionary("badge") {
2005
- show(badgeLabel, badge.string("text"), lines: 1)
2006
- badgeLabel.font = nativeListTabularFont(ofSize: 12, weight: .medium)
2007
- badgeLabel.textColor = badge.string("tone") == "success"
2008
- ? nativeListColor(theme, "positive", "#218358")
2009
- : badge.string("tone") == "danger"
2010
- ? nativeListColor(theme, "negative", "#CE2C31")
2011
- : nativeListColor(theme, "secondaryText", "#646464")
2012
- setLineHeight(badgeLabel, text: badge.string("text"), lineHeight: 16)
2013
- }
2014
- let status = item.data.string("status")
2015
- if !status.isEmpty, status != "none" {
2016
- show(statusLabel, status, lines: 1)
2017
- statusLabel.font = nativeListTabularFont(ofSize: 12)
2018
- setLineHeight(statusLabel, text: status, lineHeight: 16)
2019
- }
2020
- rootStack.addArrangedSubview(mainStack)
2021
- contentView.layer.cornerRadius = 8
2022
- contentView.clipsToBounds = true
2023
- }
2024
-
2025
- private func bindActivity(_ item: NativeListItem, theme: [String: Any]?) {
2026
- addLeading(
2027
- item.data.dictionary("leading"),
2028
- secondaryVisual: item.data.dictionary("secondaryLeading"),
2029
- key: item.key
2030
- )
2031
- rootStack.addArrangedSubview(mainStack)
2032
- show(titleLabel, item.data.string("title"), lines: 1)
2033
- show(subtitleLabel, item.data.string("description"), lines: 2)
2034
- mainStack.spacing = 0
2035
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
2036
- setLineHeight(subtitleLabel, text: item.data.string("description"), lineHeight: 20)
2037
- if item.data.string("status") == "Failed" {
2038
- show(badgeLabel, " Failed ", lines: 1)
2039
- badgeLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
2040
- badgeLabel.backgroundColor = nativeListColor(
2041
- theme,
2042
- "criticalBackground",
2043
- "#F3000D14"
2044
- )
2045
- badgeLabel.layer.cornerRadius = 4
2046
- badgeLabel.clipsToBounds = true
2047
- } else {
2048
- show(statusLabel, item.data.string("status"), lines: 1)
2049
- }
2050
- rootStack.addArrangedSubview(trailingStack)
2051
- showAccessory(0, item.data.string("primaryAmount"))
2052
- showAccessory(1, item.data.string("secondaryAmount"))
2053
- let primaryAmountColor = item.data.string("primaryAmount").hasPrefix("+")
2054
- ? nativeListColor(theme, "positive", "#218358")
2055
- : nativeListColor(theme, "primaryText", "#202020")
2056
- setButtonLine(
2057
- accessoryButtons[0],
2058
- text: item.data.string("primaryAmount"),
2059
- font: nativeListTabularFont(ofSize: 16, weight: .medium),
2060
- color: primaryAmountColor,
2061
- lineHeight: 24
2062
- )
2063
- setButtonLine(
2064
- accessoryButtons[1],
2065
- text: item.data.string("secondaryAmount"),
2066
- font: nativeListTabularFont(ofSize: 14),
2067
- color: nativeListColor(theme, "secondaryText", "#646464"),
2068
- lineHeight: 20
2069
- )
2070
- let actions = item.data.dictionaries("footerActions").prefix(3)
2071
- if !actions.isEmpty {
2072
- actionStack.isHidden = false
2073
- for (index, action) in actions.enumerated() {
2074
- let button = actionButtons[index]
2075
- button.isHidden = false
2076
- button.isEnabled = !action.bool("disabled")
2077
- button.setTitle(action.string("label"), for: .normal)
2078
- button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
2079
- button.setTitleColor(
2080
- action.string("tone") == "danger"
2081
- ? nativeListColor(theme, "negative", "#CE2C31")
2082
- : nativeListColor(theme, "primaryText", "#202020"),
2083
- for: .normal
2084
- )
2085
- footerActionKeys.append(action.string("key"))
2086
- }
2087
- }
2088
- }
2089
-
2090
- private func bindMessage(_ item: NativeListItem, theme: [String: Any]?) {
2091
- rootStack.alignment = .top
2092
- rootTopConstraint.constant = 16
2093
- rootBottomConstraint.constant = -16
2094
- leadingWidth.constant = 28
2095
- leadingHeight.constant = 28
2096
- if let leading = item.data.dictionary("leading") { addLeading(leading, key: item.key) }
2097
- unreadDot.isHidden = !item.data.bool("unread")
2098
- rootStack.addArrangedSubview(mainStack)
2099
- show(titleLabel, item.data.string("title"), lines: 2)
2100
- titleLabel.font = nativeListFont(ofSize: 14, weight: .semibold)
2101
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
2102
- show(
2103
- subtitleLabel,
2104
- item.data.string("body"),
2105
- lines: min(3, max(1, item.data.int("bodyLines", default: 3)))
2106
- )
2107
- subtitleLabel.font = nativeListFont(ofSize: 14)
2108
- setLineHeight(subtitleLabel, text: item.data.string("body"), lineHeight: 20)
2109
- show(statusLabel, item.data.string("time"), lines: 1)
2110
- statusLabel.font = nativeListFont(ofSize: 12)
2111
- statusLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
2112
- statusLabel.topInset = 2
2113
- setLineHeight(statusLabel, text: item.data.string("time"), lineHeight: 16)
2114
- if let thumbnail = item.data.dictionary("thumbnail") {
2115
- rootStack.addArrangedSubview(secondaryImage)
2116
- secondaryWidth.constant = 64
2117
- secondaryHeight.constant = 64
2118
- secondaryWidth.isActive = true
2119
- secondaryHeight.isActive = true
2120
- secondaryImage.layer.borderWidth = 1 / UIScreen.main.scale
2121
- secondaryImage.layer.borderColor = UIColor(
2122
- nativeListHex: "#0000000F",
2123
- fallback: .lightGray
2124
- ).cgColor
2125
- bindImage(thumbnail, into: secondaryImage, token: item.key, slot: 0, variant: "generic")
2126
- }
2127
- }
2128
-
2129
- private func bindDataRow(
2130
- _ item: NativeListItem,
2131
- theme: [String: Any]?,
2132
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
2133
- ) {
2134
- if item.data.bool("favorite") || item.data.bool("favoriteActive") {
2135
- favoriteIconImageView.isHidden = false
2136
- let favoriteActive = item.data.bool("favoriteActive")
2137
- favoriteIconImageView.image = nativeListIcon(
2138
- named: favoriteActive ? "StarSolid" : "StarOutline"
2139
- )
2140
- favoriteIconImageView.tintColor = nativeListColor(
2141
- theme,
2142
- favoriteActive ? "icon" : "iconSubdued",
2143
- favoriteActive ? "#646464" : "#8D8D8D"
2144
- )
2145
- rootStack.addArrangedSubview(favoriteIconImageView)
2146
- if currentLayout == "table" {
2147
- rootStack.setCustomSpacing(8, after: favoriteIconImageView)
2148
- }
2149
- }
2150
- rootStack.spacing = currentLayout == "table" ? 10 : 8
2151
- if let leading = item.data.dictionary("leading") {
2152
- leadingWidth.constant = 40
2153
- leadingHeight.constant = 40
2154
- addLeading(leading, key: item.key)
2155
- }
2156
- var hasLeadingAccessory = false
2157
- if let checkbox = item.data.dictionary("checkbox") {
2158
- bindCheckbox(item, checkbox, checkboxState)
2159
- hasLeadingAccessory = true
2160
- }
2161
- if item.data["index"] != nil {
2162
- showAccessory(0, String(item.data.int("index")))
2163
- hasLeadingAccessory = true
2164
- }
2165
- if hasLeadingAccessory { rootStack.addArrangedSubview(trailingStack) }
2166
- let columns = Array(item.data.dictionaries("columns").prefix(4))
2167
- let rowBadges = item.data.dictionaries("badges")
2168
- if currentLayout == "table" {
2169
- rootStack.addArrangedSubview(tableDataStack)
2170
- for (index, column) in columns.enumerated() {
2171
- tableDataColumns[index].isHidden = false
2172
- tableDataColumns[index].bind(
2173
- column: column,
2174
- badges: index == 0 ? rowBadges : [],
2175
- theme: theme
2176
- )
2177
- }
2178
- if let firstColumn = columns.first {
2179
- let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1)))
2180
- for index in 1..<columns.count {
2181
- let weight = CGFloat(max(1, columns[index].int("weight", default: 1)))
2182
- dataWeightConstraints.append(
2183
- tableDataColumns[index].widthAnchor.constraint(
2184
- equalTo: tableDataColumns[0].widthAnchor,
2185
- multiplier: weight / firstWeight
2186
- )
2187
- )
2188
- }
2189
- NSLayoutConstraint.activate(dataWeightConstraints)
2190
- }
2191
- return
2192
- }
2193
-
2194
- rootStack.addArrangedSubview(dataStack)
2195
- for (index, column) in columns.enumerated() {
2196
- let label = dataLabels[index]
2197
- label.isHidden = false
2198
- let primaryColor = dataTextColor(column.string("tone"), theme: theme)
2199
- let attributed = NSMutableAttributedString(
2200
- string: column.string("text"),
2201
- attributes: [
2202
- .font: nativeListFont(ofSize: 14, weight: .medium),
2203
- .foregroundColor: primaryColor,
2204
- ]
2205
- )
2206
- if index == 0 {
2207
- for badge in rowBadges.prefix(2) {
2208
- attributed.append(NSAttributedString(
2209
- string: " \(badge.string("text")) ",
2210
- attributes: [
2211
- .font: nativeListFont(ofSize: 12, weight: .medium),
2212
- .foregroundColor: nativeListColor(theme, "info", "#0D74CE"),
2213
- .backgroundColor: UIColor(nativeListHex: "#008FF519", fallback: .systemBlue),
2214
- ]
2215
- ))
2216
- }
2217
- }
2218
- let secondaryText = column.string("secondaryText")
2219
- if !secondaryText.isEmpty {
2220
- attributed.append(NSAttributedString(
2221
- string: "\n\(secondaryText)",
2222
- attributes: [
2223
- .font: nativeListFont(ofSize: 12),
2224
- .foregroundColor: dataTextColor(
2225
- column.string("secondaryTone", default: "secondary"),
2226
- theme: theme
2227
- ),
2228
- ]
2229
- ))
2230
- label.numberOfLines = 2
2231
- }
2232
- label.attributedText = attributed
2233
- label.textAlignment = column.string("alignment") == "end" ? .right : column.string("alignment") == "center" ? .center : .left
2234
- label.setContentHuggingPriority(.defaultLow, for: .horizontal)
2235
- }
2236
- if let firstColumn = columns.first {
2237
- let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1)))
2238
- for index in 1..<columns.count {
2239
- let weight = CGFloat(max(1, columns[index].int("weight", default: 1)))
2240
- dataWeightConstraints.append(
2241
- dataLabels[index].widthAnchor.constraint(
2242
- equalTo: dataLabels[0].widthAnchor,
2243
- multiplier: weight / firstWeight
2244
- )
2245
- )
2246
- }
2247
- NSLayoutConstraint.activate(dataWeightConstraints)
2248
- }
2249
- }
2250
-
2251
- private func marketFontWeight(_ value: String, fallback: NativeListFontWeight) -> NativeListFontWeight {
2252
- switch value {
2253
- case "regular": return .regular
2254
- case "semibold": return .semibold
2255
- case "bold": return .bold
2256
- case "medium": return .medium
2257
- default: return fallback
2258
- }
2259
- }
2260
-
2261
- private func marketTextAlignment(_ value: String) -> NSTextAlignment {
2262
- switch value {
2263
- case "center": return .center
2264
- case "start": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left
2265
- case "end": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .left : .right
2266
- default: return .natural
2267
- }
2268
- }
2269
-
2270
- private func applyMarketTextStyle(
2271
- _ label: UILabel,
2272
- data: [String: Any]?,
2273
- theme: [String: Any]?,
2274
- defaultSize: CGFloat,
2275
- defaultLineHeight: CGFloat,
2276
- defaultWeight: NativeListFontWeight,
2277
- defaultColor: UIColor,
2278
- defaultAlignment: NSTextAlignment = .natural
2279
- ) {
2280
- let size = CGFloat(data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize))
2281
- let lineHeight = CGFloat(data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight))
2282
- label.font = nativeListTabularFont(ofSize: size, weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight))
2283
- label.textColor = data?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: defaultColor) } ?? defaultColor
2284
- label.textAlignment = data?["alignment"] == nil
2285
- ? defaultAlignment
2286
- : marketTextAlignment(data?.string("alignment") ?? "")
2287
- label.numberOfLines = min(2, max(1, data?.int("lines", default: 1) ?? 1))
2288
- setLineHeight(label, text: label.text ?? "", lineHeight: lineHeight)
2289
- }
2290
-
2291
- private func applyMarketButtonStyle(
2292
- _ button: UIButton,
2293
- data: [String: Any]?,
2294
- defaultSize: CGFloat,
2295
- defaultLineHeight: CGFloat,
2296
- defaultWeight: NativeListFontWeight,
2297
- color: UIColor,
2298
- defaultAlignment: UIControl.ContentHorizontalAlignment
2299
- ) {
2300
- let size = CGFloat(data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize))
2301
- let lineHeight = CGFloat(data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight))
2302
- setButtonLine(
2303
- button,
2304
- text: button.title(for: .normal) ?? "",
2305
- font: nativeListTabularFont(ofSize: size, weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight)),
2306
- color: data?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: color) } ?? color,
2307
- lineHeight: lineHeight
2308
- )
2309
- if data?["alignment"] == nil {
2310
- button.contentHorizontalAlignment = defaultAlignment
2311
- } else {
2312
- button.contentHorizontalAlignment = data?.string("alignment") == "start"
2313
- ? .leading
2314
- : data?.string("alignment") == "end" ? .trailing : .center
2315
- }
2316
- button.titleLabel?.numberOfLines = min(2, max(1, data?.int("lines", default: 1) ?? 1))
2317
- }
2318
-
2319
- private func marketAttributedText(
2320
- _ text: String,
2321
- segments: [[String: Any]],
2322
- style: [String: Any]?,
2323
- defaultSize: CGFloat,
2324
- defaultLineHeight: CGFloat,
2325
- defaultWeight: NativeListFontWeight,
2326
- color: UIColor,
2327
- defaultAlignment: NSTextAlignment
2328
- ) -> NSAttributedString {
2329
- let size = CGFloat(style?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize))
2330
- let lineHeight = CGFloat(style?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight))
2331
- let paragraph = NSMutableParagraphStyle()
2332
- paragraph.minimumLineHeight = lineHeight
2333
- paragraph.maximumLineHeight = lineHeight
2334
- paragraph.alignment = style?["alignment"] == nil
2335
- ? defaultAlignment
2336
- : marketTextAlignment(style?.string("alignment") ?? "")
2337
- let weight = marketFontWeight(style?.string("fontWeight") ?? "", fallback: defaultWeight)
2338
- let resolvedColor = style?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: color) } ?? color
2339
- let result = NSMutableAttributedString(string: "")
2340
- let source = segments.isEmpty ? [["text": text]] : segments
2341
- for segment in source {
2342
- let segmentSize = segment.string("style") == "subscript" ? ceil(size * 0.6) : size
2343
- result.append(NSAttributedString(string: segment.string("text"), attributes: [
2344
- .font: nativeListTabularFont(ofSize: segmentSize, weight: weight),
2345
- .foregroundColor: resolvedColor,
2346
- .kern: 0,
2347
- .paragraphStyle: paragraph,
2348
- .baselineOffset: max(0, (lineHeight - nativeListTabularFont(ofSize: size, weight: weight).lineHeight) / 2),
2349
- ]))
2350
- }
2351
- return result
2352
- }
2353
-
2354
- private func marketLeading(_ item: NativeListItem, style: [String: Any]?) -> [String: Any]? {
2355
- guard var visual = item.data.dictionary("leading") else { return nil }
2356
- guard let imageStyle = style?.dictionary("image") else { return visual }
2357
- if let shape = imageStyle["shape"] as? String { visual["shape"] = shape }
2358
- if let contentFit = imageStyle["contentFit"] as? String, var image = visual.dictionary("image") {
2359
- image["contentFit"] = contentFit
2360
- visual["image"] = image
2361
- }
2362
- return visual
2363
- }
2364
-
2365
- private func bindMarket(_ item: NativeListItem, theme: [String: Any]?) {
2366
- let variant = item.data.string("variant")
2367
- let style = item.data.dictionary("style")
2368
- let imageStyle = style?.dictionary("image")
2369
- let imageWidth = CGFloat(imageStyle?.double("width", default: variant == "stock" ? 40 : 32) ?? (variant == "stock" ? 40 : 32))
2370
- let imageHeight = CGFloat(imageStyle?.double("height", default: variant == "stock" ? 40 : 32) ?? (variant == "stock" ? 40 : 32))
2371
- let horizontalPadding = CGFloat(style?.double("horizontalPadding", default: variant == "perp" ? 16 : 20) ?? (variant == "perp" ? 16 : 20))
2372
- let verticalPadding = CGFloat(style?.double("verticalPadding", default: 12) ?? 12)
2373
- rootLeadingConstraint.constant = horizontalPadding
2374
- rootTrailingConstraint.constant = -horizontalPadding
2375
- rootTopConstraint.constant = verticalPadding
2376
- rootBottomConstraint.constant = -verticalPadding
2377
- rootStack.spacing = CGFloat(style?.double("leadingGap", default: variant == "perp" ? 8 : 14) ?? (variant == "perp" ? 8 : 14))
2378
- if let leadingAction = item.data.dictionary("leadingAction") {
2379
- leadingActionButton.isHidden = false
2380
- let tintColor = UIColor(
2381
- nativeListHex: leadingAction.string("tintColor", default: "#646464"),
2382
- fallback: .darkGray
2383
- )
2384
- leadingActionButton.tintColor = tintColor
2385
- if let image = nativeListIcon(named: leadingAction.string("name")) {
2386
- leadingActionButton.setImage(image, for: .normal)
2387
- leadingActionButton.setImage(
2388
- image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
2389
- for: .disabled
2390
- )
2391
- }
2392
- leadingActionButton.isEnabled = !leadingAction.bool("disabled")
2393
- leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4
2394
- leadingActionButton.accessibilityIdentifier = leadingAction["testID"] as? String
2395
- leadingActionButton.accessibilityLabel = leadingAction["accessibilityLabel"] as? String
2396
- leadingActionKey = leadingAction.string("actionKey")
2397
- rootStack.addArrangedSubview(leadingActionButton)
2398
- rootStack.setCustomSpacing(5, after: leadingActionButton)
2399
- }
2400
- leadingWidth.constant = imageWidth
2401
- leadingHeight.constant = imageHeight
2402
- if let visual = marketLeading(item, style: style) {
2403
- addLeading(visual, key: item.key)
2404
- let radius = CGFloat(imageStyle?.double("cornerRadius", default: imageStyle?.string("shape") == "square" ? 0 : imageStyle?.string("shape") == "rounded" ? 8 : Double(min(imageWidth, imageHeight) / 2)) ?? Double(min(imageWidth, imageHeight) / 2))
2405
- leadingContainer.layer.cornerRadius = radius
2406
- leadingImages.first?.layer.cornerRadius = radius
2407
- if let image = leadingImages.first, !visual.string("borderColor").isEmpty {
2408
- // Match Token's border box: the bitmap occupies the one-point inset.
2409
- leadingContainer.layer.borderWidth = 1
2410
- leadingContainer.layer.borderColor = UIColor(nativeListHex: visual.string("borderColor"), fallback: .clear).cgColor
2411
- for constraint in leadingSlotConstraints where constraint.firstItem === image {
2412
- switch constraint.firstAttribute {
2413
- case .leading, .top: constraint.constant = 1
2414
- case .trailing, .bottom: constraint.constant = -1
2415
- default: break
2416
- }
2417
- }
2418
- image.layer.cornerRadius = 0
2419
- let mask = CAShapeLayer()
2420
- mask.path = UIBezierPath(roundedRect: CGRect(x: -1, y: -1, width: imageWidth, height: imageHeight), cornerRadius: radius).cgPath
2421
- image.layer.mask = mask
2422
- }
2423
- if let diagnostic = item.data.dictionary("diagnostics")?.string("imageBindActionKey"), !diagnostic.isEmpty,
2424
- visual.dictionary("image") != nil || visual.dictionary("networkImage") != nil {
2425
- onAction?(item, diagnostic, nil, nil)
2426
- }
2427
- }
2428
- rootStack.addArrangedSubview(mainStack)
2429
- // OneKey patch: opt in to the source Market row's content gap.
2430
- // rootStack.setCustomSpacing(0, after: mainStack)
2431
- rootStack.setCustomSpacing(CGFloat(style?.double("contentTrailingGap", default: 0) ?? 0), after: mainStack)
2432
- mainStack.spacing = CGFloat(style?.double("lineGap", default: 0) ?? 0)
2433
- titleRowStack.spacing = CGFloat(style?.double("titleBadgeGap", default: 4) ?? 4)
2434
- // OneKey patch: opt in without changing the other row templates' filled layout.
2435
- if style?.string("titleBadgeLayout") == "inline" {
2436
- mainStack.alignment = .leading
2437
- titleRowStack.setContentHuggingPriority(.required, for: .horizontal)
2438
- }
2439
- show(titleLabel, item.data.string("title"), lines: style?.dictionary("title")?.int("lines", default: 1) ?? 1)
2440
- applyMarketTextStyle(titleLabel, data: style?.dictionary("title"), theme: theme, defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, defaultColor: nativeListColor(theme, "primaryText", "#202020"))
2441
- let badges = Array(item.data.dictionaries("badges").prefix(marketBadgeButtons.count))
2442
- marketBadgeActionKeys = badges.map { $0["actionKey"] as? String }
2443
- for (index, badge) in badges.enumerated() {
2444
- let button = marketBadgeButtons[index]
2445
- let badgeStyle = badge.dictionary("style")
2446
- let badgeFontSize = CGFloat(badgeStyle?.double("fontSize", default: 11) ?? 11)
2447
- let badgeFontWeight = marketFontWeight(badgeStyle?.string("fontWeight") ?? "", fallback: .medium)
2448
- // OneKey patch: SizableText supplies tabular numerals for explicit Market metrics.
2449
- let badgeFont = badgeStyle == nil
2450
- ? nativeListFont(ofSize: badgeFontSize, weight: badgeFontWeight)
2451
- : nativeListTabularFont(ofSize: badgeFontSize, weight: badgeFontWeight)
2452
- let hasBuiltInIcon = badge.string("iconName") == "verified"
2453
- let hasRemoteIcon = badge.dictionary("icon") != nil
2454
- let hasIcon = hasBuiltInIcon || hasRemoteIcon
2455
- let text = badge.string("text")
2456
- let toneColor: UIColor
2457
- switch badge.string("tone") {
2458
- case "success": toneColor = nativeListColor(theme, "positive", "#218358")
2459
- case "danger": toneColor = nativeListColor(theme, "negative", "#CE2C31")
2460
- case "info": toneColor = nativeListColor(theme, "info", "#0D74CE")
2461
- case "warning": toneColor = nativeListColor(theme, "primaryText", "#202020")
2462
- default: toneColor = nativeListColor(theme, "secondaryText", "#646464")
2463
- }
2464
- let foreground = UIColor(
2465
- nativeListHex: badge.string("textColor", default: ""),
2466
- fallback: toneColor
2467
- )
2468
- button.isHidden = false
2469
- button.isEnabled = true
2470
- button.isUserInteractionEnabled = !badge.string("actionKey").isEmpty
2471
- button.accessibilityTraits = button.isUserInteractionEnabled ? .button : .staticText
2472
- button.setTitle(text, for: .normal)
2473
- button.setTitleColor(foreground, for: .normal)
2474
- button.titleLabel?.font = badgeFont
2475
- if let lineHeight = badgeStyle?["lineHeight"] as? Double {
2476
- setButtonLine(button, text: text, font: badgeFont, color: foreground, lineHeight: CGFloat(lineHeight))
2477
- // The explicit text-only line box must not cover an adjacent icon.
2478
- if hasIcon { button.marketLineHeight = nil }
2479
- }
2480
- button.tintColor = foreground
2481
- button.backgroundColor = UIColor(
2482
- nativeListHex: badge.string("backgroundColor", default: ""),
2483
- fallback: hasIcon && text.isEmpty
2484
- ? .clear
2485
- : nativeListColor(theme, "strongBackground", "#0000000F")
2486
- )
2487
- let iconOnly = hasIcon && text.isEmpty
2488
- let iconSize: CGFloat = hasBuiltInIcon ? 16 : 14
2489
- // OneKey patch: preserve the native defaults unless the caller supplies padding.
2490
- let padding = CGFloat(badgeStyle?.double("horizontalPadding", default: 5) ?? 5)
2491
- let hasCustomPadding = badgeStyle?["horizontalPadding"] != nil
2492
- let leftPadding = hasCustomPadding ? padding + (hasRemoteIcon ? iconSize + 2 : 0) : hasRemoteIcon ? 20 : hasIcon ? 3 : 5
2493
- // button.contentEdgeInsets = UIEdgeInsets(top: 0, left: iconOnly ? 0 : hasRemoteIcon ? 20 : hasIcon ? 3 : 5, bottom: 0, right: iconOnly ? 0 : 5)
2494
- button.contentEdgeInsets = UIEdgeInsets(top: 0, left: iconOnly ? 0 : leftPadding, bottom: 0, right: iconOnly ? 0 : padding)
2495
- button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: text.isEmpty ? 0 : 3)
2496
- button.titleEdgeInsets = .zero
2497
- button.imageView?.contentMode = .scaleAspectFit
2498
- if hasBuiltInIcon {
2499
- button.setImage(
2500
- nativeListIcon(named: "BadgeVerifiedSolid", size: CGSize(width: iconSize, height: iconSize)),
2501
- for: .normal
2502
- )
2503
- }
2504
- // OneKey patch: match source badge metrics at physical-pixel precision.
2505
- // let height = button.heightAnchor.constraint(equalToConstant: 18)
2506
- let height = button.heightAnchor.constraint(equalToConstant: CGFloat(badgeStyle?.double("height", default: 18) ?? 18))
2507
- let textWidth = (text as NSString).size(withAttributes: [.font: badgeFont]).width
2508
- let scale = max(1, traitCollection.displayScale)
2509
- let roundedTextWidth = badgeStyle == nil ? ceil(textWidth) : ceil(textWidth * scale) / scale
2510
- let extraWidth = hasCustomPadding ? padding * 2 + (hasIcon ? iconSize + 2 : 0) : hasIcon ? iconSize + 11 : 10
2511
- // let width = button.widthAnchor.constraint(equalToConstant: iconOnly ? iconSize : ceil(textWidth) + (hasIcon ? iconSize + 11 : 10))
2512
- let width = button.widthAnchor.constraint(equalToConstant: iconOnly ? iconSize : roundedTextWidth + extraWidth)
2513
- NSLayoutConstraint.activate([width, height])
2514
- selectorConstraints.append(contentsOf: [width, height])
2515
- if let icon = badge.dictionary("icon") {
2516
- marketBadgeImages[index].isHidden = false
2517
- marketBadgeImages[index].layer.cornerRadius = 7
2518
- marketBadgeImages[index].clipsToBounds = true
2519
- let imageLeading = marketBadgeImages[index].leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: text.isEmpty ? 0 : 4)
2520
- imageLeading.isActive = true
2521
- selectorConstraints.append(imageLeading)
2522
- bindImage(icon, into: marketBadgeImages[index], token: item.key, slot: 20 + index, variant: "generic")
2523
- }
2524
- button.accessibilityLabel = badge.string("accessibilityLabel", default: badge.string("text"))
2525
- }
2526
- if !item.data.string("subtitle").isEmpty || !item.data.dictionaries("subtitleSegments").isEmpty {
2527
- show(subtitleLabel, item.data.string("subtitle"), lines: style?.dictionary("subtitle")?.int("lines", default: 1) ?? 1)
2528
- applyMarketTextStyle(subtitleLabel, data: style?.dictionary("subtitle"), theme: theme, defaultSize: 14, defaultLineHeight: 20, defaultWeight: .regular, defaultColor: nativeListColor(theme, "secondaryText", "#646464"))
2529
- if !item.data.dictionaries("subtitleSegments").isEmpty {
2530
- subtitleLabel.attributedText = marketAttributedText(item.data.string("subtitle"), segments: item.data.dictionaries("subtitleSegments"), style: style?.dictionary("subtitle"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .regular, color: nativeListColor(theme, "secondaryText", "#646464"), defaultAlignment: .natural)
2531
- }
2532
- }
2533
- // OneKey patch: preserve volume width while the localized name truncates.
2534
- let subtitlePrefix = item.data.dictionary("subtitlePrefix")
2535
- let subtitlePadding = CGFloat(style?.double("subtitleTrailingPadding", default: 0) ?? 0)
2536
- if subtitlePrefix != nil || subtitlePadding > 0 {
2537
- mainStack.removeArrangedSubview(subtitleLabel)
2538
- subtitleLabel.removeFromSuperview()
2539
- mainStack.removeArrangedSubview(tertiaryLabel)
2540
- tertiaryLabel.removeFromSuperview()
2541
- marketSubtitleStack.axis = .horizontal
2542
- marketSubtitleStack.alignment = .center
2543
- marketSubtitleStack.spacing = 0
2544
- marketSubtitleStack.clipsToBounds = true
2545
- show(tertiaryLabel, subtitlePrefix?.string("text") ?? "", lines: 1)
2546
- applyMarketTextStyle(tertiaryLabel, data: subtitlePrefix?.dictionary("style"), theme: theme, defaultSize: 12, defaultLineHeight: 16, defaultWeight: .regular, defaultColor: nativeListColor(theme, "secondaryText", "#646464"))
2547
- tertiaryLabel.setContentHuggingPriority(.required, for: .horizontal)
2548
- tertiaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2549
- subtitleLabel.setContentHuggingPriority(.required, for: .horizontal)
2550
- subtitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
2551
- marketSubtitleStack.addArrangedSubview(tertiaryLabel)
2552
- marketSubtitleStack.addArrangedSubview(subtitleLabel)
2553
- marketSubtitleStack.addArrangedSubview(marketSubtitleSpacer)
2554
- if !tertiaryLabel.isHidden && !subtitleLabel.isHidden {
2555
- marketSubtitleStack.setCustomSpacing(CGFloat(subtitlePrefix?.double("gap", default: 4) ?? 4), after: tertiaryLabel)
2556
- }
2557
- mainStack.insertArrangedSubview(marketSubtitleStack, at: 1)
2558
- let width = marketSubtitleStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -subtitlePadding)
2559
- width.isActive = true
2560
- selectorConstraints.append(width)
2561
- if let maxWidth = subtitlePrefix?["maxWidth"] as? Double {
2562
- let limit = tertiaryLabel.widthAnchor.constraint(lessThanOrEqualToConstant: CGFloat(maxWidth))
2563
- limit.isActive = true
2564
- selectorConstraints.append(limit)
2565
- }
2566
- marketSubtitleStack.isHidden = tertiaryLabel.isHidden && subtitleLabel.isHidden
2567
- }
2568
- rootStack.addArrangedSubview(trailingStack)
2569
- trailingStack.axis = .horizontal
2570
- trailingStack.alignment = .center
2571
- trailingStack.spacing = CGFloat(style?.double("trailingGap", default: 8) ?? 8)
2572
- updateMarketQuote(item, theme: theme)
2573
- }
2574
-
2575
- func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) {
2576
- guard currentItem?.key == item.key, item.type == "market" else { return }
2577
- currentItem = item
2578
- NSLayoutConstraint.deactivate(accessorySizeConstraints)
2579
- accessorySizeConstraints.removeAll()
2580
- let style = item.data.dictionary("style")
2581
- let price = accessoryButtons[0]
2582
- price.isUserInteractionEnabled = false
2583
- price.isHidden = false
2584
- price.setAttributedTitle(nil, for: .normal)
2585
- price.setTitle(item.data.string("price"), for: .normal)
2586
- applyMarketButtonStyle(price, data: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), defaultAlignment: .trailing)
2587
- if !item.data.dictionaries("priceSegments").isEmpty {
2588
- price.setAttributedTitle(marketAttributedText(item.data.string("price"), segments: item.data.dictionaries("priceSegments"), style: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), defaultAlignment: marketTextAlignment("end")), for: .normal)
2589
- }
2590
- let changeData = item.data.dictionary("change") ?? [:]
2591
- let change = accessoryButtons[1]
2592
- change.isUserInteractionEnabled = false
2593
- change.isHidden = false
2594
- change.setAttributedTitle(nil, for: .normal)
2595
- change.setTitle(changeData.string("text"), for: .normal)
2596
- let defaultChangeColor = nativeListColor(theme, "inverseText", "#FFFFFF")
2597
- applyMarketButtonStyle(change, data: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .medium, color: UIColor(nativeListHex: changeData.string("textColor", default: "#FFFFFF"), fallback: defaultChangeColor), defaultAlignment: .center)
2598
- if !changeData.dictionaries("textSegments").isEmpty {
2599
- let changeTextColor = UIColor(nativeListHex: changeData.string("textColor", default: ""), fallback: defaultChangeColor)
2600
- change.setAttributedTitle(marketAttributedText(changeData.string("text"), segments: changeData.dictionaries("textSegments"), style: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .medium, color: changeTextColor, defaultAlignment: .center), for: .normal)
2601
- }
2602
- let toneKey = changeData.string("tone") == "positive" ? "positive" : changeData.string("tone") == "negative" ? "negative" : "secondaryText"
2603
- change.backgroundColor = UIColor(nativeListHex: changeData.string("backgroundColor", default: ""), fallback: nativeListColor(theme, toneKey, changeData.string("tone") == "positive" ? "#218358" : changeData.string("tone") == "negative" ? "#CE2C31" : "#8D8D8D"))
2604
- change.layer.cornerRadius = CGFloat(style?.double("changeCornerRadius", default: 8) ?? 8)
2605
- change.clipsToBounds = true
2606
- let width = change.widthAnchor.constraint(equalToConstant: CGFloat(style?.double("changeWidth", default: 80) ?? 80))
2607
- let height = change.heightAnchor.constraint(equalToConstant: CGFloat(style?.double("changeHeight", default: 32) ?? 32))
2608
- width.isActive = true
2609
- height.isActive = true
2610
- accessorySizeConstraints.append(contentsOf: [width, height])
2611
- accessibilityLabel = item.data.string("accessibilityLabel", default: [item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), changeData.string("text")].filter { !$0.isEmpty }.joined(separator: ", "))
2612
- }
2613
-
2614
- private func bindMediaTile(_ item: NativeListItem, theme: [String: Any]?) {
2615
- rootStack.axis = .vertical
2616
- rootStack.alignment = .fill
2617
- rootStack.spacing = 8
2618
- rootLeadingConstraint.constant = 10
2619
- rootTrailingConstraint.constant = -10
2620
- rootTopConstraint.constant = 10
2621
- rootBottomConstraint.constant = -10
2622
- leadingWidth.isActive = false
2623
- leadingHeight.isActive = false
2624
- mediaHeight.isActive = true
2625
- let imageState = item.data.string("imageState")
2626
- let imageVisual = imageState == "empty" || imageState == "error"
2627
- ? nil
2628
- : item.data.dictionary("image").map { ["kind": "image", "image": $0] }
2629
- addLeading(imageVisual, key: item.key, to: rootStack)
2630
- leadingContainer.layer.cornerRadius = 10
2631
- leadingContainer.clipsToBounds = true
2632
- if imageState == "empty" {
2633
- leadingContainer.backgroundColor = .clear
2634
- fallbackLabel.isHidden = true
2635
- } else if imageState == "error" {
2636
- leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#0000000F")
2637
- fallbackLabel.isHidden = true
2638
- leadingIconWidth.constant = 24
2639
- leadingIconHeight.constant = 24
2640
- leadingIconImageView.image = nativeListIcon(named: "ImageSquareWavesOutline")
2641
- leadingIconImageView.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray)
2642
- leadingIconImageView.isHidden = false
2643
- }
2644
- mainStack.removeArrangedSubview(titleRowStack)
2645
- titleRowStack.removeFromSuperview()
2646
- mainStack.removeArrangedSubview(subtitleLabel)
2647
- subtitleLabel.removeFromSuperview()
2648
- mediaMetadataStack.axis = .horizontal
2649
- mediaMetadataStack.alignment = .center
2650
- mediaMetadataStack.spacing = 8
2651
- mediaMetadataStack.addArrangedSubview(subtitleLabel)
2652
- subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2653
- if let networkImage = item.data.dictionary("networkImage") {
2654
- mediaMetadataStack.addArrangedSubview(mediaNetworkImage)
2655
- // OneKey patch: Preserve badge layout without exposing a loading/error tile.
2656
- // mediaNetworkImage.isHidden = false
2657
- bindImage(
2658
- networkImage,
2659
- into: mediaNetworkImage,
2660
- token: item.key,
2661
- slot: 2,
2662
- variant: "network",
2663
- hideUntilLoaded: true
2664
- )
2665
- }
2666
- mainStack.insertArrangedSubview(mediaMetadataStack, at: 0)
2667
- mainStack.insertArrangedSubview(titleRowStack, at: 1)
2668
- show(subtitleLabel, item.data.string("subtitle"), lines: 1)
2669
- subtitleLabel.font = nativeListFont(ofSize: 12)
2670
- show(titleLabel, item.data.string("title"), lines: 1)
2671
- titleLabel.font = nativeListFont(ofSize: 16, weight: .medium)
2672
- if let badge = item.data.dictionary("badge") {
2673
- mediaBadgeLabel.text = " \(badge.string("text")) "
2674
- mediaBadgeLabel.isHidden = false
2675
- }
2676
- rootStack.addArrangedSubview(mainStack)
2677
- let closeAction = item.data.string("closeActionKey")
2678
- if !closeAction.isEmpty {
2679
- showAccessory(0, "×", action: (closeAction, nil))
2680
- rootStack.addArrangedSubview(trailingStack)
2681
- }
2682
- }
2683
-
2684
- private func bindMetricCard(_ item: NativeListItem, theme: [String: Any]?) {
2685
- rootStack.axis = .vertical
2686
- rootStack.alignment = .leading
2687
- rootStack.spacing = 4
2688
- rootLeadingConstraint.constant = 14
2689
- rootTrailingConstraint.constant = -14
2690
- rootTopConstraint.constant = 14
2691
- rootBottomConstraint.constant = -14
2692
- let variant = item.data.string("variant", default: "standard")
2693
- if variant == "activity" || variant == "performance" {
2694
- bindCompositeMetricCard(item, theme: theme, variant: variant)
2695
- return
2696
- }
2697
- if let visual = item.data.dictionary("visual") {
2698
- leadingWidth.constant = 32
2699
- leadingHeight.constant = 32
2700
- addLeading(visual, key: item.key, to: rootStack)
2701
- leadingContainer.clipsToBounds = true
2702
- }
2703
- show(subtitleLabel, item.data.string("title"), lines: 1)
2704
- subtitleLabel.font = nativeListFont(ofSize: 11)
2705
- subtitleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
2706
- show(titleLabel, item.data.string("value"), lines: 1)
2707
- titleLabel.font = nativeListFont(
2708
- ofSize: item.data.string("size") == "large" ? 24 : 18,
2709
- weight: .semibold
2710
- )
2711
- show(statusLabel, item.data.string("trend"), lines: 1)
2712
- let tone = item.data.string("trendTone", default: "neutral")
2713
- if tone == "positive" {
2714
- statusLabel.textColor = nativeListColor(theme, "positive", "#218358")
2715
- } else if tone == "negative" {
2716
- statusLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
2717
- }
2718
- show(metricSubtitleLabel, item.data.string("subtitle"), lines: 1)
2719
- if let badge = item.data.dictionary("badge") {
2720
- show(badgeLabel, badge.string("text"), lines: 1)
2721
- }
2722
- rootStack.addArrangedSubview(mainStack)
2723
- contentView.layer.cornerRadius = 12
2724
- contentView.clipsToBounds = true
2725
- }
2726
-
2727
- private func bindCompositeMetricCard(
2728
- _ item: NativeListItem,
2729
- theme: [String: Any]?,
2730
- variant: String
2731
- ) {
2732
- rootStack.alignment = .fill
2733
- show(titleLabel, item.data.string("title"), lines: 1)
2734
- titleLabel.font = nativeListFont(ofSize: 11)
2735
- titleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
2736
- setLineHeight(
2737
- titleLabel,
2738
- text: item.data.string("title").uppercased(),
2739
- lineHeight: 14,
2740
- letterSpacing: 1.2
2741
- )
2742
- mainStack.spacing = 14
2743
- metricCompositeStack.spacing = 14
2744
- mainStack.addArrangedSubview(metricCompositeStack)
2745
- rootStack.addArrangedSubview(mainStack)
2746
-
2747
- let metrics = item.data.dictionaries("metrics")
2748
- let topCount = min(2, metrics.count)
2749
- if variant == "activity" {
2750
- metricCompositeStack.addArrangedSubview(
2751
- makeMetricRow(
2752
- Array(metrics.prefix(topCount)),
2753
- theme: theme,
2754
- style: "activityHero"
2755
- )
2756
- )
2757
- let divider = UIView()
2758
- divider.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0")
2759
- divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
2760
- metricCompositeStack.addArrangedSubview(divider)
2761
- metricCompositeStack.addArrangedSubview(
2762
- makeMetricRow(
2763
- Array(metrics.dropFirst(topCount)),
2764
- theme: theme,
2765
- style: "compact"
2766
- )
2767
- )
2768
- } else {
2769
- let performanceSummary = UIStackView()
2770
- performanceSummary.axis = .vertical
2771
- performanceSummary.alignment = .fill
2772
- performanceSummary.spacing = 8
2773
- performanceSummary.addArrangedSubview(
2774
- makeMetricRow(
2775
- Array(metrics.prefix(topCount)),
2776
- theme: theme,
2777
- style: "performanceHero"
2778
- )
2779
- )
2780
- let progress = min(1, max(0, item.data.double("progress")))
2781
- let progressRow = UIStackView()
2782
- progressRow.axis = .horizontal
2783
- progressRow.spacing = 0
2784
- progressRow.layer.cornerRadius = 2
2785
- progressRow.clipsToBounds = true
2786
- let wins = UIView()
2787
- wins.backgroundColor = nativeListColor(theme, "positive", "#218358")
2788
- let losses = UIView()
2789
- losses.backgroundColor = nativeListColor(theme, "negative", "#CE2C31")
2790
- progressRow.addArrangedSubview(wins)
2791
- progressRow.addArrangedSubview(losses)
2792
- progressRow.heightAnchor.constraint(equalToConstant: 4).isActive = true
2793
- if progress <= 0 {
2794
- wins.isHidden = true
2795
- } else if progress >= 1 {
2796
- losses.isHidden = true
2797
- } else {
2798
- wins.widthAnchor.constraint(
2799
- equalTo: progressRow.widthAnchor,
2800
- multiplier: CGFloat(progress)
2801
- ).isActive = true
2802
- }
2803
- performanceSummary.addArrangedSubview(progressRow)
2804
- metricCompositeStack.addArrangedSubview(performanceSummary)
2805
- metricCompositeStack.addArrangedSubview(
2806
- makeMetricRow(
2807
- Array(metrics.dropFirst(topCount)),
2808
- theme: theme,
2809
- style: "compactShaded"
2810
- )
2811
- )
2812
- }
2813
- contentView.layer.cornerRadius = 12
2814
- contentView.clipsToBounds = true
2815
- }
2816
-
2817
- private func makeMetricRow(
2818
- _ metrics: [[String: Any]],
2819
- theme: [String: Any]?,
2820
- style: String
2821
- ) -> UIStackView {
2822
- let shaded = style == "compactShaded"
2823
- let row = UIStackView()
2824
- row.axis = .horizontal
2825
- row.alignment = style.hasSuffix("Hero") ? .bottom : .fill
2826
- row.distribution = .fillEqually
2827
- row.spacing = shaded ? 8 : 12
2828
- metrics.enumerated().forEach { index, metric in
2829
- let column = UIStackView()
2830
- column.axis = .vertical
2831
- column.alignment = shaded
2832
- ? .leading
2833
- : index == 0 ? .leading : index == metrics.count - 1 ? .trailing : .center
2834
- column.spacing = 2
2835
- if shaded {
2836
- column.isLayoutMarginsRelativeArrangement = true
2837
- column.layoutMargins = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
2838
- column.backgroundColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray)
2839
- column.layer.cornerRadius = 8
2840
- }
2841
- let label = UILabel()
2842
- label.font = nativeListFont(ofSize: 11)
2843
- label.textColor = nativeListColor(theme, "disabledText", "#8D8D8D")
2844
- setLineHeight(label, text: metric.string("label"), lineHeight: 14)
2845
- let value = UILabel()
2846
- let valueSize: CGFloat
2847
- let valueLineHeight: CGFloat
2848
- let valueWeight: NativeListFontWeight
2849
- switch style {
2850
- case "activityHero" where index == 0:
2851
- valueSize = 16
2852
- valueLineHeight = 24
2853
- valueWeight = .semibold
2854
- case "performanceHero" where index == 0:
2855
- valueSize = 18
2856
- valueLineHeight = 24
2857
- valueWeight = .semibold
2858
- case "activityHero", "performanceHero":
2859
- valueSize = 14
2860
- valueLineHeight = 20
2861
- valueWeight = .semibold
2862
- default:
2863
- valueSize = 14
2864
- valueLineHeight = 20
2865
- valueWeight = .medium
2866
- }
2867
- value.font = nativeListTabularFont(ofSize: valueSize, weight: valueWeight)
2868
- value.textColor = dataTextColor(metric.string("tone"), theme: theme)
2869
- setLineHeight(value, text: metric.string("value"), lineHeight: valueLineHeight)
2870
- column.addArrangedSubview(label)
2871
- if let visual = metric.dictionary("visual") {
2872
- let valueRow = UIStackView()
2873
- valueRow.axis = .horizontal
2874
- valueRow.alignment = .center
2875
- valueRow.spacing = 6
2876
- valueRow.addArrangedSubview(
2877
- makeMetricVisual(visual, key: metric.string("key"), slot: index)
2878
- )
2879
- valueRow.addArrangedSubview(value)
2880
- column.addArrangedSubview(valueRow)
2881
- } else {
2882
- column.addArrangedSubview(value)
2883
- }
2884
- row.addArrangedSubview(column)
2885
- }
2886
- return row
2887
- }
2888
-
2889
- private func makeMetricVisual(
2890
- _ visual: [String: Any],
2891
- key: String,
2892
- slot: Int
2893
- ) -> UIView {
2894
- let container = UIView()
2895
- container.translatesAutoresizingMaskIntoConstraints = false
2896
- container.backgroundColor = UIColor(
2897
- nativeListHex: visual.string("backgroundColor", default: "#0000000F"),
2898
- fallback: .lightGray
2899
- )
2900
- container.layer.cornerRadius = visual.string("shape") == "square" ? 0 : 8
2901
- container.clipsToBounds = true
2902
- NSLayoutConstraint.activate([
2903
- container.widthAnchor.constraint(equalToConstant: 16),
2904
- container.heightAnchor.constraint(equalToConstant: 16),
2905
- ])
2906
-
2907
- if visual.string("kind") == "icon" {
2908
- let imageView = UIImageView(image: nativeListIcon(named: visual.string("name")))
2909
- imageView.translatesAutoresizingMaskIntoConstraints = false
2910
- imageView.tintColor = UIColor(
2911
- nativeListHex: visual.string("tintColor", default: "#00000072"),
2912
- fallback: .darkGray
2913
- )
2914
- imageView.contentMode = .scaleAspectFit
2915
- container.addSubview(imageView)
2916
- NSLayoutConstraint.activate([
2917
- imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
2918
- imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
2919
- imageView.topAnchor.constraint(equalTo: container.topAnchor),
2920
- imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
2921
- ])
2922
- } else if let source = visualSources(visual).first {
2923
- let imageView = OneKeyImageReusableView(frame: .zero)
2924
- imageView.translatesAutoresizingMaskIntoConstraints = false
2925
- imageView.clipsToBounds = true
2926
- imageView.layer.cornerRadius = container.layer.cornerRadius
2927
- container.addSubview(imageView)
2928
- NSLayoutConstraint.activate([
2929
- imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
2930
- imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
2931
- imageView.topAnchor.constraint(equalTo: container.topAnchor),
2932
- imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
2933
- ])
2934
- bindImage(source.data, into: imageView, token: key, slot: slot, variant: source.variant)
2935
- }
2936
- return container
2937
- }
2938
-
2939
- private func bindSectionHeader(
2940
- _ item: NativeListItem,
2941
- theme: [String: Any]?,
2942
- layout: String,
2943
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
2944
- ) {
2945
- rootStack.addArrangedSubview(mainStack)
2946
- let variant = item.data.string("variant")
2947
- // OneKey patch: title help is a separate target from checkbox and value actions.
2948
- if !item.data.string("titleActionKey").isEmpty {
2949
- titleLabel.isUserInteractionEnabled = true
2950
- titleLabel.addGestureRecognizer(selectorTitleTap)
2951
- titleLabel.setContentHuggingPriority(.required, for: .horizontal)
2952
- }
2953
- let isSummary = variant == "summary"
2954
- let isGallery = variant == "gallery"
2955
- let isTable = layout == "table"
2956
- let isNetworkSelector = item.data.string("presentation") == "networkSelector"
2957
- let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil
2958
- if isExplicitNetworkHeader {
2959
- // OneKey patch: the flexible title consumes spare space before trailing totals.
2960
- trailingStack.setContentHuggingPriority(.required, for: .horizontal)
2961
- trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal)
2962
- }
2963
- titleLabel.reservesDottedUnderlineSpace = isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty
2964
- let isHistory = variant == "history" ||
2965
- item.key.hasPrefix("history-") ||
2966
- (item.sectionKey?.hasPrefix("history-") ?? false)
2967
- let headerWeight: NativeListFontWeight = isSummary
2968
- ? .medium
2969
- : isExplicitNetworkHeader && (item.data.dictionary("checkbox") != nil || item.data.string("titleActionKey").isEmpty) ? .semibold
2970
- : isNetworkSelector ? .medium
2971
- : isGallery || layout == "sectioned" ? .semibold : .regular
2972
- titleLabel.font = nativeListFont(
2973
- ofSize: isGallery ? 18 : isSummary ? 16 : isTable ? 11 : isHistory ? 12 : 14,
2974
- weight: isHistory ? .semibold : headerWeight
2975
- )
2976
- titleLabel.textColor = nativeListColor(
2977
- theme,
2978
- isSummary || isGallery ? "primaryText" : "secondaryText",
2979
- isSummary || isGallery ? "#202020" : "#646464"
2980
- )
2981
- show(titleLabel, item.data.string("title"), lines: 1)
2982
- if isNetworkSelector {
2983
- titleLabel.showsDottedUnderline = !isExplicitNetworkHeader || !item.data.string("titleActionKey").isEmpty
2984
- titleLabel.dottedUnderlineVerticalOffset = isExplicitNetworkHeader ? 1 : 2
2985
- titleLabel.dottedUnderlineColor = nativeListColor(
2986
- theme,
2987
- "secondaryText",
2988
- "#646464"
2989
- )
2990
- rootLeadingConstraint.constant = 12
2991
- rootTrailingConstraint.constant = -12
2992
- let isAlphabet = isExplicitNetworkHeader && item.data.string("titleActionKey").isEmpty
2993
- rootTopConstraint.constant = isAlphabet ? 8 : 12
2994
- rootBottomConstraint.constant = isAlphabet ? -8 : isExplicitNetworkHeader ? -12 : -15
2995
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
2996
- } else if isHistory {
2997
- rootLeadingConstraint.constant = 0
2998
- rootTrailingConstraint.constant = 0
2999
- rootTopConstraint.constant = 0
3000
- rootBottomConstraint.constant = 0
3001
- setLineHeight(
3002
- titleLabel,
3003
- text: item.data.string("title").uppercased(),
3004
- lineHeight: 16,
3005
- letterSpacing: 0.8
3006
- )
3007
- } else if !isTable && !isGallery && !isSummary {
3008
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
3009
- if layout == "linear" {
3010
- // TokenManager's section title is a plain body label with mt=10,
3011
- // unlike the reusable 36-point SectionHeader used by sectioned lists.
3012
- titleLabel.font = nativeListFont(ofSize: 14)
3013
- rootTopConstraint.constant = 10
3014
- rootBottomConstraint.constant = 0
3015
- }
3016
- }
3017
- if isTable {
3018
- mainStack.alignment = .leading
3019
- titleRowStack.spacing = 4
3020
- titleLabel.setContentHuggingPriority(.required, for: .horizontal)
3021
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14)
3022
- rootTopConstraint.constant = 12
3023
- rootBottomConstraint.constant = -2
3024
- if let icon = item.data.dictionary("titleIcon") {
3025
- headerTitleIconImageView.image = nativeListIcon(named: icon.string("name"))
3026
- headerTitleIconImageView.tintColor = UIColor(
3027
- nativeListHex: icon.string("tintColor", default: "#646464"),
3028
- fallback: .darkGray
3029
- )
3030
- headerTitleIconImageView.isHidden = false
3031
- titleRowStack.insertArrangedSubview(headerTitleIconImageView, at: 1)
3032
- if icon.string("name") != "ChevronGrabberVerOutline" {
3033
- titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020")
3034
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14)
3035
- }
3036
- }
3037
- }
3038
- if isGallery {
3039
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
3040
- rootTopConstraint.constant = 0
3041
- rootBottomConstraint.constant = -8
3042
- }
3043
- show(subtitleLabel, item.data.string("subtitle"), lines: 1)
3044
- if isSummary {
3045
- titleLabel.showsDottedUnderline = true
3046
- titleLabel.dottedUnderlineColor = nativeListColor(
3047
- theme,
3048
- "secondaryText",
3049
- "#646464"
3050
- )
3051
- rootStack.addArrangedSubview(trailingStack)
3052
- // The surrounding Stack contributes mt=16/pb=12 while its XStack uses
3053
- // px=20/py=8. The collection's 8-point inset supplies the outer 8 points
3054
- // of horizontal padding, so the cell supplies the remaining 12.
3055
- rootLeadingConstraint.constant = 12
3056
- rootTrailingConstraint.constant = -12
3057
- rootTopConstraint.constant = 24
3058
- rootBottomConstraint.constant = -20
3059
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
3060
- accessoryButtons[0].accessibilityIdentifier = item.data["valueActionTestID"] as? String
3061
- let valueActionKey = item.data.string("valueActionKey")
3062
- let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty
3063
- ? nil
3064
- : (valueActionKey, nil)
3065
- showAccessory(
3066
- 0,
3067
- item.data.string("value"),
3068
- action: action,
3069
- color: nativeListColor(theme, "secondaryText", "#646464")
3070
- )
3071
- accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular)
3072
- setButtonLine(
3073
- accessoryButtons[0],
3074
- text: item.data.string("value"),
3075
- font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular),
3076
- color: nativeListColor(theme, "secondaryText", "#646464"),
3077
- lineHeight: 24
3078
- )
3079
- } else if !isGallery && !isHistory {
3080
- rootStack.addArrangedSubview(trailingStack)
3081
- showAccessory(0, item.data.string("value"))
3082
- if isTable {
3083
- trailingStack.axis = .horizontal
3084
- trailingStack.alignment = .center
3085
- trailingStack.spacing = 4
3086
- accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 11)
3087
- accessoryButtons[0].setTitleColor(
3088
- nativeListColor(theme, "secondaryText", "#646464"),
3089
- for: .normal
3090
- )
3091
- if let icon = item.data.dictionary("valueIcon") {
3092
- headerValueIconImageView.image = nativeListIcon(named: icon.string("name"))
3093
- headerValueIconImageView.tintColor = UIColor(
3094
- nativeListHex: icon.string("tintColor", default: "#646464"),
3095
- fallback: .darkGray
3096
- )
3097
- headerValueIconImageView.isHidden = false
3098
- trailingStack.addArrangedSubview(headerValueIconImageView)
3099
- if icon.string("name") != "ChevronGrabberVerOutline" {
3100
- accessoryButtons[0].setTitleColor(
3101
- nativeListColor(theme, "primaryText", "#202020"),
3102
- for: .normal
3103
- )
3104
- }
3105
- }
3106
- }
3107
- if let checkbox = item.data.dictionary("checkbox") {
3108
- bindCheckbox(item, checkbox, checkboxState)
3109
- }
3110
- if item.data.dictionary("checkbox") != nil && !item.data.string("value").isEmpty {
3111
- titleLabel.showsDottedUnderline = true
3112
- titleLabel.dottedUnderlineColor = nativeListColor(
3113
- theme,
3114
- "secondaryText",
3115
- "#646464"
3116
- )
3117
- trailingStack.axis = .horizontal
3118
- trailingStack.alignment = .center
3119
- trailingStack.spacing = 12
3120
- rootTopConstraint.constant = 8
3121
- rootBottomConstraint.constant = -8
3122
- accessoryButtons[0].titleLabel?.font = nativeListTabularFont(
3123
- ofSize: 16,
3124
- weight: .medium
3125
- )
3126
- setButtonLine(
3127
- accessoryButtons[0],
3128
- text: item.data.string("value"),
3129
- font: nativeListTabularFont(ofSize: 16, weight: .medium),
3130
- color: nativeListColor(theme, "primaryText", "#202020"),
3131
- lineHeight: 24
3132
- )
3133
- }
3134
- }
3135
- applyValueSegments(item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: theme)
3136
- }
3137
-
3138
- // OneKey patch: small zero-count digits remain on the regular amount baseline.
3139
- private func applyValueSegments(_ segments: [[String: Any]], to button: UIButton, theme: [String: Any]?) {
3140
- guard !segments.isEmpty else { return }
3141
- let value = NSMutableAttributedString(string: "")
3142
- let color = nativeListColor(theme, "primaryText", "#202020")
3143
- // OneKey patch: rich currency changes font runs without dropping the established line baseline.
3144
- let current = button.attributedTitle(for: .normal)
3145
- var attributes = current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:]
3146
- attributes[.foregroundColor] = color
3147
- if currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil {
3148
- let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
3149
- paragraph.alignment = .right
3150
- attributes[.paragraphStyle] = paragraph
3151
- }
3152
- for segment in segments {
3153
- attributes[.font] = nativeListTabularFont(ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium)
3154
- value.append(NSAttributedString(string: segment.string("text"), attributes: attributes))
3155
- }
3156
- button.setAttributedTitle(value, for: .normal)
3157
- }
3158
-
3159
- private func bindAction(
3160
- _ item: NativeListItem,
3161
- theme: [String: Any]?,
3162
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
3163
- ) {
3164
- let isAccountSelector = item.data.string("presentation") == "accountSelector"
3165
- if let icon = item.data.dictionary("icon") {
3166
- leadingWidth.constant = isAccountSelector ? 32 : 40
3167
- leadingHeight.constant = isAccountSelector ? 32 : 40
3168
- leadingIconWidth.constant = 24
3169
- leadingIconHeight.constant = 24
3170
- addLeading(icon, key: item.key)
3171
- if isAccountSelector {
3172
- leadingContainer.layer.cornerCurve = .continuous
3173
- leadingContainer.layer.cornerRadius = 8
3174
- leadingContainer.layer.borderWidth = 0
3175
- }
3176
- if icon["backgroundColor"] == nil {
3177
- leadingContainer.backgroundColor = .clear
3178
- leadingContainer.layer.borderWidth = 0
3179
- leadingContainer.layer.borderColor = nil
3180
- }
3181
- }
3182
- rootStack.addArrangedSubview(mainStack)
3183
- show(titleLabel, item.data.string("title"), lines: 1)
3184
- if isAccountSelector {
3185
- // OneKey patch: ListItem.Text is medium; empty-search actions use regular body text.
3186
- titleLabel.font = nativeListFont(ofSize: 16, weight: item.data.dictionary("icon") == nil ? .regular : .medium)
3187
- titleLabel.textColor = nativeListColor(theme, item.data.string("tone") == "primary" ? "primaryText" : "secondaryText", item.data.string("tone") == "primary" ? "#202020" : "#646464")
3188
- } else if item.data.string("tone") == "danger" {
3189
- titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31")
3190
- }
3191
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24)
3192
- if let checkbox = item.data.dictionary("checkbox") {
3193
- bindCheckbox(item, checkbox, checkboxState)
3194
- rootStack.addArrangedSubview(trailingStack)
3195
- } else {
3196
- let accessories = item.data.dictionaries("trailing")
3197
- if !accessories.isEmpty {
3198
- rootStack.addArrangedSubview(trailingStack)
3199
- bindAccessories(item, accessories, theme, checkboxState)
3200
- if accessories.count == 1, accessories[0].string("kind") == "chevron" {
3201
- // ListItem.DrillIn uses mx=-6 around its 24-point icon.
3202
- rootTrailingConstraint.constant = -6
3203
- }
3204
- }
3205
- }
3206
- }
3207
-
3208
- // OneKey patch: preserve the actual title frame for Popover placement.
3209
- @objc private func selectorTitlePressed() {
3210
- guard let item = currentItem else { return }
3211
- let action = item.data.string("titleActionKey")
3212
- guard !action.isEmpty else { return }
3213
- onAction?(item, action, nil, actionOrigin(sourceView: titleLabel, source: "leadingAction"))
3214
- }
3215
-
3216
- private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor {
3217
- switch tone.isEmpty ? "primary" : tone {
3218
- // OneKey patch: account warnings and hidden balances use existing theme tokens.
3219
- case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D")
3220
- case "caution": return nativeListColor(theme, "caution", "#AB6400")
3221
- case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
3222
- case "positive": return nativeListColor(theme, "positive", "#218358")
3223
- case "negative": return nativeListColor(theme, "negative", "#CE2C31")
3224
- default: return nativeListColor(theme, "primaryText", "#202020")
3225
- }
3226
- }
3227
-
3228
- private func bindSystem(_ item: NativeListItem, theme: [String: Any]?) {
3229
- rootStack.alignment = .center
3230
- rootStack.distribution = .fill
3231
- let variant = item.data.string("variant")
3232
- let isMarket = item.data.string("presentation") == "market"
3233
- if isMarket {
3234
- rootLeadingConstraint.constant = 20
3235
- rootTrailingConstraint.constant = -20
3236
- rootTopConstraint.constant = 12
3237
- rootBottomConstraint.constant = -12
3238
- }
3239
- if isMarket && variant == "retry" {
3240
- let message = item.data.string("message")
3241
- let text = item.data.string("actionText", default: "Retry")
3242
- rootStack.axis = .vertical
3243
- // The source tertiary Button has -5 vertical margins around its 30pt frame.
3244
- rootStack.spacing = 7
3245
- rootLeadingConstraint.constant = 32
3246
- rootTrailingConstraint.constant = -32
3247
- let height = CGFloat(item.data.double("height", default: message.isEmpty ? 52 : 120))
3248
- let top = message.isEmpty ? 11 : max(32, (height - 56) / 2)
3249
- rootTopConstraint.constant = top
3250
- rootBottomConstraint.constant = -(height - top - (message.isEmpty ? 30 : 61))
3251
- if !message.isEmpty {
3252
- show(titleLabel, message, lines: 2)
3253
- titleLabel.font = nativeListTabularFont(ofSize: 16)
3254
- titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
3255
- titleLabel.textAlignment = .center
3256
- setLineHeight(titleLabel, text: message, lineHeight: 24)
3257
- rootStack.addArrangedSubview(mainStack)
3258
- }
3259
- showAccessory(0, text, action: (item.data.string("actionKey"), nil))
3260
- let button = accessoryButtons[0]
3261
- button.backgroundColor = .clear
3262
- button.layer.cornerRadius = 15
3263
- setButtonLine(button, text: text, font: nativeListTabularFont(ofSize: 14, weight: .medium),
3264
- color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20)
3265
- let textWidth = button.intrinsicContentSize.width
3266
- selectorConstraints.append(contentsOf: [
3267
- button.widthAnchor.constraint(equalToConstant: textWidth + 18),
3268
- button.heightAnchor.constraint(equalToConstant: 30),
3269
- ])
3270
- NSLayoutConstraint.activate(selectorConstraints)
3271
- rootStack.addArrangedSubview(trailingStack)
3272
- return
3273
- }
3274
- if variant == "loading" && item.data.string("loadingStyle") == "skeleton" {
3275
- rootLeadingConstraint.constant = 20
3276
- rootTrailingConstraint.constant = -20
3277
- rootTopConstraint.constant = 12
3278
- rootBottomConstraint.constant = -12
3279
- let skeleton = NativeListMarketSkeleton(background: nativeListColor(theme, "background", "#FFFFFF"))
3280
- skeleton.translatesAutoresizingMaskIntoConstraints = false
3281
- skeleton.heightAnchor.constraint(equalToConstant: 32).isActive = true
3282
- rootStack.addArrangedSubview(skeleton)
3283
- selectorViews.append(skeleton)
3284
- return
3285
- }
3286
- if variant == "loading" && item.data.string("loadingStyle") == "spinner" {
3287
- rootTopConstraint.constant = 16
3288
- rootBottomConstraint.constant = -16
3289
- rootStack.addArrangedSubview(mainStack)
3290
- mainStack.alignment = .center
3291
- mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
3292
- let indicator = UIActivityIndicatorView(style: .medium)
3293
- indicator.color = nativeListColor(theme, "icon", "#0000009B")
3294
- indicator.startAnimating()
3295
- mainStack.addArrangedSubview(indicator)
3296
- selectorViews.append(indicator)
3297
- return
3298
- }
3299
- if isMarket && variant == "noMatch" {
3300
- let padding = max(32, (CGFloat(item.data.double("height", default: 88)) - 24) / 2)
3301
- rootTopConstraint.constant = padding
3302
- rootBottomConstraint.constant = -padding
3303
- rootStack.addArrangedSubview(mainStack)
3304
- mainStack.alignment = .center
3305
- mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
3306
- titleLabel.font = nativeListTabularFont(ofSize: 16)
3307
- titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
3308
- titleLabel.textAlignment = .center
3309
- show(titleLabel, item.data.string("message"), lines: 1)
3310
- setLineHeight(titleLabel, text: item.data.string("message"), lineHeight: 24)
3311
- return
3312
- }
3313
- if isMarket && variant == "end" {
3314
- rootTopConstraint.constant = 16
3315
- rootBottomConstraint.constant = -16
3316
- // OneKey patch: an empty title stack must not consume the dot's line height.
3317
- titleRowStack.isHidden = true
3318
- rootStack.addArrangedSubview(mainStack)
3319
- mainStack.alignment = .center
3320
- mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
3321
- let indicator = UIStackView()
3322
- indicator.axis = .horizontal
3323
- indicator.alignment = .center
3324
- indicator.spacing = 8
3325
- for width in [CGFloat(80), 4, 80] {
3326
- let mark = UIView()
3327
- mark.translatesAutoresizingMaskIntoConstraints = false
3328
- mark.backgroundColor = nativeListColor(theme, "separator", "#0000001F")
3329
- mark.layer.cornerRadius = width == 4 ? 2 : 0
3330
- NSLayoutConstraint.activate([
3331
- mark.widthAnchor.constraint(equalToConstant: width),
3332
- mark.heightAnchor.constraint(equalToConstant: width == 4 ? 4 : 1),
3333
- ])
3334
- indicator.addArrangedSubview(mark)
3335
- }
3336
- mainStack.addArrangedSubview(indicator)
3337
- selectorViews.append(indicator)
3338
- return
3339
- }
3340
- // OneKey patch: deprecated-wallet warnings stay inside the scrolling list.
3341
- if variant == "warning" {
3342
- rootStack.addArrangedSubview(mainStack)
3343
- rootTopConstraint.constant = 14
3344
- rootBottomConstraint.constant = -14
3345
- mainStack.spacing = 4
3346
- titleLabel.font = nativeListFont(ofSize: 14, weight: .medium)
3347
- titleLabel.numberOfLines = 0
3348
- subtitleLabel.font = nativeListFont(ofSize: 14)
3349
- subtitleLabel.numberOfLines = 0
3350
- show(titleLabel, item.data.string("title"), lines: 0)
3351
- show(subtitleLabel, item.data.string("message"), lines: 0)
3352
- setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20)
3353
- setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20)
3354
- let borderColor = UIColor(nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray)
3355
- for top in [true, false] {
3356
- let border = UIView()
3357
- border.translatesAutoresizingMaskIntoConstraints = false
3358
- border.backgroundColor = borderColor
3359
- contentView.addSubview(border)
3360
- NSLayoutConstraint.activate([
3361
- border.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -8),
3362
- border.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 8),
3363
- border.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
3364
- top ? border.topAnchor.constraint(equalTo: contentView.topAnchor) : border.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
3365
- ])
3366
- selectorViews.append(border)
3367
- }
3368
- return
3369
- }
3370
- if variant == "loading" {
3371
- leadingWidth.constant = isMarket ? 32 : 40
3372
- leadingHeight.constant = isMarket ? 32 : 40
3373
- leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
3374
- leadingContainer.layer.cornerRadius = isMarket ? 16 : 20
3375
- rootStack.addArrangedSubview(leadingContainer)
3376
- configureSkeleton(skeletonPrimary, width: 120, height: 12, theme: theme)
3377
- configureSkeleton(skeletonSecondary, width: 80, height: 12, theme: theme)
3378
- mainStack.spacing = 8
3379
- mainStack.addArrangedSubview(skeletonPrimary)
3380
- mainStack.addArrangedSubview(skeletonSecondary)
3381
- }
3382
- show(
3383
- titleLabel,
3384
- item.data.string("message", default: variant == "end" ? "End" : ""),
3385
- lines: 2
3386
- )
3387
- let alignsToStart = variant == "retry" || variant == "noMatch" || variant == "end"
3388
- titleLabel.textAlignment = alignsToStart ? .natural : .center
3389
- mainStack.alignment = variant == "loading" || alignsToStart ? .leading : .center
3390
- if alignsToStart {
3391
- mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
3392
- titleLabel.font = nativeListFont(ofSize: 14)
3393
- titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464")
3394
- setLineHeight(titleLabel, text: titleLabel.text ?? "", lineHeight: 20)
3395
- }
3396
- rootStack.addArrangedSubview(mainStack)
3397
- if variant == "retry" {
3398
- showAccessory(0, "Retry", action: (item.data.string("actionKey"), nil))
3399
- accessoryButtons[0].backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0")
3400
- accessoryButtons[0].layer.cornerRadius = 14
3401
- accessoryButtons[0].contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10)
3402
- setButtonLine(
3403
- accessoryButtons[0],
3404
- text: "Retry",
3405
- font: nativeListFont(ofSize: 14, weight: .medium),
3406
- color: nativeListColor(theme, "primaryText", "#202020"),
3407
- lineHeight: 20
3408
- )
3409
- rootStack.addArrangedSubview(trailingStack)
3410
- }
3411
- }
3412
-
3413
- private func addLeading(
3414
- _ visual: [String: Any]?,
3415
- secondaryVisual: [String: Any]? = nil,
3416
- key: String,
3417
- to stack: UIStackView? = nil
3418
- ) {
3419
- let targetStack = stack ?? rootStack
3420
- targetStack.addArrangedSubview(leadingContainer)
3421
- guard let visual else { return }
3422
- let kind = visual.string("kind")
3423
- var sources = visualSources(visual)
3424
- if let secondaryVisual, let secondarySource = visualSources(secondaryVisual).first {
3425
- sources.append(secondarySource)
3426
- }
3427
- let isIcon = kind == "icon"
3428
- let shape = visual.string(
3429
- "shape",
3430
- default: kind == "image" || mediaHeight.isActive ? "rounded" : "circle"
3431
- )
3432
- let cornerIcon = visual.dictionary("cornerIcon")
3433
- let fallbackText = String(visual.string("fallbackText").prefix(2))
3434
- let fallbackIconData = visual.dictionary("fallbackIcon")
3435
- let fallbackIconSize = fallbackIconData.map {
3436
- let slotSize = min(leadingWidth.constant, leadingHeight.constant)
3437
- return $0.string("name") == "GlobusOutline" ? slotSize * 1.2 : slotSize
3438
- }
3439
- let sourceLoadingStrategy = sources.first?.data.string("loadingStrategy", default: "none") ?? "none"
3440
- let showsSourcePlaceholder = !isIcon && !sources.isEmpty && sourceLoadingStrategy != "none"
3441
- let handlesSourceFallback = !isIcon && !sources.isEmpty &&
3442
- showsSourcePlaceholder &&
3443
- (visual["fallbackText"] != nil || fallbackIconData != nil)
3444
- let sourceFallbackKey = handlesSourceFallback
3445
- ? sources.first.flatMap { sourceFallbackStateKey($0.data) }
3446
- : nil
3447
- let restoresSourceFallback = sourceFallbackKey.map(NativeListSourceFallbackState.has) ?? false
3448
- fallbackLabel.text = handlesSourceFallback && !restoresSourceFallback ? nil : fallbackText
3449
- let sourceFallbackBackground = currentTheme?["strongBackground"] as? String ?? "#0000000F"
3450
- // OneKey patch: source-backed visuals default to no placeholder/background.
3451
- // A non-none image loadingStrategy opts back into the themed placeholder.
3452
- let visualBackground = visual.string(
3453
- "backgroundColor",
3454
- default: !isIcon && !sources.isEmpty ? "#00000000" : sourceFallbackBackground
3455
- )
3456
- let visualBackgroundColor = UIColor(nativeListHex: visualBackground, fallback: .clear)
3457
- leadingContainer.backgroundColor = handlesSourceFallback
3458
- ? UIColor(nativeListHex: sourceFallbackBackground, fallback: .clear)
3459
- : visualBackgroundColor
3460
- leadingContainer.layer.cornerRadius = leadingCornerRadius(shape: shape)
3461
- leadingContainer.clipsToBounds = true
3462
- fallbackLabel.isHidden = isIcon || (!sources.isEmpty && !handlesSourceFallback)
3463
- if isIcon {
3464
- leadingContainer.layer.borderWidth = 1 / UIScreen.main.scale
3465
- leadingContainer.layer.borderColor = UIColor(
3466
- nativeListHex: "#0000001F",
3467
- fallback: .lightGray
3468
- ).cgColor
3469
- leadingIconImageView.isHidden = false
3470
- leadingIconImageView.tintColor = UIColor(
3471
- nativeListHex: visual.string("tintColor", default: "#646464"),
3472
- fallback: .darkGray
3473
- )
3474
- leadingIconImageView.image = nativeListIcon(named: visual.string("name"))
3475
- leadingIconImageView.contentMode = .scaleAspectFit
3476
- } else if sources.isEmpty, let fallbackIconData {
3477
- fallbackLabel.isHidden = true
3478
- leadingIconImageView.isHidden = false
3479
- leadingIconImageView.tintColor = UIColor(
3480
- nativeListHex: fallbackIconData.string("tintColor", default: "#646464"),
3481
- fallback: .darkGray
3482
- )
3483
- leadingIconImageView.image = nativeListIcon(named: fallbackIconData.string("name"))
3484
- leadingIconImageView.contentMode = .scaleAspectFit
3485
- }
3486
- if handlesSourceFallback, let fallbackIconData {
3487
- fallbackLabel.isHidden = true
3488
- leadingIconImageView.image = nativeListIcon(named: fallbackIconData.string("name"))
3489
- leadingIconImageView.tintColor = UIColor(
3490
- nativeListHex: fallbackIconData.string("tintColor", default: "#646464"),
3491
- fallback: .darkGray
3492
- )
3493
- leadingIconImageView.contentMode = .scaleAspectFit
3494
- leadingIconWidth.constant = fallbackIconSize ?? leadingWidth.constant
3495
- leadingIconHeight.constant = fallbackIconSize ?? leadingHeight.constant
3496
- leadingIconImageView.isHidden = !restoresSourceFallback
3497
- } else if handlesSourceFallback {
3498
- fallbackLabel.isHidden = !restoresSourceFallback
3499
- }
3500
- let visibleSources = Array(sources.prefix(leadingImages.count))
3501
- let tokenPair = kind == "token" && visibleSources.count > 1
3502
- leadingContainer.clipsToBounds = !tokenPair && cornerIcon == nil
3503
- if let cornerIcon {
3504
- leadingCornerIconBackground.isHidden = false
3505
- leadingCornerIconBackground.backgroundColor = UIColor(
3506
- nativeListHex: cornerIcon.string("backgroundColor", default: "#FFFFFF"),
3507
- fallback: .white
3508
- )
3509
- leadingCornerIconImageView.image = nativeListIcon(named: cornerIcon.string("name"))
3510
- leadingCornerIconImageView.tintColor = UIColor(
3511
- nativeListHex: cornerIcon.string("tintColor", default: "#646464"),
3512
- fallback: .darkGray
3513
- )
3514
- }
3515
- if tokenPair {
3516
- leadingOverlayBackground.isHidden = false
3517
- leadingOverlayBackground.backgroundColor = visualBackdropColor
3518
- leadingSlotConstraints.append(contentsOf: [
3519
- leadingOverlayBackground.widthAnchor.constraint(equalToConstant: 20),
3520
- leadingOverlayBackground.heightAnchor.constraint(equalToConstant: 20),
3521
- leadingOverlayBackground.trailingAnchor.constraint(
3522
- equalTo: leadingContainer.trailingAnchor,
3523
- constant: 4
3524
- ),
3525
- leadingOverlayBackground.bottomAnchor.constraint(
3526
- equalTo: leadingContainer.bottomAnchor,
3527
- constant: 4
3528
- ),
3529
- ])
3530
- }
3531
- for (index, source) in visibleSources.enumerated() {
3532
- let imageView = leadingImages[index]
3533
- // OneKey patch: Decorative badges and source-owned fallbacks stay hidden until loaded.
3534
- // imageView.isHidden = false
3535
- let ownsSourceFallback = index == 0 && handlesSourceFallback
3536
- imageView.isHidden = index > 0 || ownsSourceFallback
3537
- imageView.clipsToBounds = true
3538
- leadingSlotConstraints.append(contentsOf: leadingConstraints(
3539
- imageView,
3540
- index: index,
3541
- count: visibleSources.count,
3542
- tokenPair: tokenPair,
3543
- shape: shape
3544
- ))
3545
- if currentItem?.data.string("presentation") == "networkSelector", currentItem?.data["height"] != nil, visibleSources.count == 1, cornerIcon == nil, visual.dictionaries("overlays").isEmpty {
3546
- // OneKey patch: a single outer mask matches NetworkAvatar's edge antialiasing.
3547
- imageView.layer.cornerRadius = 0
3548
- imageView.clipsToBounds = false
3549
- }
3550
- let fallbackIcon = index == 0 ? fallbackIconData : nil
3551
- let expectedEpoch = bindingEpoch
3552
- bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant,
3553
- onLoad: !ownsSourceFallback ? nil : { [weak self, weak imageView] in
3554
- guard let self, self.bindingEpoch == expectedEpoch else { return }
3555
- if let sourceFallbackKey {
3556
- NativeListSourceFallbackState.forget(sourceFallbackKey)
3557
- }
3558
- imageView?.isHidden = false
3559
- self.fallbackLabel.isHidden = true
3560
- self.leadingIconImageView.isHidden = true
3561
- self.leadingContainer.backgroundColor = visualBackgroundColor
3562
- },
3563
- onError: !ownsSourceFallback ? nil : { [weak self, weak imageView] in
3564
- guard let self, self.bindingEpoch == expectedEpoch else { return }
3565
- if let sourceFallbackKey {
3566
- NativeListSourceFallbackState.remember(sourceFallbackKey)
3567
- }
3568
- imageView?.isHidden = true
3569
- if let fallbackIcon {
3570
- self.fallbackLabel.isHidden = true
3571
- self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name"))
3572
- self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray)
3573
- self.leadingIconImageView.isHidden = false
3574
- } else {
3575
- self.leadingIconImageView.isHidden = true
3576
- self.fallbackLabel.text = fallbackText
3577
- self.fallbackLabel.isHidden = false
3578
- }
3579
- },
3580
- hideUntilLoaded: index > 0 || ownsSourceFallback)
3581
- }
3582
- // OneKey patch: source-derived wallet decorations may occupy both corners.
3583
- let overlays = visual.dictionaries("overlays")
3584
- if !overlays.isEmpty { leadingContainer.clipsToBounds = false }
3585
- for (index, overlay) in overlays.enumerated() {
3586
- let size = CGFloat(overlay.double("size", default: 20))
3587
- let isWalletText = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil && !overlay.string("text").isEmpty && overlay.dictionary("image") == nil && overlay.string("name").isEmpty
3588
- let inset = CGFloat(overlay.double("padding", default: 0))
3589
- let offsetX = CGFloat(overlay.double("offsetX", default: overlay.double("offset", default: 2)))
3590
- let offsetY = CGFloat(overlay.double("offsetY", default: overlay.double("offset", default: 2)))
3591
- let height = CGFloat(overlay.double("height", default: isWalletText ? 16 : Double(size)))
3592
- let textWidth = (overlay.string("text") as NSString).size(withAttributes: [.font: nativeListTabularFont(ofSize: 12), .kern: 0]).width
3593
- let naturalTextWidth = ceil(textWidth * UIScreen.main.scale) / UIScreen.main.scale + 4
3594
- let width = CGFloat(overlay.double("width", default: isWalletText ? Double(naturalTextWidth) : Double(size)))
3595
- let frame = UIView()
3596
- frame.translatesAutoresizingMaskIntoConstraints = false
3597
- // OneKey patch: The row theme, not a light-only white, owns badge backing.
3598
- // frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear)
3599
- frame.backgroundColor = UIColor(
3600
- nativeListHex: overlay.string(
3601
- "backgroundColor",
3602
- default: currentTheme?["rowBackground"] as? String ?? "#00000000"
3603
- ),
3604
- fallback: .clear
3605
- )
3606
- frame.layer.cornerRadius = min(width, height) / 2
3607
- frame.clipsToBounds = true
3608
- leadingContainer.addSubview(frame)
3609
- selectorViews.append(frame)
3610
- let topLeft = overlay.string("position") == "topLeft"
3611
- leadingSlotConstraints.append(contentsOf: [
3612
- frame.widthAnchor.constraint(equalToConstant: width),
3613
- frame.heightAnchor.constraint(equalToConstant: height),
3614
- topLeft ? frame.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: -offsetX) : frame.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: offsetX),
3615
- topLeft ? frame.topAnchor.constraint(equalTo: leadingContainer.topAnchor, constant: -offsetY) : frame.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: offsetY),
3616
- ])
3617
- let content: UIView
3618
- if let image = overlay.dictionary("image") {
3619
- let imageView = OneKeyImageReusableView(frame: .zero)
3620
- bindImage(
3621
- image,
3622
- into: imageView,
3623
- token: key,
3624
- slot: 10 + index,
3625
- variant: "generic",
3626
- hideUntilLoaded: true
3627
- )
3628
- selectorImages.append(imageView)
3629
- content = imageView
3630
- } else if !overlay.string("text").isEmpty {
3631
- let label = UILabel()
3632
- label.text = overlay.string("text")
3633
- label.textAlignment = .center
3634
- label.font = nativeListFont(ofSize: isWalletText ? 12 : 10, weight: isWalletText ? .regular : .medium)
3635
- label.textColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray)
3636
- if isWalletText { setLineHeight(label, text: overlay.string("text"), lineHeight: 16) }
3637
- content = label
3638
- } else {
3639
- let imageView = UIImageView(image: nativeListIcon(named: overlay.string("name")))
3640
- imageView.contentMode = .scaleAspectFit
3641
- imageView.tintColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray)
3642
- content = imageView
3643
- }
3644
- content.translatesAutoresizingMaskIntoConstraints = false
3645
- frame.addSubview(content)
3646
- NSLayoutConstraint.activate([
3647
- content.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: isWalletText ? 2 : inset),
3648
- content.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: isWalletText ? -2 : -inset),
3649
- content.topAnchor.constraint(equalTo: frame.topAnchor, constant: isWalletText ? 0 : inset),
3650
- content.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: isWalletText ? 0 : -inset),
3651
- ])
3652
- }
3653
- if let fallbackIcon = visual.dictionary("fallbackIcon"), sources.isEmpty {
3654
- fallbackLabel.isHidden = true
3655
- leadingIconImageView.isHidden = false
3656
- leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name"))
3657
- leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray)
3658
- if currentItem?.data.string("presentation") == "walletSidebar", currentItem?.data["height"] != nil, fallbackIcon.string("name") == "LockSolid" {
3659
- leadingIconWidth.constant = 40
3660
- leadingIconHeight.constant = 40
3661
- leadingContainer.clipsToBounds = false
3662
- }
3663
- }
3664
- if visual.string("borderStyle") == "dashed" {
3665
- let border = CAShapeLayer()
3666
- border.strokeColor = UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray).cgColor
3667
- border.fillColor = UIColor.clear.cgColor
3668
- border.lineWidth = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil ? 1 : 2
3669
- border.lineDashPattern = [4, 4]
3670
- let borderInset = border.lineWidth / 2
3671
- border.path = UIBezierPath(ovalIn: CGRect(x: borderInset, y: borderInset, width: leadingWidth.constant - border.lineWidth, height: leadingHeight.constant - border.lineWidth)).cgPath
3672
- leadingContainer.layer.addSublayer(border)
3673
- selectorBorder = border
3674
- }
3675
- NSLayoutConstraint.activate(leadingSlotConstraints)
3676
- }
3677
-
3678
- private func visualSources(_ visual: [String: Any]) -> [(data: [String: Any], variant: String)] {
3679
- let kind = visual.string("kind")
3680
- if kind == "stackedImages" {
3681
- return visual.dictionaries("images").prefix(3).map { ($0, "generic") }
3682
- }
3683
- guard kind != "icon", let image = visual.dictionary("image") else { return [] }
3684
- let variant: String
3685
- switch kind {
3686
- case "token": variant = "token"
3687
- case "network": variant = "network"
3688
- case "account", "wallet": variant = "avatar"
3689
- default: variant = "generic"
3690
- }
3691
- var sources: [(data: [String: Any], variant: String)] = [(image, variant)]
3692
- if kind == "token", let networkImage = visual.dictionary("networkImage") {
3693
- sources.append((networkImage, "network"))
3694
- }
3695
- return sources
3696
- }
3697
-
3698
- private func sourceFallbackStateKey(_ source: [String: Any]) -> String? {
3699
- let uri = source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines)
3700
- guard !uri.isEmpty else { return nil }
3701
- // The fallback-state cache is process-wide, so key it by a digest of the request identity
3702
- // instead of retaining raw header values, which can carry credentials such as Authorization.
3703
- let headers = source.dictionary("headers") ?? [:]
3704
- let fields: [(name: String, value: String)] = headers.map { key, value in
3705
- (name: key.lowercased(), value: String(describing: value))
3706
- }
3707
- let sortedFields = fields.sorted { lhs, rhs in
3708
- lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
3709
- }
3710
- var canonicalValue = "\(uri.utf8.count):\(uri)"
3711
- for field in sortedFields {
3712
- canonicalValue += "\(field.name.utf8.count):\(field.name)\(field.value.utf8.count):\(field.value)"
3713
- }
3714
- let digest = SHA256.hash(data: Data(canonicalValue.utf8))
3715
- return digest.map { String(format: "%02x", $0) }.joined()
3716
- }
3717
-
3718
- private func leadingConstraints(
3719
- _ imageView: UIView,
3720
- index: Int,
3721
- count: Int,
3722
- tokenPair: Bool,
3723
- shape: String
3724
- ) -> [NSLayoutConstraint] {
3725
- if count == 1 || tokenPair && index == 0 {
3726
- imageView.layer.cornerRadius = leadingCornerRadius(shape: shape)
3727
- return [
3728
- imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor),
3729
- imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor),
3730
- imageView.topAnchor.constraint(equalTo: leadingContainer.topAnchor),
3731
- imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor),
3732
- ]
3733
- }
3734
- if tokenPair && index == 1 {
3735
- let overlaySize: CGFloat = 16
3736
- imageView.layer.cornerRadius = overlaySize / 2
3737
- return [
3738
- imageView.widthAnchor.constraint(equalToConstant: overlaySize),
3739
- imageView.heightAnchor.constraint(equalToConstant: overlaySize),
3740
- imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: 2),
3741
- imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: 2),
3742
- ]
3743
- }
3744
- let imageSize = leadingHeight.constant * 0.72
3745
- let availableOffset = leadingWidth.constant - imageSize
3746
- let offset = CGFloat(index) * availableOffset / CGFloat(max(1, count - 1))
3747
- imageView.layer.cornerRadius = imageSize / 2
3748
- return [
3749
- imageView.widthAnchor.constraint(equalToConstant: imageSize),
3750
- imageView.heightAnchor.constraint(equalToConstant: imageSize),
3751
- imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: offset),
3752
- imageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor),
3753
- ]
3754
- }
3755
-
3756
- private func leadingCornerRadius(shape: String) -> CGFloat {
3757
- switch shape {
3758
- case "square": return 0
3759
- case "rounded": return min(10, leadingHeight.constant / 4)
3760
- default: return leadingHeight.constant / 2
3761
- }
3762
- }
3763
-
3764
- private func bindAccessories(
3765
- _ item: NativeListItem,
3766
- _ accessories: [[String: Any]],
3767
- _ theme: [String: Any]?,
3768
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
3769
- ) {
3770
- var textIndex = 0
3771
- for accessory in accessories.prefix(2) {
3772
- switch accessory.string("kind") {
3773
- case "value":
3774
- showAccessory(textIndex, accessory.string("text"))
3775
- if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil {
3776
- accessoryButtons[textIndex].contentHorizontalAlignment = .trailing
3777
- accessoryButtons[textIndex].titleLabel?.textAlignment = .right
3778
- }
3779
- applyValueSegments(accessory.dictionaries("textSegments"), to: accessoryButtons[textIndex], theme: theme)
3780
- textIndex += 1
3781
- case "valuePair":
3782
- showValuePairAccessory(textIndex, accessory, theme: theme)
3783
- textIndex += 1
3784
- case "checkbox": bindCheckbox(item, accessory, checkboxState)
3785
- case "radio": showAccessory(textIndex, accessory.bool("checked") ? "●" : "○", action: accessoryAction(accessory)); textIndex += 1
3786
- case "switch": showAccessory(textIndex, accessory.bool("value") ? "ON" : "OFF", action: accessoryAction(accessory)); textIndex += 1
3787
- case "chevron":
3788
- var icon = accessory
3789
- icon["name"] = "ChevronRightSmallOutline"
3790
- if icon["tintColor"] == nil {
3791
- icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
3792
- }
3793
- if icon.string("actionKey").isEmpty { icon["actionKey"] = "press" }
3794
- showAccessoryIcon(textIndex, icon)
3795
- textIndex += 1
3796
- case "menu": showMenuAccessory(textIndex, action: accessoryAction(accessory)); textIndex += 1
3797
- case "drag":
3798
- var icon = accessory
3799
- icon["name"] = "DragOutline"
3800
- if icon["tintColor"] == nil {
3801
- icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
3802
- }
3803
- showAccessoryIcon(textIndex, icon)
3804
- textIndex += 1
3805
- case "icon":
3806
- var icon = accessory
3807
- if icon["tintColor"] == nil {
3808
- icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D"
3809
- }
3810
- showAccessoryIcon(textIndex, icon)
3811
- textIndex += 1
3812
- case "spinner": spinner.startAnimating()
3813
- case "progress": showAccessory(textIndex, "\(Int(accessory.double("value") * 100))%"); textIndex += 1
3814
- default: break
3815
- }
3816
- }
3817
- }
3818
-
3819
- private func bindCheckbox(
3820
- _ item: NativeListItem,
3821
- _ data: [String: Any],
3822
- _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
3823
- ) {
3824
- let targetData = data.dictionary("target")
3825
- let scope = targetData?.string("scope", default: "row") ?? "row"
3826
- let key = scope == "section" ? targetData?.string("sectionKey") : scope == "row" ? item.key : nil
3827
- let target = NativeSelectionTarget(scope: scope, key: key)
3828
- let state = checkboxState(item, target, data.string("state", default: "unchecked"))
3829
- boundCheckboxData = data
3830
- boundCheckboxTarget = target
3831
- updateCheckboxPresentation(item, data, target: target, state: state)
3832
- }
3833
-
3834
- private func updateCheckboxPresentation(
3835
- _ item: NativeListItem,
3836
- _ data: [String: Any],
3837
- target: NativeSelectionTarget,
3838
- state: String
3839
- ) {
3840
- let accessoryDisabled = data.bool("disabled")
3841
- if data.bool("loading") {
3842
- checkboxButton.isHidden = true
3843
- spinner.startAnimating()
3844
- spinner.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1
3845
- checkboxAction = nil
3846
- return
3847
- }
3848
- checkboxButton.isHidden = false
3849
- checkboxButton.backgroundColor = state == "unchecked" ? checkboxUncheckedColor : checkboxCheckedColor
3850
- checkboxButton.layer.borderColor = state == "unchecked"
3851
- ? checkboxBorderColor.cgColor
3852
- : UIColor.clear.cgColor
3853
- let glyphName = state == "indeterminate"
3854
- ? "CheckboxIndeterminateCustom"
3855
- : "CheckboxCheckedCustom"
3856
- checkboxButton.setImage(
3857
- state == "unchecked" ? nil : nativeListIcon(named: glyphName),
3858
- for: .normal
3859
- )
3860
- checkboxButton.tintColor = checkboxIconColor
3861
- // A disabled ListItem already applies 0.5 to its complete content. Avoid
3862
- // multiplying that opacity on the nested control a second time.
3863
- checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1
3864
- checkboxButton.isEnabled = !item.data.bool("disabled") && !accessoryDisabled && !data.bool("loading")
3865
- checkboxAction = (data.string("actionKey", default: "selection"), target)
3866
- }
3867
-
3868
- private func show(_ label: UILabel, _ value: String, lines: Int) {
3869
- guard !value.isEmpty else { return }
3870
- label.text = value
3871
- label.numberOfLines = lines
3872
- label.isHidden = false
3873
- }
3874
-
3875
- private func setLineHeight(
3876
- _ label: UILabel,
3877
- text: String,
3878
- lineHeight: CGFloat,
3879
- letterSpacing: CGFloat = 0
3880
- ) {
3881
- let paragraphStyle = NSMutableParagraphStyle()
3882
- paragraphStyle.minimumLineHeight = lineHeight
3883
- paragraphStyle.maximumLineHeight = lineHeight
3884
- paragraphStyle.alignment = label.textAlignment
3885
- if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" {
3886
- // Attributed paragraphs must preserve the tail ellipsis.
3887
- paragraphStyle.lineBreakMode = label.lineBreakMode
3888
- }
3889
- var attributes: [NSAttributedString.Key: Any] = [
3890
- .font: label.font as Any,
3891
- .foregroundColor: label.textColor as Any,
3892
- .paragraphStyle: paragraphStyle,
3893
- ]
3894
- let isNetworkFallback =
3895
- label === fallbackLabel &&
3896
- currentItem?.type == "identity" &&
3897
- currentItem?.data.string("presentation") == "networkSelector"
3898
- if currentItem?.type == "market" || currentItem?.type == "system" && currentItem?.data.string("presentation") == "market" && currentItem?.data.string("variant") == "noMatch" || (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || currentItem?.type == "system" && currentItem?.data.string("variant") == "warning" || isNetworkFallback {
3899
- // OneKey patch: React Native centers font metrics inside explicit line heights.
3900
- let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2)
3901
- // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel.
3902
- let isSelectorHeading = lineHeight == 20 && (currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" || currentItem?.type == "market" && label.font.pointSize == 14)
3903
- let scale = window?.screen.scale ?? traitCollection.displayScale
3904
- attributes[.baselineOffset] = isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset
3905
- }
3906
- if letterSpacing != 0 || currentItem?.type == "market" || currentItem?.data.string("presentation") == "market" { attributes[.kern] = letterSpacing }
3907
- label.attributedText = NSAttributedString(string: text, attributes: attributes)
3908
- }
3909
-
3910
- private func setButtonLine(
3911
- _ button: UIButton,
3912
- text: String,
3913
- font: UIFont,
3914
- color: UIColor,
3915
- lineHeight: CGFloat
3916
- ) {
3917
- guard !text.isEmpty else { return }
3918
- let paragraphStyle = NSMutableParagraphStyle()
3919
- paragraphStyle.minimumLineHeight = lineHeight
3920
- paragraphStyle.maximumLineHeight = lineHeight
3921
- let isMarketText = currentItem?.type == "market" || currentItem?.type == "system" && currentItem?.data.string("presentation") == "market" && currentItem?.data.string("variant") == "retry"
3922
- let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary"
3923
- let isSelectorSummary = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary"
3924
- (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil
3925
- (button as? NativeListAccessoryButton)?.marketLineHeight = isMarketText ? lineHeight : nil
3926
- // OneKey patch: summary text uses its source line box; currency retains trailing alignment.
3927
- // Market's line box already handles alignment; source text starts at its origin.
3928
- paragraphStyle.alignment = isSelectorValue ? .right : isSelectorSummary || isMarketText ? .natural : .center
3929
- if isSelectorSummary {
3930
- button.contentHorizontalAlignment = .leading
3931
- button.titleLabel?.textAlignment = .natural
3932
- }
3933
- if isSelectorValue {
3934
- button.contentHorizontalAlignment = .trailing
3935
- button.titleLabel?.textAlignment = .right
3936
- }
3937
- let baselineOffset: CGFloat = isMarketText || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil ? max(0, (lineHeight - font.lineHeight) / 2) : 0
3938
- var attributes: [NSAttributedString.Key: Any] = [
3939
- .font: font,
3940
- .foregroundColor: color,
3941
- .paragraphStyle: paragraphStyle,
3942
- .baselineOffset: isMarketText && lineHeight == 20 && font.pointSize == 14 ? ceil(baselineOffset * max(1, traitCollection.displayScale)) / max(1, traitCollection.displayScale) : baselineOffset,
3943
- ]
3944
- if isMarketText { attributes[.kern] = 0 }
3945
- button.setAttributedTitle(
3946
- NSAttributedString(string: text, attributes: attributes),
3947
- for: .normal
3948
- )
3949
- }
3950
-
3951
- private func showAccessory(
3952
- _ index: Int,
3953
- _ value: String,
3954
- action: (String, NativeSelectionTarget?)? = nil,
3955
- color: UIColor? = nil
3956
- ) {
3957
- guard accessoryButtons.indices.contains(index), !value.isEmpty else { return }
3958
- let button = accessoryButtons[index]
3959
- button.isHidden = false
3960
- button.setTitle(value, for: .normal)
3961
- button.setContentHuggingPriority(.required, for: .horizontal)
3962
- button.setContentCompressionResistancePriority(.required, for: .horizontal)
3963
- if let color { button.setTitleColor(color, for: .normal) }
3964
- while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
3965
- accessoryActions[index] = action ?? ("", nil)
3966
- }
3967
-
3968
- private func showValuePairAccessory(
3969
- _ index: Int,
3970
- _ accessory: [String: Any],
3971
- theme: [String: Any]?
3972
- ) {
3973
- guard accessoryButtons.indices.contains(index) else { return }
3974
- let primary = accessory.string("primary")
3975
- let secondary = accessory.string("secondary")
3976
- guard !primary.isEmpty || !secondary.isEmpty else { return }
3977
- let button = accessoryButtons[index]
3978
- let text = NSMutableAttributedString()
3979
- let primaryParagraph = NSMutableParagraphStyle()
3980
- primaryParagraph.alignment = .right
3981
- primaryParagraph.minimumLineHeight = 20
3982
- primaryParagraph.maximumLineHeight = 20
3983
- text.append(NSAttributedString(
3984
- string: primary,
3985
- attributes: [
3986
- .font: nativeListFont(ofSize: 16, weight: .medium),
3987
- .foregroundColor: accessoryTextColor(
3988
- accessory.string("primaryTone"),
3989
- defaultTone: "primary",
3990
- theme: theme
3991
- ),
3992
- .paragraphStyle: primaryParagraph,
3993
- ]
3994
- ))
3995
- if !primary.isEmpty, !secondary.isEmpty { text.append(NSAttributedString(string: "\n")) }
3996
- let secondaryParagraph = NSMutableParagraphStyle()
3997
- secondaryParagraph.alignment = .right
3998
- secondaryParagraph.minimumLineHeight = 20
3999
- secondaryParagraph.maximumLineHeight = 20
4000
- text.append(NSAttributedString(
4001
- string: secondary,
4002
- attributes: [
4003
- .font: nativeListFont(ofSize: 14),
4004
- .foregroundColor: accessoryTextColor(
4005
- accessory.string("secondaryTone"),
4006
- defaultTone: "secondary",
4007
- theme: theme
4008
- ),
4009
- .paragraphStyle: secondaryParagraph,
4010
- ]
4011
- ))
4012
- button.isHidden = false
4013
- button.titleLabel?.numberOfLines = 2
4014
- button.contentHorizontalAlignment = .trailing
4015
- button.setAttributedTitle(text, for: .normal)
4016
- button.setContentHuggingPriority(.required, for: .horizontal)
4017
- button.setContentCompressionResistancePriority(.required, for: .horizontal)
4018
- while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
4019
- accessoryActions[index] = ("", nil)
4020
- }
4021
-
4022
- private func showMenuAccessory(
4023
- _ index: Int,
4024
- action: (String, NativeSelectionTarget?)?
4025
- ) {
4026
- showAccessory(index, "⋮", action: action)
4027
- guard accessoryButtons.indices.contains(index) else { return }
4028
- let button = accessoryButtons[index]
4029
- button.titleLabel?.font = nativeListFont(ofSize: 20, weight: .medium)
4030
- accessorySizeConstraints.append(contentsOf: [
4031
- button.widthAnchor.constraint(equalToConstant: 24),
4032
- button.heightAnchor.constraint(equalToConstant: 24),
4033
- ])
4034
- NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2)))
4035
- }
4036
-
4037
- private func accessoryTextColor(
4038
- _ tone: String,
4039
- defaultTone: String,
4040
- theme: [String: Any]?
4041
- ) -> UIColor {
4042
- switch tone.isEmpty ? defaultTone : tone {
4043
- case "positive": return nativeListColor(theme, "positive", "#218358")
4044
- case "negative": return nativeListColor(theme, "negative", "#CE2C31")
4045
- // OneKey patch: account warnings and hidden balances use existing theme tokens.
4046
- case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D")
4047
- case "caution": return nativeListColor(theme, "caution", "#AB6400")
4048
- case "secondary": return nativeListColor(theme, "secondaryText", "#646464")
4049
- default: return nativeListColor(theme, "primaryText", "#202020")
4050
- }
4051
- }
4052
-
4053
- private func showAccessoryIcon(_ index: Int, _ data: [String: Any]) {
4054
- guard accessoryButtons.indices.contains(index) else { return }
4055
- let button = accessoryButtons[index]
4056
- button.isHidden = false
4057
- button.isEnabled = !data.bool("disabled")
4058
- button.alpha = button.isEnabled ? 1 : 0.4
4059
- let isAccountCreate = currentItem?.data.string("presentation") == "accountSelector" && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline"
4060
- let tintColor = data["tintColor"] == nil && isAccountCreate
4061
- ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D")
4062
- : UIColor(nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray)
4063
- button.tintColor = tintColor
4064
- if let image = nativeListIcon(named: data.string("name")) {
4065
- button.setImage(image, for: .normal)
4066
- button.setImage(
4067
- image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
4068
- for: .disabled
4069
- )
4070
- }
4071
- let isDrillIn = data.string("kind") == "chevron"
4072
- let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn
4073
- let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36
4074
- if isAccountCreate { button.layer.cornerRadius = 8 }
4075
- if isAccountIcon && !isAccountCreate && !data.string("actionKey").isEmpty {
4076
- button.pressedBackgroundColor = nativeListColor(currentTheme, "rowPressedBackground", "#E8E8E8")
4077
- button.layer.cornerRadius = size / 2
4078
- }
4079
- if isAccountIcon { rootStack.setCustomSpacing(5, after: mainStack) }
4080
- button.accessibilityIdentifier = data["testID"] as? String
4081
- button.accessibilityLabel = data["accessibilityLabel"] as? String
4082
- if !isDrillIn, !data.string("actionKey").isEmpty {
4083
- // Reproduce the trailing edge of IconButton's m=-7 while keeping its
4084
- // full 36-point frame for padding/highlight behavior.
4085
- rootTrailingConstraint.constant = -5
4086
- }
4087
- accessorySizeConstraints.append(contentsOf: [
4088
- button.widthAnchor.constraint(equalToConstant: size),
4089
- button.heightAnchor.constraint(equalToConstant: size),
4090
- ])
4091
- NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2)))
4092
- while accessoryActions.count <= index { accessoryActions.append(("", nil)) }
4093
- accessoryActions[index] = data.bool("disabled")
4094
- ? ("", nil)
4095
- : (data.string("actionKey"), nil)
4096
- }
4097
-
4098
- private func accessoryAction(
4099
- _ accessory: [String: Any],
4100
- defaultKey: String = ""
4101
- ) -> (String, NativeSelectionTarget?)? {
4102
- if accessory.bool("disabled") { return nil }
4103
- let key = accessory.string("actionKey", default: defaultKey)
4104
- return key.isEmpty ? nil : (key, nil)
4105
- }
4106
-
4107
- private func bindImage(
4108
- _ source: [String: Any],
4109
- into imageView: OneKeyImageReusableView,
4110
- token: String,
4111
- slot: Int,
4112
- variant: String,
4113
- onLoad: (() -> Void)? = nil,
4114
- onError: (() -> Void)? = nil,
4115
- hideUntilLoaded: Bool = false,
4116
- retryAttempt: Int = 0
4117
- ) {
4118
- let imageID = ObjectIdentifier(imageView)
4119
- selectorImageRetries.removeValue(forKey: imageID)?.cancel()
4120
- let expectedEpoch = bindingEpoch
4121
- let retryLimit = max(0, source.int("retryTimes", default: 0))
4122
- let headersJson: String?
4123
- if let headers = source.dictionary("headers"),
4124
- JSONSerialization.isValidJSONObject(headers),
4125
- let data = try? JSONSerialization.data(withJSONObject: headers) {
4126
- headersJson = String(data: data, encoding: .utf8)
4127
- } else {
4128
- headersJson = nil
4129
- }
4130
- if hideUntilLoaded { imageView.isHidden = true }
4131
- let handleLoad: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in
4132
- guard let self, self.bindingEpoch == expectedEpoch else { return }
4133
- imageView?.isHidden = false
4134
- onLoad?()
4135
- } : onLoad
4136
- let handleError: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in
4137
- guard let self, self.bindingEpoch == expectedEpoch else { return }
4138
- imageView?.isHidden = true
4139
- onError?()
4140
- } : onError
4141
- imageView.configure(
4142
- sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines),
4143
- sourceHeadersJson: headersJson,
4144
- variant: variant,
4145
- contentFit: source.string("contentFit", default: "cover"),
4146
- cachePolicy: source.string("cachePolicy", default: "memory-disk"),
4147
- autoplay: source.bool("autoplay"),
4148
- recyclingKey: retryAttempt == 0 ? "\(token):\(slot)" : "\(token):\(slot):retry:\(retryAttempt)",
4149
- optimizeTos: retryAttempt == 0 && (source["optimizeTos"] == nil || source.bool("optimizeTos")),
4150
- overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"),
4151
- loadingStrategy: source.string("loadingStrategy", default: "none"),
4152
- placeholderColor: currentTheme?["strongBackground"] as? String ?? "#0000000F",
4153
- onLoad: retryLimit == 0 ? handleLoad : { [weak self] in
4154
- guard let self, self.bindingEpoch == expectedEpoch else { return }
4155
- self.selectorImageRetries.removeValue(forKey: imageID)?.cancel()
4156
- handleLoad?()
4157
- },
4158
- onError: retryLimit == 0 ? handleError : { [weak self, weak imageView] in
4159
- guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return }
4160
- // Report every failed attempt: a rebind during the retry delay cancels the pending retry,
4161
- // so deferring to the last attempt would never record the source fallback state.
4162
- handleError?()
4163
- guard retryAttempt < retryLimit, self.selectorImageRetries[imageID] == nil else { return }
4164
- let retry = DispatchWorkItem { [weak self, weak imageView] in
4165
- guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return }
4166
- self.selectorImageRetries.removeValue(forKey: imageID)
4167
- self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, hideUntilLoaded: hideUntilLoaded, retryAttempt: retryAttempt + 1)
4168
- }
4169
- self.selectorImageRetries[imageID] = retry
4170
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(Int.random(in: 0...2)), execute: retry)
4171
- }
4172
- )
4173
- }
4174
-
4175
- private func applyGroupPosition(_ position: String) {
4176
- let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
4177
- let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
4178
- switch position {
4179
- case "first": layer.maskedCorners = top
4180
- case "last": layer.maskedCorners = bottom
4181
- case "single": layer.maskedCorners = top.union(bottom)
4182
- default:
4183
- layer.maskedCorners = []
4184
- layer.cornerRadius = 0
4185
- layer.masksToBounds = false
4186
- return
4187
- }
4188
- let isWalletSidebar = currentItem?.type == "identity"
4189
- && currentItem?.data.string("presentation") == "walletSidebar"
4190
- layer.cornerRadius = isWalletSidebar ? 20 : 12
4191
- layer.cornerCurve = isWalletSidebar ? .continuous : .circular
4192
- layer.masksToBounds = true
4193
- }
4194
-
4195
- private func configureSkeleton(
4196
- _ view: UIView,
4197
- width: CGFloat,
4198
- height: CGFloat,
4199
- theme: [String: Any]?
4200
- ) {
4201
- view.backgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8")
4202
- view.layer.cornerRadius = 4
4203
- view.translatesAutoresizingMaskIntoConstraints = false
4204
- if view.constraints.isEmpty {
4205
- NSLayoutConstraint.activate([
4206
- view.widthAnchor.constraint(equalToConstant: width),
4207
- view.heightAnchor.constraint(equalToConstant: height),
4208
- ])
4209
- }
4210
- }
4211
-
4212
- @objc private func accessoryPressed(_ sender: UIButton) {
4213
- guard let item = currentItem, accessoryActions.indices.contains(sender.tag) else { return }
4214
- let action = accessoryActions[sender.tag]
4215
- guard !action.0.isEmpty else { return }
4216
- let source = item.type == "mediaTile" && item.data.string("closeActionKey") == action.0
4217
- ? "mediaClose"
4218
- : "trailingAccessory"
4219
- onAction?(
4220
- item,
4221
- action.0,
4222
- action.1,
4223
- actionOrigin(sourceView: sender, source: source, slot: sender.tag)
4224
- )
4225
- }
4226
-
4227
- @objc private func marketBadgePressed(_ sender: UIButton) {
4228
- guard let item = currentItem,
4229
- marketBadgeActionKeys.indices.contains(sender.tag),
4230
- let actionKey = marketBadgeActionKeys[sender.tag],
4231
- !actionKey.isEmpty else { return }
4232
- onAction?(
4233
- item,
4234
- actionKey,
4235
- nil,
4236
- actionOrigin(sourceView: sender, source: "marketBadge", slot: sender.tag)
4237
- )
4238
- }
4239
-
4240
- @objc private func footerActionPressed(_ sender: UIButton) {
4241
- guard let item = currentItem, footerActionKeys.indices.contains(sender.tag) else { return }
4242
- onAction?(
4243
- item,
4244
- footerActionKeys[sender.tag],
4245
- nil,
4246
- actionOrigin(sourceView: sender, source: "footerAction", slot: sender.tag)
4247
- )
4248
- }
4249
-
4250
- @objc private func checkboxPressed() {
4251
- guard let item = currentItem, let action = checkboxAction else { return }
4252
- onAction?(
4253
- item,
4254
- action.0,
4255
- action.1,
4256
- actionOrigin(sourceView: checkboxButton, source: "trailingAccessory")
4257
- )
4258
- }
4259
-
4260
- @objc private func leadingActionPressed() {
4261
- guard let item = currentItem, let actionKey = leadingActionKey, !actionKey.isEmpty else { return }
4262
- onAction?(
4263
- item,
4264
- actionKey,
4265
- nil,
4266
- actionOrigin(sourceView: leadingActionButton, source: "leadingAction")
4267
- )
4268
- }
4269
-
4270
- private func actionOrigin(
4271
- sourceView: UIView,
4272
- source: String,
4273
- slot: Int? = nil
4274
- ) -> NativeListActionOrigin {
4275
- let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector"
4276
- && source == "trailingAccessory"
4277
- && accessoryButtons.contains { $0 === sourceView }
4278
- && sourceView.bounds.width == 38
4279
- return NativeListActionOrigin(
4280
- sourceView: sourceView,
4281
- ownerCell: self,
4282
- bindingEpoch: bindingEpoch,
4283
- source: source,
4284
- slot: slot,
4285
- anchorInset: isAccountIcon ? 7 : 0
4286
- )
4287
- }
4288
-
4289
- func rowActionOrigin() -> NativeListActionOrigin {
4290
- actionOrigin(sourceView: contentView, source: "row")
4291
- }
4292
-
4293
- private func invalidateCurrentBinding() {
4294
- selectorImageRetries.values.forEach { $0.cancel() }
4295
- selectorImageRetries.removeAll()
4296
- guard currentItem != nil else { return }
4297
- onBindingInvalidated?(self, bindingEpoch)
4298
- bindingEpoch &+= 1
4299
- }
4300
- }