@onekeyfe/react-native-native-list 3.0.157-alpha.253 → 3.0.157-alpha.255
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
- package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
- package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
- package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +163 -26
- package/android/src/main/res/values/native_scroll_coordinator_ids.xml +4 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
- package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
- package/docs/DESIGN.md +4 -2
- package/docs/ROW_TEMPLATES.md +45 -0
- package/docs/SPEC.md +227 -1
- package/docs/STYLE_SPEC.md +8 -0
- package/ios/HybridNativeList.swift +30 -0
- package/ios/NativeListActivityDetailsView.swift +229 -0
- package/ios/NativeListActivityRenderer.swift +24 -2
- package/ios/NativeListMediaPreviewSlot.swift +199 -0
- package/ios/NativeListMediaTileRenderer.swift +10 -4
- package/ios/NativeListModels.swift +4 -1
- package/ios/NativeListScrollPositionTracker.swift +30 -0
- package/ios/NativeListSectionHeaderRenderer.swift +27 -1
- package/ios/RNCNativeListView.swift +159 -4
- package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
- package/ios/tests/NativeListLifecycleTests.swift +213 -0
- package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
- package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
- package/lib/module/NativeList.js +56 -3
- package/lib/module/NativeList.web.js +5 -0
- package/lib/module/validation.js +64 -4
- package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
- package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
- package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
- package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
- package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
- package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
- package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
- package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
- package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
- package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
- package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
- package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
- package/lib/typescript/src/NativeList.types.d.ts +14 -1
- package/lib/typescript/src/models.d.ts +33 -5
- package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
- package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
- package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
- package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
- package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
- package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
- package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
- package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
- package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
- package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
- package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
- package/package.json +6 -5
- package/react-native-native-list.podspec +6 -0
- package/scripts/enable-container-slots.mjs +58 -0
- package/scripts/enable-container-slots.test.mjs +58 -0
- package/src/NativeList.nitro.ts +3 -0
- package/src/NativeList.tsx +84 -2
- package/src/NativeList.types.ts +14 -1
- package/src/NativeList.web.tsx +5 -0
- package/src/models.ts +38 -1
- package/src/validation.ts +146 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
private final class NativeListPausedVideoView: UIView {
|
|
5
|
+
var onWindowChanged: (() -> Void)?
|
|
6
|
+
var onLayoutChanged: (() -> Void)?
|
|
7
|
+
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
|
8
|
+
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
|
9
|
+
override func didMoveToWindow() { super.didMoveToWindow(); onWindowChanged?() }
|
|
10
|
+
override func layoutSubviews() { super.layoutSubviews(); onLayoutChanged?() }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Uses the same AVPlayer source semantics as native Video, always paused and muted.
|
|
14
|
+
final class NativeListMediaPreviewSlot {
|
|
15
|
+
let view = UIView()
|
|
16
|
+
private let image = NativeListImageSlot()
|
|
17
|
+
private let video = NativeListPausedVideoView()
|
|
18
|
+
private var identity: String?
|
|
19
|
+
private var epoch = 0
|
|
20
|
+
private var candidateGeneration = 0
|
|
21
|
+
private var playerGeneration = 0
|
|
22
|
+
private var result: Bool?
|
|
23
|
+
private var completion: ((Bool) -> Void)?
|
|
24
|
+
private var player: AVPlayer?
|
|
25
|
+
private var itemObservation: NSKeyValueObservation?
|
|
26
|
+
private var readyObservation: NSKeyValueObservation?
|
|
27
|
+
private var resumeVideo: (() -> Void)?
|
|
28
|
+
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
29
|
+
private var viewportObservations: [NSKeyValueObservation] = []
|
|
30
|
+
|
|
31
|
+
init() {
|
|
32
|
+
view.isUserInteractionEnabled = false
|
|
33
|
+
for child in [image.view, video] {
|
|
34
|
+
child.frame = view.bounds
|
|
35
|
+
child.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
36
|
+
view.addSubview(child)
|
|
37
|
+
}
|
|
38
|
+
video.clipsToBounds = true
|
|
39
|
+
video.isHidden = true
|
|
40
|
+
video.onWindowChanged = { [weak self] in self?.observeViewport(); self?.updateVisibility() }
|
|
41
|
+
video.onLayoutChanged = { [weak self] in self?.updateVisibility() }
|
|
42
|
+
lifecycleObservers = [
|
|
43
|
+
NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.releasePlayer() },
|
|
44
|
+
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.updateVisibility() },
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func bind(
|
|
49
|
+
_ source: [String: Any], key: String, fit: String? = nil,
|
|
50
|
+
placeholder: String = "#0000000F", probeOrder: [String] = ["image"],
|
|
51
|
+
completion: ((Bool) -> Void)? = nil
|
|
52
|
+
) {
|
|
53
|
+
let next = NativeListImageSlot.signature([
|
|
54
|
+
"source": source, "key": key, "fit": fit ?? "", "order": probeOrder,
|
|
55
|
+
"placeholder": placeholder,
|
|
56
|
+
])
|
|
57
|
+
self.completion = completion
|
|
58
|
+
if identity == next {
|
|
59
|
+
if let result { completion?(result) }
|
|
60
|
+
updateVisibility()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
recycle()
|
|
64
|
+
identity = next
|
|
65
|
+
self.completion = completion
|
|
66
|
+
video.playerLayer.videoGravity = (fit ?? source.string("contentFit")) == "cover" ? .resizeAspectFill : .resizeAspect
|
|
67
|
+
probe(source, key: key, fit: fit, placeholder: placeholder, order: probeOrder, attempt: 0, token: epoch)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func probe(
|
|
71
|
+
_ source: [String: Any], key: String, fit: String?, placeholder: String,
|
|
72
|
+
order: [String], attempt: Int, token: Int
|
|
73
|
+
) {
|
|
74
|
+
guard token == epoch else { return }
|
|
75
|
+
candidateGeneration &+= 1
|
|
76
|
+
let candidate = candidateGeneration
|
|
77
|
+
resumeVideo = nil
|
|
78
|
+
viewportObservations.removeAll()
|
|
79
|
+
releasePlayer()
|
|
80
|
+
guard attempt < order.count else {
|
|
81
|
+
result = false
|
|
82
|
+
completion?(false)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
let finished: (Bool) -> Void = { [weak self] loaded in
|
|
86
|
+
guard let self, self.epoch == token, self.candidateGeneration == candidate else { return }
|
|
87
|
+
if loaded {
|
|
88
|
+
guard self.result != true else { return }
|
|
89
|
+
self.result = true
|
|
90
|
+
self.completion?(true)
|
|
91
|
+
} else {
|
|
92
|
+
self.probe(source, key: key, fit: fit, placeholder: placeholder, order: order, attempt: attempt + 1, token: token)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if order[attempt] == "image" {
|
|
96
|
+
video.isHidden = true
|
|
97
|
+
image.view.isHidden = false
|
|
98
|
+
image.bind(source, key: key, fit: fit, placeholder: placeholder, completion: finished)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
image.recycle()
|
|
102
|
+
image.view.isHidden = true
|
|
103
|
+
video.isHidden = false
|
|
104
|
+
guard let url = URL(string: source.string("uri")), !source.string("uri").isEmpty else { finished(false); return }
|
|
105
|
+
resumeVideo = { [weak self] in
|
|
106
|
+
guard let self, self.epoch == token, self.candidateGeneration == candidate, self.player == nil else { return }
|
|
107
|
+
self.playerGeneration &+= 1
|
|
108
|
+
let generation = self.playerGeneration
|
|
109
|
+
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": source.dictionary("headers") ?? [:]])
|
|
110
|
+
let item = AVPlayerItem(asset: asset)
|
|
111
|
+
item.preferredForwardBufferDuration = 1
|
|
112
|
+
let player = AVPlayer(playerItem: item)
|
|
113
|
+
player.isMuted = true
|
|
114
|
+
player.pause()
|
|
115
|
+
self.player = player
|
|
116
|
+
self.video.playerLayer.player = player
|
|
117
|
+
self.itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
|
|
118
|
+
DispatchQueue.main.async {
|
|
119
|
+
guard let self, self.playerGeneration == generation else { return }
|
|
120
|
+
if item.status == .failed { finished(false) }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
self.readyObservation = self.video.playerLayer.observe(\.isReadyForDisplay, options: [.initial, .new]) { [weak self] layer, _ in
|
|
124
|
+
DispatchQueue.main.async {
|
|
125
|
+
guard let self, self.playerGeneration == generation else { return }
|
|
126
|
+
if layer.isReadyForDisplay { finished(true) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
observeViewport()
|
|
131
|
+
updateVisibility()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func observeViewport() {
|
|
135
|
+
viewportObservations.removeAll()
|
|
136
|
+
guard resumeVideo != nil, video.window != nil else { return }
|
|
137
|
+
var ancestor = video.superview
|
|
138
|
+
while let current = ancestor {
|
|
139
|
+
if let scrollView = current as? UIScrollView {
|
|
140
|
+
viewportObservations.append(scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
141
|
+
viewportObservations.append(scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
142
|
+
}
|
|
143
|
+
// Pager or host layouts may translate a retained page without scrolling it.
|
|
144
|
+
viewportObservations.append(current.layer.observe(\.transform, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
145
|
+
viewportObservations.append(current.layer.observe(\.position, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
146
|
+
viewportObservations.append(current.layer.observe(\.isHidden, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
147
|
+
viewportObservations.append(current.layer.observe(\.opacity, options: [.new]) { [weak self] _, _ in self?.updateVisibility() })
|
|
148
|
+
ancestor = current.superview
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Retained pager pages can have a window while lying outside its clipped viewport.
|
|
153
|
+
static func intersectsViewport(_ view: UIView) -> Bool {
|
|
154
|
+
guard let window = view.window, !view.bounds.isEmpty else { return false }
|
|
155
|
+
var visible = view.convert(view.bounds, to: window).intersection(window.bounds)
|
|
156
|
+
var ancestor: UIView? = view
|
|
157
|
+
while let current = ancestor {
|
|
158
|
+
if current.isHidden || current.alpha <= 0.01 { return false }
|
|
159
|
+
if current.clipsToBounds || current is UIScrollView {
|
|
160
|
+
visible = visible.intersection(current.convert(current.bounds, to: window))
|
|
161
|
+
}
|
|
162
|
+
if visible.isNull || visible.isEmpty { return false }
|
|
163
|
+
ancestor = current.superview
|
|
164
|
+
}
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func updateVisibility() {
|
|
169
|
+
if Self.intersectsViewport(video) && UIApplication.shared.applicationState == .active { resumeVideo?() }
|
|
170
|
+
else { releasePlayer() }
|
|
171
|
+
}
|
|
172
|
+
private func releasePlayer() {
|
|
173
|
+
playerGeneration &+= 1
|
|
174
|
+
itemObservation = nil
|
|
175
|
+
readyObservation = nil
|
|
176
|
+
player?.pause()
|
|
177
|
+
video.playerLayer.player = nil
|
|
178
|
+
player?.replaceCurrentItem(with: nil)
|
|
179
|
+
player = nil
|
|
180
|
+
}
|
|
181
|
+
func recycle() {
|
|
182
|
+
epoch &+= 1
|
|
183
|
+
candidateGeneration &+= 1
|
|
184
|
+
resumeVideo = nil
|
|
185
|
+
viewportObservations.removeAll()
|
|
186
|
+
releasePlayer()
|
|
187
|
+
image.recycle()
|
|
188
|
+
image.view.isHidden = false
|
|
189
|
+
video.isHidden = true
|
|
190
|
+
identity = nil
|
|
191
|
+
result = nil
|
|
192
|
+
completion = nil
|
|
193
|
+
}
|
|
194
|
+
deinit {
|
|
195
|
+
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
196
|
+
player?.pause()
|
|
197
|
+
player?.replaceCurrentItem(with: nil)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -8,7 +8,7 @@ final class NativeListMediaTileCell: NativeListRendererCell {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
private let picture = UIView()
|
|
11
|
-
private let image =
|
|
11
|
+
private let image = NativeListMediaPreviewSlot()
|
|
12
12
|
private let network = NativeListImageSlot()
|
|
13
13
|
private let errorIcon = UIImageView()
|
|
14
14
|
private let title = NativeListTextLabel()
|
|
@@ -27,7 +27,7 @@ final class NativeListMediaTileCell: NativeListRendererCell {
|
|
|
27
27
|
override var defaultCornerRadius: CGFloat { 16 }
|
|
28
28
|
override var showsSelection: Bool { false }
|
|
29
29
|
override var pressChangesBackground: Bool { false }
|
|
30
|
-
override var assetFields: [String] { ["image", "networkImage"] }
|
|
30
|
+
override var assetFields: [String] { ["image", "media", "networkImage"] }
|
|
31
31
|
override var isHighlighted: Bool { didSet { picture.alpha = isHighlighted ? 0.8 : 1 } }
|
|
32
32
|
|
|
33
33
|
override init(frame: CGRect) {
|
|
@@ -120,7 +120,8 @@ final class NativeListMediaTileCell: NativeListRendererCell {
|
|
|
120
120
|
errorIcon.isHidden = state != "error"
|
|
121
121
|
errorIcon.image = nativeListIcon(named: "ImageSquareWavesOutline")
|
|
122
122
|
errorIcon.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray)
|
|
123
|
-
|
|
123
|
+
let media = item.data.dictionary("media")
|
|
124
|
+
if state != "empty", state != "error", let source = media?.dictionary("source") ?? item.data.dictionary("image") {
|
|
124
125
|
// Legacy source-backed media had a transparent container. Clear only a fill this
|
|
125
126
|
// renderer applied, so OneKeyImage keeps ownership of its own placeholder color.
|
|
126
127
|
if ownsImageFill {
|
|
@@ -129,7 +130,12 @@ final class NativeListMediaTileCell: NativeListRendererCell {
|
|
|
129
130
|
}
|
|
130
131
|
image.bind(
|
|
131
132
|
source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String,
|
|
132
|
-
placeholder: placeholder
|
|
133
|
+
placeholder: placeholder, probeOrder: media?["probeOrder"] as? [String] ?? ["image"]
|
|
134
|
+
) { [weak self] loaded in
|
|
135
|
+
self?.errorIcon.isHidden = loaded
|
|
136
|
+
self?.ownsImageFill = !loaded
|
|
137
|
+
self?.image.view.backgroundColor = loaded ? .clear : UIColor(nativeListHex: placeholder, fallback: .lightGray)
|
|
138
|
+
}
|
|
133
139
|
} else {
|
|
134
140
|
image.recycle()
|
|
135
141
|
image.view.backgroundColor =
|
|
@@ -93,6 +93,7 @@ struct NativeListConfig {
|
|
|
93
93
|
var selectedKeys: Set<String>
|
|
94
94
|
let reorderable: Bool
|
|
95
95
|
let pullToRefresh: Bool
|
|
96
|
+
let refreshTriggerDistance: CGFloat?
|
|
96
97
|
var refreshing: Bool
|
|
97
98
|
let loadMore: Bool
|
|
98
99
|
let endReachedThreshold: Double
|
|
@@ -144,7 +145,7 @@ struct NativeListConfig {
|
|
|
144
145
|
generation: generation,
|
|
145
146
|
layout: layout,
|
|
146
147
|
orientation: layoutData["orientation"] as? String ?? "vertical",
|
|
147
|
-
gridColumns: min(
|
|
148
|
+
gridColumns: min(7, max(1, layoutData["gridColumns"] as? Int ?? 1)),
|
|
148
149
|
stickyHeaders: layoutData["stickyHeaders"] as? Bool ?? false,
|
|
149
150
|
contentPadding: contentPadding,
|
|
150
151
|
contentPaddingHorizontal: contentPaddingHorizontal,
|
|
@@ -156,6 +157,8 @@ struct NativeListConfig {
|
|
|
156
157
|
selectedKeys: selectedKeys,
|
|
157
158
|
reorderable: capabilities?["reorderable"] as? Bool ?? false,
|
|
158
159
|
pullToRefresh: capabilities?["pullToRefresh"] as? Bool ?? false,
|
|
160
|
+
refreshTriggerDistance: (capabilities?["refreshTriggerDistance"] as? Double)
|
|
161
|
+
.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil },
|
|
159
162
|
refreshing: capabilities?["refreshing"] as? Bool ?? false,
|
|
160
163
|
loadMore: capabilities?["loadMore"] as? Bool ?? false,
|
|
161
164
|
endReachedThreshold: capabilities?["endReachedThreshold"] as? Double ?? 0.2,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A per-view hysteresis observer. A nil result means no bridge event is needed.
|
|
4
|
+
struct NativeListScrollPositionTracker {
|
|
5
|
+
private var start: Double?
|
|
6
|
+
private var end: Double?
|
|
7
|
+
private var previous: Bool?
|
|
8
|
+
|
|
9
|
+
mutating func configure(json: String) {
|
|
10
|
+
start = nil
|
|
11
|
+
end = nil
|
|
12
|
+
previous = nil
|
|
13
|
+
guard let data = json.data(using: .utf8),
|
|
14
|
+
let values = try? JSONSerialization.jsonObject(with: data) as? [String: Double],
|
|
15
|
+
let start = values["start"], let end = values["end"],
|
|
16
|
+
start.isFinite, end.isFinite, start >= 0, end > start else { return }
|
|
17
|
+
self.start = start
|
|
18
|
+
self.end = end
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
mutating func resetDelivery() { previous = nil }
|
|
22
|
+
|
|
23
|
+
mutating func update(offset: Double) -> Bool? {
|
|
24
|
+
guard let start, let end, offset.isFinite else { return nil }
|
|
25
|
+
let next = previous == true ? offset > start : offset >= end
|
|
26
|
+
guard previous != next else { return nil }
|
|
27
|
+
previous = next
|
|
28
|
+
return next
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -99,7 +99,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
99
99
|
if layout == "table" { return 28 }
|
|
100
100
|
if item.data.string("presentation") == "networkSelector" { return 47 }
|
|
101
101
|
if item.data.string("variant") == "history" {
|
|
102
|
-
return 16
|
|
102
|
+
return item.data.bool("titleLoading") ? 20 : 16
|
|
103
103
|
}
|
|
104
104
|
if item.data.string("variant") == "summary" { return 68 }
|
|
105
105
|
if item.data.string("variant") == "gallery" { return 32 }
|
|
@@ -110,6 +110,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
110
110
|
private let mainStack = UIStackView()
|
|
111
111
|
private let titleRowStack = UIStackView()
|
|
112
112
|
private let titleLabel = NativeListDottedUnderlineLabel()
|
|
113
|
+
private let titleSpinner = UIActivityIndicatorView(style: .medium)
|
|
113
114
|
private let subtitleLabel = NativeListTextLabel()
|
|
114
115
|
private let trailingStack = UIStackView()
|
|
115
116
|
private let accessoryButtons = [NativeListAccessoryButton(type: .system)]
|
|
@@ -132,6 +133,12 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
132
133
|
root.axis = .horizontal
|
|
133
134
|
root.alignment = .center
|
|
134
135
|
root.spacing = 12
|
|
136
|
+
titleSpinner.translatesAutoresizingMaskIntoConstraints = false
|
|
137
|
+
titleSpinner.isAccessibilityElement = false
|
|
138
|
+
NSLayoutConstraint.activate([
|
|
139
|
+
titleSpinner.widthAnchor.constraint(equalToConstant: 20),
|
|
140
|
+
titleSpinner.heightAnchor.constraint(equalToConstant: 20),
|
|
141
|
+
])
|
|
135
142
|
mainStack.axis = .vertical
|
|
136
143
|
mainStack.alignment = .fill
|
|
137
144
|
mainStack.spacing = 2
|
|
@@ -174,6 +181,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
174
181
|
selectorTypographyRestorers = []
|
|
175
182
|
currentItem = item
|
|
176
183
|
currentTheme = theme
|
|
184
|
+
titleSpinner.stopAnimating()
|
|
177
185
|
NSLayoutConstraint.deactivate(selectorConstraints)
|
|
178
186
|
selectorConstraints = []
|
|
179
187
|
root.arrangedSubviews.forEach {
|
|
@@ -244,6 +252,8 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
244
252
|
if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) }
|
|
245
253
|
if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) }
|
|
246
254
|
applyTextStyle(item)
|
|
255
|
+
titleSpinner.color = titleLabel.textColor
|
|
256
|
+
updateTitleSpinner()
|
|
247
257
|
contentInsets = UIEdgeInsets(
|
|
248
258
|
top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset)
|
|
249
259
|
root.alignment =
|
|
@@ -301,6 +311,18 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
301
311
|
override func recycleContent() {
|
|
302
312
|
checkboxControl.reset()
|
|
303
313
|
currentItem = nil
|
|
314
|
+
titleSpinner.stopAnimating()
|
|
315
|
+
}
|
|
316
|
+
override func didMoveToWindow() {
|
|
317
|
+
super.didMoveToWindow()
|
|
318
|
+
updateTitleSpinner()
|
|
319
|
+
}
|
|
320
|
+
private func updateTitleSpinner() {
|
|
321
|
+
if window != nil, currentItem?.data.bool("titleLoading") == true {
|
|
322
|
+
titleSpinner.startAnimating()
|
|
323
|
+
} else {
|
|
324
|
+
titleSpinner.stopAnimating()
|
|
325
|
+
}
|
|
304
326
|
}
|
|
305
327
|
@objc private func titlePressed() {
|
|
306
328
|
if let item = currentItem {
|
|
@@ -324,6 +346,10 @@ final class NativeListSectionHeaderCell: NativeListRendererCell {
|
|
|
324
346
|
layout: String,
|
|
325
347
|
_ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String
|
|
326
348
|
) {
|
|
349
|
+
if item.data.bool("titleLoading") {
|
|
350
|
+
root.addArrangedSubview(titleSpinner)
|
|
351
|
+
root.setCustomSpacing(8, after: titleSpinner)
|
|
352
|
+
}
|
|
327
353
|
root.addArrangedSubview(mainStack)
|
|
328
354
|
let variant = item.data.string("variant")
|
|
329
355
|
// OneKey patch: title help is a separate target from checkbox and value actions.
|
|
@@ -4,6 +4,54 @@ import UIKit
|
|
|
4
4
|
import UniformTypeIdentifiers
|
|
5
5
|
|
|
6
6
|
final class NativeListView: UIView {
|
|
7
|
+
private var refreshEmittedForDrag = false
|
|
8
|
+
private var minimumDragOffset: CGFloat = 0
|
|
9
|
+
private var reactContainerSlots: [UIView] = []
|
|
10
|
+
private var containerSlotHeights: [CGFloat] = [0, 0, 0]
|
|
11
|
+
private var lastLaidOutContentHeight: CGFloat?
|
|
12
|
+
private var needsContentShrinkCorrection = false
|
|
13
|
+
private var contentShrinkCorrectionScheduled = false
|
|
14
|
+
|
|
15
|
+
@objc func mountContainerSlot(_ view: UIView, atIndex index: Int) {
|
|
16
|
+
reactContainerSlots.insert(view, at: min(index, reactContainerSlots.count))
|
|
17
|
+
collectionView.addSubview(view)
|
|
18
|
+
setNeedsLayout()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@objc func unmountContainerSlot(_ view: UIView) {
|
|
22
|
+
reactContainerSlots.removeAll { $0 === view }
|
|
23
|
+
view.removeFromSuperview()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func setContainerSlotHeightsJson(_ json: String) {
|
|
27
|
+
guard let data = json.data(using: .utf8),
|
|
28
|
+
let heights = try? JSONSerialization.jsonObject(with: data) as? [Double],
|
|
29
|
+
heights.count == 3, heights.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return }
|
|
30
|
+
containerSlotHeights = heights.map { CGFloat($0) }
|
|
31
|
+
if let config { configureLayout(config) }
|
|
32
|
+
setNeedsLayout()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private func layoutContainerSlots() {
|
|
36
|
+
guard reactContainerSlots.count == 3 else { return }
|
|
37
|
+
let empty = config?.items.isEmpty ?? true
|
|
38
|
+
let header = containerSlotHeights[0]
|
|
39
|
+
let emptyHeight = empty ? containerSlotHeights[1] : 0
|
|
40
|
+
let footer = containerSlotHeights[2]
|
|
41
|
+
let paddingTop = config?.contentPaddingTop ?? 0
|
|
42
|
+
let paddingBottom = config?.contentPaddingBottom ?? 0
|
|
43
|
+
let footerY = max(paddingTop + header + emptyHeight,
|
|
44
|
+
collectionView.contentSize.height - paddingBottom - footer)
|
|
45
|
+
let positions = [paddingTop, paddingTop + header, footerY]
|
|
46
|
+
for (index, view) in reactContainerSlots.enumerated() {
|
|
47
|
+
view.isHidden = index == 1 && !empty
|
|
48
|
+
let horizontalPadding = config?.contentPaddingHorizontal ?? 0
|
|
49
|
+
view.frame = CGRect(x: horizontalPadding, y: positions[index],
|
|
50
|
+
width: max(0, collectionView.bounds.width - horizontalPadding * 2),
|
|
51
|
+
height: containerSlotHeights[index])
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
7
55
|
private final class ActionAnchorRecord {
|
|
8
56
|
let token: String
|
|
9
57
|
weak var sourceView: UIView?
|
|
@@ -47,6 +95,26 @@ final class NativeListView: UIView {
|
|
|
47
95
|
case end(Bool)
|
|
48
96
|
}
|
|
49
97
|
|
|
98
|
+
private var scrollPositionTracker = NativeListScrollPositionTracker()
|
|
99
|
+
var onScrollPositionThresholdChange: ((Bool) -> Void)? {
|
|
100
|
+
didSet {
|
|
101
|
+
scrollPositionTracker.resetDelivery()
|
|
102
|
+
emitScrollPositionThresholdIfNeeded()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func setScrollPositionThresholdsJson(_ json: String) {
|
|
107
|
+
scrollPositionTracker.configure(json: json)
|
|
108
|
+
emitScrollPositionThresholdIfNeeded()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func emitScrollPositionThresholdIfNeeded() {
|
|
112
|
+
guard let callback = onScrollPositionThresholdChange,
|
|
113
|
+
let next = scrollPositionTracker.update(offset: Double(max(0,
|
|
114
|
+
collectionView.contentOffset.y + collectionView.contentInset.top))) else { return }
|
|
115
|
+
callback(next)
|
|
116
|
+
}
|
|
117
|
+
|
|
50
118
|
var onRowAction: ((String) -> Void)?
|
|
51
119
|
var onActionAnchorInvalidated: ((String) -> Void)?
|
|
52
120
|
var onSelectionDelta: ((String) -> Void)?
|
|
@@ -60,7 +128,14 @@ final class NativeListView: UIView {
|
|
|
60
128
|
}
|
|
61
129
|
|
|
62
130
|
private let flowLayout = NativeListFlowLayout()
|
|
63
|
-
private lazy var collectionView
|
|
131
|
+
private lazy var collectionView: NativeListCollectionView = {
|
|
132
|
+
let view = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout)
|
|
133
|
+
view.onContentLayout = { [weak self] in
|
|
134
|
+
self?.layoutContainerSlots()
|
|
135
|
+
self?.contentLayoutDidChange()
|
|
136
|
+
}
|
|
137
|
+
return view
|
|
138
|
+
}()
|
|
64
139
|
private let footerContainer = UIView()
|
|
65
140
|
private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.action)
|
|
66
141
|
private var footerRendererKey = NativeListRendererKey.action
|
|
@@ -333,6 +408,7 @@ final class NativeListView: UIView {
|
|
|
333
408
|
rebindRowsForLayoutDirection(config)
|
|
334
409
|
}
|
|
335
410
|
performPendingScrollIfNeeded()
|
|
411
|
+
layoutContainerSlots()
|
|
336
412
|
}
|
|
337
413
|
|
|
338
414
|
// A layout-direction change may arrive without a layout pass (semantic attribute or trait).
|
|
@@ -673,6 +749,39 @@ final class NativeListView: UIView {
|
|
|
673
749
|
collectionView.setContentOffset(point, animated: animated)
|
|
674
750
|
}
|
|
675
751
|
|
|
752
|
+
private func contentLayoutDidChange() {
|
|
753
|
+
let height = collectionView.contentSize.height
|
|
754
|
+
if config?.orientation != "horizontal",
|
|
755
|
+
let previous = lastLaidOutContentHeight, height < previous {
|
|
756
|
+
needsContentShrinkCorrection = true
|
|
757
|
+
}
|
|
758
|
+
lastLaidOutContentHeight = height
|
|
759
|
+
scheduleContentShrinkCorrection()
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private func scheduleContentShrinkCorrection() {
|
|
763
|
+
guard needsContentShrinkCorrection, !contentShrinkCorrectionScheduled, !disposed else { return }
|
|
764
|
+
contentShrinkCorrectionScheduled = true
|
|
765
|
+
// Wait for the collection layout and ancestor pager inset observers to settle.
|
|
766
|
+
DispatchQueue.main.async { [weak self] in
|
|
767
|
+
guard let self else { return }
|
|
768
|
+
self.contentShrinkCorrectionScheduled = false
|
|
769
|
+
guard !self.disposed, self.needsContentShrinkCorrection,
|
|
770
|
+
!self.collectionView.isTracking, !self.collectionView.isDragging,
|
|
771
|
+
!self.collectionView.isDecelerating,
|
|
772
|
+
self.collectionView.refreshControl?.isRefreshing != true,
|
|
773
|
+
self.config?.refreshing != true else { return }
|
|
774
|
+
self.needsContentShrinkCorrection = false
|
|
775
|
+
guard self.config?.orientation != "horizontal", self.collectionView.bounds.height > 0 else { return }
|
|
776
|
+
let insets = self.collectionView.adjustedContentInset
|
|
777
|
+
let maximum = max(-insets.top,
|
|
778
|
+
self.collectionView.contentSize.height - self.collectionView.bounds.height + insets.bottom)
|
|
779
|
+
if self.collectionView.contentOffset.y > maximum {
|
|
780
|
+
self.setScrollOffset(self.collectionView.contentOffset.y, animated: false)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
676
785
|
func setRefreshing(_ refreshing: Bool) {
|
|
677
786
|
guard var current = config else { return }
|
|
678
787
|
current.refreshing = refreshing
|
|
@@ -681,6 +790,7 @@ final class NativeListView: UIView {
|
|
|
681
790
|
collectionView.refreshControl?.beginRefreshing()
|
|
682
791
|
} else {
|
|
683
792
|
collectionView.refreshControl?.endRefreshing()
|
|
793
|
+
scheduleContentShrinkCorrection()
|
|
684
794
|
}
|
|
685
795
|
}
|
|
686
796
|
|
|
@@ -693,11 +803,14 @@ final class NativeListView: UIView {
|
|
|
693
803
|
flowLayout.minimumLineSpacing = config.itemSpacing
|
|
694
804
|
flowLayout.minimumInteritemSpacing = config.itemSpacing
|
|
695
805
|
flowLayout.sectionInset = UIEdgeInsets(
|
|
696
|
-
top: config.contentPaddingTop,
|
|
806
|
+
top: config.contentPaddingTop + containerSlotHeights[0],
|
|
697
807
|
left: config.contentPaddingHorizontal + (isRightToLeft ? indexGutter : 0),
|
|
698
|
-
bottom: config.contentPaddingBottom,
|
|
808
|
+
bottom: config.contentPaddingBottom + containerSlotHeights[2],
|
|
699
809
|
right: config.contentPaddingHorizontal + (isRightToLeft ? 0 : indexGutter)
|
|
700
810
|
)
|
|
811
|
+
flowLayout.minimumContainerContentHeight = config.items.isEmpty
|
|
812
|
+
? config.contentPaddingTop + config.contentPaddingBottom + containerSlotHeights.reduce(0, +)
|
|
813
|
+
: 0
|
|
701
814
|
flowLayout.stickyItemIndexes = config.stickyHeaders
|
|
702
815
|
? Set(config.items.enumerated().compactMap {
|
|
703
816
|
$0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary"
|
|
@@ -1347,6 +1460,7 @@ final class NativeListView: UIView {
|
|
|
1347
1460
|
} else {
|
|
1348
1461
|
collectionView.refreshControl = nil
|
|
1349
1462
|
}
|
|
1463
|
+
if !config.refreshing { scheduleContentShrinkCorrection() }
|
|
1350
1464
|
}
|
|
1351
1465
|
|
|
1352
1466
|
private func configureFooter(_ config: NativeListConfig) {
|
|
@@ -1539,6 +1653,7 @@ final class NativeListView: UIView {
|
|
|
1539
1653
|
current.rowPressToggles == next.rowPressToggles,
|
|
1540
1654
|
current.reorderable == next.reorderable,
|
|
1541
1655
|
current.pullToRefresh == next.pullToRefresh,
|
|
1656
|
+
current.refreshTriggerDistance == next.refreshTriggerDistance,
|
|
1542
1657
|
current.refreshing == next.refreshing,
|
|
1543
1658
|
current.loadMore == next.loadMore,
|
|
1544
1659
|
current.endReachedThreshold == next.endReachedThreshold,
|
|
@@ -1849,6 +1964,8 @@ final class NativeListView: UIView {
|
|
|
1849
1964
|
itemsByKey.removeAll()
|
|
1850
1965
|
sectionIndexEntries.removeAll()
|
|
1851
1966
|
pendingScrollRequest = nil
|
|
1967
|
+
needsContentShrinkCorrection = false
|
|
1968
|
+
lastLaidOutContentHeight = nil
|
|
1852
1969
|
lastVisibleRange = nil
|
|
1853
1970
|
onRowAction = nil
|
|
1854
1971
|
onActionAnchorInvalidated = nil
|
|
@@ -1856,6 +1973,7 @@ final class NativeListView: UIView {
|
|
|
1856
1973
|
onReorder = nil
|
|
1857
1974
|
onEndReached = nil
|
|
1858
1975
|
onVisibleRangeChanged = nil
|
|
1976
|
+
onScrollPositionThresholdChange = nil
|
|
1859
1977
|
}
|
|
1860
1978
|
|
|
1861
1979
|
private func createActionAnchor(origin: NativeListActionOrigin) -> [String: Any]? {
|
|
@@ -1921,6 +2039,9 @@ final class NativeListView: UIView {
|
|
|
1921
2039
|
}
|
|
1922
2040
|
|
|
1923
2041
|
@objc private func refreshTriggered() {
|
|
2042
|
+
guard !disposed, config?.pullToRefresh == true,
|
|
2043
|
+
config?.refreshing != true, !refreshEmittedForDrag else { return }
|
|
2044
|
+
refreshEmittedForDrag = true
|
|
1924
2045
|
emit(onRowAction, ["actionKey": "nativeList.refresh"])
|
|
1925
2046
|
}
|
|
1926
2047
|
|
|
@@ -2142,7 +2263,31 @@ extension NativeListView: UICollectionViewDelegateFlowLayout {
|
|
|
2142
2263
|
return item.isRowPressEnabled
|
|
2143
2264
|
}
|
|
2144
2265
|
|
|
2266
|
+
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
2267
|
+
minimumDragOffset = 0
|
|
2268
|
+
refreshEmittedForDrag = false
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
2272
|
+
defer { if !decelerate { scheduleContentShrinkCorrection() } }
|
|
2273
|
+
minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
|
|
2274
|
+
guard let threshold = config?.refreshTriggerDistance,
|
|
2275
|
+
config?.pullToRefresh == true, config?.refreshing != true,
|
|
2276
|
+
minimumDragOffset <= -threshold, !refreshEmittedForDrag else { return }
|
|
2277
|
+
collectionView.refreshControl?.beginRefreshing()
|
|
2278
|
+
refreshTriggered()
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
2282
|
+
scheduleContentShrinkCorrection()
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2145
2285
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
2286
|
+
if scrollView.isDragging {
|
|
2287
|
+
minimumDragOffset = min(minimumDragOffset, scrollView.contentOffset.y + scrollView.contentInset.top)
|
|
2288
|
+
}
|
|
2289
|
+
layoutContainerSlots()
|
|
2290
|
+
emitScrollPositionThresholdIfNeeded()
|
|
2146
2291
|
invalidateActionAnchor(reason: "scroll")
|
|
2147
2292
|
syncSectionIndexToVisibleRows()
|
|
2148
2293
|
emitVisibleRangeIfNeeded()
|
|
@@ -2205,6 +2350,13 @@ extension NativeListView: UICollectionViewDragDelegate, UICollectionViewDropDele
|
|
|
2205
2350
|
// quick tap highlighted and unhighlighted a row within one frame and the pressed background never
|
|
2206
2351
|
// rendered. Deliver touches immediately, as Android rows do.
|
|
2207
2352
|
private final class NativeListCollectionView: UICollectionView {
|
|
2353
|
+
var onContentLayout: (() -> Void)?
|
|
2354
|
+
|
|
2355
|
+
override func layoutSubviews() {
|
|
2356
|
+
super.layoutSubviews()
|
|
2357
|
+
onContentLayout?()
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2208
2360
|
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
|
|
2209
2361
|
super.init(frame: frame, collectionViewLayout: layout)
|
|
2210
2362
|
delaysContentTouches = false
|
|
@@ -2223,6 +2375,7 @@ private final class NativeListCollectionView: UICollectionView {
|
|
|
2223
2375
|
|
|
2224
2376
|
final class NativeListFlowLayout: UICollectionViewFlowLayout {
|
|
2225
2377
|
var stickyItemIndexes: Set<Int> = []
|
|
2378
|
+
var minimumContainerContentHeight: CGFloat = 0
|
|
2226
2379
|
private var horizontalAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
|
|
2227
2380
|
private var horizontalContentSize: CGSize = .zero
|
|
2228
2381
|
|
|
@@ -2251,7 +2404,9 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout {
|
|
|
2251
2404
|
}
|
|
2252
2405
|
|
|
2253
2406
|
override var collectionViewContentSize: CGSize {
|
|
2254
|
-
scrollDirection == .horizontal
|
|
2407
|
+
if scrollDirection == .horizontal { return horizontalContentSize }
|
|
2408
|
+
let size = super.collectionViewContentSize
|
|
2409
|
+
return CGSize(width: size.width, height: max(size.height, minimumContainerContentHeight))
|
|
2255
2410
|
}
|
|
2256
2411
|
|
|
2257
2412
|
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|