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