craft-native 0.0.113 → 0.0.115
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/dist/android/src/index.js +6 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -58,6 +58,7 @@ dependencies {
|
|
|
58
58
|
implementation("androidx.core:core-ktx:1.12.0")
|
|
59
59
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
|
60
60
|
implementation("androidx.fragment:fragment-ktx:1.6.2")
|
|
61
|
+
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
|
61
62
|
implementation("androidx.webkit:webkit:1.12.1")
|
|
62
63
|
{{JAVASCRIPT_ENGINE_DEPENDENCY}}
|
|
63
64
|
implementation("com.google.android.material:material:1.11.0")
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.115";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
|
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
310
310
|
`);
|
|
311
311
|
}
|
|
312
312
|
// package.json
|
|
313
|
-
var version = "0.0.
|
|
313
|
+
var version = "0.0.115";
|
|
314
314
|
|
|
315
315
|
// src/sdk-version.ts
|
|
316
316
|
var SDK_VERSION = version;
|
|
@@ -3649,6 +3649,13 @@ var _watchRecursiveWarned = false;
|
|
|
3649
3649
|
async function watch(path, callback, options = {}) {
|
|
3650
3650
|
validatePath(path);
|
|
3651
3651
|
const recursive = options.recursive ?? true;
|
|
3652
|
+
const native2 = getCraftFs();
|
|
3653
|
+
if (native2?.watch) {
|
|
3654
|
+
const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
|
|
3655
|
+
return () => {
|
|
3656
|
+
handle.unwatch();
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3652
3659
|
if (typeof window !== "undefined" && window.craft) {
|
|
3653
3660
|
const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
|
|
3654
3661
|
await callBridge("fs.watch", { path, watchId, recursive });
|
package/dist/index.d.cts
CHANGED
|
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
|
|
|
81
81
|
* Quick helper to load a URL
|
|
82
82
|
*/
|
|
83
83
|
export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
|
|
84
|
-
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
|
84
|
+
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
|
|
|
81
81
|
* Quick helper to load a URL
|
|
82
82
|
*/
|
|
83
83
|
export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
|
|
84
|
-
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
|
84
|
+
export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
// package.json
|
|
61
|
-
var version = "0.0.
|
|
61
|
+
var version = "0.0.115";
|
|
62
62
|
|
|
63
63
|
// src/sdk-version.ts
|
|
64
64
|
var SDK_VERSION = version;
|
|
@@ -3409,6 +3409,13 @@ var _watchRecursiveWarned = false;
|
|
|
3409
3409
|
async function watch(path, callback, options = {}) {
|
|
3410
3410
|
validatePath(path);
|
|
3411
3411
|
const recursive = options.recursive ?? true;
|
|
3412
|
+
const native2 = getCraftFs();
|
|
3413
|
+
if (native2?.watch) {
|
|
3414
|
+
const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
|
|
3415
|
+
return () => {
|
|
3416
|
+
handle.unwatch();
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3412
3419
|
if (typeof window !== "undefined" && window.craft) {
|
|
3413
3420
|
const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
|
|
3414
3421
|
await callBridge("fs.watch", { path, watchId, recursive });
|
package/dist/ios/src/index.js
CHANGED
|
@@ -406,7 +406,9 @@ async function init(options) {
|
|
|
406
406
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
407
407
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
408
408
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
409
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
409
410
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
411
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
410
412
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
411
413
|
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
412
414
|
<true/>
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
|
|
4
|
+
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
|
5
|
+
guard let current = collectionView?.bounds else { return true }
|
|
6
|
+
return scrollDirection == .vertical
|
|
7
|
+
? abs(current.width - newBounds.width) > .ulpOfOne
|
|
8
|
+
: abs(current.height - newBounds.height) > .ulpOfOne
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// A keyed, recycling native list used by the stx-native FlatList primitive.
|
|
13
|
+
/// The screen controller owns rendered row state; this view owns collection
|
|
14
|
+
/// diffs, viewport state, and UICollectionView cell reuse.
|
|
15
|
+
final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
|
|
16
|
+
typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
|
|
17
|
+
|
|
18
|
+
private final class Cell: UICollectionViewCell {
|
|
19
|
+
static let reuseIdentifier = "CraftNativeFlatListCell"
|
|
20
|
+
|
|
21
|
+
private(set) var representedIdentity: String?
|
|
22
|
+
private(set) var hostedView: UIView?
|
|
23
|
+
var onRecycle: ((String) -> Void)?
|
|
24
|
+
|
|
25
|
+
override func prepareForReuse() {
|
|
26
|
+
super.prepareForReuse()
|
|
27
|
+
releaseHostedView()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func host(_ view: UIView, identity: String) {
|
|
31
|
+
if representedIdentity != identity { releaseHostedView() }
|
|
32
|
+
representedIdentity = identity
|
|
33
|
+
guard hostedView !== view else { return }
|
|
34
|
+
hostedView?.removeFromSuperview()
|
|
35
|
+
hostedView = view
|
|
36
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
37
|
+
contentView.addSubview(view)
|
|
38
|
+
NSLayoutConstraint.activate([
|
|
39
|
+
view.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
40
|
+
view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
41
|
+
view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
42
|
+
view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
43
|
+
])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func releaseHostedView() {
|
|
47
|
+
if let identity = representedIdentity { onRecycle?(identity) }
|
|
48
|
+
hostedView?.removeFromSuperview()
|
|
49
|
+
hostedView = nil
|
|
50
|
+
representedIdentity = nil
|
|
51
|
+
onRecycle = nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override func preferredLayoutAttributesFitting(
|
|
55
|
+
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
56
|
+
) -> UICollectionViewLayoutAttributes {
|
|
57
|
+
guard let hostedView = hostedView else { return layoutAttributes }
|
|
58
|
+
let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
59
|
+
let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
|
|
60
|
+
let measured = hostedView.systemLayoutSizeFitting(
|
|
61
|
+
target,
|
|
62
|
+
withHorizontalFittingPriority: .required,
|
|
63
|
+
verticalFittingPriority: .fittingSizeLevel
|
|
64
|
+
)
|
|
65
|
+
attributes.size.height = max(1, measured.height)
|
|
66
|
+
return attributes
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private struct Item {
|
|
71
|
+
let identity: String
|
|
72
|
+
let node: [String: Any]
|
|
73
|
+
let signature: Data
|
|
74
|
+
let isChrome: Bool
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private struct ViewportAnchor {
|
|
78
|
+
let identity: String
|
|
79
|
+
let distanceFromOrigin: CGFloat
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private let flowLayout: UICollectionViewFlowLayout
|
|
83
|
+
private var diffableDataSource: UICollectionViewDiffableDataSource<Int, String>!
|
|
84
|
+
private var items: [Item] = []
|
|
85
|
+
private var itemsByIdentity: [String: Item] = [:]
|
|
86
|
+
private var renderItem: RenderItem?
|
|
87
|
+
private var recycleItem: ((String) -> Void)?
|
|
88
|
+
private var endReached: (() -> Void)?
|
|
89
|
+
private var endReachedThreshold = 0.1
|
|
90
|
+
private var endReachedSignature: String?
|
|
91
|
+
private var columns = 1
|
|
92
|
+
private var isApplyingSnapshot = false
|
|
93
|
+
private var pendingApply: (() -> Void)?
|
|
94
|
+
|
|
95
|
+
init() {
|
|
96
|
+
let layout = CraftNativeFlatListLayout()
|
|
97
|
+
layout.estimatedItemSize = CGSize(width: 320, height: 44)
|
|
98
|
+
layout.minimumLineSpacing = 0
|
|
99
|
+
layout.minimumInteritemSpacing = 0
|
|
100
|
+
flowLayout = layout
|
|
101
|
+
super.init(frame: .zero, collectionViewLayout: layout)
|
|
102
|
+
backgroundColor = .clear
|
|
103
|
+
alwaysBounceVertical = true
|
|
104
|
+
delegate = self
|
|
105
|
+
register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
|
|
106
|
+
diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
|
|
107
|
+
[weak self] collectionView, indexPath, identity in
|
|
108
|
+
guard let self = self,
|
|
109
|
+
let item = self.itemsByIdentity[identity],
|
|
110
|
+
let cell = collectionView.dequeueReusableCell(
|
|
111
|
+
withReuseIdentifier: Cell.reuseIdentifier,
|
|
112
|
+
for: indexPath
|
|
113
|
+
) as? Cell,
|
|
114
|
+
let renderItem = self.renderItem else { return UICollectionViewCell() }
|
|
115
|
+
let view = renderItem(item.node, identity, cell.hostedView)
|
|
116
|
+
cell.onRecycle = self.recycleItem
|
|
117
|
+
cell.host(view, identity: identity)
|
|
118
|
+
return cell
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
required init?(coder: NSCoder) { nil }
|
|
123
|
+
|
|
124
|
+
var visibleItemIdentities: [String] {
|
|
125
|
+
indexPathsForVisibleItems
|
|
126
|
+
.sorted { $0.item < $1.item }
|
|
127
|
+
.compactMap { diffableDataSource.itemIdentifier(for: $0) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func apply(
|
|
131
|
+
nodes: [[String: Any]],
|
|
132
|
+
horizontal: Bool,
|
|
133
|
+
columns: Int,
|
|
134
|
+
inverted: Bool,
|
|
135
|
+
endReachedThreshold: Double,
|
|
136
|
+
renderItem: @escaping RenderItem,
|
|
137
|
+
recycleItem: @escaping (String) -> Void,
|
|
138
|
+
endReached: (() -> Void)?
|
|
139
|
+
) {
|
|
140
|
+
if isApplyingSnapshot {
|
|
141
|
+
pendingApply = { [weak self] in
|
|
142
|
+
self?.apply(
|
|
143
|
+
nodes: nodes,
|
|
144
|
+
horizontal: horizontal,
|
|
145
|
+
columns: columns,
|
|
146
|
+
inverted: inverted,
|
|
147
|
+
endReachedThreshold: endReachedThreshold,
|
|
148
|
+
renderItem: renderItem,
|
|
149
|
+
recycleItem: recycleItem,
|
|
150
|
+
endReached: endReached
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
isApplyingSnapshot = true
|
|
156
|
+
|
|
157
|
+
self.renderItem = renderItem
|
|
158
|
+
self.recycleItem = recycleItem
|
|
159
|
+
self.endReached = endReached
|
|
160
|
+
self.endReachedThreshold = max(0, endReachedThreshold)
|
|
161
|
+
self.columns = max(1, columns)
|
|
162
|
+
|
|
163
|
+
layoutIfNeeded()
|
|
164
|
+
let oldItems = itemsByIdentity
|
|
165
|
+
let viewportAnchor = currentViewportAnchor()
|
|
166
|
+
var seen: [String: Int] = [:]
|
|
167
|
+
var next = nodes.enumerated().map { index, node -> Item in
|
|
168
|
+
let base = Self.identity(for: node) ?? "index:\(index)"
|
|
169
|
+
let occurrence = seen[base, default: 0]
|
|
170
|
+
seen[base] = occurrence + 1
|
|
171
|
+
let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
172
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
173
|
+
let role = props["listRole"] as? String
|
|
174
|
+
let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
|
|
175
|
+
return Item(identity: identity, node: node, signature: signature, isChrome: role != nil && role != "item")
|
|
176
|
+
}
|
|
177
|
+
if inverted { next.reverse() }
|
|
178
|
+
items = next
|
|
179
|
+
itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
|
|
180
|
+
|
|
181
|
+
flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
|
|
182
|
+
alwaysBounceHorizontal = horizontal
|
|
183
|
+
alwaysBounceVertical = !horizontal
|
|
184
|
+
showsHorizontalScrollIndicator = horizontal
|
|
185
|
+
showsVerticalScrollIndicator = !horizontal
|
|
186
|
+
flowLayout.invalidateLayout()
|
|
187
|
+
|
|
188
|
+
let nextIds = Set(itemsByIdentity.keys)
|
|
189
|
+
for identity in oldItems.keys where !nextIds.contains(identity) { recycleItem(identity) }
|
|
190
|
+
let changed = next.compactMap { item -> String? in
|
|
191
|
+
guard let old = oldItems[item.identity], old.signature != item.signature else { return nil }
|
|
192
|
+
return item.identity
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
|
196
|
+
snapshot.appendSections([0])
|
|
197
|
+
snapshot.appendItems(next.map(\.identity), toSection: 0)
|
|
198
|
+
let retainedOffset = contentOffset
|
|
199
|
+
diffableDataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
|
|
200
|
+
guard let self = self else { return }
|
|
201
|
+
let live = Set(self.diffableDataSource.snapshot().itemIdentifiers)
|
|
202
|
+
let changedLive = changed.filter { live.contains($0) }
|
|
203
|
+
let restoreViewport = { [weak self] in
|
|
204
|
+
guard let self = self else { return }
|
|
205
|
+
self.layoutIfNeeded()
|
|
206
|
+
if let viewportAnchor = viewportAnchor {
|
|
207
|
+
self.restore(viewportAnchor)
|
|
208
|
+
} else {
|
|
209
|
+
self.setContentOffset(self.clamped(retainedOffset), animated: false)
|
|
210
|
+
}
|
|
211
|
+
self.finishApplyingSnapshot()
|
|
212
|
+
}
|
|
213
|
+
if !changedLive.isEmpty {
|
|
214
|
+
var update = self.diffableDataSource.snapshot()
|
|
215
|
+
update.reconfigureItems(changedLive)
|
|
216
|
+
self.diffableDataSource.apply(update, animatingDifferences: false, completion: restoreViewport)
|
|
217
|
+
} else {
|
|
218
|
+
restoreViewport()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let dataItems = next.filter { !$0.isChrome }
|
|
223
|
+
let contentSignature = "\(dataItems.count):\(dataItems.last?.identity ?? "empty")"
|
|
224
|
+
if contentSignature != endReachedSignature { endReachedSignature = nil }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func discardAll() {
|
|
228
|
+
visibleCells.compactMap { $0 as? Cell }.forEach { $0.releaseHostedView() }
|
|
229
|
+
itemsByIdentity.keys.forEach { recycleItem?($0) }
|
|
230
|
+
items = []
|
|
231
|
+
itemsByIdentity = [:]
|
|
232
|
+
renderItem = nil
|
|
233
|
+
recycleItem = nil
|
|
234
|
+
endReached = nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func scrollViewDidScroll(_ scrollView: UIScrollView) { evaluateEndReached() }
|
|
238
|
+
|
|
239
|
+
func collectionView(
|
|
240
|
+
_ collectionView: UICollectionView,
|
|
241
|
+
layout collectionViewLayout: UICollectionViewLayout,
|
|
242
|
+
sizeForItemAt indexPath: IndexPath
|
|
243
|
+
) -> CGSize {
|
|
244
|
+
guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
|
|
245
|
+
let item = items[indexPath.item]
|
|
246
|
+
if flowLayout.scrollDirection == .horizontal {
|
|
247
|
+
return CGSize(width: max(1, bounds.width * 0.8), height: max(1, bounds.height))
|
|
248
|
+
}
|
|
249
|
+
let count = item.isChrome ? 1 : columns
|
|
250
|
+
let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
|
|
251
|
+
return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private func evaluateEndReached() {
|
|
255
|
+
guard !isApplyingSnapshot, pendingApply == nil,
|
|
256
|
+
let endReached = endReached,
|
|
257
|
+
let lastVisible = indexPathsForVisibleItems.map(\.item).max() else { return }
|
|
258
|
+
let dataIndices = items.indices.filter { !items[$0].isChrome }
|
|
259
|
+
guard !dataIndices.isEmpty,
|
|
260
|
+
let lastDataOrdinal = dataIndices.lastIndex(where: { $0 <= lastVisible }) else { return }
|
|
261
|
+
let remaining = dataIndices.count - lastDataOrdinal - 1
|
|
262
|
+
let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
|
|
263
|
+
guard remaining <= thresholdItems else { return }
|
|
264
|
+
let lastIdentity = items[dataIndices[dataIndices.count - 1]].identity
|
|
265
|
+
let signature = "\(dataIndices.count):\(lastIdentity)"
|
|
266
|
+
guard endReachedSignature != signature else { return }
|
|
267
|
+
endReachedSignature = signature
|
|
268
|
+
endReached()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func finishApplyingSnapshot() {
|
|
272
|
+
DispatchQueue.main.async { [weak self] in
|
|
273
|
+
guard let self = self else { return }
|
|
274
|
+
self.isApplyingSnapshot = false
|
|
275
|
+
if let pendingApply = self.pendingApply {
|
|
276
|
+
self.pendingApply = nil
|
|
277
|
+
pendingApply()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
self.evaluateEndReached()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func clamped(_ offset: CGPoint) -> CGPoint {
|
|
285
|
+
CGPoint(
|
|
286
|
+
x: min(max(-adjustedContentInset.left, offset.x), max(-adjustedContentInset.left, contentSize.width - bounds.width + adjustedContentInset.right)),
|
|
287
|
+
y: min(max(-adjustedContentInset.top, offset.y), max(-adjustedContentInset.top, contentSize.height - bounds.height + adjustedContentInset.bottom))
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private func currentViewportAnchor() -> ViewportAnchor? {
|
|
292
|
+
let horizontal = flowLayout.scrollDirection == .horizontal
|
|
293
|
+
let ordered = indexPathsForVisibleItems.sorted { lhs, rhs in
|
|
294
|
+
let left = layoutAttributesForItem(at: lhs)?.frame
|
|
295
|
+
let right = layoutAttributesForItem(at: rhs)?.frame
|
|
296
|
+
return horizontal ? (left?.minX ?? 0) < (right?.minX ?? 0) : (left?.minY ?? 0) < (right?.minY ?? 0)
|
|
297
|
+
}
|
|
298
|
+
guard let indexPath = ordered.first,
|
|
299
|
+
let identity = diffableDataSource.itemIdentifier(for: indexPath),
|
|
300
|
+
let frame = layoutAttributesForItem(at: indexPath)?.frame else { return nil }
|
|
301
|
+
let distance = horizontal ? frame.minX - contentOffset.x : frame.minY - contentOffset.y
|
|
302
|
+
return ViewportAnchor(identity: identity, distanceFromOrigin: distance)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private func restore(_ anchor: ViewportAnchor) {
|
|
306
|
+
guard let indexPath = diffableDataSource.indexPath(for: anchor.identity),
|
|
307
|
+
let frame = layoutAttributesForItem(at: indexPath)?.frame else { return }
|
|
308
|
+
var offset = contentOffset
|
|
309
|
+
if flowLayout.scrollDirection == .horizontal {
|
|
310
|
+
offset.x = frame.minX - anchor.distanceFromOrigin
|
|
311
|
+
} else {
|
|
312
|
+
offset.y = frame.minY - anchor.distanceFromOrigin
|
|
313
|
+
}
|
|
314
|
+
setContentOffset(clamped(offset), animated: false)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private static func identity(for node: [String: Any]) -> String? {
|
|
318
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
319
|
+
return [node["id"], node["key"], props["key"], props["testID"]]
|
|
320
|
+
.compactMap { $0 as? String }
|
|
321
|
+
.first { !$0.isEmpty }
|
|
322
|
+
}
|
|
323
|
+
}
|