@shortkitsdk/react-native 0.2.4 → 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.
Files changed (30) hide show
  1. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +49 -11
  2. package/ios/ShortKitBridge.swift +85 -7
  3. package/ios/ShortKitCarouselOverlayBridge.swift +177 -12
  4. package/ios/ShortKitFeedView.swift +48 -25
  5. package/ios/ShortKitModule.mm +29 -4
  6. package/ios/ShortKitOverlayBridge.swift +2 -4
  7. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -0
  8. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1635 -457
  9. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +50 -16
  10. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  11. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +50 -16
  12. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  13. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -0
  14. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1635 -457
  15. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +50 -16
  16. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  17. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +50 -16
  18. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  19. package/package.json +1 -1
  20. package/plugin/build/withShortKitIOS.js +11 -2
  21. package/src/CarouselOverlayManager.tsx +0 -1
  22. package/src/OverlayManager.tsx +1 -1
  23. package/src/ShortKitContext.ts +11 -6
  24. package/src/ShortKitProvider.tsx +140 -66
  25. package/src/index.ts +4 -1
  26. package/src/serialization.ts +3 -3
  27. package/src/specs/NativeShortKitModule.ts +18 -16
  28. package/src/types.ts +26 -3
  29. package/src/useShortKitCarousel.ts +2 -2
  30. 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.CustomFeedItem
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
- parseCustomFeedItems(json)?.let { sdk.setFeedItems(it) }
125
+ parseFeedInputs(json)?.let { sdk.setFeedItems(it) }
128
126
  pendingFeedItems = null
129
127
  }
130
128
  pendingAppendItems?.let { json ->
131
- parseCustomFeedItems(json)?.let { sdk.appendFeedItems(it) }
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 = parseCustomFeedItems(items) ?: return
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 = parseCustomFeedItems(items) ?: return
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 parseCustomFeedItems(json: String): List<CustomFeedItem>? {
815
+ private fun parseFeedInputs(json: String): List<FeedInput>? {
778
816
  return try {
779
817
  val arr = JSONArray(json)
780
- val result = mutableListOf<CustomFeedItem>()
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(CustomFeedItem.Video(playbackId))
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(CustomFeedItem.ImageCarousel(carouselItem))
829
+ result.add(FeedInput.ImageCarousel(carouselItem))
792
830
  }
793
831
  }
794
832
  }
@@ -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.parseCustomFeedItems(json) else { return }
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.parseCustomFeedItems(json) else { return }
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 CustomFeedItem[] from the JS bridge.
569
- private static func parseCustomFeedItems(_ json: String) -> [CustomFeedItem]? {
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: [CustomFeedItem] = []
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 transparent UIView that conforms to `CarouselOverlay` and bridges
5
- /// carousel overlay lifecycle calls to JS events via `ShortKitBridge`.
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
- /// The actual carousel overlay UI is rendered by React Native on the JS side
8
- /// through the `CarouselOverlayManager` component. This view simply relays
9
- /// the SDK lifecycle events so the JS carousel overlay knows when to
10
- /// configure, activate, reset, etc.
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 = .clear
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
- bridge?.emitCarouselOverlayEvent("onCarouselOverlayFadeOut", body: [:])
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
- bridge?.emitCarouselOverlayEvent("onCarouselOverlayRestore", body: [:])
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
- /// Deferred page update to avoid flashing stale metadata.
41
- private var pageUpdateWorkItem: DispatchWorkItem?
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
- pageUpdateWorkItem?.cancel()
96
- pageUpdateWorkItem = nil
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 (React hasn't processed OVERLAY_ACTIVATE
139
- // yet). If we update currentPage immediately, delta snaps to 0 and
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
- // By deferring ~80ms, overlay-next (which already shows the correct
143
- // data via NextOverlayProvider) stays visible at y=0 while React
144
- // processes the state update. After the delay, overlay-current has
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 && pageUpdateWorkItem == nil {
157
+ if nearestPage != currentPage && pendingSwap == nil {
149
158
  let targetPage = nearestPage
150
- let workItem = DispatchWorkItem { [weak self] in
159
+ pendingSwap = { [weak self] in
151
160
  guard let self else { return }
152
161
  self.currentPage = targetPage
153
- self.pageUpdateWorkItem = nil
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
- pageUpdateWorkItem = workItem
165
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.08, execute: workItem)
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 let workItem = pageUpdateWorkItem {
168
- // User is scrolling again — apply pending update immediately
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
- workItem.cancel()
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`