@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
@@ -0,0 +1,140 @@
1
+ import CryptoKit
2
+ import OneKeyImage
3
+ import UIKit
4
+
5
+ // Request lifetime is independent of row action epochs. An unchanged descriptor
6
+ // can outlive a text-only rebind; a new row/slot/source always invalidates it.
7
+ final class NativeListImageSlot {
8
+ let view = OneKeyImageReusableView(frame: .zero)
9
+ private var identity: String?
10
+ private var epoch = 0
11
+ private var retry: DispatchWorkItem?
12
+ private var completion: ((Bool) -> Void)?
13
+ private var result: Bool?
14
+
15
+ static func signature(_ value: [String: Any]) -> String {
16
+ guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
17
+ else { return "" }
18
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
19
+ }
20
+
21
+ func bind(
22
+ _ source: [String: Any], key: String, variant: String = "generic",
23
+ fit: String? = nil, placeholder: String = "#0000000F", completion: ((Bool) -> Void)? = nil
24
+ ) {
25
+ self.completion = completion
26
+ let next = Self.signature([
27
+ "source": source, "key": key, "variant": variant,
28
+ "fit": fit ?? source.string("contentFit", default: "cover"), "placeholder": placeholder,
29
+ ])
30
+ if identity == next {
31
+ if let result { completion?(result) }
32
+ return
33
+ }
34
+ recycle()
35
+ self.completion = completion
36
+ identity = next
37
+ configure(
38
+ source, key: key, variant: variant, fit: fit, placeholder: placeholder, attempt: 0,
39
+ token: epoch)
40
+ }
41
+
42
+ private func configure(
43
+ _ source: [String: Any], key: String, variant: String, fit: String?, placeholder: String,
44
+ attempt: Int, token: Int
45
+ ) {
46
+ guard token == self.epoch else { return }
47
+ var completed = false
48
+ let headers = source.dictionary("headers").flatMap {
49
+ try? JSONSerialization.data(withJSONObject: $0, options: [.sortedKeys])
50
+ }
51
+ self.view.configure(
52
+ sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines),
53
+ sourceHeadersJson: headers.flatMap { String(data: $0, encoding: .utf8) },
54
+ variant: variant, contentFit: fit ?? source.string("contentFit", default: "cover"),
55
+ cachePolicy: source.string("cachePolicy", default: "memory-disk"),
56
+ autoplay: source.bool("autoplay"),
57
+ recyclingKey: attempt == 0 ? key : "\(key):retry:\(attempt)",
58
+ optimizeTos: attempt == 0 && source.bool("optimizeTos", default: true),
59
+ overscan: source.double("overscan", default: 1.1),
60
+ loadingStrategy: source.string("loadingStrategy", default: "none"),
61
+ placeholderColor: placeholder,
62
+ onLoad: { [weak self] in
63
+ guard let self, self.epoch == token, !completed else { return }
64
+ completed = true
65
+ self.retry?.cancel()
66
+ self.retry = nil
67
+ self.result = true
68
+ self.completion?(true)
69
+ },
70
+ onError: { [weak self] in
71
+ guard let self, self.epoch == token, !completed else { return }
72
+ completed = true
73
+ self.result = false
74
+ self.completion?(false)
75
+ guard attempt < source.int("retryTimes"), self.retry == nil else { return }
76
+ let work = DispatchWorkItem { [weak self] in
77
+ guard let self, self.epoch == token else { return }
78
+ self.retry = nil
79
+ self.configure(
80
+ source, key: key, variant: variant, fit: fit, placeholder: placeholder,
81
+ attempt: attempt + 1, token: token)
82
+ }
83
+ self.retry = work
84
+ DispatchQueue.main.asyncAfter(
85
+ deadline: .now() + Double(Int.random(in: 0...2)), execute: work)
86
+ })
87
+ }
88
+
89
+ func recycle() {
90
+ epoch &+= 1
91
+ retry?.cancel()
92
+ retry = nil
93
+ completion = nil
94
+ identity = nil
95
+ result = nil
96
+ view.prepareForReuse()
97
+ }
98
+ deinit { retry?.cancel() }
99
+ }
100
+
101
+ enum NativeListSourceFallbackState {
102
+ private static let sources: NSCache<NSString, NSNumber> = {
103
+ let cache = NSCache<NSString, NSNumber>()
104
+ cache.countLimit = 128
105
+ return cache
106
+ }()
107
+
108
+ static func has(_ key: String) -> Bool {
109
+ sources.object(forKey: key as NSString) != nil
110
+ }
111
+
112
+ static func remember(_ key: String) {
113
+ sources.setObject(NSNumber(value: true), forKey: key as NSString)
114
+ }
115
+
116
+ static func forget(_ key: String) {
117
+ sources.removeObject(forKey: key as NSString)
118
+ }
119
+ }
120
+
121
+ func nativeListSourceFallbackStateKey(_ source: [String: Any]) -> String? {
122
+ let uri = source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines)
123
+ guard !uri.isEmpty else { return nil }
124
+ // The fallback-state cache is process-wide, so key it by a digest of the request identity
125
+ // instead of retaining raw header values, which can carry credentials such as Authorization.
126
+ let headers = source.dictionary("headers") ?? [:]
127
+ let fields: [(name: String, value: String)] = headers.map { key, value in
128
+ (name: key.lowercased(), value: String(describing: value))
129
+ }
130
+ let sortedFields = fields.sorted { lhs, rhs in
131
+ lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
132
+ }
133
+ var canonicalValue = "\(uri.utf8.count):\(uri)"
134
+ for field in sortedFields {
135
+ canonicalValue +=
136
+ "\(field.name.utf8.count):\(field.name)\(field.value.utf8.count):\(field.value)"
137
+ }
138
+ let digest = SHA256.hash(data: Data(canonicalValue.utf8))
139
+ return digest.map { String(format: "%02x", $0) }.joined()
140
+ }
@@ -0,0 +1,372 @@
1
+ import UIKit
2
+
3
+ // Bounded visual primitive: three source slots, two optional overlays. It knows
4
+ // visual descriptors and request lifetimes, never row types or business keys.
5
+ final class NativeListLeadingVisual: UIView {
6
+ var glyphSize: CGFloat = 18
7
+ var bitmapBorderWidth: CGFloat = 0
8
+ private let fallback = NativeListTextLabel()
9
+ var fallbackTextView: UILabel { fallback }
10
+ var singleOuterMask = false
11
+ var alwaysClips = false
12
+ var dashedBorderWidth: CGFloat = 2
13
+ var overlayTextFontSize: CGFloat = 10
14
+ var overlayTextLineHeight: CGFloat?
15
+ var overlayTextHorizontalInset: CGFloat?
16
+ private let icon = UIImageView()
17
+ private let unread = UIView()
18
+ private let networkBackdrop = UIView()
19
+ private let corner = UIImageView()
20
+ private let cornerBackdrop = UIView()
21
+ private var slots: [NativeListImageSlot] = []
22
+ private var overlaySlots: [NativeListImageSlot] = []
23
+ private var overlays: [(UIView, [String: Any])] = []
24
+ private let dashedBorder = CAShapeLayer()
25
+ private var visual: [String: Any] = [:]
26
+ private var imageStyle: [String: Any] = [:]
27
+ private var sources: [([String: Any], String)] = []
28
+
29
+ override init(frame: CGRect) {
30
+ super.init(frame: frame)
31
+ [fallback, icon, networkBackdrop, cornerBackdrop, corner, unread].forEach(addSubview)
32
+ fallback.textAlignment = .center
33
+ fallback.font = nativeListFont(ofSize: 13, weight: .bold)
34
+ icon.contentMode = .scaleAspectFit
35
+ corner.contentMode = .scaleAspectFit
36
+ unread.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .red)
37
+ unread.layer.cornerRadius = 4
38
+ networkBackdrop.layer.cornerRadius = 10
39
+ cornerBackdrop.layer.cornerRadius = 10
40
+ layer.addSublayer(dashedBorder)
41
+ }
42
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
43
+
44
+ func bind(
45
+ _ visual: [String: Any], style: [String: Any], key: String, theme: [String: Any]?,
46
+ isUnread: Bool, secondaryVisual: [String: Any]? = nil
47
+ ) {
48
+ self.visual = visual
49
+ imageStyle = style
50
+ let kind = visual.string("kind")
51
+ let variant =
52
+ kind == "token"
53
+ ? "token"
54
+ : kind == "network" ? "network" : ["account", "wallet"].contains(kind) ? "avatar" : "generic"
55
+ sources =
56
+ kind == "stackedImages"
57
+ ? visual.dictionaries("images").prefix(3).map { ($0, "generic") }
58
+ : kind == "icon" ? [] : visual.dictionary("image").map { [($0, variant)] } ?? []
59
+ if kind == "token", !sources.isEmpty, let network = visual.dictionary("networkImage") {
60
+ sources.append((network, "network"))
61
+ }
62
+ if let secondaryVisual {
63
+ let secondaryKind = secondaryVisual.string("kind")
64
+ let source =
65
+ secondaryKind == "stackedImages"
66
+ ? secondaryVisual.dictionaries("images").first
67
+ : secondaryKind == "icon" ? nil : secondaryVisual.dictionary("image")
68
+ if let source {
69
+ sources.append(
70
+ (
71
+ source,
72
+ secondaryKind == "token"
73
+ ? "token"
74
+ : secondaryKind == "network"
75
+ ? "network" : ["account", "wallet"].contains(secondaryKind) ? "avatar" : "generic"
76
+ ))
77
+ }
78
+ }
79
+ while slots.count < sources.count {
80
+ let slot = NativeListImageSlot()
81
+ slots.append(slot)
82
+ insertSubview(slot.view, belowSubview: slots.count == 1 ? networkBackdrop : cornerBackdrop)
83
+ }
84
+ let placeholder = theme?.string("strongBackground", default: "#0000000F") ?? "#0000000F"
85
+ let fallbackIcon = visual.dictionary("fallbackIcon")
86
+ let sourceFallback =
87
+ kind != "icon" && !sources.isEmpty
88
+ && sources[0].0.string("loadingStrategy", default: "none") != "none"
89
+ && (visual["fallbackText"] != nil || fallbackIcon != nil)
90
+ let fallbackKey = sourceFallback ? nativeListSourceFallbackStateKey(sources[0].0) : nil
91
+ let restored = fallbackKey.map(NativeListSourceFallbackState.has) ?? false
92
+ let fallbackData = kind == "icon" ? visual : fallbackIcon
93
+ fallback.text = String(visual.string("fallbackText").prefix(2))
94
+ fallback.textColor = UIColor(nativeListHex: "#8D8D8D", fallback: .gray)
95
+ icon.image = fallbackData.map { nativeListIcon(named: $0.string("name")) } ?? nil
96
+ icon.tintColor = UIColor(
97
+ nativeListHex: fallbackData?.string("tintColor", default: "#646464") ?? "#646464",
98
+ fallback: .darkGray)
99
+ fallback.isHidden = kind == "icon" || fallbackIcon != nil || !sources.isEmpty && !restored
100
+ icon.isHidden = !(kind == "icon" || (sources.isEmpty || restored) && fallbackIcon != nil)
101
+ let visualBackground = UIColor(
102
+ nativeListHex: visual.string(
103
+ "backgroundColor", default: sources.isEmpty ? placeholder : "#00000000"), fallback: .clear)
104
+ backgroundColor =
105
+ sourceFallback ? UIColor(nativeListHex: placeholder, fallback: .clear) : visualBackground
106
+ layer.borderWidth = kind == "icon" ? 1 / UIScreen.main.scale : 0
107
+ layer.borderColor = UIColor(nativeListHex: "#0000001F", fallback: .clear).cgColor
108
+ if bitmapBorderWidth > 0 {
109
+ layer.borderWidth = bitmapBorderWidth
110
+ layer.borderColor =
111
+ UIColor(nativeListHex: visual.string("borderColor"), fallback: .clear).cgColor
112
+ }
113
+ for (index, slot) in slots.enumerated() {
114
+ guard index < sources.count else {
115
+ slot.recycle()
116
+ slot.view.isHidden = true
117
+ continue
118
+ }
119
+ let source = sources[index]
120
+ slot.view.isHidden = index > 0 || sourceFallback
121
+ slot.bind(
122
+ source.0, key: "\(key):leading:\(index)", variant: source.1,
123
+ fit: index == 0 ? style["contentFit"] as? String : nil, placeholder: placeholder
124
+ ) { [weak self, weak slot] loaded in
125
+ slot?.view.isHidden = !loaded && (index > 0 || sourceFallback)
126
+ guard let self, index == 0, sourceFallback else { return }
127
+ if let fallbackKey {
128
+ if loaded {
129
+ NativeListSourceFallbackState.forget(fallbackKey)
130
+ } else {
131
+ NativeListSourceFallbackState.remember(fallbackKey)
132
+ }
133
+ }
134
+ self.backgroundColor =
135
+ loaded ? visualBackground : UIColor(nativeListHex: placeholder, fallback: .clear)
136
+ self.fallback.isHidden = loaded || fallbackIcon != nil
137
+ self.icon.isHidden = loaded || fallbackIcon == nil
138
+ }
139
+ }
140
+ networkBackdrop.isHidden = !(kind == "token" && sources.count > 1)
141
+ networkBackdrop.backgroundColor = nativeListColor(theme, "rowBackground", "#FFFFFF")
142
+ let badge = visual.dictionary("cornerIcon")
143
+ corner.isHidden = badge == nil
144
+ cornerBackdrop.isHidden = badge == nil
145
+ corner.image = badge.flatMap { nativeListIcon(named: $0.string("name")) }
146
+ corner.tintColor = UIColor(
147
+ nativeListHex: badge?.string("tintColor", default: "#646464") ?? "#646464",
148
+ fallback: .darkGray)
149
+ cornerBackdrop.backgroundColor = UIColor(
150
+ nativeListHex: badge?.string("backgroundColor", default: "#FFFFFF") ?? "#FFFFFF",
151
+ fallback: .white)
152
+ unread.isHidden = !isUnread
153
+ let previousOverlays = overlays
154
+ overlays.removeAll(keepingCapacity: true)
155
+ let descriptors = Array(visual.dictionaries("overlays").prefix(2))
156
+ while overlaySlots.count < descriptors.count { overlaySlots.append(NativeListImageSlot()) }
157
+ for (index, data) in descriptors.enumerated() {
158
+ let frame = index < previousOverlays.count ? previousOverlays[index].0 : UIView()
159
+ frame.clipsToBounds = true
160
+ frame.backgroundColor = UIColor(
161
+ nativeListHex: data.string(
162
+ "backgroundColor",
163
+ default: theme?.string("rowBackground", default: "#00000000") ?? "#00000000"),
164
+ fallback: .clear)
165
+ let child: UIView
166
+ if let image = data.dictionary("image") {
167
+ let slot = overlaySlots[index]
168
+ child = slot.view
169
+ child.isHidden = true
170
+ slot.bind(image, key: "\(key):overlay:\(index)", placeholder: placeholder) {
171
+ [weak child] loaded in child?.isHidden = !loaded
172
+ }
173
+ } else if !data.string("text").isEmpty {
174
+ overlaySlots[index].recycle()
175
+ let label = frame.subviews.first as? UILabel ?? UILabel()
176
+ label.text = data.string("text")
177
+ label.textAlignment = .center
178
+ // A line-height box is the legacy wallet text badge: regular weight, centered metrics.
179
+ label.font = nativeListFont(
180
+ ofSize: overlayTextFontSize, weight: overlayTextLineHeight == nil ? .medium : .regular)
181
+ if let line = overlayTextLineHeight {
182
+ let paragraph = NSMutableParagraphStyle()
183
+ paragraph.minimumLineHeight = line
184
+ paragraph.maximumLineHeight = line
185
+ paragraph.alignment = .center
186
+ label.attributedText = NSAttributedString(
187
+ string: data.string("text"),
188
+ attributes: [
189
+ .font: label.font as Any, .paragraphStyle: paragraph,
190
+ .baselineOffset: max(0, (line - label.font.lineHeight) / 2),
191
+ ])
192
+ }
193
+ label.textColor = UIColor(
194
+ nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray)
195
+ child = label
196
+ } else {
197
+ overlaySlots[index].recycle()
198
+ let glyph = frame.subviews.first as? UIImageView ?? UIImageView()
199
+ glyph.image = nativeListIcon(named: data.string("name"))
200
+ glyph.contentMode = .scaleAspectFit
201
+ glyph.tintColor = UIColor(
202
+ nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray)
203
+ child = glyph
204
+ }
205
+ if child.superview !== frame {
206
+ frame.subviews.forEach { $0.removeFromSuperview() }
207
+ frame.addSubview(child)
208
+ }
209
+ if frame.superview == nil { addSubview(frame) }
210
+ overlays.append((frame, data))
211
+ }
212
+ previousOverlays.dropFirst(descriptors.count).forEach { $0.0.removeFromSuperview() }
213
+ for slot in overlaySlots.dropFirst(descriptors.count) { slot.recycle() }
214
+ bringSubviewToFront(unread)
215
+ setNeedsLayout()
216
+ }
217
+
218
+ override func layoutSubviews() {
219
+ super.layoutSubviews()
220
+ let kind = visual.string("kind")
221
+ let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
222
+ func logical(_ rect: CGRect) -> CGRect {
223
+ rtl
224
+ ? CGRect(x: bounds.width - rect.maxX, y: rect.minY, width: rect.width, height: rect.height)
225
+ : rect
226
+ }
227
+ let shape = imageStyle.string(
228
+ "shape", default: visual.string("shape", default: kind == "image" ? "rounded" : "circle"))
229
+ let radius =
230
+ imageStyle["cornerRadius"] != nil
231
+ ? CGFloat(imageStyle.double("cornerRadius"))
232
+ : shape == "square"
233
+ ? 0
234
+ : shape == "rounded"
235
+ ? (imageStyle["shape"] != nil ? 10 : min(10, bounds.height / 4))
236
+ : min(bounds.width, bounds.height) / 2
237
+ layer.cornerRadius = radius
238
+ clipsToBounds =
239
+ alwaysClips
240
+ || sources.count < 2 && overlays.isEmpty && visual.dictionary("cornerIcon") == nil
241
+ let ellipse = imageStyle.string("shape") == "circle" && imageStyle["cornerRadius"] == nil
242
+ if ellipse && clipsToBounds {
243
+ let mask = layer.mask as? CAShapeLayer ?? CAShapeLayer()
244
+ mask.path = UIBezierPath(ovalIn: bounds).cgPath
245
+ layer.mask = mask
246
+ } else {
247
+ layer.mask = nil
248
+ }
249
+ fallback.frame = bounds
250
+ let fallbackIcon = visual.dictionary("fallbackIcon")
251
+ let iconSize: CGFloat =
252
+ !sources.isEmpty && fallbackIcon != nil
253
+ ? min(bounds.width, bounds.height)
254
+ * (fallbackIcon?.string("name") == "GlobusOutline" ? 1.2 : 1) : glyphSize
255
+ icon.frame = CGRect(
256
+ x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize,
257
+ height: iconSize)
258
+ let tokenPair = kind == "token" && sources.count > 1
259
+ for (index, slot) in slots.prefix(sources.count).enumerated() {
260
+ let frame: CGRect
261
+ if sources.count == 1 || tokenPair && index == 0 {
262
+ frame = bounds
263
+ } else if tokenPair {
264
+ frame = CGRect(x: bounds.width - 14, y: bounds.height - 14, width: 16, height: 16)
265
+ } else {
266
+ let side = bounds.height * 0.72
267
+ frame = CGRect(
268
+ x: CGFloat(index) * (bounds.width - side) / CGFloat(max(1, sources.count - 1)),
269
+ y: (bounds.height - side) / 2, width: side, height: side)
270
+ }
271
+ slot.view.frame = logical(
272
+ index == 0 ? frame.insetBy(dx: bitmapBorderWidth, dy: bitmapBorderWidth) : frame)
273
+ slot.view.clipsToBounds =
274
+ !(singleOuterMask && sources.count == 1 && overlays.isEmpty
275
+ && visual.dictionary("cornerIcon") == nil)
276
+ slot.view.layer.cornerRadius =
277
+ index == 0 && singleOuterMask && sources.count == 1 && overlays.isEmpty
278
+ && visual.dictionary("cornerIcon") == nil
279
+ ? 0 : index == 0 && (sources.count == 1 || tokenPair) ? radius : frame.height / 2
280
+ }
281
+ for (index, slot) in slots.enumerated() {
282
+ if index == 0 && bitmapBorderWidth > 0 {
283
+ let mask = slot.view.layer.mask as? CAShapeLayer ?? CAShapeLayer()
284
+ mask.path =
285
+ UIBezierPath(
286
+ roundedRect: slot.view.bounds.insetBy(dx: -bitmapBorderWidth, dy: -bitmapBorderWidth),
287
+ cornerRadius: radius
288
+ ).cgPath
289
+ slot.view.layer.mask = mask
290
+ slot.view.layer.cornerRadius = 0
291
+ } else if ellipse && index == 0 {
292
+ let mask = slot.view.layer.mask as? CAShapeLayer ?? CAShapeLayer()
293
+ mask.path = UIBezierPath(ovalIn: slot.view.bounds).cgPath
294
+ slot.view.layer.mask = mask
295
+ } else {
296
+ slot.view.layer.mask = nil
297
+ }
298
+ }
299
+ networkBackdrop.frame = logical(
300
+ CGRect(x: bounds.width - 16, y: bounds.height - 16, width: 20, height: 20))
301
+ cornerBackdrop.frame = networkBackdrop.frame
302
+ corner.frame = cornerBackdrop.frame.insetBy(dx: 1, dy: 1)
303
+ unread.frame = logical(CGRect(x: bounds.width - 8, y: 0, width: 8, height: 8))
304
+ for (frame, data) in overlays {
305
+ let size = CGFloat(data.double("size", default: 20))
306
+ // Legacy wallet text badges size to their text (tabular 12pt + 4) at the line height.
307
+ let textBadgeLine =
308
+ data.dictionary("image") == nil && !data.string("text").isEmpty
309
+ && data.string("name").isEmpty ? overlayTextLineHeight : nil
310
+ let naturalWidth = textBadgeLine.map { _ -> CGFloat in
311
+ let scale = max(1, window?.screen.scale ?? traitCollection.displayScale)
312
+ let textWidth = (data.string("text") as NSString).size(withAttributes: [
313
+ .font: nativeListTabularFont(ofSize: overlayTextFontSize), .kern: 0,
314
+ ]).width
315
+ return ceil(textWidth * scale) / scale + 4
316
+ }
317
+ let width = CGFloat(data.double("width", default: Double(naturalWidth ?? size)))
318
+ let height = CGFloat(data.double("height", default: Double(textBadgeLine ?? size)))
319
+ let offset = data.double("offset", default: 2)
320
+ let x = CGFloat(data.double("offsetX", default: offset))
321
+ let y = CGFloat(data.double("offsetY", default: offset))
322
+ frame.frame = logical(
323
+ CGRect(
324
+ x: data.string("position") == "topLeft" ? -x : bounds.width - width + x,
325
+ y: data.string("position") == "topLeft" ? -y : bounds.height - height + y, width: width,
326
+ height: height))
327
+ frame.layer.cornerRadius = min(width, height) / 2
328
+ let inset = CGFloat(data.double("padding", default: 0))
329
+ frame.subviews.first?.frame = frame.bounds.insetBy(
330
+ dx: data["text"] != nil ? overlayTextHorizontalInset ?? inset : inset,
331
+ dy: data["text"] != nil && overlayTextHorizontalInset != nil ? 0 : inset)
332
+ }
333
+ dashedBorder.isHidden = visual.string("borderStyle") != "dashed"
334
+ dashedBorder.strokeColor =
335
+ UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray)
336
+ .cgColor
337
+ dashedBorder.fillColor = UIColor.clear.cgColor
338
+ dashedBorder.lineWidth = dashedBorderWidth
339
+ dashedBorder.lineDashPattern = [4, 4]
340
+ dashedBorder.path =
341
+ UIBezierPath(ovalIn: bounds.insetBy(dx: dashedBorderWidth / 2, dy: dashedBorderWidth / 2))
342
+ .cgPath
343
+ }
344
+ func recycle() {
345
+ slots.forEach { $0.recycle() }
346
+ overlaySlots.forEach { $0.recycle() }
347
+ }
348
+ /// Empty slot (legacy leading container without a visual): no image, fallback, fill,
349
+ /// border or overlays from a previous binding.
350
+ func clear() {
351
+ recycle()
352
+ visual = [:]
353
+ sources = []
354
+ slots.forEach { $0.view.isHidden = true }
355
+ overlays.forEach { $0.0.removeFromSuperview() }
356
+ overlays.removeAll()
357
+ fallback.text = nil
358
+ fallback.isHidden = true
359
+ icon.image = nil
360
+ icon.isHidden = true
361
+ networkBackdrop.isHidden = true
362
+ corner.isHidden = true
363
+ cornerBackdrop.isHidden = true
364
+ unread.isHidden = true
365
+ backgroundColor = .clear
366
+ layer.borderWidth = 0
367
+ layer.mask = nil
368
+ dashedBorder.isHidden = true
369
+ dashedBorder.path = nil
370
+ setNeedsLayout()
371
+ }
372
+ }