@shortkitsdk/react-native 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +49 -11
- package/ios/ShortKitBridge.swift +85 -7
- package/ios/ShortKitCarouselOverlayBridge.swift +177 -12
- package/ios/ShortKitFeedView.swift +48 -25
- package/ios/ShortKitModule.mm +29 -4
- package/ios/ShortKitOverlayBridge.swift +2 -4
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1635 -457
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +50 -16
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +50 -16
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1635 -457
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +50 -16
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +50 -16
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/package.json +1 -1
- package/src/CarouselOverlayManager.tsx +0 -1
- package/src/OverlayManager.tsx +1 -1
- package/src/ShortKitContext.ts +11 -6
- package/src/ShortKitProvider.tsx +140 -66
- package/src/index.ts +4 -1
- package/src/serialization.ts +3 -3
- package/src/specs/NativeShortKitModule.ts +18 -16
- package/src/types.ts +26 -3
- package/src/useShortKitCarousel.ts +2 -2
- package/src/useShortKitPlayer.ts +0 -1
|
@@ -8,7 +8,7 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
|
8
8
|
import com.shortkit.CarouselImage
|
|
9
9
|
import com.shortkit.ContentItem
|
|
10
10
|
import com.shortkit.ContentSignal
|
|
11
|
-
import com.shortkit.
|
|
11
|
+
import com.shortkit.FeedInput
|
|
12
12
|
import com.shortkit.ImageCarouselItem
|
|
13
13
|
import com.shortkit.FeedConfig
|
|
14
14
|
import com.shortkit.FeedHeight
|
|
@@ -95,7 +95,6 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
95
95
|
override fun initialize(
|
|
96
96
|
apiKey: String,
|
|
97
97
|
config: String,
|
|
98
|
-
embedId: String?,
|
|
99
98
|
clientAppName: String?,
|
|
100
99
|
clientAppVersion: String?,
|
|
101
100
|
customDimensions: String?
|
|
@@ -112,7 +111,6 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
112
111
|
context = context,
|
|
113
112
|
apiKey = apiKey,
|
|
114
113
|
config = feedConfig,
|
|
115
|
-
embedId = embedId,
|
|
116
114
|
userId = null,
|
|
117
115
|
adProvider = null,
|
|
118
116
|
clientAppName = clientAppName,
|
|
@@ -124,11 +122,11 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
124
122
|
|
|
125
123
|
// Replay any feed items that arrived before initialization
|
|
126
124
|
pendingFeedItems?.let { json ->
|
|
127
|
-
|
|
125
|
+
parseFeedInputs(json)?.let { sdk.setFeedItems(it) }
|
|
128
126
|
pendingFeedItems = null
|
|
129
127
|
}
|
|
130
128
|
pendingAppendItems?.let { json ->
|
|
131
|
-
|
|
129
|
+
parseFeedInputs(json)?.let { sdk.appendFeedItems(it) }
|
|
132
130
|
pendingAppendItems = null
|
|
133
131
|
}
|
|
134
132
|
|
|
@@ -247,7 +245,7 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
247
245
|
override fun setFeedItems(items: String) {
|
|
248
246
|
val sdk = shortKit
|
|
249
247
|
if (sdk != null) {
|
|
250
|
-
val parsed =
|
|
248
|
+
val parsed = parseFeedInputs(items) ?: return
|
|
251
249
|
sdk.setFeedItems(parsed)
|
|
252
250
|
} else {
|
|
253
251
|
pendingFeedItems = items
|
|
@@ -258,7 +256,7 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
258
256
|
override fun appendFeedItems(items: String) {
|
|
259
257
|
val sdk = shortKit
|
|
260
258
|
if (sdk != null) {
|
|
261
|
-
val parsed =
|
|
259
|
+
val parsed = parseFeedInputs(items) ?: return
|
|
262
260
|
sdk.appendFeedItems(parsed)
|
|
263
261
|
} else {
|
|
264
262
|
pendingAppendItems = items
|
|
@@ -286,6 +284,46 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
286
284
|
}
|
|
287
285
|
}
|
|
288
286
|
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
// Storyboard / Seek Thumbnails
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
@ReactMethod
|
|
292
|
+
override fun prefetchStoryboard(playbackId: String) {
|
|
293
|
+
shortKit?.player?.prefetchStoryboard(playbackId)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@ReactMethod
|
|
297
|
+
override fun getStoryboardData(playbackId: String, promise: com.facebook.react.bridge.Promise) {
|
|
298
|
+
val player = shortKit?.player
|
|
299
|
+
if (player == null) {
|
|
300
|
+
promise.resolve(null)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
// Try cached data first
|
|
304
|
+
val json = player.getStoryboardData(playbackId)
|
|
305
|
+
if (json != null) {
|
|
306
|
+
promise.resolve(json)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
// Trigger prefetch and retry after a short delay
|
|
310
|
+
player.prefetchStoryboard(playbackId)
|
|
311
|
+
scope?.launch {
|
|
312
|
+
// Wait for prefetch to complete (poll with timeout)
|
|
313
|
+
var retries = 0
|
|
314
|
+
while (retries < 30) { // 3 seconds max
|
|
315
|
+
kotlinx.coroutines.delay(100)
|
|
316
|
+
val data = player.getStoryboardData(playbackId)
|
|
317
|
+
if (data != null) {
|
|
318
|
+
promise.resolve(data)
|
|
319
|
+
return@launch
|
|
320
|
+
}
|
|
321
|
+
retries++
|
|
322
|
+
}
|
|
323
|
+
promise.resolve(null)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
289
327
|
// -----------------------------------------------------------------------
|
|
290
328
|
// Overlay lifecycle events (called by Fabric view)
|
|
291
329
|
// -----------------------------------------------------------------------
|
|
@@ -774,21 +812,21 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
774
812
|
}
|
|
775
813
|
}
|
|
776
814
|
|
|
777
|
-
private fun
|
|
815
|
+
private fun parseFeedInputs(json: String): List<FeedInput>? {
|
|
778
816
|
return try {
|
|
779
817
|
val arr = JSONArray(json)
|
|
780
|
-
val result = mutableListOf<
|
|
818
|
+
val result = mutableListOf<FeedInput>()
|
|
781
819
|
for (i in 0 until arr.length()) {
|
|
782
820
|
val obj = arr.getJSONObject(i)
|
|
783
821
|
when (obj.optString("type")) {
|
|
784
822
|
"video" -> {
|
|
785
823
|
val playbackId = obj.optString("playbackId", null) ?: continue
|
|
786
|
-
result.add(
|
|
824
|
+
result.add(FeedInput.Video(playbackId))
|
|
787
825
|
}
|
|
788
826
|
"imageCarousel" -> {
|
|
789
827
|
val itemObj = obj.optJSONObject("item") ?: continue
|
|
790
828
|
val carouselItem = parseImageCarouselItem(itemObj) ?: continue
|
|
791
|
-
result.add(
|
|
829
|
+
result.add(FeedInput.ImageCarousel(carouselItem))
|
|
792
830
|
}
|
|
793
831
|
}
|
|
794
832
|
}
|
package/ios/ShortKitBridge.swift
CHANGED
|
@@ -23,7 +23,6 @@ import ShortKitSDK
|
|
|
23
23
|
@objc public init(
|
|
24
24
|
apiKey: String,
|
|
25
25
|
config configJSON: String,
|
|
26
|
-
embedId: String?,
|
|
27
26
|
clientAppName: String?,
|
|
28
27
|
clientAppVersion: String?,
|
|
29
28
|
customDimensions customDimensionsJSON: String?,
|
|
@@ -38,7 +37,6 @@ import ShortKitSDK
|
|
|
38
37
|
let sdk = ShortKit(
|
|
39
38
|
apiKey: apiKey,
|
|
40
39
|
config: feedConfig,
|
|
41
|
-
embedId: embedId,
|
|
42
40
|
clientAppName: clientAppName,
|
|
43
41
|
clientAppVersion: clientAppVersion,
|
|
44
42
|
customDimensions: dimensions
|
|
@@ -146,19 +144,75 @@ import ShortKitSDK
|
|
|
146
144
|
// MARK: - Custom Feed
|
|
147
145
|
|
|
148
146
|
@objc public func setFeedItems(_ json: String) {
|
|
149
|
-
guard let items = Self.
|
|
147
|
+
guard let items = Self.parseFeedInputs(json) else { return }
|
|
150
148
|
Task { @MainActor in
|
|
151
149
|
self.shortKit?.setFeedItems(items)
|
|
152
150
|
}
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
@objc public func appendFeedItems(_ json: String) {
|
|
156
|
-
guard let items = Self.
|
|
154
|
+
guard let items = Self.parseFeedInputs(json) else { return }
|
|
157
155
|
Task { @MainActor in
|
|
158
156
|
self.shortKit?.appendFeedItems(items)
|
|
159
157
|
}
|
|
160
158
|
}
|
|
161
159
|
|
|
160
|
+
// MARK: - Storyboard / Seek Thumbnails
|
|
161
|
+
|
|
162
|
+
@objc public func prefetchStoryboard(_ playbackId: String) {
|
|
163
|
+
Task {
|
|
164
|
+
_ = await StoryboardProvider.shared.fetch(playbackId: playbackId)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@objc public func getStoryboardData(_ playbackId: String, completion: @escaping (String?) -> Void) {
|
|
169
|
+
Task {
|
|
170
|
+
// Try cache first, otherwise fetch
|
|
171
|
+
let storyboard: CachedStoryboard?
|
|
172
|
+
if let cached = StoryboardProvider.shared.cached(for: playbackId) {
|
|
173
|
+
storyboard = cached
|
|
174
|
+
} else {
|
|
175
|
+
storyboard = await StoryboardProvider.shared.fetch(playbackId: playbackId)
|
|
176
|
+
}
|
|
177
|
+
guard let meta = storyboard?.metadata else {
|
|
178
|
+
completion(nil)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
// Compute sprite sheet dimensions from tile positions
|
|
182
|
+
var maxX = 0, maxY = 0
|
|
183
|
+
for tile in meta.tiles {
|
|
184
|
+
if tile.x > maxX { maxX = tile.x }
|
|
185
|
+
if tile.y > maxY { maxY = tile.y }
|
|
186
|
+
}
|
|
187
|
+
let imageWidth = maxX + meta.tileWidth
|
|
188
|
+
let imageHeight = maxY + meta.tileHeight
|
|
189
|
+
// Build JSON matching the shape the JS side expects
|
|
190
|
+
var tilesArr: [[String: Any]] = []
|
|
191
|
+
for tile in meta.tiles {
|
|
192
|
+
tilesArr.append(["start": tile.start, "x": tile.x, "y": tile.y])
|
|
193
|
+
}
|
|
194
|
+
let result: [String: Any] = [
|
|
195
|
+
"url": meta.url,
|
|
196
|
+
"tileWidth": meta.tileWidth,
|
|
197
|
+
"tileHeight": meta.tileHeight,
|
|
198
|
+
"duration": meta.duration,
|
|
199
|
+
"imageWidth": imageWidth,
|
|
200
|
+
"imageHeight": imageHeight,
|
|
201
|
+
"tiles": tilesArr,
|
|
202
|
+
]
|
|
203
|
+
if let data = try? JSONSerialization.data(withJSONObject: result),
|
|
204
|
+
let json = String(data: data, encoding: .utf8) {
|
|
205
|
+
completion(json)
|
|
206
|
+
} else {
|
|
207
|
+
completion(nil)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@objc public func notifyOverlayReady() {
|
|
213
|
+
NotificationCenter.default.post(name: .shortKitOverlayReady, object: nil)
|
|
214
|
+
}
|
|
215
|
+
|
|
162
216
|
@objc public func fetchContent(_ limit: Int, completion: @escaping (String) -> Void) {
|
|
163
217
|
Task {
|
|
164
218
|
do {
|
|
@@ -288,6 +342,24 @@ import ShortKitSDK
|
|
|
288
342
|
}
|
|
289
343
|
.store(in: &cancellables)
|
|
290
344
|
|
|
345
|
+
// Feed scroll phase
|
|
346
|
+
player.feedScrollPhase
|
|
347
|
+
.receive(on: DispatchQueue.main)
|
|
348
|
+
.sink { [weak self] phase in
|
|
349
|
+
switch phase {
|
|
350
|
+
case .dragging(let fromId):
|
|
351
|
+
self?.emit("onFeedScrollPhase", body: [
|
|
352
|
+
"phase": "dragging",
|
|
353
|
+
"fromId": fromId
|
|
354
|
+
])
|
|
355
|
+
case .settled:
|
|
356
|
+
self?.emit("onFeedScrollPhase", body: [
|
|
357
|
+
"phase": "settled"
|
|
358
|
+
])
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
.store(in: &cancellables)
|
|
362
|
+
|
|
291
363
|
// Format change
|
|
292
364
|
player.formatChange
|
|
293
365
|
.receive(on: DispatchQueue.main)
|
|
@@ -565,14 +637,14 @@ import ShortKitSDK
|
|
|
565
637
|
}
|
|
566
638
|
}
|
|
567
639
|
|
|
568
|
-
/// Parse a JSON string of
|
|
569
|
-
private static func
|
|
640
|
+
/// Parse a JSON string of FeedInput[] from the JS bridge.
|
|
641
|
+
private static func parseFeedInputs(_ json: String) -> [FeedInput]? {
|
|
570
642
|
guard let data = json.data(using: .utf8),
|
|
571
643
|
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
572
644
|
return nil
|
|
573
645
|
}
|
|
574
646
|
|
|
575
|
-
var result: [
|
|
647
|
+
var result: [FeedInput] = []
|
|
576
648
|
for obj in arr {
|
|
577
649
|
guard let type = obj["type"] as? String else { continue }
|
|
578
650
|
switch type {
|
|
@@ -614,3 +686,9 @@ extension ShortKitBridge: ShortKitDelegate {
|
|
|
614
686
|
])
|
|
615
687
|
}
|
|
616
688
|
}
|
|
689
|
+
|
|
690
|
+
// MARK: - Notification Names
|
|
691
|
+
|
|
692
|
+
extension Notification.Name {
|
|
693
|
+
static let shortKitOverlayReady = Notification.Name("ShortKitOverlayReady")
|
|
694
|
+
}
|
|
@@ -1,54 +1,219 @@
|
|
|
1
1
|
import UIKit
|
|
2
2
|
import ShortKitSDK
|
|
3
3
|
|
|
4
|
-
/// A
|
|
5
|
-
///
|
|
4
|
+
/// A UIView that conforms to `CarouselOverlay` and renders carousel images
|
|
5
|
+
/// natively inside the feed cell using a horizontal paging UIScrollView.
|
|
6
6
|
///
|
|
7
|
-
///
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
7
|
+
/// This matches the Swift SDK's `DefaultCarouselView` architecture: images
|
|
8
|
+
/// are rendered INSIDE the cell (part of the feed scroll view), so vertical
|
|
9
|
+
/// feed scrolling and horizontal image swiping work naturally through UIKit's
|
|
10
|
+
/// gesture conflict resolution.
|
|
11
|
+
///
|
|
12
|
+
/// The RN side only renders metadata (title, description, page dots, buttons)
|
|
13
|
+
/// on top via the `CarouselOverlayManager`.
|
|
11
14
|
@objc public class ShortKitCarouselOverlayBridge: UIView, @unchecked Sendable, CarouselOverlay {
|
|
12
15
|
|
|
13
16
|
// MARK: - Bridge Reference
|
|
14
17
|
|
|
15
|
-
/// Weak reference to the bridge, set by the factory closure in `parseFeedConfig`.
|
|
16
18
|
weak var bridge: ShortKitBridge?
|
|
17
19
|
|
|
20
|
+
// MARK: - CarouselOverlay
|
|
21
|
+
|
|
22
|
+
public var cachedImage: ((String) -> UIImage?)?
|
|
23
|
+
|
|
18
24
|
// MARK: - State
|
|
19
25
|
|
|
20
|
-
/// Stores the last configured ImageCarouselItem so we can pass it with
|
|
21
|
-
/// lifecycle events that don't receive the item as a parameter.
|
|
22
26
|
private var currentItem: ImageCarouselItem?
|
|
27
|
+
private var loadTasks: [Task<Void, Never>] = []
|
|
28
|
+
private var currentPage: Int = 0
|
|
29
|
+
private var autoScrollTimer: Timer?
|
|
30
|
+
|
|
31
|
+
// MARK: - UI
|
|
32
|
+
|
|
33
|
+
private let scrollView = UIScrollView()
|
|
34
|
+
private let pageControl = UIPageControl()
|
|
35
|
+
private var imageViews: [UIImageView] = []
|
|
23
36
|
|
|
24
37
|
// MARK: - Init
|
|
25
38
|
|
|
26
39
|
override init(frame: CGRect) {
|
|
27
40
|
super.init(frame: frame)
|
|
28
|
-
backgroundColor = .
|
|
41
|
+
backgroundColor = .black
|
|
29
42
|
isUserInteractionEnabled = true
|
|
43
|
+
setupScrollView()
|
|
44
|
+
setupPageControl()
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
required init?(coder: NSCoder) {
|
|
33
48
|
fatalError("init(coder:) is not supported")
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
private func setupScrollView() {
|
|
52
|
+
scrollView.isPagingEnabled = true
|
|
53
|
+
scrollView.showsHorizontalScrollIndicator = false
|
|
54
|
+
scrollView.bounces = false
|
|
55
|
+
scrollView.delegate = self
|
|
56
|
+
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
57
|
+
addSubview(scrollView)
|
|
58
|
+
NSLayoutConstraint.activate([
|
|
59
|
+
scrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
60
|
+
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
61
|
+
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
62
|
+
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
63
|
+
])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func setupPageControl() {
|
|
67
|
+
pageControl.isUserInteractionEnabled = false
|
|
68
|
+
pageControl.translatesAutoresizingMaskIntoConstraints = false
|
|
69
|
+
addSubview(pageControl)
|
|
70
|
+
NSLayoutConstraint.activate([
|
|
71
|
+
pageControl.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
72
|
+
pageControl.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -40),
|
|
73
|
+
])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Layout
|
|
77
|
+
|
|
78
|
+
public override func layoutSubviews() {
|
|
79
|
+
super.layoutSubviews()
|
|
80
|
+
let w = bounds.width
|
|
81
|
+
let h = bounds.height
|
|
82
|
+
guard w > 0, h > 0 else { return }
|
|
83
|
+
scrollView.contentSize = CGSize(width: w * CGFloat(imageViews.count), height: h)
|
|
84
|
+
for (i, iv) in imageViews.enumerated() {
|
|
85
|
+
iv.frame = CGRect(x: w * CGFloat(i), y: 0, width: w, height: h)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
36
89
|
// MARK: - CarouselOverlay
|
|
37
90
|
|
|
38
91
|
public func configure(with item: ImageCarouselItem) {
|
|
92
|
+
NSLog("[CarouselBridge] configure id=\(item.id) images=\(item.images.count)")
|
|
39
93
|
currentItem = item
|
|
94
|
+
|
|
95
|
+
// Cancel previous loads & auto-scroll
|
|
96
|
+
cancelLoads()
|
|
97
|
+
autoScrollTimer?.invalidate()
|
|
98
|
+
autoScrollTimer = nil
|
|
99
|
+
|
|
100
|
+
// Remove old image views
|
|
101
|
+
for iv in imageViews { iv.removeFromSuperview() }
|
|
102
|
+
imageViews.removeAll()
|
|
103
|
+
|
|
104
|
+
// Create image views and load images
|
|
105
|
+
for image in item.images {
|
|
106
|
+
let iv = UIImageView()
|
|
107
|
+
iv.contentMode = .scaleAspectFill
|
|
108
|
+
iv.clipsToBounds = true
|
|
109
|
+
iv.backgroundColor = .black
|
|
110
|
+
scrollView.addSubview(iv)
|
|
111
|
+
imageViews.append(iv)
|
|
112
|
+
|
|
113
|
+
if let cached = cachedImage?(image.url) {
|
|
114
|
+
iv.image = cached
|
|
115
|
+
} else {
|
|
116
|
+
let task = Task { [weak iv] in
|
|
117
|
+
guard let url = URL(string: image.url),
|
|
118
|
+
let (data, _) = try? await URLSession.shared.data(from: url),
|
|
119
|
+
!Task.isCancelled,
|
|
120
|
+
let uiImage = UIImage(data: data) else { return }
|
|
121
|
+
await MainActor.run { iv?.image = uiImage }
|
|
122
|
+
}
|
|
123
|
+
loadTasks.append(task)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Reset scroll position and page
|
|
128
|
+
currentPage = 0
|
|
129
|
+
scrollView.contentOffset = .zero
|
|
130
|
+
setNeedsLayout()
|
|
131
|
+
|
|
132
|
+
// Update page control
|
|
133
|
+
pageControl.numberOfPages = item.images.count
|
|
134
|
+
pageControl.currentPage = 0
|
|
135
|
+
pageControl.isHidden = item.images.count <= 1
|
|
136
|
+
|
|
137
|
+
// Start auto-scroll if configured
|
|
138
|
+
let interval = item.autoScrollInterval ?? 0
|
|
139
|
+
if interval > 0 && item.images.count > 1 {
|
|
140
|
+
startAutoScroll(interval: interval)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Emit configure to JS so the metadata overlay updates
|
|
40
144
|
bridge?.emitCarouselOverlayEvent("onCarouselOverlayConfigure", item: item)
|
|
41
145
|
}
|
|
42
146
|
|
|
43
147
|
public func resetState() {
|
|
148
|
+
NSLog("[CarouselBridge] resetState")
|
|
149
|
+
cancelLoads()
|
|
150
|
+
autoScrollTimer?.invalidate()
|
|
151
|
+
autoScrollTimer = nil
|
|
152
|
+
for iv in imageViews { iv.removeFromSuperview() }
|
|
153
|
+
imageViews.removeAll()
|
|
154
|
+
scrollView.contentOffset = .zero
|
|
155
|
+
currentPage = 0
|
|
156
|
+
currentItem = nil
|
|
44
157
|
bridge?.emitCarouselOverlayEvent("onCarouselOverlayReset", body: [:])
|
|
45
158
|
}
|
|
46
159
|
|
|
47
160
|
public func fadeOutForTransition() {
|
|
48
|
-
|
|
161
|
+
// No-op — native images are inside the cell, transitions are handled
|
|
162
|
+
// by the feed's collection view.
|
|
49
163
|
}
|
|
50
164
|
|
|
51
165
|
public func restoreFromTransition() {
|
|
52
|
-
|
|
166
|
+
// No-op
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// MARK: - Auto-Scroll
|
|
170
|
+
|
|
171
|
+
private func startAutoScroll(interval: Double) {
|
|
172
|
+
autoScrollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
173
|
+
guard let self, !self.imageViews.isEmpty else { return }
|
|
174
|
+
let nextPage = (self.currentPage + 1) % self.imageViews.count
|
|
175
|
+
let offset = CGFloat(nextPage) * self.scrollView.bounds.width
|
|
176
|
+
self.scrollView.setContentOffset(CGPoint(x: offset, y: 0), animated: true)
|
|
177
|
+
self.currentPage = nextPage
|
|
178
|
+
self.pageControl.currentPage = nextPage
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MARK: - Helpers
|
|
183
|
+
|
|
184
|
+
private func cancelLoads() {
|
|
185
|
+
for task in loadTasks { task.cancel() }
|
|
186
|
+
loadTasks.removeAll()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// MARK: - UIScrollViewDelegate
|
|
192
|
+
|
|
193
|
+
extension ShortKitCarouselOverlayBridge: UIScrollViewDelegate {
|
|
194
|
+
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
195
|
+
guard scrollView.bounds.width > 0 else { return }
|
|
196
|
+
let page = Int(round(scrollView.contentOffset.x / scrollView.bounds.width))
|
|
197
|
+
currentPage = page
|
|
198
|
+
pageControl.currentPage = page
|
|
199
|
+
|
|
200
|
+
// Restart auto-scroll after user interaction
|
|
201
|
+
if let interval = currentItem?.autoScrollInterval, interval > 0, imageViews.count > 1 {
|
|
202
|
+
autoScrollTimer?.invalidate()
|
|
203
|
+
startAutoScroll(interval: interval)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
|
208
|
+
guard scrollView.bounds.width > 0 else { return }
|
|
209
|
+
let page = Int(round(scrollView.contentOffset.x / scrollView.bounds.width))
|
|
210
|
+
currentPage = page
|
|
211
|
+
pageControl.currentPage = page
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
215
|
+
// Pause auto-scroll during user interaction
|
|
216
|
+
autoScrollTimer?.invalidate()
|
|
217
|
+
autoScrollTimer = nil
|
|
53
218
|
}
|
|
54
219
|
}
|
|
@@ -37,8 +37,12 @@ import ShortKitSDK
|
|
|
37
37
|
private weak var nextCarouselOverlayView: UIView?
|
|
38
38
|
/// The page index used for overlay transform calculations.
|
|
39
39
|
private var currentPage: Int = 0
|
|
40
|
-
///
|
|
41
|
-
private var
|
|
40
|
+
/// Closure to execute the pending overlay transform swap once JS signals ready.
|
|
41
|
+
private var pendingSwap: (() -> Void)?
|
|
42
|
+
/// Fallback timer in case JS never calls notifyOverlayReady.
|
|
43
|
+
private var swapFallbackWorkItem: DispatchWorkItem?
|
|
44
|
+
/// Observer for the overlay-ready notification from the JS bridge.
|
|
45
|
+
private var overlayReadyObserver: NSObjectProtocol?
|
|
42
46
|
|
|
43
47
|
// MARK: - Lifecycle
|
|
44
48
|
|
|
@@ -89,11 +93,17 @@ import ShortKitSDK
|
|
|
89
93
|
feedVC.didMove(toParent: parentVC)
|
|
90
94
|
|
|
91
95
|
setupScrollTracking(feedVC)
|
|
96
|
+
setupOverlayReadyObserver()
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
private func removeFeedViewController() {
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
pendingSwap = nil
|
|
101
|
+
swapFallbackWorkItem?.cancel()
|
|
102
|
+
swapFallbackWorkItem = nil
|
|
103
|
+
if let observer = overlayReadyObserver {
|
|
104
|
+
NotificationCenter.default.removeObserver(observer)
|
|
105
|
+
overlayReadyObserver = nil
|
|
106
|
+
}
|
|
97
107
|
scrollObservation?.invalidate()
|
|
98
108
|
scrollObservation = nil
|
|
99
109
|
currentOverlayView?.transform = .identity
|
|
@@ -132,44 +142,45 @@ import ShortKitSDK
|
|
|
132
142
|
|
|
133
143
|
let offset = scrollView.contentOffset.y
|
|
134
144
|
|
|
135
|
-
// Detect page change, but DEFER updating currentPage
|
|
145
|
+
// Detect page change, but DEFER updating currentPage until JS
|
|
146
|
+
// signals that overlay-current has been re-rendered with new content.
|
|
136
147
|
//
|
|
137
148
|
// Why: when the scroll settles on a new page, overlay-current still
|
|
138
|
-
// shows the OLD page's metadata
|
|
139
|
-
//
|
|
140
|
-
// overlay-current becomes visible with stale data.
|
|
149
|
+
// shows the OLD page's metadata. If we update currentPage immediately,
|
|
150
|
+
// delta snaps to 0 and overlay-current becomes visible with stale data.
|
|
141
151
|
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// been re-rendered with correct data and takes over seamlessly.
|
|
152
|
+
// Instead, we store a pending swap closure. JS calls notifyOverlayReady()
|
|
153
|
+
// after processing OVERLAY_ACTIVATE and resetting overlay opacity, which
|
|
154
|
+
// executes the swap deterministically. A fallback timer catches edge cases.
|
|
146
155
|
let nearestPage = Int(round(offset / cellHeight))
|
|
147
156
|
if abs(offset - CGFloat(nearestPage) * cellHeight) < 1.0 {
|
|
148
|
-
if nearestPage != currentPage &&
|
|
157
|
+
if nearestPage != currentPage && pendingSwap == nil {
|
|
149
158
|
let targetPage = nearestPage
|
|
150
|
-
|
|
159
|
+
pendingSwap = { [weak self] in
|
|
151
160
|
guard let self else { return }
|
|
152
161
|
self.currentPage = targetPage
|
|
153
|
-
self.
|
|
162
|
+
self.pendingSwap = nil
|
|
163
|
+
self.swapFallbackWorkItem?.cancel()
|
|
164
|
+
self.swapFallbackWorkItem = nil
|
|
154
165
|
// Reapply overlay transforms now that currentPage is updated.
|
|
155
|
-
// Without this, overlay-next (static NextOverlayProvider state)
|
|
156
|
-
// stays visible at y=0 while overlay-current (live state) stays
|
|
157
|
-
// hidden — no scroll event fires to trigger handleScrollOffset.
|
|
158
166
|
let h = self.bounds.height
|
|
159
167
|
self.currentOverlayView?.transform = .identity
|
|
160
168
|
self.nextOverlayView?.transform = CGAffineTransform(translationX: 0, y: h)
|
|
161
169
|
self.currentCarouselOverlayView?.transform = .identity
|
|
162
170
|
self.nextCarouselOverlayView?.transform = CGAffineTransform(translationX: 0, y: h)
|
|
163
171
|
}
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
// Fallback: if JS never signals (e.g. overlay config is 'none'),
|
|
173
|
+
// execute after 500ms to avoid getting stuck.
|
|
174
|
+
let fallback = DispatchWorkItem { [weak self] in
|
|
175
|
+
self?.pendingSwap?()
|
|
176
|
+
}
|
|
177
|
+
swapFallbackWorkItem = fallback
|
|
178
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: fallback)
|
|
166
179
|
}
|
|
167
|
-
} else if
|
|
168
|
-
// User is scrolling again —
|
|
180
|
+
} else if pendingSwap != nil {
|
|
181
|
+
// User is scrolling again — execute pending swap immediately
|
|
169
182
|
// so transforms stay aligned for the new gesture.
|
|
170
|
-
|
|
171
|
-
pageUpdateWorkItem = nil
|
|
172
|
-
currentPage = nearestPage
|
|
183
|
+
pendingSwap?()
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
let delta = offset - CGFloat(currentPage) * cellHeight
|
|
@@ -202,6 +213,18 @@ import ShortKitSDK
|
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
// MARK: - Overlay Ready Handshake
|
|
217
|
+
|
|
218
|
+
private func setupOverlayReadyObserver() {
|
|
219
|
+
overlayReadyObserver = NotificationCenter.default.addObserver(
|
|
220
|
+
forName: .shortKitOverlayReady,
|
|
221
|
+
object: nil,
|
|
222
|
+
queue: .main
|
|
223
|
+
) { [weak self] _ in
|
|
224
|
+
self?.pendingSwap?()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
205
228
|
/// Find the sibling RN overlay views by nativeID.
|
|
206
229
|
///
|
|
207
230
|
/// In Fabric interop, `ShortKitFeedView` (a Paper-style `RCTViewManager`
|