@onekeyfe/react-native-native-list 6.7.0-alpha.378 → 6.7.0-alpha.383
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.
- package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +14 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +240 -11
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +7 -4
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +6 -3
- package/android/src/main/java/com/onekey/nativelist/NativeListButtonGroupRenderer.kt +102 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +12 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +87 -7
- package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +177 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +43 -14
- package/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +25 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +33 -2
- package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +22 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +2 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +2 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +3 -2
- package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +69 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +55 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +1912 -107
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListPressPolicyTest.kt +1 -0
- package/docs/ROW_TEMPLATES.md +60 -9
- package/docs/SPEC.md +450 -5
- package/docs/STYLE_SPEC.md +6 -0
- package/ios/HybridNativeList.swift +21 -2
- package/ios/NativeListAccessoryStack.swift +227 -39
- package/ios/NativeListActivityDetailsView.swift +18 -8
- package/ios/NativeListActivityRenderer.swift +8 -2
- package/ios/NativeListButtonGroupRenderer.swift +89 -0
- package/ios/NativeListDataRowRenderer.swift +155 -27
- package/ios/NativeListDesignAssets.swift +3 -0
- package/ios/NativeListIdentityRenderer.swift +166 -30
- package/ios/NativeListLeadingVisual.swift +49 -14
- package/ios/NativeListMessageRenderer.swift +34 -1
- package/ios/NativeListModels.swift +42 -2
- package/ios/NativeListRendererRegistry.swift +1 -0
- package/ios/NativeListResolvedText.swift +2 -1
- package/ios/NativeListRowHost.swift +14 -0
- package/ios/NativeListSectionHeaderRenderer.swift +88 -0
- package/ios/RNCNativeListView.swift +1013 -55
- package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/Contents.json +15 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_crypto_coin_outline.imageset/icon.svg +4 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/Contents.json +15 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_gas_solid.imageset/icon.svg +3 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/Contents.json +15 -0
- package/ios/Resources/NativeListIcons.xcassets/onekey_refresh_ccw_outline.imageset/icon.svg +3 -0
- package/ios/tests/NativeListActivityPresentationTests.swift +56 -0
- package/ios/tests/NativeListLifecycleTests.swift +2720 -3
- package/lib/module/NativeList.js +175 -28
- package/lib/module/NativeList.web.js +129 -30
- package/lib/module/index.js +1 -1
- package/lib/module/models.js +1 -0
- package/lib/module/selection.js +1 -1
- package/lib/module/validation.js +421 -8
- package/lib/module/web/NativeListWebEngine.js +507 -19
- package/lib/module/web/templates/ActivityRowRenderer.js +8 -7
- package/lib/module/web/templates/ButtonGroupRowRenderer.js +47 -0
- package/lib/module/web/templates/DataRowRenderer.js +40 -5
- package/lib/module/web/templates/IdentityRowRenderer.js +90 -4
- package/lib/module/web/templates/MessageRowRenderer.js +18 -2
- package/lib/module/web/templates/RowContainerStyle.js +1 -0
- package/lib/module/web/templates/RowRendererRegistry.js +3 -0
- package/lib/module/web/templates/SectionHeaderRowRenderer.js +36 -2
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
- package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
- package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
- package/lib/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
- package/lib/typescript/src/NativeList.d.ts +1 -1
- package/lib/typescript/src/NativeList.nitro.d.ts +2 -0
- package/lib/typescript/src/NativeList.types.d.ts +18 -0
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/models.d.ts +105 -9
- package/lib/typescript/src/validation.d.ts +12 -0
- package/lib/typescript/src/web/NativeListWebEngine.d.ts +32 -0
- package/lib/typescript/src/web/templates/ButtonGroupRowRenderer.d.ts +14 -0
- package/lib/typescript/src/web/templates/IdentityRowRenderer.d.ts +1 -1
- package/lib/typescript/src/web/templates/MessageRowRenderer.d.ts +9 -3
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +26 -0
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +4 -0
- package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +10 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +20 -0
- package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +14 -0
- package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +12 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +43 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +4 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +4 -0
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +6 -0
- package/nitrogen/generated/shared/json/NativeListConfig.json +2 -0
- package/package.json +3 -3
- package/react-native-native-list.podspec +1 -0
- package/src/NativeList.nitro.ts +2 -0
- package/src/NativeList.tsx +313 -44
- package/src/NativeList.types.ts +22 -0
- package/src/NativeList.web.tsx +204 -48
- package/src/index.ts +2 -0
- package/src/models.ts +117 -3
- package/src/selection.ts +1 -0
- package/src/validation.ts +852 -6
- package/src/web/NativeListWebEngine.ts +803 -20
- package/src/web/templates/ActivityRowRenderer.ts +14 -6
- package/src/web/templates/ButtonGroupRowRenderer.ts +58 -0
- package/src/web/templates/DataRowRenderer.ts +93 -20
- package/src/web/templates/IdentityRowRenderer.ts +150 -5
- package/src/web/templates/MessageRowRenderer.ts +23 -7
- package/src/web/templates/RowContainerStyle.ts +2 -0
- package/src/web/templates/RowRendererRegistry.ts +11 -1
- package/src/web/templates/SectionHeaderRowRenderer.ts +47 -4
|
@@ -4,6 +4,81 @@ import XCTest
|
|
|
4
4
|
|
|
5
5
|
@MainActor
|
|
6
6
|
final class NativeListLifecycleTests: XCTestCase {
|
|
7
|
+
func testAcceptedRowIndexReusesStableShapeAndRejectsLateInvalidPatch() throws {
|
|
8
|
+
let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
|
|
9
|
+
func snapshot(_ keys: [String], generation: Int, title: String = "Old") throws -> String {
|
|
10
|
+
let rows: [[String: Any]] = keys.map { key in
|
|
11
|
+
["key": key, "type": "action", "title": title, "actionKey": "open"]
|
|
12
|
+
}
|
|
13
|
+
let value: [String: Any] = ["schemaVersion": 1, "generation": generation,
|
|
14
|
+
"layout": ["kind": "linear"], "rows": rows]
|
|
15
|
+
return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
|
|
16
|
+
}
|
|
17
|
+
func index() throws -> (ObjectIdentifier, [String: Int]) {
|
|
18
|
+
let optional = try XCTUnwrap(Mirror(reflecting: view).descendant("acceptedRowIndex"))
|
|
19
|
+
let value = try XCTUnwrap(Mirror(reflecting: optional).children.first?.value)
|
|
20
|
+
let byKey = try XCTUnwrap(Mirror(reflecting: value).descendant("byKey") as? [String: Int])
|
|
21
|
+
return (ObjectIdentifier(value as AnyObject), byKey)
|
|
22
|
+
}
|
|
23
|
+
func title(_ key: String) throws -> String {
|
|
24
|
+
let items = try XCTUnwrap(Mirror(reflecting: view).descendant("itemsByKey")
|
|
25
|
+
as? [String: NativeListItem])
|
|
26
|
+
return try XCTUnwrap(items[key]?.data["title"] as? String)
|
|
27
|
+
}
|
|
28
|
+
func patch(_ rows: [[String: Any]]) throws {
|
|
29
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: rows),
|
|
30
|
+
as: UTF8.self))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
view.applySnapshotJson(try snapshot(["alpha", "beta"], generation: 1))
|
|
34
|
+
let initial = try index()
|
|
35
|
+
XCTAssertEqual(initial.1, ["alpha": 0, "beta": 1])
|
|
36
|
+
view.applySnapshotJson(try snapshot(["alpha", "beta"], generation: 1, title: "New"))
|
|
37
|
+
XCTAssertEqual(try index().0, initial.0)
|
|
38
|
+
try patch([["key": "alpha", "type": "action", "changes": ["title": "Patched"]],
|
|
39
|
+
["key": "missing", "type": "action", "changes": ["title": "Invalid"]]])
|
|
40
|
+
XCTAssertEqual(try title("alpha"), "New")
|
|
41
|
+
XCTAssertEqual(try title("beta"), "New")
|
|
42
|
+
XCTAssertEqual(try index().0, initial.0)
|
|
43
|
+
try patch([["key": "alpha", "type": "action", "changes": ["title": "Patched"]]])
|
|
44
|
+
XCTAssertEqual(try title("alpha"), "Patched")
|
|
45
|
+
XCTAssertEqual(try index().0, initial.0)
|
|
46
|
+
|
|
47
|
+
view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 1))
|
|
48
|
+
let reordered = try index()
|
|
49
|
+
XCTAssertNotEqual(reordered.0, initial.0)
|
|
50
|
+
XCTAssertEqual(reordered.1, ["beta": 0, "alpha": 1])
|
|
51
|
+
try patch([["key": "alpha", "type": "action", "changes": ["title": "After reorder"]]])
|
|
52
|
+
XCTAssertEqual(try title("alpha"), "After reorder")
|
|
53
|
+
view.applySnapshotJson(try snapshot(["beta", "gamma", "alpha"], generation: 1))
|
|
54
|
+
let inserted = try index()
|
|
55
|
+
XCTAssertNotEqual(inserted.0, reordered.0)
|
|
56
|
+
XCTAssertEqual(inserted.1, ["beta": 0, "gamma": 1, "alpha": 2])
|
|
57
|
+
try patch([["key": "alpha", "type": "action", "changes": ["title": "After insert"]]])
|
|
58
|
+
XCTAssertEqual(try title("alpha"), "After insert")
|
|
59
|
+
view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 1))
|
|
60
|
+
let deleted = try index()
|
|
61
|
+
XCTAssertNotEqual(deleted.0, inserted.0)
|
|
62
|
+
XCTAssertEqual(deleted.1, ["beta": 0, "alpha": 1])
|
|
63
|
+
try patch([["key": "alpha", "type": "action", "changes": ["title": "After delete"]]])
|
|
64
|
+
XCTAssertEqual(try title("alpha"), "After delete")
|
|
65
|
+
view.applySnapshotJson(try snapshot(["beta", "alpha"], generation: 2))
|
|
66
|
+
XCTAssertNotEqual(try index().0, deleted.0)
|
|
67
|
+
let beforeTypeChange = try index().0
|
|
68
|
+
let typeChanged: [String: Any] = ["schemaVersion": 1, "generation": 2,
|
|
69
|
+
"layout": ["kind": "linear"], "rows": [
|
|
70
|
+
["key": "beta", "type": "identity", "title": "Beta",
|
|
71
|
+
"leading": ["kind": "icon", "name": "coin"]],
|
|
72
|
+
["key": "alpha", "type": "action", "title": "Alpha", "actionKey": "open"],
|
|
73
|
+
]]
|
|
74
|
+
view.applySnapshotJson(String(decoding: try JSONSerialization.data(withJSONObject: typeChanged),
|
|
75
|
+
as: UTF8.self))
|
|
76
|
+
XCTAssertNotEqual(try index().0, beforeTypeChange)
|
|
77
|
+
view.dispose()
|
|
78
|
+
XCTAssertEqual(Mirror(reflecting: try XCTUnwrap(Mirror(reflecting: view)
|
|
79
|
+
.descendant("acceptedRowIndex"))).children.count, 0)
|
|
80
|
+
}
|
|
81
|
+
|
|
7
82
|
func testScrollEdgeStylesPreserveOverlayGeometryAndRestoreOriginalStyle() throws {
|
|
8
83
|
guard #available(iOS 26.0, *) else {
|
|
9
84
|
throw XCTSkip("Scroll-edge styles require iOS 26 or newer")
|
|
@@ -15,7 +90,8 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
15
90
|
let originalStyle = list.topEdgeEffect.style
|
|
16
91
|
let originalInset = list.contentInset.top
|
|
17
92
|
let overlay = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 64))
|
|
18
|
-
view.mountContainerSlot(
|
|
93
|
+
for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
94
|
+
view.mountContainerSlot(overlay, atIndex: 6)
|
|
19
95
|
view.setTopScrollEdge(effect: .hard, overlayHeight: 64)
|
|
20
96
|
XCTAssertEqual(list.topEdgeEffect.style, .hard)
|
|
21
97
|
XCTAssertEqual(list.contentInset.top, originalInset + 64, accuracy: 0.01)
|
|
@@ -153,6 +229,723 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
153
229
|
XCTAssertNotEqual(cell.bindingEpoch, changedEpoch)
|
|
154
230
|
}
|
|
155
231
|
|
|
232
|
+
func testRiskIdentityTitleIconAndButtonResetOnProductionCellBind() throws {
|
|
233
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 56))
|
|
234
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
|
|
235
|
+
window.addSubview(cell)
|
|
236
|
+
let titleIcon = try XCTUnwrap(
|
|
237
|
+
Mirror(reflecting: cell).children.first { $0.label == "titleIconView" }?.value as? UIImageView)
|
|
238
|
+
let titleStack = try XCTUnwrap(
|
|
239
|
+
Mirror(reflecting: cell).children.first { $0.label == "titleRowStack" }?.value as? UIStackView)
|
|
240
|
+
let titleLabel = try XCTUnwrap(
|
|
241
|
+
Mirror(reflecting: cell).children.first { $0.label == "titleLabel" }?.value as? UILabel)
|
|
242
|
+
let trailing = try XCTUnwrap(
|
|
243
|
+
Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
|
|
244
|
+
as? NativeListAccessoryStack)
|
|
245
|
+
func bind(_ data: [String: Any]) throws {
|
|
246
|
+
let item = try NativeListItem(data: data)
|
|
247
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
248
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
249
|
+
cell.layoutIfNeeded()
|
|
250
|
+
}
|
|
251
|
+
let base: [String: Any] = ["key": "risk", "type": "identity", "title": "Risk",
|
|
252
|
+
"height": 56, "leading": ["kind": "token",
|
|
253
|
+
"fallbackIcon": ["name": "CryptoCoinOutline"]]]
|
|
254
|
+
try bind(base)
|
|
255
|
+
XCTAssertTrue(titleIcon.isHidden)
|
|
256
|
+
XCTAssertLessThanOrEqual(titleIcon.bounds.width, 0.5)
|
|
257
|
+
try bind(base.merging(["titleIcon": ["name": "GasSolid"],
|
|
258
|
+
"trailing": [["kind": "button", "label": "Show", "actionKey": "show",
|
|
259
|
+
"backgroundColor": "#DDDDDD", "pressedBackgroundColor": "#AAAAAA"]]]) { _, newer in newer })
|
|
260
|
+
XCTAssertFalse(titleIcon.isHidden)
|
|
261
|
+
XCTAssertNotNil(titleIcon.image)
|
|
262
|
+
XCTAssertEqual(titleIcon.bounds.width, 20, accuracy: 0.5)
|
|
263
|
+
XCTAssertEqual(titleStack.customSpacing(after: titleLabel), 4, accuracy: 0.1)
|
|
264
|
+
let button = try XCTUnwrap(trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
|
|
265
|
+
.first { !$0.isHidden })
|
|
266
|
+
XCTAssertEqual(button.intrinsicContentSize.height, 30, accuracy: 0.5)
|
|
267
|
+
let normal = button.backgroundColor
|
|
268
|
+
button.isHighlighted = true
|
|
269
|
+
XCTAssertNotEqual(button.backgroundColor, normal)
|
|
270
|
+
button.isHighlighted = false
|
|
271
|
+
XCTAssertEqual(button.backgroundColor, normal)
|
|
272
|
+
try bind(base)
|
|
273
|
+
XCTAssertTrue(titleIcon.isHidden)
|
|
274
|
+
XCTAssertLessThanOrEqual(titleIcon.bounds.width, 0.5)
|
|
275
|
+
XCTAssertTrue(trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
|
|
276
|
+
.allSatisfy { $0.isHidden })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func testIdentityTitleSuffixesUsePlainLabelsAndClearOnRebind() throws {
|
|
280
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60))
|
|
281
|
+
let titleStack = try XCTUnwrap(
|
|
282
|
+
Mirror(reflecting: cell).children.first { $0.label == "titleRowStack" }?.value as? UIStackView)
|
|
283
|
+
func bind(_ suffixes: [[String: Any]]?) throws {
|
|
284
|
+
var data: [String: Any] = [
|
|
285
|
+
"key": "recent", "type": "identity", "title": "Alice",
|
|
286
|
+
"leading": ["kind": "account", "fallbackText": "A"],
|
|
287
|
+
]
|
|
288
|
+
if let suffixes { data["titleSuffixes"] = suffixes }
|
|
289
|
+
let item = try NativeListItem(data: data)
|
|
290
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
291
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
292
|
+
cell.layoutIfNeeded()
|
|
293
|
+
}
|
|
294
|
+
try bind([
|
|
295
|
+
["text": "EVM", "tone": "secondary", "maxWidth": 128],
|
|
296
|
+
["text": "2 days ago", "tone": "disabled"],
|
|
297
|
+
])
|
|
298
|
+
let suffixes = titleStack.arrangedSubviews.compactMap { $0 as? UILabel }
|
|
299
|
+
.filter { ["EVM", "2 days ago"].contains($0.text ?? "") }
|
|
300
|
+
XCTAssertEqual(suffixes.map(\.text), ["EVM", "2 days ago"])
|
|
301
|
+
XCTAssertTrue(suffixes.allSatisfy { $0.font.pointSize == 12 && $0.numberOfLines == 1 })
|
|
302
|
+
XCTAssertFalse(suffixes.contains { $0 is NativeListInsetLabel })
|
|
303
|
+
try bind(nil)
|
|
304
|
+
XCTAssertFalse(titleStack.arrangedSubviews.compactMap { $0 as? UILabel }
|
|
305
|
+
.contains { ["EVM", "2 days ago"].contains($0.text ?? "") })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func testDataRowTitleIconsClearWhenCellIsRebound() throws {
|
|
309
|
+
let cell = NativeListDataRowCell(frame: CGRect(x: 0, y: 0, width: 380, height: 60))
|
|
310
|
+
func bind(_ column: [String: Any]) throws {
|
|
311
|
+
let item = try NativeListItem(data: [
|
|
312
|
+
"key": "asset", "type": "dataRow", "columns": [column],
|
|
313
|
+
])
|
|
314
|
+
cell.bind(item: item, theme: nil, layout: "table", selected: false,
|
|
315
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
316
|
+
cell.layoutIfNeeded()
|
|
317
|
+
}
|
|
318
|
+
func titleIcons(in view: UIView) -> [UIImageView] {
|
|
319
|
+
let current = (view as? UIImageView).map { [$0] } ?? []
|
|
320
|
+
return current + view.subviews.flatMap(titleIcons)
|
|
321
|
+
}
|
|
322
|
+
try bind([
|
|
323
|
+
"key": "asset", "text": "ETH",
|
|
324
|
+
"titleIcon": ["name": "GasSolid", "accessibilityLabel": "Gas help"],
|
|
325
|
+
"secondaryTitleIcon": ["name": "InfoCircleOutline", "accessibilityLabel": "Info help"],
|
|
326
|
+
])
|
|
327
|
+
let visible = titleIcons(in: cell).filter { !$0.isHidden && $0.accessibilityLabel != nil }
|
|
328
|
+
XCTAssertEqual(visible.compactMap(\.accessibilityLabel), ["Gas help", "Info help"])
|
|
329
|
+
XCTAssertTrue(visible.allSatisfy { $0.image != nil })
|
|
330
|
+
|
|
331
|
+
try bind(["key": "asset", "text": "USDC"])
|
|
332
|
+
XCTAssertTrue(titleIcons(in: cell).allSatisfy {
|
|
333
|
+
$0.accessibilityLabel != "Gas help" && $0.accessibilityLabel != "Info help"
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
func testDataRowLinearRebindKeepsSizeConstraintsAndClearsOldContent() throws {
|
|
338
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 100))
|
|
339
|
+
let cell = NativeListDataRowCell(frame: CGRect(x: 0, y: 0, width: 700, height: 60))
|
|
340
|
+
window.addSubview(cell)
|
|
341
|
+
window.isHidden = false
|
|
342
|
+
defer { window.isHidden = true }
|
|
343
|
+
func bind(_ title: String, secondary: String, layout: String = "linear",
|
|
344
|
+
visualWidth: Int, trailing: String?, icon: Bool = false, badge: Bool = false,
|
|
345
|
+
key: String = "asset") throws {
|
|
346
|
+
var column: [String: Any] = ["key": "asset", "text": title, "secondaryText": secondary]
|
|
347
|
+
if icon { column["titleIcon"] = ["name": "GasSolid", "accessibilityLabel": "Gas help"] }
|
|
348
|
+
let item = try NativeListItem(data: [
|
|
349
|
+
"key": key, "type": "dataRow", "height": 60,
|
|
350
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
351
|
+
"columns": [column], "badges": badge ? [["text": "Badge"]] : [],
|
|
352
|
+
"trailing": trailing.map { [["kind": "button", "label": $0, "actionKey": "swap"]] } ?? [],
|
|
353
|
+
"style": ["image": ["width": visualWidth, "height": visualWidth]],
|
|
354
|
+
])
|
|
355
|
+
cell.bind(item: item, theme: nil, layout: layout, selected: false,
|
|
356
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
357
|
+
window.layoutIfNeeded()
|
|
358
|
+
cell.layoutIfNeeded()
|
|
359
|
+
}
|
|
360
|
+
func property<T>(_ name: String, in object: Any, as type: T.Type) throws -> T {
|
|
361
|
+
try XCTUnwrap(Mirror(reflecting: object).children.first { $0.label == name }?.value as? T)
|
|
362
|
+
}
|
|
363
|
+
let columns = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView)
|
|
364
|
+
let firstColumn = columns.arrangedSubviews[0]
|
|
365
|
+
let primary: UILabel = try property("primaryLabel", in: firstColumn, as: UILabel.self)
|
|
366
|
+
let secondary: UILabel = try property("secondaryLabel", in: firstColumn, as: UILabel.self)
|
|
367
|
+
let secondaryLine: UIStackView = try property("secondaryLine", in: firstColumn,
|
|
368
|
+
as: UIStackView.self)
|
|
369
|
+
let titleIcon: UIImageView = try property("titleIconView", in: firstColumn,
|
|
370
|
+
as: UIImageView.self)
|
|
371
|
+
let badges: UIStackView = try property("badgesStack", in: firstColumn,
|
|
372
|
+
as: UIStackView.self)
|
|
373
|
+
|
|
374
|
+
try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Swap",
|
|
375
|
+
icon: true, badge: true)
|
|
376
|
+
let sizeConstraints: [NSLayoutConstraint] = try property("rowConstraints", in: cell,
|
|
377
|
+
as: [NSLayoutConstraint].self)
|
|
378
|
+
XCTAssertEqual(sizeConstraints.count, 4)
|
|
379
|
+
let trailingStack = cell.root.arrangedSubviews[4]
|
|
380
|
+
let trailingWidth = try XCTUnwrap(trailingStack.constraints.first {
|
|
381
|
+
$0.firstAttribute == .width && ($0.firstItem as? UIView) === trailingStack
|
|
382
|
+
})
|
|
383
|
+
XCTAssertTrue(trailingWidth.isActive)
|
|
384
|
+
let shortTrailingWidth = trailingWidth.constant
|
|
385
|
+
XCTAssertEqual(sizeConstraints[2].constant, 32, accuracy: 0.1)
|
|
386
|
+
XCTAssertTrue(primary.attributedText?.string.contains("Badge") ?? false)
|
|
387
|
+
XCTAssertFalse(titleIcon.isHidden)
|
|
388
|
+
XCTAssertEqual(badges.arrangedSubviews.count, 0)
|
|
389
|
+
|
|
390
|
+
try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Manage asset")
|
|
391
|
+
XCTAssertGreaterThan(trailingWidth.constant, shortTrailingWidth + 1)
|
|
392
|
+
let columnsFrame = columns.convert(columns.bounds, to: cell)
|
|
393
|
+
let trailingFrame = trailingStack.convert(trailingStack.bounds, to: cell)
|
|
394
|
+
XCTAssertGreaterThanOrEqual(trailingFrame.minX, columnsFrame.maxX - 0.5)
|
|
395
|
+
try bind("ETH", secondary: "Ethereum", visualWidth: 32, trailing: "Swap")
|
|
396
|
+
XCTAssertEqual(trailingWidth.constant, shortTrailingWidth, accuracy: 1)
|
|
397
|
+
|
|
398
|
+
try bind("", secondary: "", visualWidth: 48, trailing: nil)
|
|
399
|
+
let reboundConstraints: [NSLayoutConstraint] = try property("rowConstraints", in: cell,
|
|
400
|
+
as: [NSLayoutConstraint].self)
|
|
401
|
+
XCTAssertEqual(reboundConstraints.count, sizeConstraints.count)
|
|
402
|
+
XCTAssertTrue(zip(sizeConstraints, reboundConstraints).allSatisfy { $0 === $1 })
|
|
403
|
+
XCTAssertEqual(sizeConstraints[2].constant, 48, accuracy: 0.1)
|
|
404
|
+
XCTAssertFalse(trailingWidth.isActive)
|
|
405
|
+
XCTAssertEqual(primary.attributedText?.string, "")
|
|
406
|
+
XCTAssertNil(secondary.attributedText)
|
|
407
|
+
XCTAssertTrue(secondaryLine.isHidden)
|
|
408
|
+
XCTAssertTrue(titleIcon.isHidden)
|
|
409
|
+
XCTAssertNil(titleIcon.accessibilityLabel)
|
|
410
|
+
XCTAssertTrue(badges.arrangedSubviews.isEmpty)
|
|
411
|
+
|
|
412
|
+
try bind("Table", secondary: "New", layout: "table", visualWidth: 40, trailing: "Swap")
|
|
413
|
+
XCTAssertTrue(trailingWidth.isActive)
|
|
414
|
+
XCTAssertEqual(sizeConstraints[2].constant, 40, accuracy: 0.1)
|
|
415
|
+
XCTAssertEqual(primary.attributedText?.string, "Table")
|
|
416
|
+
XCTAssertEqual(secondary.attributedText?.string, "New")
|
|
417
|
+
try bind("Linear", secondary: "", visualWidth: 40, trailing: nil)
|
|
418
|
+
XCTAssertEqual(primary.attributedText?.string, "Linear")
|
|
419
|
+
XCTAssertNil(secondary.attributedText)
|
|
420
|
+
cell.prepareForReuse()
|
|
421
|
+
XCTAssertNil(primary.attributedText)
|
|
422
|
+
XCTAssertTrue(titleIcon.isHidden)
|
|
423
|
+
try bind("Replacement", secondary: "", visualWidth: 40, trailing: nil, key: "next")
|
|
424
|
+
XCTAssertEqual(primary.attributedText?.string, "Replacement")
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func testIdentityTrailingActionUsesDescriptorSlotAfterCheckboxAndRebind() throws {
|
|
428
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
|
|
429
|
+
let controller = UIViewController()
|
|
430
|
+
window.rootViewController = controller
|
|
431
|
+
window.isHidden = false
|
|
432
|
+
defer { window.isHidden = true }
|
|
433
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
|
|
434
|
+
controller.view.addSubview(cell)
|
|
435
|
+
let trailing = try XCTUnwrap(
|
|
436
|
+
Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
|
|
437
|
+
as? NativeListAccessoryStack)
|
|
438
|
+
var actions: [(rowKey: String, key: String, source: String, slot: Int?)] = []
|
|
439
|
+
cell.onAction = { item, key, _, origin in
|
|
440
|
+
actions.append((item.key, key, origin?.source ?? "", origin?.slot))
|
|
441
|
+
}
|
|
442
|
+
func bind(_ trailingData: [[String: Any]], key: String = "row") throws {
|
|
443
|
+
let item = try NativeListItem(data: [
|
|
444
|
+
"key": key, "type": "identity", "title": "Row", "trailing": trailingData,
|
|
445
|
+
])
|
|
446
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
447
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
448
|
+
window.layoutIfNeeded()
|
|
449
|
+
cell.layoutIfNeeded()
|
|
450
|
+
}
|
|
451
|
+
func visibleButtons() -> [NativeListAccessoryButton] {
|
|
452
|
+
trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
|
|
453
|
+
.filter { !$0.isHidden }
|
|
454
|
+
}
|
|
455
|
+
let checkbox: [String: Any] = [
|
|
456
|
+
"kind": "checkbox", "state": "unchecked", "actionKey": "selection",
|
|
457
|
+
]
|
|
458
|
+
let first: [String: Any] = ["kind": "button", "label": "First", "actionKey": "first"]
|
|
459
|
+
let second: [String: Any] = ["kind": "button", "label": "Second", "actionKey": "second"]
|
|
460
|
+
|
|
461
|
+
try bind([checkbox, second])
|
|
462
|
+
XCTAssertEqual(visibleButtons().count, 1)
|
|
463
|
+
XCTAssertTrue(visibleButtons()[0].isEnabled)
|
|
464
|
+
XCTAssertTrue(visibleButtons()[0].isUserInteractionEnabled)
|
|
465
|
+
XCTAssertTrue(visibleButtons()[0].allTargets.contains(trailing))
|
|
466
|
+
XCTAssertNotNil(trailing.onAction)
|
|
467
|
+
try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
|
|
468
|
+
XCTAssertEqual(actions.last?.rowKey, "row")
|
|
469
|
+
XCTAssertEqual(actions.last?.key, "second")
|
|
470
|
+
XCTAssertEqual(actions.last?.source, "trailingAccessory")
|
|
471
|
+
XCTAssertEqual(actions.last?.slot, 1)
|
|
472
|
+
let selection = try XCTUnwrap(trailing.arrangedSubviews.compactMap { $0 as? UIButton }
|
|
473
|
+
.first { !($0 is NativeListAccessoryButton) && !$0.isHidden })
|
|
474
|
+
try dispatchAccessoryTouchUpInside(selection, on: trailing)
|
|
475
|
+
XCTAssertEqual(actions.last?.key, "selection")
|
|
476
|
+
XCTAssertNil(actions.last?.slot)
|
|
477
|
+
|
|
478
|
+
try bind([first, second])
|
|
479
|
+
XCTAssertEqual(visibleButtons().count, 2)
|
|
480
|
+
try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
|
|
481
|
+
XCTAssertEqual(actions.last?.key, "first")
|
|
482
|
+
XCTAssertEqual(actions.last?.slot, 0)
|
|
483
|
+
try dispatchAccessoryTouchUpInside(visibleButtons()[1], on: trailing)
|
|
484
|
+
XCTAssertEqual(actions.last?.key, "second")
|
|
485
|
+
XCTAssertEqual(actions.last?.slot, 1)
|
|
486
|
+
|
|
487
|
+
try bind([second], key: "replacement")
|
|
488
|
+
XCTAssertEqual(visibleButtons().count, 1)
|
|
489
|
+
try dispatchAccessoryTouchUpInside(visibleButtons()[0], on: trailing)
|
|
490
|
+
XCTAssertEqual(actions.last?.rowKey, "replacement")
|
|
491
|
+
XCTAssertEqual(actions.last?.key, "second")
|
|
492
|
+
XCTAssertEqual(actions.last?.slot, 0)
|
|
493
|
+
XCTAssertEqual(actions.count, 5)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
func testIdentityTertiaryIconAnchorInsetSurvivesAccessoryRebind() throws {
|
|
497
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
|
|
498
|
+
let controller = UIViewController()
|
|
499
|
+
window.rootViewController = controller
|
|
500
|
+
window.isHidden = false
|
|
501
|
+
defer { window.isHidden = true }
|
|
502
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 88))
|
|
503
|
+
controller.view.addSubview(cell)
|
|
504
|
+
let trailing = try XCTUnwrap(
|
|
505
|
+
Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
|
|
506
|
+
as? NativeListAccessoryStack)
|
|
507
|
+
var actions: [(rowKey: String, source: String, slot: Int?, inset: CGFloat,
|
|
508
|
+
width: CGFloat)] = []
|
|
509
|
+
cell.onAction = { item, _, _, origin in
|
|
510
|
+
actions.append((item.key, origin?.source ?? "", origin?.slot, origin?.anchorInset ?? -1,
|
|
511
|
+
origin?.sourceView?.bounds.width ?? -1))
|
|
512
|
+
}
|
|
513
|
+
func bind(_ descriptors: [[String: Any]], key: String,
|
|
514
|
+
presentation: String? = nil) throws -> NativeListAccessoryButton {
|
|
515
|
+
var data: [String: Any] = [
|
|
516
|
+
"key": key, "type": "identity", "title": "Row", "height": 88,
|
|
517
|
+
"trailing": descriptors,
|
|
518
|
+
]
|
|
519
|
+
if let presentation { data["presentation"] = presentation }
|
|
520
|
+
cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
|
|
521
|
+
selected: false, checkboxState: { _, _, fallback in fallback },
|
|
522
|
+
fonts: NativeListFontFamilies())
|
|
523
|
+
window.layoutIfNeeded()
|
|
524
|
+
cell.layoutIfNeeded()
|
|
525
|
+
return try XCTUnwrap(trailing.firstVisibleControl as? NativeListAccessoryButton)
|
|
526
|
+
}
|
|
527
|
+
let tertiary: [String: Any] = [
|
|
528
|
+
"kind": "icon", "name": "CryptoCoinOutline", "buttonStyle": "tertiary",
|
|
529
|
+
"actionKey": "menu",
|
|
530
|
+
]
|
|
531
|
+
let ordinary: [String: Any] = [
|
|
532
|
+
"kind": "icon", "name": "CryptoCoinOutline", "actionKey": "menu",
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
let first = try bind([
|
|
536
|
+
["kind": "checkbox", "state": "unchecked", "actionKey": "selection"], tertiary,
|
|
537
|
+
], key: "tertiary-first")
|
|
538
|
+
XCTAssertEqual(first.bounds.width, 38, accuracy: 0.5)
|
|
539
|
+
try dispatchAccessoryTouchUpInside(first, on: trailing)
|
|
540
|
+
XCTAssertEqual(actions.last?.rowKey, "tertiary-first")
|
|
541
|
+
XCTAssertEqual(actions.last?.source, "trailingAccessory")
|
|
542
|
+
XCTAssertEqual(actions.last?.slot, 1)
|
|
543
|
+
XCTAssertEqual(actions.last?.inset, 7)
|
|
544
|
+
let firstOrigin = try XCTUnwrap(actions.last)
|
|
545
|
+
XCTAssertEqual(firstOrigin.width - 2 * firstOrigin.inset, 24, accuracy: 0.5)
|
|
546
|
+
|
|
547
|
+
let normal = try bind([ordinary], key: "ordinary")
|
|
548
|
+
XCTAssertEqual(normal.bounds.width, 36, accuracy: 0.5)
|
|
549
|
+
try dispatchAccessoryTouchUpInside(normal, on: trailing)
|
|
550
|
+
XCTAssertEqual(actions.last?.rowKey, "ordinary")
|
|
551
|
+
XCTAssertEqual(actions.last?.slot, 0)
|
|
552
|
+
XCTAssertEqual(actions.last?.inset, 0)
|
|
553
|
+
XCTAssertEqual(actions.last?.width, 36)
|
|
554
|
+
|
|
555
|
+
let value = try bind([["kind": "value", "text": "Value"]], key: "value")
|
|
556
|
+
value.bounds.size.width = 38
|
|
557
|
+
XCTAssertEqual(trailing.anchorInset(for: value), 0)
|
|
558
|
+
|
|
559
|
+
let rebound = try bind([tertiary], key: "tertiary-again")
|
|
560
|
+
try dispatchAccessoryTouchUpInside(rebound, on: trailing)
|
|
561
|
+
XCTAssertEqual(actions.last?.rowKey, "tertiary-again")
|
|
562
|
+
XCTAssertEqual(actions.last?.slot, 0)
|
|
563
|
+
XCTAssertEqual(actions.last?.inset, 7)
|
|
564
|
+
let reboundOrigin = try XCTUnwrap(actions.last)
|
|
565
|
+
XCTAssertEqual(reboundOrigin.width - 2 * reboundOrigin.inset, 24, accuracy: 0.5)
|
|
566
|
+
|
|
567
|
+
let account = try bind([ordinary], key: "account", presentation: "accountSelector")
|
|
568
|
+
XCTAssertEqual(account.bounds.width, 38, accuracy: 0.5)
|
|
569
|
+
try dispatchAccessoryTouchUpInside(account, on: trailing)
|
|
570
|
+
XCTAssertEqual(actions.last?.rowKey, "account")
|
|
571
|
+
XCTAssertEqual(actions.last?.slot, 0)
|
|
572
|
+
XCTAssertEqual(actions.last?.inset, 7)
|
|
573
|
+
let accountOrigin = try XCTUnwrap(actions.last)
|
|
574
|
+
XCTAssertEqual(accountOrigin.width - 2 * accountOrigin.inset, 24, accuracy: 0.5)
|
|
575
|
+
|
|
576
|
+
let finalOrdinary = try bind([ordinary], key: "ordinary-again")
|
|
577
|
+
try dispatchAccessoryTouchUpInside(finalOrdinary, on: trailing)
|
|
578
|
+
XCTAssertEqual(actions.last?.rowKey, "ordinary-again")
|
|
579
|
+
XCTAssertEqual(actions.last?.inset, 0)
|
|
580
|
+
XCTAssertEqual(actions.count, 5)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
func testValuePairSegmentsStyleAndPlainRebindOnProductionAccessoryStack() throws {
|
|
584
|
+
let stack = NativeListAccessoryStack(frame: CGRect(x: 0, y: 0, width: 180, height: 48))
|
|
585
|
+
let item = try NativeListItem(data: [
|
|
586
|
+
"key": "pair", "type": "identity", "title": "Pair",
|
|
587
|
+
"leading": ["kind": "icon", "name": "coin"],
|
|
588
|
+
])
|
|
589
|
+
let pair: [String: Any] = [
|
|
590
|
+
"kind": "valuePair", "primary": "0.001", "secondary": "$0.01",
|
|
591
|
+
"primaryTextSegments": [["text": "0.00"], ["text": "1", "style": "subscript"]],
|
|
592
|
+
"secondaryTextSegments": [["text": "$0.0"], ["text": "1", "style": "subscript"]],
|
|
593
|
+
]
|
|
594
|
+
func bind(_ descriptor: [String: Any], size: Int? = nil) {
|
|
595
|
+
let style: [String: Any] = size.map { ["value": ["fontSize": $0]] } ?? [:]
|
|
596
|
+
stack.bind(item, descriptors: [descriptor], theme: nil, style: style,
|
|
597
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
598
|
+
}
|
|
599
|
+
func fontSize(_ title: NSAttributedString, at index: Int) throws -> CGFloat {
|
|
600
|
+
try XCTUnwrap(title.attribute(.font, at: index, effectiveRange: nil) as? UIFont).pointSize
|
|
601
|
+
}
|
|
602
|
+
bind(pair)
|
|
603
|
+
let button = try XCTUnwrap(stack.arrangedSubviews.compactMap { $0 as? UIButton }
|
|
604
|
+
.first { !$0.isHidden })
|
|
605
|
+
let rich = try XCTUnwrap(button.attributedTitle(for: .normal))
|
|
606
|
+
XCTAssertEqual(rich.string, "0.001\n$0.01")
|
|
607
|
+
XCTAssertEqual(try fontSize(rich, at: 0), 16)
|
|
608
|
+
XCTAssertEqual(try fontSize(rich, at: 4), 10)
|
|
609
|
+
XCTAssertEqual(try fontSize(rich, at: 6), 14)
|
|
610
|
+
XCTAssertEqual(try fontSize(rich, at: 10), 9)
|
|
611
|
+
bind(pair, size: 20)
|
|
612
|
+
let styled = try XCTUnwrap(button.attributedTitle(for: .normal))
|
|
613
|
+
XCTAssertEqual(try fontSize(styled, at: 0), 20)
|
|
614
|
+
XCTAssertEqual(try fontSize(styled, at: 4), 12)
|
|
615
|
+
XCTAssertEqual(try fontSize(styled, at: 10), 12)
|
|
616
|
+
bind(["kind": "valuePair", "primary": "10", "secondary": "$2"])
|
|
617
|
+
let plain = try XCTUnwrap(button.attributedTitle(for: .normal))
|
|
618
|
+
XCTAssertEqual(plain.string, "10\n$2")
|
|
619
|
+
XCTAssertEqual(try fontSize(plain, at: 0), 16)
|
|
620
|
+
XCTAssertEqual(try fontSize(plain, at: 3), 14)
|
|
621
|
+
bind(["kind": "valuePair", "primary": "10", "secondary": "$2",
|
|
622
|
+
"primaryTextSegments": [], "secondaryTextSegments": []])
|
|
623
|
+
XCTAssertEqual(button.attributedTitle(for: .normal)?.string, "10\n$2")
|
|
624
|
+
stack.reset()
|
|
625
|
+
XCTAssertNil(button.title(for: .normal))
|
|
626
|
+
XCTAssertNil(button.attributedTitle(for: .normal))
|
|
627
|
+
XCTAssertTrue(button.isHidden)
|
|
628
|
+
bind(["kind": "value", "text": "Updated"])
|
|
629
|
+
XCTAssertEqual(button.title(for: .normal), "Updated")
|
|
630
|
+
XCTAssertEqual(button.titleLabel?.text, "Updated")
|
|
631
|
+
XCTAssertNil(button.attributedTitle(for: .normal))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
func testDecorativeRefreshKeepsSixteenPointFrameAcrossActionRebind() throws {
|
|
635
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
|
|
636
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 80))
|
|
637
|
+
window.addSubview(cell)
|
|
638
|
+
let stack = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
639
|
+
$0.label == "trailingStack"
|
|
640
|
+
}?.value as? NativeListAccessoryStack)
|
|
641
|
+
func bind(actionKey: String? = nil) throws {
|
|
642
|
+
var icon: [String: Any] = ["kind": "icon", "name": "RefreshCcwOutline"]
|
|
643
|
+
if let actionKey { icon["actionKey"] = actionKey }
|
|
644
|
+
let item = try NativeListItem(data: [
|
|
645
|
+
"key": "refresh", "type": "identity", "title": "Network", "height": 56,
|
|
646
|
+
"leading": ["kind": "icon", "name": "coin"], "trailing": [icon],
|
|
647
|
+
])
|
|
648
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
649
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
650
|
+
window.layoutIfNeeded()
|
|
651
|
+
cell.layoutIfNeeded()
|
|
652
|
+
}
|
|
653
|
+
try bind()
|
|
654
|
+
let button = try XCTUnwrap(stack.firstVisibleControl as? UIButton)
|
|
655
|
+
XCTAssertEqual(button.bounds.width, 16, accuracy: 0.5)
|
|
656
|
+
XCTAssertEqual(button.bounds.height, 16, accuracy: 0.5)
|
|
657
|
+
XCTAssertNotNil(button.image(for: .normal))
|
|
658
|
+
XCTAssertEqual(button.image(for: .normal)?.size.width, 16)
|
|
659
|
+
XCTAssertEqual(button.image(for: .normal)?.size.height, 16)
|
|
660
|
+
XCTAssertFalse(button.isUserInteractionEnabled)
|
|
661
|
+
try bind(actionKey: "refresh")
|
|
662
|
+
XCTAssertEqual(button.bounds.width, 36, accuracy: 0.5)
|
|
663
|
+
XCTAssertEqual(button.bounds.height, 36, accuracy: 0.5)
|
|
664
|
+
XCTAssertTrue(button.isUserInteractionEnabled)
|
|
665
|
+
try bind()
|
|
666
|
+
XCTAssertEqual(button.bounds.width, 16, accuracy: 0.5)
|
|
667
|
+
XCTAssertEqual(button.bounds.height, 16, accuracy: 0.5)
|
|
668
|
+
XCTAssertEqual(button.image(for: .normal)?.size.width, 16)
|
|
669
|
+
XCTAssertEqual(button.image(for: .normal)?.size.height, 16)
|
|
670
|
+
XCTAssertFalse(button.isUserInteractionEnabled)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
func testLowValueAndOverviewAccessoryLayoutsOnProductionIdentityCell() throws {
|
|
674
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 60))
|
|
675
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 180))
|
|
676
|
+
window.addSubview(cell)
|
|
677
|
+
let main = try XCTUnwrap(Mirror(reflecting: cell).children.first { $0.label == "mainStack" }?
|
|
678
|
+
.value as? UIStackView)
|
|
679
|
+
let trailing = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
680
|
+
$0.label == "trailingStack"
|
|
681
|
+
}?.value as? NativeListAccessoryStack)
|
|
682
|
+
func bind(_ data: [String: Any], height: CGFloat) throws {
|
|
683
|
+
cell.frame.size.height = height
|
|
684
|
+
cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
|
|
685
|
+
selected: false, checkboxState: { _, _, fallback in fallback },
|
|
686
|
+
fonts: NativeListFontFamilies())
|
|
687
|
+
window.layoutIfNeeded()
|
|
688
|
+
cell.layoutIfNeeded()
|
|
689
|
+
}
|
|
690
|
+
let pair: [String: Any] = ["kind": "valuePair", "primary": "1.0193B",
|
|
691
|
+
"secondary": "$1,018,849,363.70"]
|
|
692
|
+
let lowValue: [String: Any] = ["key": "low", "type": "identity", "height": 60,
|
|
693
|
+
"title": "FRAX", "subtitleSegments": [
|
|
694
|
+
["text": "$0.9918", "tone": "secondary", "textSegments": [
|
|
695
|
+
["text": "$0.99"], ["text": "18", "style": "subscript"]]],
|
|
696
|
+
["text": "\u{00a0}+0.05%", "tone": "positive"]],
|
|
697
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
698
|
+
"trailing": [pair], "style": ["horizontalPadding": 12, "verticalPadding": 8,
|
|
699
|
+
"leadingGap": 12, "image": ["width": 40, "height": 40]]]
|
|
700
|
+
try bind(lowValue, height: 60)
|
|
701
|
+
let priceLabels = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
702
|
+
$0.label == "semanticSubtitleLabels"
|
|
703
|
+
}?.value as? [UILabel])
|
|
704
|
+
XCTAssertEqual(priceLabels.count, 2)
|
|
705
|
+
XCTAssertGreaterThan(main.bounds.width, 150)
|
|
706
|
+
XCTAssertTrue(priceLabels.allSatisfy { $0.bounds.width > 0 && !$0.isHidden })
|
|
707
|
+
XCTAssertLessThanOrEqual(priceLabels.last?.frame.maxX ?? .infinity, main.bounds.width + 0.5)
|
|
708
|
+
XCTAssertEqual(trailing.bounds.width, trailing.firstVisibleControl?.bounds.width ?? -1,
|
|
709
|
+
accuracy: 0.5)
|
|
710
|
+
let detail: [String: Any] = ["key": "detail", "type": "identity", "height": 60,
|
|
711
|
+
"title": "Ethereum", "subtitle": "100.0%",
|
|
712
|
+
"leading": ["kind": "network", "fallbackIcon": ["name": "GlobusOutline"]],
|
|
713
|
+
"trailing": [pair, ["kind": "chevron"]],
|
|
714
|
+
"style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
|
|
715
|
+
"trailingGap": 12, "image": ["width": 32, "height": 32]]]
|
|
716
|
+
try bind(detail, height: 60)
|
|
717
|
+
XCTAssertEqual(trailing.axis, .horizontal)
|
|
718
|
+
let detailControls = trailing.arrangedSubviews.filter { !$0.isHidden }
|
|
719
|
+
XCTAssertEqual(detailControls.count, 2)
|
|
720
|
+
let detailRects = detailControls.map { $0.convert($0.bounds, to: cell) }
|
|
721
|
+
XCTAssertTrue(detailRects.allSatisfy { $0.minY >= 0 && $0.maxY <= 60 })
|
|
722
|
+
XCTAssertEqual(detailRects[0].midY, detailRects[1].midY, accuracy: 1)
|
|
723
|
+
XCTAssertEqual(detailRects[1].minX - detailRects[0].maxX, 12, accuracy: 0.5)
|
|
724
|
+
XCTAssertEqual(detailRects[1].maxX, cell.bounds.maxX - 12, accuracy: 0.5)
|
|
725
|
+
let missing: [String: Any] = ["key": "missing", "type": "identity", "height": 48,
|
|
726
|
+
"title": "Ethereum", "leading": ["kind": "network",
|
|
727
|
+
"fallbackIcon": ["name": "GlobusOutline"]],
|
|
728
|
+
"trailing": [["kind": "icon", "name": "RefreshCcwOutline"], ["kind": "chevron"]],
|
|
729
|
+
"style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
|
|
730
|
+
"trailingGap": 12, "image": ["width": 32, "height": 32]]]
|
|
731
|
+
try bind(missing, height: 48)
|
|
732
|
+
XCTAssertEqual(trailing.axis, .horizontal)
|
|
733
|
+
let missingControls = trailing.arrangedSubviews.filter { !$0.isHidden }
|
|
734
|
+
XCTAssertEqual(missingControls.count, 2)
|
|
735
|
+
XCTAssertEqual(missingControls[0].bounds.width, 16, accuracy: 0.5)
|
|
736
|
+
XCTAssertEqual(missingControls[1].bounds.width, 24, accuracy: 0.5)
|
|
737
|
+
let missingRects = missingControls.map { $0.convert($0.bounds, to: cell) }
|
|
738
|
+
XCTAssertTrue(missingRects.allSatisfy { $0.minY >= 0 && $0.maxY <= 48 })
|
|
739
|
+
XCTAssertEqual(missingRects[0].midY, missingRects[1].midY, accuracy: 1)
|
|
740
|
+
XCTAssertEqual(missingRects[1].minX - missingRects[0].maxX, 12, accuracy: 0.5)
|
|
741
|
+
XCTAssertEqual(missingRects[1].maxX, cell.bounds.maxX - 12, accuracy: 0.5)
|
|
742
|
+
try bind(detail, height: 60)
|
|
743
|
+
XCTAssertEqual(trailing.axis, .horizontal)
|
|
744
|
+
try bind(["key": "plain", "type": "identity", "title": "Plain", "height": 56,
|
|
745
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]]],
|
|
746
|
+
height: 56)
|
|
747
|
+
let remainingConstraints = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
748
|
+
$0.label == "selectorConstraints"
|
|
749
|
+
}?.value as? [NSLayoutConstraint])
|
|
750
|
+
XCTAssertTrue(remainingConstraints.isEmpty)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
func testSwapValuePairAndTertiaryMenuIconFitAndKeepActionState() throws {
|
|
754
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 60))
|
|
755
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 120))
|
|
756
|
+
window.addSubview(cell)
|
|
757
|
+
defer { cell.removeFromSuperview() }
|
|
758
|
+
let trailing = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
759
|
+
$0.label == "trailingStack"
|
|
760
|
+
}?.value as? NativeListAccessoryStack)
|
|
761
|
+
var actions: [(String, Int?)] = []
|
|
762
|
+
cell.onAction = { _, action, _, origin in actions.append((action, origin?.slot)) }
|
|
763
|
+
func bind(disabled: Bool) throws {
|
|
764
|
+
let item = try NativeListItem(data: [
|
|
765
|
+
"key": "swap-token", "type": "identity", "height": 60,
|
|
766
|
+
"title": "ETH", "subtitle": "Ethereum",
|
|
767
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
768
|
+
"trailing": [
|
|
769
|
+
["kind": "valuePair", "primary": "0.001328", "secondary": "$3.08"],
|
|
770
|
+
["kind": "icon", "name": "DotVerSolid", "buttonStyle": "tertiary",
|
|
771
|
+
"actionKey": "menu", "disabled": disabled],
|
|
772
|
+
],
|
|
773
|
+
"style": ["horizontalPadding": 20, "verticalPadding": 8,
|
|
774
|
+
"leadingGap": 12, "trailingGap": 8,
|
|
775
|
+
"image": ["width": 40, "height": 40]],
|
|
776
|
+
])
|
|
777
|
+
cell.bind(item: item, theme: nil, layout: "linear", selected: false,
|
|
778
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
779
|
+
window.layoutIfNeeded()
|
|
780
|
+
cell.layoutIfNeeded()
|
|
781
|
+
}
|
|
782
|
+
try bind(disabled: false)
|
|
783
|
+
XCTAssertEqual(trailing.axis, .horizontal)
|
|
784
|
+
let controls = trailing.arrangedSubviews.compactMap { !$0.isHidden ? $0 as? UIButton : nil }
|
|
785
|
+
XCTAssertEqual(controls.count, 2)
|
|
786
|
+
let rects = controls.map { $0.convert($0.bounds, to: cell) }
|
|
787
|
+
XCTAssertTrue(rects.allSatisfy { $0.minY >= 0 && $0.maxY <= cell.bounds.height })
|
|
788
|
+
let amountLabel = try XCTUnwrap(controls[0].titleLabel)
|
|
789
|
+
let amountRect = amountLabel.convert(amountLabel.bounds, to: cell)
|
|
790
|
+
XCTAssertEqual(amountLabel.numberOfLines, 2)
|
|
791
|
+
XCTAssertGreaterThan(amountRect.height, 30)
|
|
792
|
+
XCTAssertGreaterThanOrEqual(amountRect.minY, 0)
|
|
793
|
+
XCTAssertLessThanOrEqual(amountRect.maxY, cell.bounds.height)
|
|
794
|
+
XCTAssertEqual(rects[0].midY, rects[1].midY, accuracy: 1)
|
|
795
|
+
XCTAssertEqual(rects[1].minX - rects[0].maxX, 8, accuracy: 0.5)
|
|
796
|
+
XCTAssertEqual(controls[1].bounds.height, 38, accuracy: 0.5)
|
|
797
|
+
XCTAssertTrue(controls[1].isEnabled)
|
|
798
|
+
try dispatchAccessoryTouchUpInside(controls[1], on: trailing)
|
|
799
|
+
XCTAssertEqual(actions.count, 1)
|
|
800
|
+
let action = try XCTUnwrap(actions.first)
|
|
801
|
+
XCTAssertEqual(action.0, "menu")
|
|
802
|
+
XCTAssertEqual(action.1, 1)
|
|
803
|
+
|
|
804
|
+
try bind(disabled: true)
|
|
805
|
+
let disabledIcon = try XCTUnwrap(trailing.arrangedSubviews.filter { !$0.isHidden }.last as? UIButton)
|
|
806
|
+
XCTAssertFalse(disabledIcon.isEnabled)
|
|
807
|
+
try dispatchAccessoryTouchUpInside(disabledIcon, on: trailing)
|
|
808
|
+
XCTAssertEqual(actions.count, 1)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
func testRiskIdentityRatioKeepsTitleVisibleWithAndWithoutButton() throws {
|
|
812
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 386, height: 56))
|
|
813
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 386, height: 200))
|
|
814
|
+
window.addSubview(cell)
|
|
815
|
+
let titleLabel = try XCTUnwrap(
|
|
816
|
+
Mirror(reflecting: cell).children.first { $0.label == "titleLabel" }?.value as? UILabel)
|
|
817
|
+
let mainStack = try XCTUnwrap(
|
|
818
|
+
Mirror(reflecting: cell).children.first { $0.label == "mainStack" }?.value as? UIStackView)
|
|
819
|
+
let trailing = try XCTUnwrap(
|
|
820
|
+
Mirror(reflecting: cell).children.first { $0.label == "trailingStack" }?.value
|
|
821
|
+
as? NativeListAccessoryStack)
|
|
822
|
+
let base: [String: Any] = [
|
|
823
|
+
"key": "risk", "type": "identity", "title": "XSR", "height": 56,
|
|
824
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
825
|
+
"style": [
|
|
826
|
+
"horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
|
|
827
|
+
"leadingContentMaxWidthRatio": 0.6,
|
|
828
|
+
"image": ["width": 40, "height": 40, "shape": "circle"],
|
|
829
|
+
"title": ["fontSize": 16, "lineHeight": 24, "fontWeight": "medium", "lines": 1],
|
|
830
|
+
],
|
|
831
|
+
]
|
|
832
|
+
|
|
833
|
+
func bind(_ data: [String: Any], capped: Bool = true) throws {
|
|
834
|
+
cell.bind(item: try NativeListItem(data: data), theme: nil, layout: "linear",
|
|
835
|
+
selected: false, checkboxState: { _, _, fallback in fallback },
|
|
836
|
+
fonts: NativeListFontFamilies())
|
|
837
|
+
window.layoutIfNeeded()
|
|
838
|
+
cell.layoutIfNeeded()
|
|
839
|
+
XCTAssertFalse(titleLabel.isHidden)
|
|
840
|
+
XCTAssertGreaterThan(titleLabel.bounds.width, 0.5,
|
|
841
|
+
"A visible token title must not collapse when the row reserves trailing space")
|
|
842
|
+
XCTAssertEqual(titleLabel.bounds.height, 24, accuracy: 0.5)
|
|
843
|
+
let intrinsicWidth = titleLabel.intrinsicContentSize.width
|
|
844
|
+
let capWidth = cell.root.bounds.width * 0.6 - 52
|
|
845
|
+
let requiredTitleWidth = capped ? min(intrinsicWidth, capWidth) : intrinsicWidth
|
|
846
|
+
XCTAssertGreaterThanOrEqual(titleLabel.bounds.width, requiredTitleWidth - 0.5,
|
|
847
|
+
"title=\(data["title"] ?? "") intrinsic=\(intrinsicWidth) "
|
|
848
|
+
+ "rendered=\(titleLabel.bounds.width) root=\(cell.root.bounds.width) "
|
|
849
|
+
+ "trailing=\(trailing.bounds.width)")
|
|
850
|
+
XCTAssertGreaterThanOrEqual(mainStack.bounds.width, titleLabel.bounds.width - 0.5)
|
|
851
|
+
XCTAssertEqual(trailing.frame.maxX, cell.root.bounds.maxX, accuracy: 0.5)
|
|
852
|
+
if capped {
|
|
853
|
+
XCTAssertLessThanOrEqual(mainStack.bounds.width, cell.root.bounds.width * 0.6 - 52 + 0.5)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
try bind(base)
|
|
858
|
+
XCTAssertNil(trailing.firstVisibleControl)
|
|
859
|
+
try bind(base.merging(["trailing": [[
|
|
860
|
+
"kind": "button", "label": "Show on home", "actionKey": "show",
|
|
861
|
+
]]]) { _, newer in newer })
|
|
862
|
+
let button = try XCTUnwrap(trailing.firstVisibleControl)
|
|
863
|
+
XCTAssertGreaterThan(button.bounds.width, 100)
|
|
864
|
+
XCTAssertEqual(trailing.frame.minX + button.frame.maxX, cell.root.bounds.maxX, accuracy: 0.5)
|
|
865
|
+
cell.frame.size.height = 88
|
|
866
|
+
try bind(base.merging(["trailing": [
|
|
867
|
+
["kind": "button", "label": "A", "actionKey": "first"],
|
|
868
|
+
["kind": "button", "label": "Longer action", "actionKey": "second"],
|
|
869
|
+
], "height": 88]) { _, newer in newer })
|
|
870
|
+
let visibleButtons = trailing.arrangedSubviews.compactMap { $0 as? NativeListAccessoryButton }
|
|
871
|
+
.filter { !$0.isHidden }
|
|
872
|
+
XCTAssertEqual(visibleButtons.count, 2)
|
|
873
|
+
XCTAssertGreaterThan(visibleButtons[1].bounds.width, visibleButtons[0].bounds.width)
|
|
874
|
+
XCTAssertEqual(trailing.frame.minX + visibleButtons[1].frame.maxX,
|
|
875
|
+
cell.root.bounds.maxX, accuracy: 0.5)
|
|
876
|
+
cell.frame.size.height = 56
|
|
877
|
+
try bind(base.merging(["title": "A very long token symbol that must truncate within the cap"])
|
|
878
|
+
{ _, newer in newer })
|
|
879
|
+
XCTAssertLessThan(titleLabel.bounds.width, titleLabel.intrinsicContentSize.width)
|
|
880
|
+
try bind(base)
|
|
881
|
+
XCTAssertNil(trailing.firstVisibleControl)
|
|
882
|
+
try bind(base.merging(["key": "searched-risk", "title": "rkl"]) { _, newer in newer })
|
|
883
|
+
XCTAssertNil(trailing.firstVisibleControl)
|
|
884
|
+
try bind(base)
|
|
885
|
+
var unrestricted = base
|
|
886
|
+
var unrestrictedStyle = try XCTUnwrap(base["style"] as? [String: Any])
|
|
887
|
+
unrestrictedStyle.removeValue(forKey: "leadingContentMaxWidthRatio")
|
|
888
|
+
unrestricted["style"] = unrestrictedStyle
|
|
889
|
+
try bind(unrestricted, capped: false)
|
|
890
|
+
XCTAssertEqual(cell.root.arrangedSubviews.count, 3)
|
|
891
|
+
XCTAssertEqual(mainStack.contentHuggingPriority(for: .horizontal).rawValue, 1, accuracy: 0.1)
|
|
892
|
+
let spacing = cell.root.customSpacing(after: mainStack)
|
|
893
|
+
XCTAssertTrue(spacing == UIStackView.spacingUseDefault || spacing == cell.root.spacing)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
func testAutoHeightIdentityReservesMeasuredTrailingWidth() throws {
|
|
897
|
+
let subtitle = "A long recipient address that must wrap within the space left beside its values and controls"
|
|
898
|
+
func item(
|
|
899
|
+
trailing: [[String: Any]], horizontalPadding: Double? = nil,
|
|
900
|
+
presentation: String = ""
|
|
901
|
+
) throws -> NativeListItem {
|
|
902
|
+
var style: [String: Any] = ["image": ["width": 40, "height": 40]]
|
|
903
|
+
if let horizontalPadding { style["horizontalPadding"] = horizontalPadding }
|
|
904
|
+
return try NativeListItem(data: [
|
|
905
|
+
"key": "recipient", "type": "identity", "title": "Recipient", "subtitle": subtitle,
|
|
906
|
+
"subtitleLines": 0, "contentHeight": "auto", "style": style,
|
|
907
|
+
"presentation": presentation,
|
|
908
|
+
"trailing": trailing,
|
|
909
|
+
])
|
|
910
|
+
}
|
|
911
|
+
func height(width: CGFloat, item: NativeListItem) throws -> CGFloat {
|
|
912
|
+
return try XCTUnwrap(NativeListIdentityCell.measure(
|
|
913
|
+
item, width: width, theme: nil, layout: "linear", fonts: NativeListFontFamilies()))
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
let pair: [[String: Any]] = [[
|
|
917
|
+
"kind": "valuePair", "primary": "$123,456.78", "secondary": "12345 TOKEN",
|
|
918
|
+
]]
|
|
919
|
+
let pairAndMenu = pair + [["kind": "menu", "actionKey": "menu"]]
|
|
920
|
+
let pairItem = try item(trailing: pair)
|
|
921
|
+
let pairHeight = try height(width: 240, item: pairItem)
|
|
922
|
+
XCTAssertGreaterThan(pairHeight, try height(width: 240, item: item(trailing: [])))
|
|
923
|
+
XCTAssertGreaterThanOrEqual(try height(width: 240, item: item(trailing: pairAndMenu)),
|
|
924
|
+
pairHeight)
|
|
925
|
+
let cell = NativeListIdentityCell(frame: CGRect(x: 0, y: 0, width: 240, height: pairHeight))
|
|
926
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 240, height: pairHeight))
|
|
927
|
+
window.addSubview(cell)
|
|
928
|
+
cell.bind(item: pairItem, theme: nil, layout: "linear", selected: false,
|
|
929
|
+
checkboxState: { _, _, fallback in fallback }, fonts: NativeListFontFamilies())
|
|
930
|
+
window.layoutIfNeeded()
|
|
931
|
+
cell.layoutIfNeeded()
|
|
932
|
+
let subtitleLabel = try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
933
|
+
$0.label == "subtitleLabel"
|
|
934
|
+
}?.value as? UILabel)
|
|
935
|
+
XCTAssertGreaterThan(subtitleLabel.bounds.width, 0.5)
|
|
936
|
+
XCTAssertGreaterThanOrEqual(subtitleLabel.bounds.height + 0.5,
|
|
937
|
+
subtitleLabel.sizeThatFits(CGSize(
|
|
938
|
+
width: subtitleLabel.bounds.width, height: .greatestFiniteMagnitude)).height)
|
|
939
|
+
let accountIcon: [[String: Any]] = [[
|
|
940
|
+
"kind": "icon", "name": "DotHorOutline", "actionKey": "menu",
|
|
941
|
+
]]
|
|
942
|
+
XCTAssertGreaterThanOrEqual(
|
|
943
|
+
try height(width: 220, item: item(trailing: accountIcon,
|
|
944
|
+
horizontalPadding: 28, presentation: "accountSelector")),
|
|
945
|
+
try height(width: 220, item: item(trailing: accountIcon,
|
|
946
|
+
presentation: "accountSelector")))
|
|
947
|
+
}
|
|
948
|
+
|
|
156
949
|
func testFixedFooterReplacesRendererAndKeepsActionRouting() throws {
|
|
157
950
|
let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480))
|
|
158
951
|
var actions: [[String: Any]] = []
|
|
@@ -246,6 +1039,35 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
246
1039
|
XCTAssertEqual(footer.accessibilityLabel, "Continue")
|
|
247
1040
|
}
|
|
248
1041
|
|
|
1042
|
+
func testPaddedSquareNetworkOverlayRoundsInnerImageAndResetsOnRebind() throws {
|
|
1043
|
+
let visual = NativeListLeadingVisual(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
|
|
1044
|
+
func bind(_ overlay: [String: Any]) {
|
|
1045
|
+
visual.bind(["kind": "token", "overlays": [overlay]], style: [:], key: "overlay",
|
|
1046
|
+
theme: nil, isUnread: false, fonts: NativeListFontFamilies())
|
|
1047
|
+
visual.layoutIfNeeded()
|
|
1048
|
+
}
|
|
1049
|
+
let image: [String: Any] = ["uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9xvY0AAAAASUVORK5CYII="]
|
|
1050
|
+
bind(["position": "bottomRight", "size": 20, "width": 20, "height": 20,
|
|
1051
|
+
"padding": 2, "image": image, "fallbackIcon": ["name": "GlobusOutline"]])
|
|
1052
|
+
let slots = try XCTUnwrap(Mirror(reflecting: visual).children.first {
|
|
1053
|
+
$0.label == "overlaySlots"
|
|
1054
|
+
}?.value as? [NativeListImageSlot])
|
|
1055
|
+
let slot = try XCTUnwrap(slots.first?.view)
|
|
1056
|
+
XCTAssertEqual(slot.bounds.size, CGSize(width: 16, height: 16))
|
|
1057
|
+
XCTAssertEqual(slot.layer.cornerRadius, 8, accuracy: 0.5)
|
|
1058
|
+
XCTAssertTrue(slot.clipsToBounds)
|
|
1059
|
+
bind(["position": "bottomRight", "width": 20, "height": 14,
|
|
1060
|
+
"padding": 2, "image": image])
|
|
1061
|
+
XCTAssertEqual(slot.layer.cornerRadius, 0)
|
|
1062
|
+
XCTAssertFalse(slot.clipsToBounds)
|
|
1063
|
+
bind(["position": "bottomRight", "size": 20, "padding": 2, "text": "E"])
|
|
1064
|
+
XCTAssertEqual(slot.layer.cornerRadius, 0)
|
|
1065
|
+
XCTAssertFalse(slot.clipsToBounds)
|
|
1066
|
+
bind(["position": "bottomRight", "size": 20, "padding": 2, "image": image])
|
|
1067
|
+
XCTAssertEqual(slot.layer.cornerRadius, 8, accuracy: 0.5)
|
|
1068
|
+
visual.recycle()
|
|
1069
|
+
}
|
|
1070
|
+
|
|
249
1071
|
func testNestedWalletMembersAndDragBadgeFollowEachBindMapping() throws {
|
|
250
1072
|
let group = NativeListWalletGroupCell(frame: CGRect(x: 0, y: 0, width: 320, height: 200))
|
|
251
1073
|
let item = try NativeListItem(data: [
|
|
@@ -348,7 +1170,7 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
348
1170
|
XCTAssertTrue(header.clipsToBounds)
|
|
349
1171
|
XCTAssertEqual(header.bounds.height, 0, "An unmeasured header cannot draw over the first rows")
|
|
350
1172
|
|
|
351
|
-
view.setContainerSlotHeightsJson("[44,44,60]")
|
|
1173
|
+
view.setContainerSlotHeightsJson("[44,44,60,0]")
|
|
352
1174
|
view.layoutIfNeeded()
|
|
353
1175
|
list.layoutIfNeeded()
|
|
354
1176
|
XCTAssertEqual(slots[0].convert(CGPoint.zero, to: list), CGPoint(x: 16, y: 0))
|
|
@@ -363,10 +1185,1905 @@ final class NativeListLifecycleTests: XCTestCase {
|
|
|
363
1185
|
XCTAssertNil(slots[2].superview)
|
|
364
1186
|
}
|
|
365
1187
|
|
|
1188
|
+
func testSidecarCentersRowsWhenConfiguredBeforeFirstWidth() throws {
|
|
1189
|
+
try assertHomeSidecarGeometry(sidecarBeforeFirstLayout: true)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
func testSidecarCentersRowsWhenConfiguredAfterFirstWidth() throws {
|
|
1193
|
+
try assertHomeSidecarGeometry(sidecarBeforeFirstLayout: false)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
func testHomeLinearColumnsStayInsideTheirStackAcrossEmptyMiddleAndReuse() throws {
|
|
1197
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 900))
|
|
1198
|
+
let controller = UIViewController()
|
|
1199
|
+
window.rootViewController = controller
|
|
1200
|
+
let view = NativeListView(frame: window.bounds)
|
|
1201
|
+
controller.view.addSubview(view)
|
|
1202
|
+
window.isHidden = false
|
|
1203
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1204
|
+
defer {
|
|
1205
|
+
view.dispose()
|
|
1206
|
+
view.removeFromSuperview()
|
|
1207
|
+
window.isHidden = true
|
|
1208
|
+
window.rootViewController = nil
|
|
1209
|
+
}
|
|
1210
|
+
for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
1211
|
+
view.setContainerSlotHeightsJson("[0,0,0,1200]")
|
|
1212
|
+
let sidecar = "{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}"
|
|
1213
|
+
view.setSidecarLayoutJson(sidecar)
|
|
1214
|
+
|
|
1215
|
+
func columns(middle: String?, two: Bool = false) -> [[String: Any]] {
|
|
1216
|
+
let first: [String: Any] = ["key": "asset", "text": "USD1",
|
|
1217
|
+
"secondaryText": "Public fixture", "weight": 1]
|
|
1218
|
+
let balance: [String: Any] = ["key": "balance", "text": middle ?? "",
|
|
1219
|
+
"secondaryText": middle == nil ? "" : "$0.01", "alignment": "end", "weight": 1]
|
|
1220
|
+
let price: [String: Any] = ["key": "price", "text": "$1.00",
|
|
1221
|
+
"secondaryText": "0.01%", "alignment": "end", "weight": 1]
|
|
1222
|
+
return two ? [first, price] : [first, balance, price]
|
|
1223
|
+
}
|
|
1224
|
+
let rows: [[String: Any]] = (0..<30).map { index in
|
|
1225
|
+
["key": "row-\(index)", "type": "dataRow", "height": 60,
|
|
1226
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
1227
|
+
"columns": index == 20 ? columns(middle: "0.01") : homeSidecarColumns(index, updated: false),
|
|
1228
|
+
"trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]],
|
|
1229
|
+
"style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
|
|
1230
|
+
"columns": ["token": "$bodyLg", "fontWeight": "medium", "lines": 1],
|
|
1231
|
+
"columnSecondary": ["token": "$bodyMd", "lines": 1],
|
|
1232
|
+
"image": ["width": 32, "height": 32]]]
|
|
1233
|
+
}
|
|
1234
|
+
let snapshot: [String: Any] = ["schemaVersion": 1, "generation": 1,
|
|
1235
|
+
"layout": ["kind": "linear", "contentPaddingHorizontal": 8], "rows": rows]
|
|
1236
|
+
view.applySnapshotJson(String(decoding: try JSONSerialization.data(withJSONObject: snapshot),
|
|
1237
|
+
as: UTF8.self))
|
|
1238
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
1239
|
+
func layout() {
|
|
1240
|
+
window.layoutIfNeeded()
|
|
1241
|
+
view.layoutIfNeeded()
|
|
1242
|
+
list.layoutIfNeeded()
|
|
1243
|
+
}
|
|
1244
|
+
func check(_ phase: String, count: Int, width: CGFloat, weights: [Int]? = nil) throws {
|
|
1245
|
+
layout()
|
|
1246
|
+
let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
|
|
1247
|
+
as? NativeListDataRowCell, phase)
|
|
1248
|
+
let stack = try XCTUnwrap(cell.root.arrangedSubviews[3] as? UIStackView, phase)
|
|
1249
|
+
let visible = stack.arrangedSubviews.filter { !$0.isHidden }
|
|
1250
|
+
XCTAssertEqual(stack.arrangedSubviews.count, 4, phase)
|
|
1251
|
+
XCTAssertEqual(stack.arrangedSubviews.map(\.isHidden),
|
|
1252
|
+
(0..<4).map { $0 >= count }, phase)
|
|
1253
|
+
XCTAssertEqual(visible.count, count, phase)
|
|
1254
|
+
let cellFrame = cell.convert(cell.bounds, to: list)
|
|
1255
|
+
let stackFrame = stack.convert(stack.bounds, to: list)
|
|
1256
|
+
let attributes = try XCTUnwrap(list.layoutAttributesForItem(
|
|
1257
|
+
at: IndexPath(item: 20, section: 0)), phase)
|
|
1258
|
+
XCTAssertEqual(attributes.frame.width, width, accuracy: 1, phase)
|
|
1259
|
+
XCTAssertEqual(cellFrame.width, width, accuracy: 1,
|
|
1260
|
+
"\(phase) window=\(window.frame) controller=\(controller.view.frame) view=\(view.frame) list=\(list.frame) bounds=\(list.bounds) attributes=\(attributes.frame)")
|
|
1261
|
+
let rowConstraints = Mirror(reflecting: cell).children.first {
|
|
1262
|
+
$0.label == "rowConstraints"
|
|
1263
|
+
}?.value as? [NSLayoutConstraint] ?? []
|
|
1264
|
+
let columnWidthConstraints = Mirror(reflecting: cell).children.first {
|
|
1265
|
+
$0.label == "columnWidthConstraints"
|
|
1266
|
+
}?.value as? [NSLayoutConstraint] ?? []
|
|
1267
|
+
let constraints = rowConstraints + columnWidthConstraints
|
|
1268
|
+
let constraintState = constraints.map { constraint in
|
|
1269
|
+
let first = stack.arrangedSubviews.firstIndex {
|
|
1270
|
+
$0 === constraint.firstItem as? UIView
|
|
1271
|
+
} ?? -1
|
|
1272
|
+
let second = stack.arrangedSubviews.firstIndex {
|
|
1273
|
+
$0 === constraint.secondItem as? UIView
|
|
1274
|
+
} ?? -1
|
|
1275
|
+
return "\(first):\(second):\(constraint.multiplier):\(constraint.isActive)"
|
|
1276
|
+
}.joined(separator: ",")
|
|
1277
|
+
let hidingState = stack.arrangedSubviews.enumerated().map { index, column in
|
|
1278
|
+
let hiding = (stack.constraints + column.constraints).filter {
|
|
1279
|
+
$0.isActive && $0.identifier == "UISV-hiding"
|
|
1280
|
+
}.map { "\($0.constant)" }.joined(separator: ",")
|
|
1281
|
+
return "\(index):\(column.isHidden):\(hiding)"
|
|
1282
|
+
}.joined(separator: ",")
|
|
1283
|
+
let geometry = "\(phase) model=\(count) cell=\(cellFrame) stack=\(stackFrame) "
|
|
1284
|
+
+ "columns=\(stack.arrangedSubviews.map { "\($0.isHidden):\($0.convert($0.bounds, to: list))" }) "
|
|
1285
|
+
+ "constraints=\(constraintState) hiding=\(hidingState)"
|
|
1286
|
+
XCTAssertGreaterThanOrEqual(stackFrame.minX, cellFrame.minX - 0.5, geometry)
|
|
1287
|
+
XCTAssertLessThanOrEqual(stackFrame.maxX, cellFrame.maxX + 0.5, geometry)
|
|
1288
|
+
for column in visible {
|
|
1289
|
+
let columnFrame = column.convert(column.bounds, to: list)
|
|
1290
|
+
XCTAssertGreaterThan(columnFrame.width, 0, geometry)
|
|
1291
|
+
XCTAssertGreaterThanOrEqual(columnFrame.minX, stackFrame.minX - 0.5, geometry)
|
|
1292
|
+
XCTAssertLessThanOrEqual(columnFrame.maxX, stackFrame.maxX + 0.5, geometry)
|
|
1293
|
+
let primaryLine = (column as? UIStackView)?.arrangedSubviews.first
|
|
1294
|
+
if let primaryLine {
|
|
1295
|
+
let lineFrame = primaryLine.convert(primaryLine.bounds, to: list)
|
|
1296
|
+
XCTAssertGreaterThanOrEqual(lineFrame.minX, columnFrame.minX - 0.5, geometry)
|
|
1297
|
+
XCTAssertLessThanOrEqual(lineFrame.maxX, columnFrame.maxX + 0.5, geometry)
|
|
1298
|
+
}
|
|
1299
|
+
for label in labels(in: column) where !label.isHidden {
|
|
1300
|
+
let labelFrame = label.convert(label.bounds, to: list)
|
|
1301
|
+
XCTAssertGreaterThanOrEqual(labelFrame.minX, columnFrame.minX - 0.5,
|
|
1302
|
+
"\(geometry) label=\(labelFrame)")
|
|
1303
|
+
XCTAssertLessThanOrEqual(labelFrame.maxX, columnFrame.maxX + 0.5,
|
|
1304
|
+
"\(geometry) label=\(labelFrame)")
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if count >= 2 {
|
|
1308
|
+
let expectedWeights = weights ?? Array(repeating: 1, count: count)
|
|
1309
|
+
XCTAssertEqual(expectedWeights.count, count, geometry)
|
|
1310
|
+
for index in 1..<count {
|
|
1311
|
+
XCTAssertEqual(visible[index].bounds.width,
|
|
1312
|
+
visible[0].bounds.width * CGFloat(expectedWeights[index])
|
|
1313
|
+
/ CGFloat(expectedWeights[0]),
|
|
1314
|
+
accuracy: 1, geometry)
|
|
1315
|
+
let previous = visible[index - 1].convert(visible[index - 1].bounds, to: stack)
|
|
1316
|
+
let current = visible[index].convert(visible[index].bounds, to: stack)
|
|
1317
|
+
XCTAssertGreaterThanOrEqual(current.minX, previous.maxX - 0.5, geometry)
|
|
1318
|
+
XCTAssertEqual(current.minX - previous.maxX, stack.spacing,
|
|
1319
|
+
accuracy: 0.5, geometry)
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
func patch(_ next: [[String: Any]]) throws {
|
|
1324
|
+
let patches: [[String: Any]] = [["key": "row-20", "type": "dataRow",
|
|
1325
|
+
"changes": ["columns": next]]]
|
|
1326
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patches),
|
|
1327
|
+
as: UTF8.self))
|
|
1328
|
+
}
|
|
1329
|
+
func widthConstraints() throws -> [NSLayoutConstraint] {
|
|
1330
|
+
let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
|
|
1331
|
+
as? NativeListDataRowCell)
|
|
1332
|
+
return try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
1333
|
+
$0.label == "columnWidthConstraints"
|
|
1334
|
+
}?.value as? [NSLayoutConstraint])
|
|
1335
|
+
}
|
|
1336
|
+
func sizeConstraints() throws -> [NSLayoutConstraint] {
|
|
1337
|
+
let cell = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0))
|
|
1338
|
+
as? NativeListDataRowCell)
|
|
1339
|
+
return try XCTUnwrap(Mirror(reflecting: cell).children.first {
|
|
1340
|
+
$0.label == "rowConstraints"
|
|
1341
|
+
}?.value as? [NSLayoutConstraint])
|
|
1342
|
+
}
|
|
1343
|
+
layout()
|
|
1344
|
+
list.setContentOffset(CGPoint(x: 0, y: 18 * 60), animated: false)
|
|
1345
|
+
try check("three populated", count: 3, width: 700)
|
|
1346
|
+
try patch(columns(middle: nil))
|
|
1347
|
+
try check("three empty middle", count: 3, width: 700)
|
|
1348
|
+
try patch(columns(middle: "0.02"))
|
|
1349
|
+
try check("three restored", count: 3, width: 700)
|
|
1350
|
+
try patch(columns(middle: nil, two: true))
|
|
1351
|
+
try check("two columns", count: 2, width: 700)
|
|
1352
|
+
try patch(columns(middle: nil))
|
|
1353
|
+
try check("three after two", count: 3, width: 700)
|
|
1354
|
+
let stableConstraints = try widthConstraints()
|
|
1355
|
+
let stableSizeConstraints = try sizeConstraints()
|
|
1356
|
+
XCTAssertEqual(stableConstraints.count, 2)
|
|
1357
|
+
XCTAssertEqual(stableSizeConstraints.count, 4)
|
|
1358
|
+
for update in 0..<10 {
|
|
1359
|
+
var next = columns(middle: nil)
|
|
1360
|
+
next[2]["text"] = "$1.\(update)"
|
|
1361
|
+
try patch(next)
|
|
1362
|
+
try check("same-shape price \(update)", count: 3, width: 700)
|
|
1363
|
+
let retained = try widthConstraints()
|
|
1364
|
+
XCTAssertEqual(retained.count, stableConstraints.count)
|
|
1365
|
+
XCTAssertTrue(zip(stableConstraints, retained).allSatisfy { $0 === $1 })
|
|
1366
|
+
XCTAssertTrue(retained.allSatisfy(\.isActive))
|
|
1367
|
+
let size = try sizeConstraints()
|
|
1368
|
+
XCTAssertEqual(size.count, stableSizeConstraints.count)
|
|
1369
|
+
XCTAssertTrue(zip(stableSizeConstraints, size).allSatisfy { $0 === $1 })
|
|
1370
|
+
XCTAssertTrue(size.allSatisfy(\.isActive))
|
|
1371
|
+
}
|
|
1372
|
+
var equivalentWeights = columns(middle: nil)
|
|
1373
|
+
for index in equivalentWeights.indices { equivalentWeights[index]["weight"] = 2 }
|
|
1374
|
+
try patch(equivalentWeights)
|
|
1375
|
+
try check("equivalent weights", count: 3, width: 700, weights: [2, 2, 2])
|
|
1376
|
+
let equivalentConstraints = try widthConstraints()
|
|
1377
|
+
XCTAssertEqual(equivalentConstraints.count, stableConstraints.count)
|
|
1378
|
+
XCTAssertTrue(zip(stableConstraints, equivalentConstraints).allSatisfy { $0 === $1 })
|
|
1379
|
+
var unequalWeights = columns(middle: nil)
|
|
1380
|
+
unequalWeights[0]["weight"] = 2
|
|
1381
|
+
try patch(unequalWeights)
|
|
1382
|
+
try check("unequal weights", count: 3, width: 700, weights: [2, 1, 1])
|
|
1383
|
+
let unequalConstraints = try widthConstraints()
|
|
1384
|
+
XCTAssertEqual(unequalConstraints.count, 2)
|
|
1385
|
+
XCTAssertTrue(stableConstraints.allSatisfy { !$0.isActive })
|
|
1386
|
+
try patch(columns(middle: nil, two: true))
|
|
1387
|
+
try check("two after weights", count: 2, width: 700)
|
|
1388
|
+
let twoConstraints = try widthConstraints()
|
|
1389
|
+
XCTAssertEqual(twoConstraints.count, 1)
|
|
1390
|
+
XCTAssertTrue(unequalConstraints.allSatisfy { !$0.isActive })
|
|
1391
|
+
try patch([])
|
|
1392
|
+
try check("zero columns", count: 0, width: 700)
|
|
1393
|
+
XCTAssertTrue((try widthConstraints()).isEmpty)
|
|
1394
|
+
XCTAssertTrue(twoConstraints.allSatisfy { !$0.isActive })
|
|
1395
|
+
try patch(columns(middle: nil))
|
|
1396
|
+
try check("three after zero", count: 3, width: 700)
|
|
1397
|
+
list.setContentOffset(.zero, animated: false)
|
|
1398
|
+
layout()
|
|
1399
|
+
XCTAssertFalse(list.indexPathsForVisibleItems.contains(IndexPath(item: 20, section: 0)))
|
|
1400
|
+
list.setContentOffset(CGPoint(x: 0, y: 18 * 60), animated: false)
|
|
1401
|
+
try check("after scroll reuse", count: 3, width: 700)
|
|
1402
|
+
window.frame.size.width = 1032
|
|
1403
|
+
controller.view.frame = window.bounds
|
|
1404
|
+
view.frame = window.bounds
|
|
1405
|
+
view.setSidecarLayoutJson("")
|
|
1406
|
+
drainMainQueue()
|
|
1407
|
+
try check("narrow no sidecar", count: 3, width: 1016)
|
|
1408
|
+
window.frame.size.width = 1376
|
|
1409
|
+
controller.view.frame = window.bounds
|
|
1410
|
+
view.frame = window.bounds
|
|
1411
|
+
view.setSidecarLayoutJson(sidecar)
|
|
1412
|
+
drainMainQueue()
|
|
1413
|
+
try check("wide sidecar restored", count: 3, width: 700)
|
|
1414
|
+
func animatedPatch(_ next: [[String: Any]], phase: String, count: Int) throws {
|
|
1415
|
+
let patches: [[String: Any]] = [["key": "row-20", "type": "dataRow",
|
|
1416
|
+
"changes": ["columns": next]]]
|
|
1417
|
+
let json = String(decoding: try JSONSerialization.data(withJSONObject: patches), as: UTF8.self)
|
|
1418
|
+
let finished = expectation(description: "Home column animation: \(phase)")
|
|
1419
|
+
UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) {
|
|
1420
|
+
view.applyPatchesJson(json)
|
|
1421
|
+
view.layoutIfNeeded()
|
|
1422
|
+
list.layoutIfNeeded()
|
|
1423
|
+
} completion: { _ in finished.fulfill() }
|
|
1424
|
+
wait(for: [finished], timeout: 1)
|
|
1425
|
+
try check(phase, count: count, width: 700)
|
|
1426
|
+
waitForStructuralFrames(0.1)
|
|
1427
|
+
try check("\(phase) stable", count: count, width: 700)
|
|
1428
|
+
}
|
|
1429
|
+
try animatedPatch(columns(middle: nil, two: true), phase: "animated two", count: 2)
|
|
1430
|
+
try animatedPatch(columns(middle: nil), phase: "animated three", count: 3)
|
|
1431
|
+
try animatedPatch(columns(middle: "0.06"), phase: "animated nonempty", count: 3)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
func testStructuralShrinkUsesTheTallerFinalColumnForOffsetClamping() throws {
|
|
1435
|
+
let cases: [(name: String, sidecar: Int, offset: CGFloat, height: CGFloat,
|
|
1436
|
+
zeroInset: Bool)] = [
|
|
1437
|
+
("sidecar stays taller", 2200, 1450, 2200, false),
|
|
1438
|
+
("left column stays taller", 1000, 1100, 1560, false),
|
|
1439
|
+
("left column zero inset", 1000, 1100, 1560, true),
|
|
1440
|
+
("sidecar becomes taller", 1700, 1050, 1700, false),
|
|
1441
|
+
]
|
|
1442
|
+
for testCase in cases {
|
|
1443
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
|
|
1444
|
+
let controller = UIViewController()
|
|
1445
|
+
window.rootViewController = controller
|
|
1446
|
+
let view = NativeListView(frame: window.bounds)
|
|
1447
|
+
controller.view.addSubview(view)
|
|
1448
|
+
window.isHidden = false
|
|
1449
|
+
defer {
|
|
1450
|
+
view.dispose()
|
|
1451
|
+
view.removeFromSuperview()
|
|
1452
|
+
window.isHidden = true
|
|
1453
|
+
window.rootViewController = nil
|
|
1454
|
+
}
|
|
1455
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1456
|
+
// Keep the viewport insets fixed while testing structural offset clamping.
|
|
1457
|
+
let explicitInset = testCase.zeroInset ? UIEdgeInsets.zero :
|
|
1458
|
+
UIEdgeInsets(top: 62, left: 0, bottom: 34, right: 0)
|
|
1459
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
1460
|
+
list.contentInset = explicitInset
|
|
1461
|
+
view.mountContainerSlot(UIView(), atIndex: 0)
|
|
1462
|
+
view.mountContainerSlot(UIView(), atIndex: 1)
|
|
1463
|
+
let footer = UIView()
|
|
1464
|
+
view.mountContainerSlot(footer, atIndex: 2)
|
|
1465
|
+
let sidecar = UIView()
|
|
1466
|
+
view.mountContainerSlot(sidecar, atIndex: 3)
|
|
1467
|
+
view.setContainerSlotHeightsJson("[0,0,60,\(testCase.sidecar)]")
|
|
1468
|
+
view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
|
|
1469
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30))
|
|
1470
|
+
view.layoutIfNeeded()
|
|
1471
|
+
list.layoutIfNeeded()
|
|
1472
|
+
drainMainQueue()
|
|
1473
|
+
XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
|
|
1474
|
+
XCTAssertEqual(list.contentSize.height, max(1860, CGFloat(testCase.sidecar)),
|
|
1475
|
+
accuracy: 1, testCase.name)
|
|
1476
|
+
list.contentOffset.y = testCase.offset
|
|
1477
|
+
list.layoutIfNeeded()
|
|
1478
|
+
XCTAssertEqual(list.contentOffset.y, testCase.offset, accuracy: 1, testCase.name)
|
|
1479
|
+
|
|
1480
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, transitionID: testCase.name))
|
|
1481
|
+
let completed = expectation(description: "Native structural display link: \(testCase.name)")
|
|
1482
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { completed.fulfill() }
|
|
1483
|
+
wait(for: [completed], timeout: 1)
|
|
1484
|
+
list.layoutIfNeeded()
|
|
1485
|
+
XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
|
|
1486
|
+
XCTAssertEqual(list.contentSize.height, testCase.height, accuracy: 1, testCase.name)
|
|
1487
|
+
let inset = list.adjustedContentInset
|
|
1488
|
+
let finalMaximum = max(-inset.top, testCase.height - list.bounds.height + inset.bottom)
|
|
1489
|
+
let footerTop: CGFloat = 1500
|
|
1490
|
+
let footerBottom: CGFloat = footerTop + 60
|
|
1491
|
+
let footerVisibleMinimum = footerBottom - list.bounds.height + inset.bottom
|
|
1492
|
+
let footerVisibleMaximum = footerTop - inset.top
|
|
1493
|
+
let expectedOffset = min(max(testCase.offset, footerVisibleMinimum),
|
|
1494
|
+
min(footerVisibleMaximum, finalMaximum))
|
|
1495
|
+
XCTAssertEqual(list.contentOffset.y, expectedOffset, accuracy: 1, testCase.name)
|
|
1496
|
+
XCTAssertNotNil(sidecar.superview, testCase.name)
|
|
1497
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, 1500, accuracy: 1, testCase.name)
|
|
1498
|
+
waitForStructuralFrames(0.1)
|
|
1499
|
+
XCTAssertEqual(list.adjustedContentInset, explicitInset, testCase.name)
|
|
1500
|
+
XCTAssertEqual(list.contentSize.height, testCase.height, accuracy: 1, testCase.name)
|
|
1501
|
+
XCTAssertEqual(list.contentOffset.y, expectedOffset, accuracy: 1, testCase.name)
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
func testStructuralTransitionFromGridOrHorizontalCommitsAtomically() throws {
|
|
1506
|
+
try requireStructuralAnimation()
|
|
1507
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 600))
|
|
1508
|
+
let controller = UIViewController()
|
|
1509
|
+
window.rootViewController = controller
|
|
1510
|
+
let view = NativeListView(frame: window.bounds)
|
|
1511
|
+
controller.view.addSubview(view)
|
|
1512
|
+
window.isHidden = false
|
|
1513
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1514
|
+
defer {
|
|
1515
|
+
view.dispose()
|
|
1516
|
+
view.removeFromSuperview()
|
|
1517
|
+
window.isHidden = true
|
|
1518
|
+
window.rootViewController = nil
|
|
1519
|
+
}
|
|
1520
|
+
view.applySnapshotJson(try structuralRowsSnapshot(
|
|
1521
|
+
count: 6, generation: 1, layoutKind: "grid"))
|
|
1522
|
+
view.layoutIfNeeded()
|
|
1523
|
+
view.applySnapshotJson(try structuralRowsSnapshot(
|
|
1524
|
+
count: 8, generation: 2, transitionID: "from-grid", layoutKind: "linear"))
|
|
1525
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
1526
|
+
XCTAssertEqual(list.numberOfItems(inSection: 0), 8)
|
|
1527
|
+
|
|
1528
|
+
view.applySnapshotJson(try structuralRowsSnapshot(
|
|
1529
|
+
count: 6, generation: 3, orientation: "horizontal"))
|
|
1530
|
+
view.layoutIfNeeded()
|
|
1531
|
+
view.applySnapshotJson(try structuralRowsSnapshot(
|
|
1532
|
+
count: 8, generation: 4, transitionID: "from-horizontal", layoutKind: "table"))
|
|
1533
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
1534
|
+
XCTAssertEqual(list.numberOfItems(inSection: 0), 8)
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
func testVisibleNativeCellsRemainBoundedAsRowsGrowTenfold() throws {
|
|
1538
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1539
|
+
let controller = UIViewController()
|
|
1540
|
+
window.rootViewController = controller
|
|
1541
|
+
let view = NativeListView(frame: window.bounds)
|
|
1542
|
+
controller.view.addSubview(view)
|
|
1543
|
+
window.isHidden = false
|
|
1544
|
+
defer { window.isHidden = true }
|
|
1545
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1546
|
+
func sampleCells(count: Int) throws -> [(visible: Int, attached: Int)] {
|
|
1547
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: count))
|
|
1548
|
+
view.layoutIfNeeded()
|
|
1549
|
+
list.layoutIfNeeded()
|
|
1550
|
+
let lastOffset = CGFloat(count * 60 - 600)
|
|
1551
|
+
return [CGFloat(0), lastOffset / 2, lastOffset].map { offset in
|
|
1552
|
+
list.setContentOffset(CGPoint(x: 0, y: offset), animated: false)
|
|
1553
|
+
list.layoutIfNeeded()
|
|
1554
|
+
return (list.visibleCells.count,
|
|
1555
|
+
list.subviews.compactMap { $0 as? NativeListRowHost }.count)
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
let hundredCells = try sampleCells(count: 100)
|
|
1559
|
+
let thousandCells = try sampleCells(count: 1000)
|
|
1560
|
+
print("NativeList 100 visible/attached first/mid/tail: \(hundredCells)")
|
|
1561
|
+
print("NativeList 1000 visible/attached first/mid/tail: \(thousandCells)")
|
|
1562
|
+
XCTAssertTrue(hundredCells.allSatisfy {
|
|
1563
|
+
$0.visible > 0 && $0.visible <= 24 && $0.attached > 0 && $0.attached <= 24
|
|
1564
|
+
})
|
|
1565
|
+
XCTAssertTrue(thousandCells.allSatisfy {
|
|
1566
|
+
$0.visible > 0 && $0.visible <= 24 && $0.attached > 0 && $0.attached <= 24
|
|
1567
|
+
})
|
|
1568
|
+
XCTAssertLessThanOrEqual(thousandCells.map(\.attached).max() ?? 0,
|
|
1569
|
+
(hundredCells.map(\.attached).max() ?? 0) + 4)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
func testStructuralTransitionRapidReversalKeepsRetiringContentAtNaturalHeight() throws {
|
|
1573
|
+
try requireStructuralAnimation()
|
|
1574
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1575
|
+
let controller = UIViewController()
|
|
1576
|
+
window.rootViewController = controller
|
|
1577
|
+
let view = NativeListView(frame: window.bounds)
|
|
1578
|
+
controller.view.addSubview(view)
|
|
1579
|
+
window.isHidden = false
|
|
1580
|
+
defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
|
|
1581
|
+
let footer = UIView()
|
|
1582
|
+
for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
1583
|
+
view.mountContainerSlot(footer, atIndex: 2)
|
|
1584
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
1585
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 1))
|
|
1586
|
+
view.layoutIfNeeded()
|
|
1587
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1588
|
+
list.layoutIfNeeded()
|
|
1589
|
+
drainMainQueue()
|
|
1590
|
+
list.contentOffset.y = 900
|
|
1591
|
+
list.layoutIfNeeded()
|
|
1592
|
+
|
|
1593
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 2,
|
|
1594
|
+
transitionID: "expand-first", durationMs: 400))
|
|
1595
|
+
waitForStructuralFrames(0.08)
|
|
1596
|
+
list.contentOffset.y = 1100
|
|
1597
|
+
list.layoutIfNeeded()
|
|
1598
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 3,
|
|
1599
|
+
transitionID: "collapse-middle", durationMs: 400))
|
|
1600
|
+
waitForStructuralFrames(0.08)
|
|
1601
|
+
list.layoutIfNeeded()
|
|
1602
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
1603
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
1604
|
+
let clip = try XCTUnwrap(flow.tailClip)
|
|
1605
|
+
let retiring = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 25, section: 0)) as? NativeListRowHost)
|
|
1606
|
+
XCTAssertGreaterThan(clip.endY, 1500)
|
|
1607
|
+
XCTAssertLessThan(clip.endY, 1800)
|
|
1608
|
+
XCTAssertEqual(retiring.bounds.height, 60, accuracy: 1)
|
|
1609
|
+
XCTAssertEqual(retiring.contentView.bounds.height, 60, accuracy: 1)
|
|
1610
|
+
XCTAssertNil(retiring.structuralContentHeight)
|
|
1611
|
+
XCTAssertEqual(retiring.alpha, 1, accuracy: 0.01)
|
|
1612
|
+
XCTAssertFalse(retiring.isUserInteractionEnabled)
|
|
1613
|
+
XCTAssertTrue(retiring.accessibilityElementsHidden)
|
|
1614
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y,
|
|
1615
|
+
clip.endY, accuracy: 1)
|
|
1616
|
+
|
|
1617
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 4,
|
|
1618
|
+
transitionID: "expand-last", durationMs: 400))
|
|
1619
|
+
waitForStructuralFrames(0.5)
|
|
1620
|
+
list.layoutIfNeeded()
|
|
1621
|
+
let keys = source.snapshot().itemIdentifiers
|
|
1622
|
+
XCTAssertEqual(keys, (0..<30).map { "row-\($0)" })
|
|
1623
|
+
XCTAssertEqual(Set(keys).count, 30)
|
|
1624
|
+
XCTAssertEqual(list.contentSize.height, 1860, accuracy: 1)
|
|
1625
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, 1800, accuracy: 1)
|
|
1626
|
+
XCTAssertGreaterThanOrEqual(list.contentOffset.y, -list.adjustedContentInset.top - 1)
|
|
1627
|
+
XCTAssertLessThanOrEqual(list.contentOffset.y,
|
|
1628
|
+
list.contentSize.height - list.bounds.height + list.adjustedContentInset.bottom + 1)
|
|
1629
|
+
for case let cell as NativeListRowHost in list.visibleCells {
|
|
1630
|
+
XCTAssertEqual(cell.alpha, 1, accuracy: 0.01)
|
|
1631
|
+
XCTAssertNil(cell.structuralContentHeight)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
func testHybridSettlementListenerFollowsRegistration() throws {
|
|
1636
|
+
let hybrid = HybridNativeList()
|
|
1637
|
+
let host = try XCTUnwrap(hybrid.view as? NativeListView)
|
|
1638
|
+
var first: [String] = []
|
|
1639
|
+
var second: [String] = []
|
|
1640
|
+
XCTAssertNil(host.onStructuralTransitionSettled)
|
|
1641
|
+
|
|
1642
|
+
hybrid.onStructuralTransitionSettled = { first.append($0) }
|
|
1643
|
+
host.onStructuralTransitionSettled?("first")
|
|
1644
|
+
XCTAssertEqual(first, ["first"])
|
|
1645
|
+
|
|
1646
|
+
hybrid.onStructuralTransitionSettled = { second.append($0) }
|
|
1647
|
+
host.onStructuralTransitionSettled?("second")
|
|
1648
|
+
XCTAssertEqual(first, ["first"])
|
|
1649
|
+
XCTAssertEqual(second, ["second"])
|
|
1650
|
+
|
|
1651
|
+
hybrid.onStructuralTransitionSettled = nil
|
|
1652
|
+
XCTAssertNil(host.onStructuralTransitionSettled)
|
|
1653
|
+
hybrid.dispose()
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
func testDisclosureWithoutListenerStillSettlesWithoutReplay() throws {
|
|
1657
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1658
|
+
let controller = UIViewController()
|
|
1659
|
+
window.rootViewController = controller
|
|
1660
|
+
let view = NativeListView(frame: window.bounds)
|
|
1661
|
+
controller.view.addSubview(view)
|
|
1662
|
+
window.isHidden = false
|
|
1663
|
+
defer {
|
|
1664
|
+
view.dispose()
|
|
1665
|
+
view.removeFromSuperview()
|
|
1666
|
+
window.isHidden = true
|
|
1667
|
+
window.rootViewController = nil
|
|
1668
|
+
}
|
|
1669
|
+
for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
1670
|
+
view.setContainerSlotHeightsJson("[0,0,60,0,100,40]")
|
|
1671
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
1672
|
+
disclosureExpanded: false))
|
|
1673
|
+
view.layoutIfNeeded()
|
|
1674
|
+
|
|
1675
|
+
let first = try structuralRowsSnapshot(count: 6, generation: 2,
|
|
1676
|
+
transitionID: "without-listener", durationMs: 0, disclosureExpanded: true)
|
|
1677
|
+
view.applySnapshotJson(first)
|
|
1678
|
+
drainMainQueue()
|
|
1679
|
+
XCTAssertEqual(Mirror(reflecting: view).descendant("pendingDisclosureSettlement")
|
|
1680
|
+
.map { Mirror(reflecting: $0).children.count }, 0)
|
|
1681
|
+
|
|
1682
|
+
var settlements: [[String: Any]] = []
|
|
1683
|
+
view.onStructuralTransitionSettled = { payload in
|
|
1684
|
+
guard let data = payload.data(using: .utf8),
|
|
1685
|
+
let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
1686
|
+
return
|
|
1687
|
+
}
|
|
1688
|
+
settlements.append(value)
|
|
1689
|
+
}
|
|
1690
|
+
view.applySnapshotJson(first)
|
|
1691
|
+
drainMainQueue()
|
|
1692
|
+
XCTAssertTrue(settlements.isEmpty)
|
|
1693
|
+
|
|
1694
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
|
|
1695
|
+
transitionID: "with-listener", durationMs: 0, disclosureExpanded: false))
|
|
1696
|
+
drainMainQueue()
|
|
1697
|
+
XCTAssertEqual(settlements.count, 1)
|
|
1698
|
+
XCTAssertEqual(settlements.first?["transitionId"] as? String, "with-listener")
|
|
1699
|
+
XCTAssertEqual(settlements.first?["status"] as? String, "completed")
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
func testDisclosureInitialEndpoints() throws {
|
|
1703
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1704
|
+
let controller = UIViewController()
|
|
1705
|
+
window.rootViewController = controller
|
|
1706
|
+
let view = NativeListView(frame: window.bounds)
|
|
1707
|
+
controller.view.addSubview(view)
|
|
1708
|
+
window.isHidden = false
|
|
1709
|
+
defer {
|
|
1710
|
+
view.dispose()
|
|
1711
|
+
view.removeFromSuperview()
|
|
1712
|
+
window.isHidden = true
|
|
1713
|
+
window.rootViewController = nil
|
|
1714
|
+
}
|
|
1715
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
1716
|
+
for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
|
|
1717
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
1718
|
+
var settlements: [String] = []
|
|
1719
|
+
view.onStructuralTransitionSettled = { settlements.append($0) }
|
|
1720
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1721
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
1722
|
+
list.contentInset = .zero
|
|
1723
|
+
|
|
1724
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
1725
|
+
disclosureExpanded: false))
|
|
1726
|
+
view.layoutIfNeeded()
|
|
1727
|
+
list.layoutIfNeeded()
|
|
1728
|
+
let footerHost = try XCTUnwrap(slots[2].superview)
|
|
1729
|
+
let contentHost = try XCTUnwrap(slots[4].superview)
|
|
1730
|
+
let toggleHost = try XCTUnwrap(slots[5].superview)
|
|
1731
|
+
XCTAssertEqual(contentHost.frame.minY, 360, accuracy: 1)
|
|
1732
|
+
XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
|
|
1733
|
+
XCTAssertTrue(contentHost.isHidden)
|
|
1734
|
+
XCTAssertFalse(contentHost.isUserInteractionEnabled)
|
|
1735
|
+
XCTAssertTrue(contentHost.accessibilityElementsHidden)
|
|
1736
|
+
XCTAssertEqual(toggleHost.frame.minY, 360, accuracy: 1)
|
|
1737
|
+
XCTAssertEqual(footerHost.frame.minY, 400, accuracy: 1)
|
|
1738
|
+
XCTAssertEqual(list.contentSize.height, 540, accuracy: 1)
|
|
1739
|
+
XCTAssertTrue(toggleHost.point(inside: CGPoint(x: 10, y: 10), with: nil))
|
|
1740
|
+
XCTAssertTrue(settlements.isEmpty)
|
|
1741
|
+
|
|
1742
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
1743
|
+
disclosureExpanded: true))
|
|
1744
|
+
view.layoutIfNeeded()
|
|
1745
|
+
list.layoutIfNeeded()
|
|
1746
|
+
XCTAssertEqual(contentHost.frame.minY, 360, accuracy: 1)
|
|
1747
|
+
XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
|
|
1748
|
+
XCTAssertFalse(contentHost.isHidden)
|
|
1749
|
+
XCTAssertTrue(contentHost.isUserInteractionEnabled)
|
|
1750
|
+
XCTAssertFalse(contentHost.accessibilityElementsHidden)
|
|
1751
|
+
XCTAssertEqual(toggleHost.frame.minY, 460, accuracy: 1)
|
|
1752
|
+
XCTAssertEqual(footerHost.frame.minY, 520, accuracy: 1)
|
|
1753
|
+
XCTAssertEqual(list.contentSize.height, 660, accuracy: 1)
|
|
1754
|
+
XCTAssertTrue(settlements.isEmpty)
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
func testDisclosureCollapseClock() throws {
|
|
1758
|
+
try requireStructuralAnimation()
|
|
1759
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1760
|
+
let controller = UIViewController()
|
|
1761
|
+
window.rootViewController = controller
|
|
1762
|
+
let view = NativeListView(frame: window.bounds)
|
|
1763
|
+
controller.view.addSubview(view)
|
|
1764
|
+
window.isHidden = false
|
|
1765
|
+
defer {
|
|
1766
|
+
view.dispose()
|
|
1767
|
+
view.removeFromSuperview()
|
|
1768
|
+
window.isHidden = true
|
|
1769
|
+
window.rootViewController = nil
|
|
1770
|
+
}
|
|
1771
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
1772
|
+
let expandedChild = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 100))
|
|
1773
|
+
slots[4].addSubview(expandedChild)
|
|
1774
|
+
let toggle = UIButton(frame: CGRect(x: 0, y: 0, width: 320, height: 40))
|
|
1775
|
+
for index in 0..<6 {
|
|
1776
|
+
view.mountContainerSlot(index == 5 ? toggle : slots[index], atIndex: index)
|
|
1777
|
+
}
|
|
1778
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
1779
|
+
var settlements: [[String: Any]] = []
|
|
1780
|
+
view.onStructuralTransitionSettled = { payload in
|
|
1781
|
+
guard let data = payload.data(using: .utf8),
|
|
1782
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
1783
|
+
return
|
|
1784
|
+
}
|
|
1785
|
+
settlements.append(object)
|
|
1786
|
+
}
|
|
1787
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1788
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
1789
|
+
list.contentInset = .zero
|
|
1790
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1,
|
|
1791
|
+
disclosureExpanded: true))
|
|
1792
|
+
view.layoutIfNeeded()
|
|
1793
|
+
list.layoutIfNeeded()
|
|
1794
|
+
drainMainQueue()
|
|
1795
|
+
list.contentOffset.y = 2400
|
|
1796
|
+
list.layoutIfNeeded()
|
|
1797
|
+
|
|
1798
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
1799
|
+
transitionID: "disclosure-collapse", durationMs: 800,
|
|
1800
|
+
disclosureExpanded: false))
|
|
1801
|
+
waitForStructuralFrames(0.16)
|
|
1802
|
+
list.layoutIfNeeded()
|
|
1803
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
1804
|
+
let clip = try XCTUnwrap(flow.tailClip)
|
|
1805
|
+
let contentHost = try XCTUnwrap(slots[4].superview)
|
|
1806
|
+
let toggleHost = try XCTUnwrap(toggle.superview)
|
|
1807
|
+
let footerHost = try XCTUnwrap(slots[2].superview)
|
|
1808
|
+
XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
|
|
1809
|
+
XCTAssertEqual(contentHost.frame.minY, clip.endY, accuracy: 1)
|
|
1810
|
+
let contentVisible = toggleHost.frame.minY - contentHost.frame.minY
|
|
1811
|
+
XCTAssertGreaterThan(contentVisible, 0)
|
|
1812
|
+
XCTAssertLessThan(contentVisible, 100)
|
|
1813
|
+
XCTAssertEqual(try XCTUnwrap(contentHost.layer.mask).frame.height,
|
|
1814
|
+
contentVisible, accuracy: 1)
|
|
1815
|
+
XCTAssertFalse(contentHost.isUserInteractionEnabled)
|
|
1816
|
+
XCTAssertTrue(contentHost.accessibilityElementsHidden)
|
|
1817
|
+
XCTAssertTrue(toggleHost.isUserInteractionEnabled)
|
|
1818
|
+
XCTAssertFalse(toggleHost.accessibilityElementsHidden)
|
|
1819
|
+
XCTAssertTrue(toggleHost.point(inside: CGPoint(x: 10, y: 10), with: nil))
|
|
1820
|
+
XCTAssertEqual(toggleHost.frame.minY, clip.endY + contentVisible, accuracy: 1)
|
|
1821
|
+
let inset = footerHost.frame.minY - toggleHost.frame.maxY
|
|
1822
|
+
XCTAssertGreaterThan(inset, 0)
|
|
1823
|
+
XCTAssertLessThan(inset, 20)
|
|
1824
|
+
XCTAssertTrue(settlements.isEmpty)
|
|
1825
|
+
|
|
1826
|
+
waitForStructuralFrames(0.75)
|
|
1827
|
+
list.layoutIfNeeded()
|
|
1828
|
+
XCTAssertNil(flow.tailClip)
|
|
1829
|
+
XCTAssertEqual(toggleHost.frame.minY, 360, accuracy: 1)
|
|
1830
|
+
XCTAssertEqual(footerHost.frame.minY, 400, accuracy: 1)
|
|
1831
|
+
XCTAssertTrue(contentHost.isHidden)
|
|
1832
|
+
XCTAssertEqual(settlements.count, 1)
|
|
1833
|
+
XCTAssertEqual(settlements.first?["transitionId"] as? String, "disclosure-collapse")
|
|
1834
|
+
XCTAssertEqual(settlements.first?["status"] as? String, "completed")
|
|
1835
|
+
XCTAssertEqual(settlements.first?["settledExpanded"] as? Bool, false)
|
|
1836
|
+
|
|
1837
|
+
let roots = (0..<6).map { $0 == 5 ? toggle : slots[$0] }
|
|
1838
|
+
let hosts = roots.map(\.superview)
|
|
1839
|
+
XCTAssertTrue(hosts.allSatisfy { $0 != nil })
|
|
1840
|
+
let toggleWindowOrigin = toggleHost.convert(CGPoint.zero, to: window)
|
|
1841
|
+
let footerWindowOrigin = footerHost.convert(CGPoint.zero, to: window)
|
|
1842
|
+
let settledOffset = list.contentOffset
|
|
1843
|
+
let settledSize = list.contentSize
|
|
1844
|
+
expandedChild.removeFromSuperview()
|
|
1845
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,0,40]")
|
|
1846
|
+
view.layoutIfNeeded()
|
|
1847
|
+
list.layoutIfNeeded()
|
|
1848
|
+
drainMainQueue()
|
|
1849
|
+
view.layoutIfNeeded()
|
|
1850
|
+
list.layoutIfNeeded()
|
|
1851
|
+
for (root, host) in zip(roots, hosts) {
|
|
1852
|
+
XCTAssertTrue(root.superview === host)
|
|
1853
|
+
XCTAssertTrue(root.window === window)
|
|
1854
|
+
}
|
|
1855
|
+
XCTAssertEqual(toggleHost.convert(CGPoint.zero, to: window).x, toggleWindowOrigin.x, accuracy: 1)
|
|
1856
|
+
XCTAssertEqual(toggleHost.convert(CGPoint.zero, to: window).y, toggleWindowOrigin.y, accuracy: 1)
|
|
1857
|
+
XCTAssertEqual(footerHost.convert(CGPoint.zero, to: window).x, footerWindowOrigin.x, accuracy: 1)
|
|
1858
|
+
XCTAssertEqual(footerHost.convert(CGPoint.zero, to: window).y, footerWindowOrigin.y, accuracy: 1)
|
|
1859
|
+
XCTAssertEqual(list.contentOffset.x, settledOffset.x, accuracy: 1)
|
|
1860
|
+
XCTAssertEqual(list.contentOffset.y, settledOffset.y, accuracy: 1)
|
|
1861
|
+
XCTAssertEqual(list.contentSize.width, settledSize.width, accuracy: 1)
|
|
1862
|
+
XCTAssertEqual(list.contentSize.height, settledSize.height, accuracy: 1)
|
|
1863
|
+
XCTAssertEqual(settlements.count, 1)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
func testDisclosureAckLatest() throws {
|
|
1867
|
+
try requireStructuralAnimation()
|
|
1868
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1869
|
+
let controller = UIViewController()
|
|
1870
|
+
window.rootViewController = controller
|
|
1871
|
+
let view = NativeListView(frame: window.bounds)
|
|
1872
|
+
controller.view.addSubview(view)
|
|
1873
|
+
window.isHidden = false
|
|
1874
|
+
defer {
|
|
1875
|
+
view.dispose()
|
|
1876
|
+
view.removeFromSuperview()
|
|
1877
|
+
window.isHidden = true
|
|
1878
|
+
window.rootViewController = nil
|
|
1879
|
+
}
|
|
1880
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
1881
|
+
for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
|
|
1882
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
1883
|
+
var settlements: [(String, String, Bool)] = []
|
|
1884
|
+
view.onStructuralTransitionSettled = { payload in
|
|
1885
|
+
guard let data = payload.data(using: .utf8),
|
|
1886
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1887
|
+
let id = object["transitionId"] as? String,
|
|
1888
|
+
let status = object["status"] as? String,
|
|
1889
|
+
let expanded = object["settledExpanded"] as? Bool else { return }
|
|
1890
|
+
settlements.append((id, status, expanded))
|
|
1891
|
+
}
|
|
1892
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
1893
|
+
disclosureExpanded: false))
|
|
1894
|
+
view.layoutIfNeeded()
|
|
1895
|
+
drainMainQueue()
|
|
1896
|
+
XCTAssertTrue(settlements.isEmpty)
|
|
1897
|
+
|
|
1898
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
1899
|
+
transitionID: "same-rows", durationMs: 400, disclosureExpanded: false))
|
|
1900
|
+
drainMainQueue()
|
|
1901
|
+
XCTAssertEqual(settlements.map(\.0), ["same-rows"])
|
|
1902
|
+
XCTAssertEqual(settlements.first?.1, "completed")
|
|
1903
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
|
|
1904
|
+
transitionID: "same-rows", durationMs: 400, disclosureExpanded: false))
|
|
1905
|
+
drainMainQueue()
|
|
1906
|
+
XCTAssertEqual(settlements.count, 1)
|
|
1907
|
+
|
|
1908
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 4,
|
|
1909
|
+
transitionID: "expand-superseded", durationMs: 400,
|
|
1910
|
+
disclosureExpanded: true))
|
|
1911
|
+
waitForStructuralFrames(0.08)
|
|
1912
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 5,
|
|
1913
|
+
transitionID: "collapse-latest", durationMs: 400,
|
|
1914
|
+
disclosureExpanded: false))
|
|
1915
|
+
waitForStructuralFrames(0.5)
|
|
1916
|
+
XCTAssertEqual(settlements.map(\.0), ["same-rows", "collapse-latest"])
|
|
1917
|
+
XCTAssertEqual(settlements.last?.1, "completed")
|
|
1918
|
+
XCTAssertEqual(settlements.last?.2, false)
|
|
1919
|
+
XCTAssertTrue(slots[4].superview?.isHidden ?? false)
|
|
1920
|
+
|
|
1921
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 0, generation: 6,
|
|
1922
|
+
transitionID: "empty-immediate", durationMs: 0,
|
|
1923
|
+
disclosureExpanded: false))
|
|
1924
|
+
drainMainQueue()
|
|
1925
|
+
XCTAssertEqual(settlements.map(\.0),
|
|
1926
|
+
["same-rows", "collapse-latest", "empty-immediate"])
|
|
1927
|
+
XCTAssertEqual(settlements.last?.1, "completed")
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
func testDisclosureGeometryFallbackUsesLatestSnapshot() throws {
|
|
1931
|
+
try requireStructuralAnimation()
|
|
1932
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1933
|
+
let controller = UIViewController()
|
|
1934
|
+
window.rootViewController = controller
|
|
1935
|
+
let view = NativeListView(frame: window.bounds)
|
|
1936
|
+
controller.view.addSubview(view)
|
|
1937
|
+
window.isHidden = false
|
|
1938
|
+
defer {
|
|
1939
|
+
view.dispose()
|
|
1940
|
+
view.removeFromSuperview()
|
|
1941
|
+
window.isHidden = true
|
|
1942
|
+
window.rootViewController = nil
|
|
1943
|
+
}
|
|
1944
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
1945
|
+
for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
|
|
1946
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
1947
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
1948
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
1949
|
+
var settlements: [(String, String)] = []
|
|
1950
|
+
view.onStructuralTransitionSettled = { payload in
|
|
1951
|
+
guard let data = payload.data(using: .utf8),
|
|
1952
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1953
|
+
let id = object["transitionId"] as? String,
|
|
1954
|
+
let status = object["status"] as? String else { return }
|
|
1955
|
+
settlements.append((id, status))
|
|
1956
|
+
}
|
|
1957
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
1958
|
+
disclosureExpanded: false))
|
|
1959
|
+
view.layoutIfNeeded()
|
|
1960
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
1961
|
+
transitionID: "expand", durationMs: 800, disclosureExpanded: true))
|
|
1962
|
+
waitForStructuralFrames(0.08)
|
|
1963
|
+
XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
1964
|
+
|
|
1965
|
+
view.setContainerSlotHeightsJson("[0,0,160,0,100,40]", followedBySnapshot: true)
|
|
1966
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
|
|
1967
|
+
transitionID: "expand", durationMs: 800, disclosureExpanded: true))
|
|
1968
|
+
drainMainQueue()
|
|
1969
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
1970
|
+
XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
|
|
1971
|
+
let contentHost = try XCTUnwrap(slots[4].superview)
|
|
1972
|
+
let toggleHost = try XCTUnwrap(slots[5].superview)
|
|
1973
|
+
let footerHost = try XCTUnwrap(slots[2].superview)
|
|
1974
|
+
XCTAssertEqual(contentHost.bounds.height, 100, accuracy: 1)
|
|
1975
|
+
XCTAssertEqual(toggleHost.frame.minY, 3_100, accuracy: 1)
|
|
1976
|
+
XCTAssertEqual(footerHost.frame.minY, 3_160, accuracy: 1)
|
|
1977
|
+
XCTAssertEqual(footerHost.bounds.height, 160, accuracy: 1)
|
|
1978
|
+
XCTAssertEqual(settlements.map(\.0), ["expand"])
|
|
1979
|
+
XCTAssertEqual(settlements.last?.1, "interrupted")
|
|
1980
|
+
|
|
1981
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 4,
|
|
1982
|
+
transitionID: "collapse", durationMs: 800, disclosureExpanded: false))
|
|
1983
|
+
waitForStructuralFrames(0.08)
|
|
1984
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 5,
|
|
1985
|
+
transitionID: "collapse", durationMs: 800))
|
|
1986
|
+
drainMainQueue()
|
|
1987
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
1988
|
+
XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
|
|
1989
|
+
XCTAssertEqual(settlements.map(\.0), ["expand"],
|
|
1990
|
+
"Removing disclosure without a new command cannot acknowledge the old target")
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
func testDisclosureLateCompletionCannotSettleNewCommand() throws {
|
|
1994
|
+
try requireStructuralAnimation()
|
|
1995
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
1996
|
+
let controller = UIViewController()
|
|
1997
|
+
window.rootViewController = controller
|
|
1998
|
+
let view = NativeListView(frame: window.bounds)
|
|
1999
|
+
controller.view.addSubview(view)
|
|
2000
|
+
window.isHidden = false
|
|
2001
|
+
defer {
|
|
2002
|
+
view.dispose()
|
|
2003
|
+
view.removeFromSuperview()
|
|
2004
|
+
window.isHidden = true
|
|
2005
|
+
window.rootViewController = nil
|
|
2006
|
+
}
|
|
2007
|
+
for index in 0..<6 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2008
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
2009
|
+
var settled: [String] = []
|
|
2010
|
+
view.onStructuralTransitionSettled = { payload in
|
|
2011
|
+
guard let data = payload.data(using: .utf8),
|
|
2012
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
2013
|
+
let id = object["transitionId"] as? String else { return }
|
|
2014
|
+
settled.append(id)
|
|
2015
|
+
}
|
|
2016
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
2017
|
+
disclosureExpanded: false))
|
|
2018
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2019
|
+
transitionID: "old-direct", disclosureExpanded: false))
|
|
2020
|
+
let oldCompletedBeforeReplacement = settled.contains("old-direct")
|
|
2021
|
+
XCTAssertFalse(oldCompletedBeforeReplacement,
|
|
2022
|
+
"The older diffable completion must remain pending when its replacement starts")
|
|
2023
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
|
|
2024
|
+
transitionID: "latest-expand", durationMs: 800, disclosureExpanded: true))
|
|
2025
|
+
drainMainQueue()
|
|
2026
|
+
XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2027
|
+
XCTAssertTrue(settled.isEmpty,
|
|
2028
|
+
"An older diffable completion must not settle the active newer transition")
|
|
2029
|
+
waitForStructuralFrames(0.9)
|
|
2030
|
+
XCTAssertEqual(settled, ["latest-expand"])
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
func testDisclosureContentGrowthAndDragSettlement() throws {
|
|
2034
|
+
try requireStructuralAnimation()
|
|
2035
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2036
|
+
let controller = UIViewController()
|
|
2037
|
+
window.rootViewController = controller
|
|
2038
|
+
let view = NativeListView(frame: window.bounds)
|
|
2039
|
+
controller.view.addSubview(view)
|
|
2040
|
+
window.isHidden = false
|
|
2041
|
+
defer {
|
|
2042
|
+
view.dispose()
|
|
2043
|
+
view.removeFromSuperview()
|
|
2044
|
+
window.isHidden = true
|
|
2045
|
+
window.rootViewController = nil
|
|
2046
|
+
}
|
|
2047
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
2048
|
+
for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
|
|
2049
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,100,40]")
|
|
2050
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2051
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2052
|
+
let contentHost = try XCTUnwrap(slots[4].superview)
|
|
2053
|
+
var settlements: [(String, String)] = []
|
|
2054
|
+
let growSettled = expectation(description: "Disclosure growth settles")
|
|
2055
|
+
view.onStructuralTransitionSettled = { payload in
|
|
2056
|
+
guard let data = payload.data(using: .utf8),
|
|
2057
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
2058
|
+
let id = object["transitionId"] as? String,
|
|
2059
|
+
let status = object["status"] as? String else { return }
|
|
2060
|
+
settlements.append((id, status))
|
|
2061
|
+
if id == "grow" && status == "completed" { growSettled.fulfill() }
|
|
2062
|
+
}
|
|
2063
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1,
|
|
2064
|
+
disclosureExpanded: false))
|
|
2065
|
+
view.layoutIfNeeded()
|
|
2066
|
+
list.layoutIfNeeded()
|
|
2067
|
+
drainMainQueue()
|
|
2068
|
+
XCTAssertTrue(contentHost.isHidden, "Initial collapsed content must be hidden")
|
|
2069
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2070
|
+
|
|
2071
|
+
let growthPhase = expectation(description: "Disclosure growth presents a partial content mask")
|
|
2072
|
+
var growthObserved = false
|
|
2073
|
+
var growthLast = "no frame sampled"
|
|
2074
|
+
let growthStartedAt = CACurrentMediaTime()
|
|
2075
|
+
let growthProbe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
|
|
2076
|
+
let clock = Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
|
|
2077
|
+
let tail = Mirror(reflecting: view).descendant("structuralTail")
|
|
2078
|
+
let tailActive = tail.map { !Mirror(reflecting: $0).children.isEmpty } ?? false
|
|
2079
|
+
let maskHeight = contentHost.layer.mask?.frame.height
|
|
2080
|
+
growthLast = "clock=\(clock != nil) tail=\(tailActive) "
|
|
2081
|
+
+ "mask=\(String(describing: maskHeight)) bounds=\(contentHost.bounds.height) "
|
|
2082
|
+
+ "elapsed=\(CACurrentMediaTime() - growthStartedAt) settlements=\(settlements)"
|
|
2083
|
+
guard !growthObserved, let clock, tailActive, let maskHeight,
|
|
2084
|
+
maskHeight > 0, maskHeight < 100 else { return }
|
|
2085
|
+
growthObserved = true
|
|
2086
|
+
let animationStart = Mirror(reflecting: view).descendant("structuralAnimationStart")
|
|
2087
|
+
as? CFTimeInterval
|
|
2088
|
+
let animationDuration = Mirror(reflecting: view).descendant("structuralAnimationDuration")
|
|
2089
|
+
as? CFTimeInterval
|
|
2090
|
+
XCTAssertNotNil(animationStart, "The active disclosure clock must have a start time")
|
|
2091
|
+
XCTAssertNotNil(animationDuration, "The active disclosure clock must have a duration")
|
|
2092
|
+
view.setContainerSlotHeightsJson("[0,0,140,0,140,40]")
|
|
2093
|
+
XCTAssertTrue(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
|
|
2094
|
+
=== clock, "Content growth must retain the active display link")
|
|
2095
|
+
XCTAssertEqual(Mirror(reflecting: view).descendant("structuralAnimationStart")
|
|
2096
|
+
as? CFTimeInterval, animationStart, "Content growth must keep the original deadline")
|
|
2097
|
+
XCTAssertEqual(Mirror(reflecting: view).descendant("structuralAnimationDuration")
|
|
2098
|
+
as? CFTimeInterval, animationDuration, "Content growth must keep the original duration")
|
|
2099
|
+
XCTAssertEqual(contentHost.layer.mask?.frame.height ?? -1, maskHeight, accuracy: 1,
|
|
2100
|
+
"Content growth must not jump the visible mask")
|
|
2101
|
+
growthPhase.fulfill()
|
|
2102
|
+
}
|
|
2103
|
+
RunLoop.main.add(growthProbe, forMode: .common)
|
|
2104
|
+
defer { growthProbe.invalidate() }
|
|
2105
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
2106
|
+
transitionID: "grow", durationMs: 800, disclosureExpanded: true))
|
|
2107
|
+
wait(for: [growthPhase], timeout: 1.5)
|
|
2108
|
+
guard growthObserved else {
|
|
2109
|
+
XCTFail("No partial disclosure growth frame observed: \(growthLast)")
|
|
2110
|
+
return
|
|
2111
|
+
}
|
|
2112
|
+
growthProbe.invalidate()
|
|
2113
|
+
wait(for: [growSettled], timeout: 1.5)
|
|
2114
|
+
XCTAssertEqual(contentHost.bounds.height, 140, accuracy: 1)
|
|
2115
|
+
XCTAssertNil(contentHost.layer.mask)
|
|
2116
|
+
XCTAssertTrue(contentHost.isUserInteractionEnabled)
|
|
2117
|
+
XCTAssertEqual(settlements.map(\.0), ["grow"], "Growth must settle exactly once")
|
|
2118
|
+
XCTAssertEqual(settlements.last?.1, "completed")
|
|
2119
|
+
|
|
2120
|
+
let collapsePhase = expectation(description: "Disclosure collapse presents a partial content mask")
|
|
2121
|
+
var collapseObserved = false
|
|
2122
|
+
var collapseLast = "no frame sampled"
|
|
2123
|
+
let collapseStartedAt = CACurrentMediaTime()
|
|
2124
|
+
let collapseProbe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
|
|
2125
|
+
let clock = Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink
|
|
2126
|
+
let tail = Mirror(reflecting: view).descendant("structuralTail")
|
|
2127
|
+
let tailActive = tail.map { !Mirror(reflecting: $0).children.isEmpty } ?? false
|
|
2128
|
+
let maskHeight = contentHost.layer.mask?.frame.height
|
|
2129
|
+
collapseLast = "clock=\(clock != nil) tail=\(tailActive) "
|
|
2130
|
+
+ "mask=\(String(describing: maskHeight)) bounds=\(contentHost.bounds.height) "
|
|
2131
|
+
+ "elapsed=\(CACurrentMediaTime() - collapseStartedAt) settlements=\(settlements)"
|
|
2132
|
+
guard !collapseObserved, clock != nil, tailActive, let maskHeight,
|
|
2133
|
+
maskHeight > 0, maskHeight < 140 else { return }
|
|
2134
|
+
collapseObserved = true
|
|
2135
|
+
view.scrollViewWillBeginDragging(list)
|
|
2136
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink,
|
|
2137
|
+
"Dragging must stop the active disclosure display link")
|
|
2138
|
+
let remainingTail = Mirror(reflecting: view).descendant("structuralTail")
|
|
2139
|
+
XCTAssertNotNil(remainingTail, "The structural tail field must remain observable")
|
|
2140
|
+
XCTAssertFalse(remainingTail.map { !Mirror(reflecting: $0).children.isEmpty } ?? true,
|
|
2141
|
+
"Dragging must clear the temporary structural tail")
|
|
2142
|
+
XCTAssertTrue(contentHost.isHidden, "Dragging a collapse must hide content")
|
|
2143
|
+
XCTAssertEqual(settlements.map(\.0), ["grow", "drag"],
|
|
2144
|
+
"Dragging must settle the collapse exactly once")
|
|
2145
|
+
XCTAssertEqual(settlements.last?.1, "interrupted")
|
|
2146
|
+
collapsePhase.fulfill()
|
|
2147
|
+
}
|
|
2148
|
+
RunLoop.main.add(collapseProbe, forMode: .common)
|
|
2149
|
+
defer { collapseProbe.invalidate() }
|
|
2150
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
|
|
2151
|
+
transitionID: "drag", durationMs: 800, disclosureExpanded: false))
|
|
2152
|
+
wait(for: [collapsePhase], timeout: 1.5)
|
|
2153
|
+
guard collapseObserved else {
|
|
2154
|
+
XCTFail("No partial disclosure collapse frame observed: \(collapseLast)")
|
|
2155
|
+
return
|
|
2156
|
+
}
|
|
2157
|
+
collapseProbe.invalidate()
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
func testDisclosureDirectCollapseKeepsToggleVisible() throws {
|
|
2161
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 600))
|
|
2162
|
+
let controller = UIViewController()
|
|
2163
|
+
window.rootViewController = controller
|
|
2164
|
+
let view = NativeListView(frame: window.bounds)
|
|
2165
|
+
controller.view.addSubview(view)
|
|
2166
|
+
window.isHidden = false
|
|
2167
|
+
defer {
|
|
2168
|
+
view.dispose()
|
|
2169
|
+
view.removeFromSuperview()
|
|
2170
|
+
window.isHidden = true
|
|
2171
|
+
window.rootViewController = nil
|
|
2172
|
+
}
|
|
2173
|
+
let slots = (0..<6).map { _ in UIView() }
|
|
2174
|
+
for (index, slot) in slots.enumerated() { view.mountContainerSlot(slot, atIndex: index) }
|
|
2175
|
+
view.setSidecarLayoutJson("{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}")
|
|
2176
|
+
view.setContainerSlotHeightsJson("[0,0,900,2200,100,40]")
|
|
2177
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2178
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2179
|
+
list.contentInset = .zero
|
|
2180
|
+
var settlements: [[String: Any]] = []
|
|
2181
|
+
view.onStructuralTransitionSettled = { payload in
|
|
2182
|
+
guard let data = payload.data(using: .utf8),
|
|
2183
|
+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
2184
|
+
return
|
|
2185
|
+
}
|
|
2186
|
+
settlements.append(object)
|
|
2187
|
+
}
|
|
2188
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1,
|
|
2189
|
+
disclosureExpanded: true))
|
|
2190
|
+
view.layoutIfNeeded()
|
|
2191
|
+
list.layoutIfNeeded()
|
|
2192
|
+
list.contentOffset.y = 2_600
|
|
2193
|
+
list.layoutIfNeeded()
|
|
2194
|
+
XCTAssertEqual(list.contentOffset.y, 2_600, accuracy: 1)
|
|
2195
|
+
|
|
2196
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2197
|
+
transitionID: "direct-collapse", durationMs: 0, disclosureExpanded: false))
|
|
2198
|
+
drainMainQueue()
|
|
2199
|
+
list.layoutIfNeeded()
|
|
2200
|
+
let toggle = try XCTUnwrap(slots[5].superview)
|
|
2201
|
+
XCTAssertEqual(toggle.frame.minY, 360, accuracy: 1)
|
|
2202
|
+
XCTAssertEqual(list.contentSize.height, 2_200, accuracy: 1)
|
|
2203
|
+
XCTAssertEqual(list.contentOffset.y, 360, accuracy: 1,
|
|
2204
|
+
"A direct collapsed endpoint must expose the stable toggle despite a taller sidecar")
|
|
2205
|
+
XCTAssertEqual(settlements.count, 1)
|
|
2206
|
+
XCTAssertEqual(settlements.first?["transitionId"] as? String, "direct-collapse")
|
|
2207
|
+
XCTAssertEqual(settlements.first?["status"] as? String, "completed")
|
|
2208
|
+
|
|
2209
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 3,
|
|
2210
|
+
disclosureExpanded: true))
|
|
2211
|
+
drainMainQueue()
|
|
2212
|
+
view.frame.size.height = 0
|
|
2213
|
+
view.layoutIfNeeded()
|
|
2214
|
+
XCTAssertEqual(list.bounds.height, 0)
|
|
2215
|
+
view.scrollToOffset(500, animated: false)
|
|
2216
|
+
let pending = try XCTUnwrap(Mirror(reflecting: view).descendant("pendingScrollRequest"))
|
|
2217
|
+
XCTAssertEqual(Mirror(reflecting: pending).children.count, 1,
|
|
2218
|
+
"The explicit command must still be queued when the direct collapse is accepted")
|
|
2219
|
+
view.frame.size.height = 600
|
|
2220
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 4,
|
|
2221
|
+
transitionID: "direct-collapse-after-pending-scroll", durationMs: 0,
|
|
2222
|
+
disclosureExpanded: false))
|
|
2223
|
+
drainMainQueue()
|
|
2224
|
+
view.layoutIfNeeded()
|
|
2225
|
+
list.layoutIfNeeded()
|
|
2226
|
+
drainMainQueue()
|
|
2227
|
+
XCTAssertEqual(list.contentOffset.y, 500, accuracy: 1,
|
|
2228
|
+
"A direct collapse must not replace an already pending explicit scroll")
|
|
2229
|
+
XCTAssertEqual(settlements.count, 2)
|
|
2230
|
+
XCTAssertEqual(settlements.last?["transitionId"] as? String,
|
|
2231
|
+
"direct-collapse-after-pending-scroll")
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
func testStructuralTailKeepsMiddleRowsNaturalAndFooterAttachedDuringCollapse() throws {
|
|
2235
|
+
try requireStructuralAnimation()
|
|
2236
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2237
|
+
let controller = UIViewController()
|
|
2238
|
+
window.rootViewController = controller
|
|
2239
|
+
let view = NativeListView(frame: window.bounds)
|
|
2240
|
+
var retiredActions = 0
|
|
2241
|
+
view.onRowAction = { _ in retiredActions += 1 }
|
|
2242
|
+
controller.view.addSubview(view)
|
|
2243
|
+
window.isHidden = false
|
|
2244
|
+
defer {
|
|
2245
|
+
view.dispose()
|
|
2246
|
+
view.removeFromSuperview()
|
|
2247
|
+
window.isHidden = true
|
|
2248
|
+
window.rootViewController = nil
|
|
2249
|
+
}
|
|
2250
|
+
for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2251
|
+
let footer = UIView()
|
|
2252
|
+
view.mountContainerSlot(footer, atIndex: 2)
|
|
2253
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2254
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2255
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2256
|
+
list.contentInset = .zero
|
|
2257
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
|
|
2258
|
+
view.layoutIfNeeded()
|
|
2259
|
+
list.layoutIfNeeded()
|
|
2260
|
+
drainMainQueue()
|
|
2261
|
+
list.contentOffset.y = 2400
|
|
2262
|
+
list.layoutIfNeeded()
|
|
2263
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2264
|
+
transitionID: "tail-50-to-6", durationMs: 800))
|
|
2265
|
+
waitForStructuralFrames(0.16)
|
|
2266
|
+
list.layoutIfNeeded()
|
|
2267
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2268
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
|
|
2269
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2270
|
+
let clip = try XCTUnwrap(flow.tailClip)
|
|
2271
|
+
XCTAssertEqual(clip.firstIndex, 6)
|
|
2272
|
+
XCTAssertGreaterThan(clip.endY, 360)
|
|
2273
|
+
XCTAssertLessThan(clip.endY, 3000)
|
|
2274
|
+
let tailCells = list.visibleCells.compactMap { cell -> NativeListRowHost? in
|
|
2275
|
+
guard let path = list.indexPath(for: cell), path.item >= 6 else { return nil }
|
|
2276
|
+
return cell as? NativeListRowHost
|
|
2277
|
+
}
|
|
2278
|
+
XCTAssertFalse(tailCells.isEmpty)
|
|
2279
|
+
XCTAssertTrue(tailCells.allSatisfy { abs($0.bounds.height - 60) <= 1 && $0.alpha == 1 })
|
|
2280
|
+
XCTAssertTrue(tailCells.allSatisfy { !$0.isUserInteractionEnabled && $0.accessibilityElementsHidden })
|
|
2281
|
+
XCTAssertFalse(view.collectionView(list, shouldSelectItemAt: IndexPath(item: 25, section: 0)))
|
|
2282
|
+
view.collectionView(list, didSelectItemAt: IndexPath(item: 25, section: 0))
|
|
2283
|
+
XCTAssertEqual(retiredActions, 0)
|
|
2284
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, clip.endY, accuracy: 1)
|
|
2285
|
+
let footerBottomBeforeResize = footer.convert(.zero, to: list).y + 60
|
|
2286
|
+
view.setContainerSlotHeightsJson("[0,0,80,0]")
|
|
2287
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y + 80,
|
|
2288
|
+
footerBottomBeforeResize, accuracy: 1)
|
|
2289
|
+
let footerHost = try XCTUnwrap(footer.superview)
|
|
2290
|
+
let hiddenTop = try XCTUnwrap(flow.tailClip).endY - footerHost.frame.minY
|
|
2291
|
+
if hiddenTop > 1 {
|
|
2292
|
+
XCTAssertFalse(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop / 2), with: nil))
|
|
2293
|
+
XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop + 1), with: nil))
|
|
2294
|
+
}
|
|
2295
|
+
waitForStructuralFrames(0.06)
|
|
2296
|
+
list.layoutIfNeeded()
|
|
2297
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, try XCTUnwrap(flow.tailClip).endY,
|
|
2298
|
+
accuracy: 1)
|
|
2299
|
+
let footerBottomBeforeSecondResize = footer.convert(.zero, to: list).y + 80
|
|
2300
|
+
view.setContainerSlotHeightsJson("[0,0,100,0]")
|
|
2301
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y + 100,
|
|
2302
|
+
footerBottomBeforeSecondResize, accuracy: 1)
|
|
2303
|
+
waitForStructuralFrames(0.7)
|
|
2304
|
+
list.layoutIfNeeded()
|
|
2305
|
+
XCTAssertNil(flow.tailClip)
|
|
2306
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
|
|
2307
|
+
XCTAssertEqual(list.contentSize.height, 460, accuracy: 1)
|
|
2308
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, 360, accuracy: 1)
|
|
2309
|
+
XCTAssertEqual(list.contentOffset.y, 0, accuracy: 1)
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
func testStructuralTailCollapsePaintsEveryExpectedViewportRow() throws {
|
|
2313
|
+
try requireStructuralAnimation()
|
|
2314
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
|
|
2315
|
+
let controller = UIViewController()
|
|
2316
|
+
window.rootViewController = controller
|
|
2317
|
+
let view = NativeListView(frame: window.bounds)
|
|
2318
|
+
controller.view.addSubview(view)
|
|
2319
|
+
window.isHidden = false
|
|
2320
|
+
defer {
|
|
2321
|
+
view.dispose()
|
|
2322
|
+
view.removeFromSuperview()
|
|
2323
|
+
window.isHidden = true
|
|
2324
|
+
window.rootViewController = nil
|
|
2325
|
+
}
|
|
2326
|
+
for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2327
|
+
view.mountContainerSlot(UIView(), atIndex: 2)
|
|
2328
|
+
view.mountContainerSlot(UIView(), atIndex: 3)
|
|
2329
|
+
view.setContainerSlotHeightsJson("[0,0,60,3000]")
|
|
2330
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2331
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2332
|
+
list.contentInset = .zero
|
|
2333
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
|
|
2334
|
+
view.layoutIfNeeded()
|
|
2335
|
+
list.layoutIfNeeded()
|
|
2336
|
+
drainMainQueue()
|
|
2337
|
+
list.contentOffset.y = 2400
|
|
2338
|
+
list.layoutIfNeeded()
|
|
2339
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2340
|
+
var checkedFrames = 0
|
|
2341
|
+
var failures: [String] = []
|
|
2342
|
+
let probe = Timer(timeInterval: 1.0 / 60, repeats: true) { _ in
|
|
2343
|
+
guard let clip = flow.tailClip else { return }
|
|
2344
|
+
list.layoutIfNeeded()
|
|
2345
|
+
let viewportTop = list.contentOffset.y
|
|
2346
|
+
let viewportBottom = viewportTop + list.bounds.height
|
|
2347
|
+
var expectedCount = 0
|
|
2348
|
+
var attributesCount = 0
|
|
2349
|
+
var attachedCount = 0
|
|
2350
|
+
var visibleCellsCount = 0
|
|
2351
|
+
var firstFailure: String?
|
|
2352
|
+
let attachedCells = list.subviews.compactMap { $0 as? NativeListRowHost }
|
|
2353
|
+
let visibleCells = list.visibleCells
|
|
2354
|
+
for index in 6..<50 {
|
|
2355
|
+
let rowTop = CGFloat(index * 60)
|
|
2356
|
+
let rowBottom = rowTop + 60
|
|
2357
|
+
let expectedTop = max(rowTop, viewportTop)
|
|
2358
|
+
let expectedBottom = min(rowBottom, viewportBottom, clip.endY)
|
|
2359
|
+
guard expectedBottom - expectedTop >= 8 else { continue }
|
|
2360
|
+
expectedCount += 1
|
|
2361
|
+
let path = IndexPath(item: index, section: 0)
|
|
2362
|
+
let attributes = flow.layoutAttributesForItem(at: path)
|
|
2363
|
+
if attributes != nil { attributesCount += 1 }
|
|
2364
|
+
let cell = attachedCells.first { list.indexPath(for: $0) == path }
|
|
2365
|
+
if cell != nil { attachedCount += 1 }
|
|
2366
|
+
let inVisibleCells = cell.map { visibleCells.contains($0) } ?? false
|
|
2367
|
+
if inVisibleCells { visibleCellsCount += 1 }
|
|
2368
|
+
let maskEnd = cell?.layer.mask?.frame.maxY ?? 60
|
|
2369
|
+
let neededMaskEnd = expectedBottom - rowTop
|
|
2370
|
+
if cell == nil || cell?.isHidden == true || cell?.alpha != 1
|
|
2371
|
+
|| maskEnd + 1 < neededMaskEnd {
|
|
2372
|
+
if firstFailure == nil {
|
|
2373
|
+
let hidden = cell.map { String($0.isHidden) } ?? "nil"
|
|
2374
|
+
let frame = cell.map { String(describing: $0.frame) } ?? "nil"
|
|
2375
|
+
firstFailure = "row=\(index) offset=\(Int(viewportTop)) clip=\(Int(clip.endY)) "
|
|
2376
|
+
+ "attached=\(cell != nil) visibleCells=\(inVisibleCells) hidden=\(hidden) "
|
|
2377
|
+
+ "frame=\(frame) attributes=\(String(describing: attributes?.frame)) "
|
|
2378
|
+
+ "maskEnd=\(Int(maskEnd)) alpha=\(cell?.alpha ?? -1)"
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
if expectedCount > 0 { checkedFrames += 1 }
|
|
2383
|
+
if let firstFailure, failures.count < 8 {
|
|
2384
|
+
failures.append("expected=\(expectedCount) attrs=\(attributesCount) "
|
|
2385
|
+
+ "attached=\(attachedCount) visibleCells=\(visibleCellsCount) " + firstFailure)
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
RunLoop.main.add(probe, forMode: .common)
|
|
2389
|
+
defer { probe.invalidate() }
|
|
2390
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2391
|
+
transitionID: "tail-viewport-paint", durationMs: 800))
|
|
2392
|
+
waitForStructuralFrames(0.82)
|
|
2393
|
+
XCTAssertGreaterThanOrEqual(checkedFrames, 5)
|
|
2394
|
+
XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n"))
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
func testStructuralTailGeometryChangeWithSameIDCommitsFinalLayout() throws {
|
|
2398
|
+
try requireStructuralAnimation()
|
|
2399
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2400
|
+
let controller = UIViewController()
|
|
2401
|
+
window.rootViewController = controller
|
|
2402
|
+
let view = NativeListView(frame: window.bounds)
|
|
2403
|
+
controller.view.addSubview(view)
|
|
2404
|
+
window.isHidden = false
|
|
2405
|
+
defer {
|
|
2406
|
+
view.dispose()
|
|
2407
|
+
view.removeFromSuperview()
|
|
2408
|
+
window.isHidden = true
|
|
2409
|
+
window.rootViewController = nil
|
|
2410
|
+
}
|
|
2411
|
+
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2412
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2413
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2414
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2415
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
|
|
2416
|
+
view.layoutIfNeeded()
|
|
2417
|
+
list.layoutIfNeeded()
|
|
2418
|
+
drainMainQueue()
|
|
2419
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2420
|
+
transitionID: "same-id-geometry", durationMs: 400))
|
|
2421
|
+
waitForStructuralFrames(0.08)
|
|
2422
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
|
|
2423
|
+
transitionID: "same-id-geometry", durationMs: 400, firstRowHeight: 70))
|
|
2424
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2425
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2426
|
+
list.layoutIfNeeded()
|
|
2427
|
+
XCTAssertNil(flow.tailClip)
|
|
2428
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
|
|
2429
|
+
XCTAssertEqual(list.contentSize.height, 430, accuracy: 1)
|
|
2430
|
+
waitForStructuralFrames(0.42)
|
|
2431
|
+
XCTAssertEqual(list.contentSize.height, 430, accuracy: 1)
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
func testStructuralTailSameIDOrientationChangeStopsVerticalTimeline() throws {
|
|
2435
|
+
try requireStructuralAnimation()
|
|
2436
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2437
|
+
let controller = UIViewController()
|
|
2438
|
+
window.rootViewController = controller
|
|
2439
|
+
let view = NativeListView(frame: window.bounds)
|
|
2440
|
+
controller.view.addSubview(view)
|
|
2441
|
+
window.isHidden = false
|
|
2442
|
+
defer {
|
|
2443
|
+
view.dispose()
|
|
2444
|
+
view.removeFromSuperview()
|
|
2445
|
+
window.isHidden = true
|
|
2446
|
+
window.rootViewController = nil
|
|
2447
|
+
}
|
|
2448
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 1))
|
|
2449
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2450
|
+
view.layoutIfNeeded()
|
|
2451
|
+
list.layoutIfNeeded()
|
|
2452
|
+
drainMainQueue()
|
|
2453
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2454
|
+
transitionID: "same-id-orientation", durationMs: 400))
|
|
2455
|
+
waitForStructuralFrames(0.08)
|
|
2456
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 3,
|
|
2457
|
+
transitionID: "same-id-orientation", durationMs: 400, orientation: "horizontal"))
|
|
2458
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2459
|
+
XCTAssertNil(flow.tailClip)
|
|
2460
|
+
XCTAssertEqual(flow.scrollDirection, .horizontal)
|
|
2461
|
+
waitForStructuralFrames(0.42)
|
|
2462
|
+
XCTAssertEqual(flow.scrollDirection, .horizontal)
|
|
2463
|
+
XCTAssertNil(flow.tailClip)
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
func testStructuralTailHostLimitFallsBackToAtomicSnapshot() throws {
|
|
2467
|
+
try requireStructuralAnimation()
|
|
2468
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2469
|
+
let controller = UIViewController()
|
|
2470
|
+
window.rootViewController = controller
|
|
2471
|
+
let view = NativeListView(frame: window.bounds)
|
|
2472
|
+
controller.view.addSubview(view)
|
|
2473
|
+
window.isHidden = false
|
|
2474
|
+
defer {
|
|
2475
|
+
view.dispose()
|
|
2476
|
+
view.removeFromSuperview()
|
|
2477
|
+
window.isHidden = true
|
|
2478
|
+
window.rootViewController = nil
|
|
2479
|
+
}
|
|
2480
|
+
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2481
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2482
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2483
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2484
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 1000, generation: 1,
|
|
2485
|
+
uniformHeight: 1))
|
|
2486
|
+
view.layoutIfNeeded()
|
|
2487
|
+
list.layoutIfNeeded()
|
|
2488
|
+
drainMainQueue()
|
|
2489
|
+
var phases: [Int] = []
|
|
2490
|
+
let observer = NotificationCenter.default.addObserver(
|
|
2491
|
+
forName: Notification.Name("OneKeyNativeListScrollCommandAccepted"),
|
|
2492
|
+
object: list, queue: nil
|
|
2493
|
+
) { notification in
|
|
2494
|
+
if let phase = notification.userInfo?["phase"] as? Int { phases.append(phase) }
|
|
2495
|
+
}
|
|
2496
|
+
defer { NotificationCenter.default.removeObserver(observer) }
|
|
2497
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 2,
|
|
2498
|
+
transitionID: "tail-host-limit", durationMs: 400, uniformHeight: 1))
|
|
2499
|
+
list.layoutIfNeeded()
|
|
2500
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2501
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2502
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<6).map { "row-\($0)" })
|
|
2503
|
+
XCTAssertNil(flow.tailClip)
|
|
2504
|
+
XCTAssertTrue(phases.isEmpty)
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
func testStructuralTailExpansionKeepsOffsetWithTallerInitialSidecar() throws {
|
|
2508
|
+
try requireStructuralAnimation()
|
|
2509
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
|
|
2510
|
+
let controller = UIViewController()
|
|
2511
|
+
window.rootViewController = controller
|
|
2512
|
+
let view = NativeListView(frame: window.bounds)
|
|
2513
|
+
controller.view.addSubview(view)
|
|
2514
|
+
window.isHidden = false
|
|
2515
|
+
defer {
|
|
2516
|
+
view.dispose()
|
|
2517
|
+
view.removeFromSuperview()
|
|
2518
|
+
window.isHidden = true
|
|
2519
|
+
window.rootViewController = nil
|
|
2520
|
+
}
|
|
2521
|
+
for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2522
|
+
view.setContainerSlotHeightsJson("[0,0,60,2200]")
|
|
2523
|
+
view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
|
|
2524
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2525
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2526
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
|
|
2527
|
+
view.layoutIfNeeded()
|
|
2528
|
+
list.layoutIfNeeded()
|
|
2529
|
+
drainMainQueue()
|
|
2530
|
+
XCTAssertEqual(list.contentSize.height, 2200, accuracy: 1)
|
|
2531
|
+
list.contentOffset.y = 1000
|
|
2532
|
+
list.layoutIfNeeded()
|
|
2533
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
2534
|
+
transitionID: "tail-6-to-50", durationMs: 400))
|
|
2535
|
+
waitForStructuralFrames(0.16)
|
|
2536
|
+
list.layoutIfNeeded()
|
|
2537
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2538
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2539
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
|
|
2540
|
+
XCTAssertGreaterThan(try XCTUnwrap(flow.tailClip).endY, 360)
|
|
2541
|
+
XCTAssertLessThan(try XCTUnwrap(flow.tailClip).endY, 3000)
|
|
2542
|
+
let suffixCells = list.visibleCells.filter {
|
|
2543
|
+
(list.indexPath(for: $0)?.item ?? -1) >= 6
|
|
2544
|
+
}
|
|
2545
|
+
XCTAssertFalse(suffixCells.isEmpty)
|
|
2546
|
+
XCTAssertTrue(suffixCells.allSatisfy {
|
|
2547
|
+
!$0.isUserInteractionEnabled && $0.accessibilityElementsHidden
|
|
2548
|
+
})
|
|
2549
|
+
XCTAssertEqual(list.contentOffset.y, 1000, accuracy: 1)
|
|
2550
|
+
waitForStructuralFrames(0.3)
|
|
2551
|
+
list.layoutIfNeeded()
|
|
2552
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
|
|
2553
|
+
XCTAssertEqual(list.contentSize.height, 3060, accuracy: 1)
|
|
2554
|
+
XCTAssertEqual(list.contentOffset.y, 1000, accuracy: 1)
|
|
2555
|
+
XCTAssertNil(flow.tailClip)
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
func testStructuralTailDifferentPrefixDuringExpansionCommitsAtomicSnapshot() throws {
|
|
2559
|
+
try requireStructuralAnimation()
|
|
2560
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
|
|
2561
|
+
let controller = UIViewController()
|
|
2562
|
+
window.rootViewController = controller
|
|
2563
|
+
let view = NativeListView(frame: window.bounds)
|
|
2564
|
+
controller.view.addSubview(view)
|
|
2565
|
+
window.isHidden = false
|
|
2566
|
+
defer {
|
|
2567
|
+
view.dispose()
|
|
2568
|
+
view.removeFromSuperview()
|
|
2569
|
+
window.isHidden = true
|
|
2570
|
+
window.rootViewController = nil
|
|
2571
|
+
}
|
|
2572
|
+
for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2573
|
+
view.setContainerSlotHeightsJson("[0,0,60,1600]")
|
|
2574
|
+
view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
|
|
2575
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2576
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2577
|
+
list.contentInset = .zero
|
|
2578
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
|
|
2579
|
+
view.layoutIfNeeded()
|
|
2580
|
+
list.layoutIfNeeded()
|
|
2581
|
+
drainMainQueue()
|
|
2582
|
+
XCTAssertEqual(list.adjustedContentInset.top, 0, accuracy: 0.01)
|
|
2583
|
+
XCTAssertEqual(list.adjustedContentInset.bottom, 0, accuracy: 0.01)
|
|
2584
|
+
list.contentOffset.y = 900
|
|
2585
|
+
list.layoutIfNeeded()
|
|
2586
|
+
|
|
2587
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
2588
|
+
transitionID: "tail-first-six", durationMs: 1200))
|
|
2589
|
+
waitForStructuralFrames(0.12)
|
|
2590
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2591
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2592
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
|
|
2593
|
+
XCTAssertGreaterThan(try XCTUnwrap(flow.tailClip).endY, 360)
|
|
2594
|
+
|
|
2595
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 10, generation: 3,
|
|
2596
|
+
transitionID: "tail-next-ten", durationMs: 1200))
|
|
2597
|
+
list.layoutIfNeeded()
|
|
2598
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<10).map { "row-\($0)" })
|
|
2599
|
+
XCTAssertNil(flow.tailClip)
|
|
2600
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2601
|
+
XCTAssertEqual(Mirror(reflecting: view).descendant("structuralSpacerHeight") as? CGFloat ?? -1,
|
|
2602
|
+
0, accuracy: 1)
|
|
2603
|
+
XCTAssertEqual(list.contentSize.height, 1600, accuracy: 1)
|
|
2604
|
+
XCTAssertEqual(list.contentOffset.y, 900, accuracy: 1)
|
|
2605
|
+
waitForStructuralFrames(1.25)
|
|
2606
|
+
list.layoutIfNeeded()
|
|
2607
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<10).map { "row-\($0)" })
|
|
2608
|
+
XCTAssertEqual(list.contentSize.height, 1600, accuracy: 1)
|
|
2609
|
+
XCTAssertEqual(list.contentOffset.y, 900, accuracy: 1)
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
func testStructuralTailInterruptedExpansionRestoresBoundaryCellInteraction() throws {
|
|
2613
|
+
try requireStructuralAnimation()
|
|
2614
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1000, height: 600))
|
|
2615
|
+
let controller = UIViewController()
|
|
2616
|
+
window.rootViewController = controller
|
|
2617
|
+
let view = NativeListView(frame: window.bounds)
|
|
2618
|
+
controller.view.addSubview(view)
|
|
2619
|
+
window.isHidden = false
|
|
2620
|
+
defer {
|
|
2621
|
+
view.dispose()
|
|
2622
|
+
view.removeFromSuperview()
|
|
2623
|
+
window.isHidden = true
|
|
2624
|
+
window.rootViewController = nil
|
|
2625
|
+
}
|
|
2626
|
+
for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2627
|
+
let footer = UIView()
|
|
2628
|
+
view.mountContainerSlot(footer, atIndex: 2)
|
|
2629
|
+
view.mountContainerSlot(UIView(), atIndex: 3)
|
|
2630
|
+
view.setContainerSlotHeightsJson("[0,0,60,2200]")
|
|
2631
|
+
view.setSidecarLayoutJson("{\"width\":300,\"gap\":24,\"maxContentWidth\":1000}")
|
|
2632
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2633
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2634
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
|
|
2635
|
+
view.layoutIfNeeded()
|
|
2636
|
+
list.layoutIfNeeded()
|
|
2637
|
+
drainMainQueue()
|
|
2638
|
+
list.contentOffset.y = 300
|
|
2639
|
+
list.layoutIfNeeded()
|
|
2640
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
2641
|
+
transitionID: "interrupt-boundary", durationMs: 1200))
|
|
2642
|
+
let footerBottomBeforeResize = footer.convert(.zero, to: list).y + 60
|
|
2643
|
+
view.setContainerSlotHeightsJson("[0,0,80,2200]")
|
|
2644
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y + 80,
|
|
2645
|
+
footerBottomBeforeResize, accuracy: 1)
|
|
2646
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2647
|
+
let prefixBottom = try XCTUnwrap(flow.layoutAttributesForItem(
|
|
2648
|
+
at: IndexPath(item: 5, section: 0))).frame.maxY
|
|
2649
|
+
let footerHost = try XCTUnwrap(footer.superview)
|
|
2650
|
+
let hiddenTop = prefixBottom - footerHost.frame.minY
|
|
2651
|
+
XCTAssertGreaterThan(hiddenTop, 0)
|
|
2652
|
+
XCTAssertFalse(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop / 2), with: nil))
|
|
2653
|
+
XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: hiddenTop + 1), with: nil))
|
|
2654
|
+
var boundaryCell: NativeListRowHost?
|
|
2655
|
+
let deadline = Date().addingTimeInterval(1)
|
|
2656
|
+
while boundaryCell == nil && Date() < deadline {
|
|
2657
|
+
if let clip = flow.tailClip {
|
|
2658
|
+
for case let cell as NativeListRowHost in list.visibleCells {
|
|
2659
|
+
guard let path = list.indexPath(for: cell), path.item >= 6,
|
|
2660
|
+
let mask = cell.layer.mask else { continue }
|
|
2661
|
+
let visibleHeight = clip.endY - cell.frame.minY
|
|
2662
|
+
if clip.endY > cell.frame.minY + 2 &&
|
|
2663
|
+
clip.endY < cell.frame.maxY - 2 &&
|
|
2664
|
+
mask.frame.height > 2 &&
|
|
2665
|
+
mask.frame.height < cell.bounds.height - 2 &&
|
|
2666
|
+
abs(mask.frame.height - visibleHeight) <= 1 {
|
|
2667
|
+
boundaryCell = cell
|
|
2668
|
+
break
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
if boundaryCell == nil { RunLoop.main.run(until: Date().addingTimeInterval(0.01)) }
|
|
2673
|
+
}
|
|
2674
|
+
let boundary = try XCTUnwrap(boundaryCell)
|
|
2675
|
+
let displayLink = try XCTUnwrap(
|
|
2676
|
+
Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2677
|
+
displayLink.isPaused = true
|
|
2678
|
+
defer { displayLink.isPaused = false }
|
|
2679
|
+
let mask = try XCTUnwrap(boundary.layer.mask)
|
|
2680
|
+
XCTAssertGreaterThan(mask.frame.height, 2)
|
|
2681
|
+
XCTAssertLessThan(mask.frame.height, boundary.bounds.height - 2)
|
|
2682
|
+
XCTAssertFalse(boundary.isUserInteractionEnabled)
|
|
2683
|
+
XCTAssertTrue(boundary.accessibilityElementsHidden)
|
|
2684
|
+
let offsetBeforeInterruption = list.contentOffset.y
|
|
2685
|
+
view.scrollViewWillBeginDragging(list)
|
|
2686
|
+
list.layoutIfNeeded()
|
|
2687
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2688
|
+
XCTAssertNil(boundary.layer.mask)
|
|
2689
|
+
XCTAssertFalse(boundary.isHidden)
|
|
2690
|
+
XCTAssertTrue(boundary.isUserInteractionEnabled)
|
|
2691
|
+
XCTAssertFalse(boundary.accessibilityElementsHidden)
|
|
2692
|
+
XCTAssertTrue(footerHost.point(inside: CGPoint(x: 10, y: 1), with: nil))
|
|
2693
|
+
XCTAssertEqual(list.contentOffset.y, offsetBeforeInterruption, accuracy: 1)
|
|
2694
|
+
XCTAssertEqual(list.contentOffset.y, 300, accuracy: 1)
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
func testStructuralTailExplicitScrollTakesOwnershipFromExpansion() throws {
|
|
2698
|
+
try requireStructuralAnimation()
|
|
2699
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2700
|
+
let controller = UIViewController()
|
|
2701
|
+
window.rootViewController = controller
|
|
2702
|
+
let view = NativeListView(frame: window.bounds)
|
|
2703
|
+
controller.view.addSubview(view)
|
|
2704
|
+
window.isHidden = false
|
|
2705
|
+
defer {
|
|
2706
|
+
view.dispose()
|
|
2707
|
+
view.removeFromSuperview()
|
|
2708
|
+
window.isHidden = true
|
|
2709
|
+
window.rootViewController = nil
|
|
2710
|
+
}
|
|
2711
|
+
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2712
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2713
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2714
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
2715
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 6, generation: 1))
|
|
2716
|
+
view.layoutIfNeeded()
|
|
2717
|
+
list.layoutIfNeeded()
|
|
2718
|
+
drainMainQueue()
|
|
2719
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 50, generation: 2,
|
|
2720
|
+
transitionID: "explicit-scroll-over-expansion", durationMs: 400))
|
|
2721
|
+
waitForStructuralFrames(0.08)
|
|
2722
|
+
view.scrollToIndex(40, animated: false, alignment: "start")
|
|
2723
|
+
drainMainQueue()
|
|
2724
|
+
list.layoutIfNeeded()
|
|
2725
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2726
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<50).map { "row-\($0)" })
|
|
2727
|
+
XCTAssertEqual(list.contentOffset.y, 2400, accuracy: 1)
|
|
2728
|
+
waitForStructuralFrames(0.4)
|
|
2729
|
+
XCTAssertEqual(list.contentOffset.y, 2400, accuracy: 1)
|
|
2730
|
+
XCTAssertTrue(list.visibleCells.allSatisfy {
|
|
2731
|
+
$0.isUserInteractionEnabled && !$0.accessibilityElementsHidden && $0.layer.mask == nil
|
|
2732
|
+
})
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
func testStructuralTransitionSurvivesVisibleDataRowPatch() throws {
|
|
2736
|
+
try requireStructuralAnimation()
|
|
2737
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2738
|
+
let controller = UIViewController()
|
|
2739
|
+
window.rootViewController = controller
|
|
2740
|
+
let view = NativeListView(frame: window.bounds)
|
|
2741
|
+
controller.view.addSubview(view)
|
|
2742
|
+
window.isHidden = false
|
|
2743
|
+
defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
|
|
2744
|
+
let footer = UIView()
|
|
2745
|
+
for index in 0..<2 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2746
|
+
view.mountContainerSlot(footer, atIndex: 2)
|
|
2747
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2748
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1, dataRows: true))
|
|
2749
|
+
view.layoutIfNeeded()
|
|
2750
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2751
|
+
list.layoutIfNeeded()
|
|
2752
|
+
drainMainQueue()
|
|
2753
|
+
list.contentOffset.y = 1100
|
|
2754
|
+
list.layoutIfNeeded()
|
|
2755
|
+
|
|
2756
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
|
|
2757
|
+
transitionID: "collapse-with-patch", durationMs: 800, dataRows: true))
|
|
2758
|
+
waitForStructuralFrames(0.3)
|
|
2759
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2760
|
+
let flow = try XCTUnwrap(list.collectionViewLayout as? NativeListFlowLayout)
|
|
2761
|
+
let heightBeforePatch = try XCTUnwrap(flow.tailClip).endY
|
|
2762
|
+
XCTAssertGreaterThan(heightBeforePatch, 1500)
|
|
2763
|
+
let retiring = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 25, section: 0)) as? NativeListRowHost)
|
|
2764
|
+
XCTAssertEqual(retiring.bounds.height, 60, accuracy: 1)
|
|
2765
|
+
func storedContent(_ key: String) throws -> String {
|
|
2766
|
+
let items = try XCTUnwrap(Mirror(reflecting: view).descendant("itemsByKey")
|
|
2767
|
+
as? [String: NativeListItem])
|
|
2768
|
+
return try XCTUnwrap(items[key]).content
|
|
2769
|
+
}
|
|
2770
|
+
let oldAcceptedContent = try storedContent("row-20")
|
|
2771
|
+
let retiredContent = try storedContent("row-25")
|
|
2772
|
+
let patch: [[String: Any]] = [[
|
|
2773
|
+
"key": "row-20", "type": "dataRow", "changes": [
|
|
2774
|
+
"revision": 2,
|
|
2775
|
+
"columns": [["key": "asset", "text": "Asset"],
|
|
2776
|
+
["key": "price", "text": "$123.45"]],
|
|
2777
|
+
],
|
|
2778
|
+
]]
|
|
2779
|
+
let invalidRetiredPatch: [String: Any] = ["key": "row-25", "type": "dataRow",
|
|
2780
|
+
"changes": ["revision": 2]]
|
|
2781
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(
|
|
2782
|
+
withJSONObject: patch + [invalidRetiredPatch]), as: UTF8.self))
|
|
2783
|
+
XCTAssertEqual(try storedContent("row-20"), oldAcceptedContent)
|
|
2784
|
+
XCTAssertEqual(try storedContent("row-25"), retiredContent)
|
|
2785
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patch),
|
|
2786
|
+
as: UTF8.self))
|
|
2787
|
+
XCTAssertNotEqual(try storedContent("row-20"), oldAcceptedContent)
|
|
2788
|
+
XCTAssertEqual(try storedContent("row-25"), retiredContent)
|
|
2789
|
+
list.layoutIfNeeded()
|
|
2790
|
+
let patched = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0)))
|
|
2791
|
+
XCTAssertTrue(labels(in: patched).contains { $0.text == "$123.45" })
|
|
2792
|
+
waitForStructuralFrames(0.05)
|
|
2793
|
+
list.layoutIfNeeded()
|
|
2794
|
+
XCTAssertLessThan(try XCTUnwrap(flow.tailClip).endY, heightBeforePatch)
|
|
2795
|
+
waitForStructuralFrames(0.55)
|
|
2796
|
+
list.layoutIfNeeded()
|
|
2797
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<25).map { "row-\($0)" })
|
|
2798
|
+
XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
|
|
2799
|
+
XCTAssertEqual(footer.convert(.zero, to: list).y, 1500, accuracy: 1)
|
|
2800
|
+
let finalPatched = try XCTUnwrap(list.cellForItem(at: IndexPath(item: 20, section: 0)))
|
|
2801
|
+
XCTAssertTrue(labels(in: finalPatched).contains { $0.text == "$123.45" })
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
func testStructuralTransitionDoesNotOverrideUserDrag() throws {
|
|
2805
|
+
try requireStructuralAnimation()
|
|
2806
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2807
|
+
let controller = UIViewController()
|
|
2808
|
+
window.rootViewController = controller
|
|
2809
|
+
let view = NativeListView(frame: window.bounds)
|
|
2810
|
+
controller.view.addSubview(view)
|
|
2811
|
+
window.isHidden = false
|
|
2812
|
+
defer { view.dispose(); view.removeFromSuperview(); window.isHidden = true }
|
|
2813
|
+
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2814
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2815
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1))
|
|
2816
|
+
view.layoutIfNeeded()
|
|
2817
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2818
|
+
list.layoutIfNeeded()
|
|
2819
|
+
drainMainQueue()
|
|
2820
|
+
list.contentOffset.y = 1100
|
|
2821
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
|
|
2822
|
+
transitionID: "collapse-drag", durationMs: 400))
|
|
2823
|
+
waitForStructuralFrames(0.08)
|
|
2824
|
+
view.scrollViewWillBeginDragging(list)
|
|
2825
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2826
|
+
XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
|
|
2827
|
+
list.contentOffset.y = 600
|
|
2828
|
+
list.layoutIfNeeded()
|
|
2829
|
+
waitForStructuralFrames(0.15)
|
|
2830
|
+
XCTAssertEqual(list.contentOffset.y, 600, accuracy: 1)
|
|
2831
|
+
waitForStructuralFrames(0.35)
|
|
2832
|
+
list.layoutIfNeeded()
|
|
2833
|
+
XCTAssertEqual(list.contentOffset.y, 600, accuracy: 1)
|
|
2834
|
+
let source = try XCTUnwrap(list.dataSource as? UICollectionViewDiffableDataSource<Int, String>)
|
|
2835
|
+
XCTAssertEqual(source.snapshot().itemIdentifiers, (0..<25).map { "row-\($0)" })
|
|
2836
|
+
XCTAssertEqual(list.contentSize.height, 1560, accuracy: 1)
|
|
2837
|
+
XCTAssertTrue(list.visibleCells.allSatisfy { abs($0.alpha - 1) < 0.01 })
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
func testStructuralTransitionDetachAndDisposeReleaseView() throws {
|
|
2841
|
+
try requireStructuralAnimation()
|
|
2842
|
+
for dispose in [false, true] {
|
|
2843
|
+
var callbacks = 0
|
|
2844
|
+
let reference = try detachStructuralTransition(dispose: dispose) { callbacks += 1 }
|
|
2845
|
+
if dispose {
|
|
2846
|
+
drainMainQueue()
|
|
2847
|
+
XCTAssertNil(reference.view)
|
|
2848
|
+
let countAtDispose = callbacks
|
|
2849
|
+
waitForStructuralFrames(0.5)
|
|
2850
|
+
XCTAssertEqual(callbacks, countAtDispose)
|
|
2851
|
+
} else {
|
|
2852
|
+
waitForStructuralFrames(0.5)
|
|
2853
|
+
XCTAssertNil(reference.view)
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
private final class WeakListReference {
|
|
2859
|
+
weak var view: NativeListView?
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
private func detachStructuralTransition(
|
|
2863
|
+
dispose: Bool, onCallback: @escaping () -> Void
|
|
2864
|
+
) throws -> WeakListReference {
|
|
2865
|
+
try autoreleasepool {
|
|
2866
|
+
let reference = WeakListReference()
|
|
2867
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 600))
|
|
2868
|
+
let controller = UIViewController()
|
|
2869
|
+
window.rootViewController = controller
|
|
2870
|
+
let view = NativeListView(frame: window.bounds)
|
|
2871
|
+
controller.view.addSubview(view)
|
|
2872
|
+
window.isHidden = false
|
|
2873
|
+
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2874
|
+
view.setContainerSlotHeightsJson("[0,0,60,0]")
|
|
2875
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 30, generation: 1))
|
|
2876
|
+
view.layoutIfNeeded()
|
|
2877
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2878
|
+
view.onVisibleRangeChanged = { _ in onCallback() }
|
|
2879
|
+
view.onEndReached = { _ in onCallback() }
|
|
2880
|
+
list.layoutIfNeeded()
|
|
2881
|
+
drainMainQueue()
|
|
2882
|
+
view.applySnapshotJson(try structuralRowsSnapshot(count: 25, generation: 2,
|
|
2883
|
+
transitionID: dispose ? "dispose-mid-transition" : "detach-mid-transition",
|
|
2884
|
+
durationMs: 400))
|
|
2885
|
+
// Interrupt an active transition without racing a wall-clock delay on a busy runner.
|
|
2886
|
+
XCTAssertNotNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2887
|
+
reference.view = view
|
|
2888
|
+
if dispose {
|
|
2889
|
+
view.dispose()
|
|
2890
|
+
XCTAssertNil(list.delegate)
|
|
2891
|
+
XCTAssertNil(list.dataSource)
|
|
2892
|
+
}
|
|
2893
|
+
view.removeFromSuperview()
|
|
2894
|
+
XCTAssertNil(Mirror(reflecting: view).descendant("structuralDisplayLink") as? CADisplayLink)
|
|
2895
|
+
XCTAssertNil((list.collectionViewLayout as? NativeListFlowLayout)?.tailClip)
|
|
2896
|
+
window.isHidden = true
|
|
2897
|
+
window.rootViewController = nil
|
|
2898
|
+
return reference
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
private func dispatchAccessoryTouchUpInside(
|
|
2903
|
+
_ control: UIButton, on stack: NativeListAccessoryStack,
|
|
2904
|
+
file: StaticString = #filePath, line: UInt = #line
|
|
2905
|
+
) throws {
|
|
2906
|
+
guard control.allTargets.contains(stack) else {
|
|
2907
|
+
XCTFail("Accessory control is not attached to its stack", file: file, line: line)
|
|
2908
|
+
return
|
|
2909
|
+
}
|
|
2910
|
+
let actions = try XCTUnwrap(
|
|
2911
|
+
control.actions(forTarget: stack, forControlEvent: .touchUpInside),
|
|
2912
|
+
file: file, line: line)
|
|
2913
|
+
guard !actions.isEmpty else {
|
|
2914
|
+
XCTFail("Accessory control has no touch-up action", file: file, line: line)
|
|
2915
|
+
return
|
|
2916
|
+
}
|
|
2917
|
+
// This pod XCTest has no UIApplication to dispatch UIControl.sendActions.
|
|
2918
|
+
guard control.isEnabled else { return }
|
|
2919
|
+
for action in actions {
|
|
2920
|
+
let selector = NSSelectorFromString(action)
|
|
2921
|
+
guard stack.responds(to: selector) else {
|
|
2922
|
+
XCTFail("Accessory action selector is missing: \(action)", file: file, line: line)
|
|
2923
|
+
continue
|
|
2924
|
+
}
|
|
2925
|
+
if action.hasSuffix(":") { _ = stack.perform(selector, with: control) }
|
|
2926
|
+
else { _ = stack.perform(selector) }
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
private func requireStructuralAnimation() throws {
|
|
2931
|
+
if UIAccessibility.isReduceMotionEnabled {
|
|
2932
|
+
throw XCTSkip("Structural animation is disabled by Reduce Motion")
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
private func waitForStructuralFrames(_ seconds: TimeInterval) {
|
|
2937
|
+
let completed = expectation(description: "Native structural display link ran for \(seconds)s")
|
|
2938
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { completed.fulfill() }
|
|
2939
|
+
wait(for: [completed], timeout: seconds + 1)
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
private func labels(in view: UIView) -> [UILabel] {
|
|
2943
|
+
let current = (view as? UILabel).map { [$0] } ?? []
|
|
2944
|
+
return current + view.subviews.flatMap(labels(in:))
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
private func structuralRowsSnapshot(
|
|
2948
|
+
count: Int, generation: Int? = nil, transitionID: String? = nil,
|
|
2949
|
+
durationMs: Int = 80, dataRows: Bool = false, firstRowHeight: Int? = nil,
|
|
2950
|
+
uniformHeight: Int = 60, orientation: String = "vertical", layoutKind: String = "linear",
|
|
2951
|
+
disclosureExpanded: Bool? = nil, disclosureInset: Int = 20
|
|
2952
|
+
) throws -> String {
|
|
2953
|
+
let rows: [[String: Any]] = (0..<count).map {
|
|
2954
|
+
let height = $0 == 0 ? firstRowHeight ?? uniformHeight : uniformHeight
|
|
2955
|
+
if dataRows {
|
|
2956
|
+
return ["key": "row-\($0)", "type": "dataRow", "height": height,
|
|
2957
|
+
"columns": [["key": "asset", "text": "Asset"],
|
|
2958
|
+
["key": "price", "text": "$1.00"]]]
|
|
2959
|
+
}
|
|
2960
|
+
return ["key": "row-\($0)", "type": "action", "title": "Row", "height": height]
|
|
2961
|
+
}
|
|
2962
|
+
var layout: [String: Any] = ["kind": layoutKind, "orientation": orientation]
|
|
2963
|
+
if layoutKind == "grid" { layout["gridColumns"] = 2 }
|
|
2964
|
+
var value: [String: Any] = [
|
|
2965
|
+
"schemaVersion": 1, "generation": generation ?? count + 1,
|
|
2966
|
+
"layout": layout, "rows": rows,
|
|
2967
|
+
]
|
|
2968
|
+
if let transitionID {
|
|
2969
|
+
value["structuralTransition"] = [
|
|
2970
|
+
"id": transitionID, "durationMs": durationMs, "anchorKey": "row-24",
|
|
2971
|
+
]
|
|
2972
|
+
}
|
|
2973
|
+
if let disclosureExpanded {
|
|
2974
|
+
value["disclosure"] = ["expanded": disclosureExpanded,
|
|
2975
|
+
"expandedTrailingInset": disclosureInset]
|
|
2976
|
+
}
|
|
2977
|
+
return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
private func assertHomeSidecarGeometry(sidecarBeforeFirstLayout: Bool) throws {
|
|
2981
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1376, height: 900))
|
|
2982
|
+
let controller = UIViewController()
|
|
2983
|
+
window.rootViewController = controller
|
|
2984
|
+
let view = NativeListView(frame: window.bounds)
|
|
2985
|
+
controller.view.addSubview(view)
|
|
2986
|
+
window.isHidden = false
|
|
2987
|
+
defer {
|
|
2988
|
+
view.dispose()
|
|
2989
|
+
view.removeFromSuperview()
|
|
2990
|
+
window.isHidden = true
|
|
2991
|
+
window.rootViewController = nil
|
|
2992
|
+
}
|
|
2993
|
+
for index in 0..<4 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
2994
|
+
view.setContainerSlotHeightsJson("[0,0,0,1200]")
|
|
2995
|
+
let sidecar = "{\"width\":400,\"gap\":24,\"maxContentWidth\":1140}"
|
|
2996
|
+
if sidecarBeforeFirstLayout { view.setSidecarLayoutJson(sidecar) }
|
|
2997
|
+
view.applySnapshotJson(try homeSidecarSnapshot())
|
|
2998
|
+
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|
|
2999
|
+
list.contentInsetAdjustmentBehavior = .never
|
|
3000
|
+
func layout() {
|
|
3001
|
+
window.layoutIfNeeded()
|
|
3002
|
+
view.layoutIfNeeded()
|
|
3003
|
+
list.layoutIfNeeded()
|
|
3004
|
+
}
|
|
3005
|
+
func assertRows(x: CGFloat, width: CGFloat) {
|
|
3006
|
+
let cells = list.visibleCells.compactMap { $0 as? NativeListDataRowCell }
|
|
3007
|
+
XCTAssertFalse(cells.isEmpty)
|
|
3008
|
+
for cell in cells {
|
|
3009
|
+
let frame = cell.convert(cell.bounds, to: list)
|
|
3010
|
+
XCTAssertEqual(frame.minX, x, accuracy: 1)
|
|
3011
|
+
XCTAssertEqual(frame.width, width, accuracy: 1)
|
|
3012
|
+
if let path = list.indexPath(for: cell),
|
|
3013
|
+
let attributes = list.layoutAttributesForItem(at: path)
|
|
3014
|
+
{
|
|
3015
|
+
XCTAssertEqual(attributes.frame.minX, x, accuracy: 1)
|
|
3016
|
+
XCTAssertEqual(attributes.frame.width, width, accuracy: 1)
|
|
3017
|
+
}
|
|
3018
|
+
let root = cell.root.convert(cell.root.bounds, to: list)
|
|
3019
|
+
XCTAssertGreaterThanOrEqual(root.minX, frame.minX - 1)
|
|
3020
|
+
XCTAssertLessThanOrEqual(root.maxX, frame.maxX + 1)
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
layout()
|
|
3024
|
+
if !sidecarBeforeFirstLayout {
|
|
3025
|
+
assertRows(x: 8, width: 1360)
|
|
3026
|
+
view.setSidecarLayoutJson(sidecar)
|
|
3027
|
+
layout()
|
|
3028
|
+
}
|
|
3029
|
+
assertRows(x: 126, width: 700)
|
|
3030
|
+
list.contentOffset.y = 18 * 60
|
|
3031
|
+
layout()
|
|
3032
|
+
assertRows(x: 126, width: 700)
|
|
3033
|
+
let patch: [[String: Any]] = [[
|
|
3034
|
+
"key": "row-20", "type": "dataRow",
|
|
3035
|
+
"changes": ["columns": homeSidecarColumns(20, updated: true),
|
|
3036
|
+
"trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]]],
|
|
3037
|
+
]]
|
|
3038
|
+
view.applyPatchesJson(String(decoding: try JSONSerialization.data(withJSONObject: patch),
|
|
3039
|
+
as: UTF8.self))
|
|
3040
|
+
layout()
|
|
3041
|
+
assertRows(x: 126, width: 700)
|
|
3042
|
+
window.frame.size.width = 1024
|
|
3043
|
+
controller.view.frame = window.bounds
|
|
3044
|
+
view.frame = window.bounds
|
|
3045
|
+
layout()
|
|
3046
|
+
assertRows(x: 8, width: 584)
|
|
3047
|
+
window.frame.size.width = 1376
|
|
3048
|
+
controller.view.frame = window.bounds
|
|
3049
|
+
view.frame = window.bounds
|
|
3050
|
+
layout()
|
|
3051
|
+
assertRows(x: 126, width: 700)
|
|
3052
|
+
view.setSidecarLayoutJson("")
|
|
3053
|
+
layout()
|
|
3054
|
+
assertRows(x: 8, width: 1360)
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
private func homeSidecarSnapshot() throws -> String {
|
|
3058
|
+
let rows: [[String: Any]] = (0..<30).map { index in
|
|
3059
|
+
["key": "row-\(index)", "type": "dataRow", "height": 60,
|
|
3060
|
+
"leading": ["kind": "token", "fallbackIcon": ["name": "CryptoCoinOutline"]],
|
|
3061
|
+
"columns": homeSidecarColumns(index, updated: false),
|
|
3062
|
+
"trailing": [["kind": "button", "label": "Swap", "actionKey": "swap"]],
|
|
3063
|
+
"style": ["horizontalPadding": 12, "verticalPadding": 8, "leadingGap": 12,
|
|
3064
|
+
"image": ["width": 32, "height": 32]]]
|
|
3065
|
+
}
|
|
3066
|
+
let snapshot: [String: Any] = [
|
|
3067
|
+
"schemaVersion": 1, "generation": 1,
|
|
3068
|
+
"layout": ["kind": "linear", "contentPaddingHorizontal": 8], "rows": rows,
|
|
3069
|
+
]
|
|
3070
|
+
return String(decoding: try JSONSerialization.data(withJSONObject: snapshot), as: UTF8.self)
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
private func homeSidecarColumns(_ index: Int, updated: Bool) -> [[String: Any]] {
|
|
3074
|
+
[
|
|
3075
|
+
["key": "asset", "text": "Asset \(index)", "secondaryText": "Public fixture", "weight": 1],
|
|
3076
|
+
["key": "balance", "text": updated ? "0.2966" : "0.000175",
|
|
3077
|
+
"secondaryText": "$0.13", "alignment": "end", "weight": 1],
|
|
3078
|
+
["key": "price", "text": updated ? "$739.22" : "$0.9989",
|
|
3079
|
+
"secondaryText": "-0.04%", "alignment": "end", "weight": 1],
|
|
3080
|
+
]
|
|
3081
|
+
}
|
|
3082
|
+
|
|
366
3083
|
private func makeScrollingList() throws -> NativeListView {
|
|
367
3084
|
let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 708))
|
|
368
3085
|
for index in 0..<3 { view.mountContainerSlot(UIView(), atIndex: index) }
|
|
369
|
-
view.setContainerSlotHeightsJson("[44,44,44]")
|
|
3086
|
+
view.setContainerSlotHeightsJson("[44,44,44,0]")
|
|
370
3087
|
view.applySnapshotJson(try scrollingSnapshot(count: 40))
|
|
371
3088
|
view.layoutIfNeeded()
|
|
372
3089
|
let list = try XCTUnwrap(view.subviews.compactMap { $0 as? UICollectionView }.first)
|