@shortkitsdk/react-native 0.2.6 → 0.2.12

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 (75) hide show
  1. package/ShortKitReactNative.podspec +1 -0
  2. package/android/build.gradle.kts +17 -1
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +379 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +570 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +1029 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +212 -219
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +17 -3
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +157 -742
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +11 -2
  11. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
  12. package/ios/ReactCarouselOverlayHost.swift +177 -0
  13. package/ios/ReactLoadingHost.swift +38 -0
  14. package/ios/ReactOverlayHost.swift +444 -0
  15. package/ios/SKFabricSurfaceWrapper.h +18 -0
  16. package/ios/SKFabricSurfaceWrapper.mm +57 -0
  17. package/ios/ShortKitBridge.swift +220 -63
  18. package/ios/ShortKitFeedView.swift +82 -228
  19. package/ios/ShortKitFeedViewManager.mm +3 -2
  20. package/ios/ShortKitModule.mm +69 -37
  21. package/ios/ShortKitPlayerNativeView.swift +39 -8
  22. package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +3683 -1249
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +56 -15
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +56 -15
  28. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  30. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +3683 -1249
  31. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +56 -15
  32. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  33. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +56 -15
  34. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
  36. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  37. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  38. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
  39. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
  40. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  41. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
  42. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  43. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  44. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  45. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  46. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
  47. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
  48. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  49. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
  50. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  51. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  52. package/ios/ShortKitWidgetNativeView.swift +3 -3
  53. package/package.json +1 -1
  54. package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
  55. package/src/ShortKitCommands.ts +31 -0
  56. package/src/ShortKitContext.ts +6 -24
  57. package/src/ShortKitFeed.tsx +124 -41
  58. package/src/ShortKitLoadingSurface.tsx +24 -0
  59. package/src/ShortKitOverlaySurface.tsx +313 -0
  60. package/src/ShortKitPlayer.tsx +30 -9
  61. package/src/ShortKitProvider.tsx +28 -285
  62. package/src/index.ts +9 -3
  63. package/src/serialization.ts +20 -39
  64. package/src/specs/NativeShortKitModule.ts +74 -45
  65. package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
  66. package/src/types.ts +84 -16
  67. package/src/useShortKit.ts +1 -3
  68. package/src/useShortKitPlayer.ts +7 -7
  69. package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
  70. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
  71. package/ios/ShortKitCarouselOverlayBridge.swift +0 -219
  72. package/ios/ShortKitOverlayBridge.swift +0 -111
  73. package/src/CarouselOverlayManager.tsx +0 -70
  74. package/src/OverlayManager.tsx +0 -87
  75. package/src/useShortKitCarousel.ts +0 -29
@@ -9,17 +9,14 @@ import android.view.View
9
9
  import android.view.ViewGroup
10
10
  import android.widget.FrameLayout
11
11
  import androidx.fragment.app.FragmentActivity
12
- import androidx.viewpager2.widget.ViewPager2
13
- import com.facebook.react.uimanager.util.ReactFindViewUtil
14
- import com.shortkit.ShortKitFeedFragment
12
+ import com.shortkit.sdk.feed.ShortKitFeedFragment
15
13
 
16
14
  /**
17
15
  * Fabric native view that embeds [ShortKitFeedFragment] using Android
18
16
  * Fragment transactions. Props are set by [ShortKitFeedViewManager].
19
17
  *
20
- * Also tracks the feed's scroll offset via [ViewPager2.OnPageChangeCallback]
21
- * and applies native translation transforms to the sibling RN overlay views
22
- * so they move with the active cell during swipe transitions.
18
+ * The native SDK owns overlay lifecycle via [ReactOverlayHost] and
19
+ * [ReactCarouselOverlayHost] this view is purely a fragment container.
23
20
  *
24
21
  * Android equivalent of iOS `ShortKitFeedView.swift`.
25
22
  */
@@ -30,44 +27,92 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
30
27
  // -----------------------------------------------------------------------
31
28
 
32
29
  var config: String? = null
33
- var overlayType: String? = null
30
+ var feedId: String? = null
31
+ var startAtItemId: String? = null
32
+ var preloadId: String? = null
34
33
 
35
34
  // -----------------------------------------------------------------------
36
35
  // Fragment management
37
36
  // -----------------------------------------------------------------------
38
37
 
39
38
  private var feedFragment: ShortKitFeedFragment? = null
39
+ private val fragmentContainer: FrameLayout
40
40
  private var fragmentContainerId: Int = View.generateViewId()
41
41
  private val handler = Handler(Looper.getMainLooper())
42
42
 
43
+ // The React Native-assigned size. Stored so we can re-apply it after the
44
+ // SDK's forceLayoutIfNeeded potentially re-measures to full-screen size.
45
+ private var rnWidth: Int = 0
46
+ private var rnHeight: Int = 0
47
+
43
48
  init {
44
- id = fragmentContainerId
49
+ // Use a child FrameLayout as the fragment container. Fabric overrides
50
+ // this view's own id, so we can't use it as the fragment container.
51
+ fragmentContainer = FrameLayout(context).apply {
52
+ id = fragmentContainerId
53
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
54
+ }
55
+ addView(fragmentContainer)
45
56
  }
46
57
 
47
58
  // -----------------------------------------------------------------------
48
- // Scroll tracking
59
+ // Fabric layout workarounds
49
60
  // -----------------------------------------------------------------------
50
61
 
51
- private var viewPager: ViewPager2? = null
52
- private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
53
-
54
- /** Video overlay for the currently active cell (nativeID="overlay-current"). */
55
- private var currentOverlayView: View? = null
56
-
57
- /** Video overlay for the upcoming cell (nativeID="overlay-next"). */
58
- private var nextOverlayView: View? = null
62
+ /**
63
+ * Fabric intercepts requestLayout() and doesn't propagate measure/layout
64
+ * to programmatically-added children. Override to schedule a manual pass.
65
+ */
66
+ override fun requestLayout() {
67
+ super.requestLayout()
68
+ @Suppress("UNNECESSARY_SAFE_CALL")
69
+ handler?.post(layoutRunnable)
70
+ }
59
71
 
60
- /** Carousel overlay for the currently active cell (nativeID="carousel-overlay-current"). */
61
- private var currentCarouselOverlayView: View? = null
72
+ private val layoutRunnable = Runnable {
73
+ measure(
74
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
75
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
76
+ )
77
+ layout(left, top, right, bottom)
78
+ }
62
79
 
63
- /** Carousel overlay for the upcoming cell (nativeID="carousel-overlay-next"). */
64
- private var nextCarouselOverlayView: View? = null
80
+ /**
81
+ * Re-apply the React Native-assigned size to the fragment container and
82
+ * all descendants (fragment root, VP, RV). The SDK's forceLayoutIfNeeded
83
+ * may have sized them to the full activity height instead.
84
+ */
85
+ private val constrainRunnable = Runnable {
86
+ val w = rnWidth
87
+ val h = rnHeight
88
+ if (w == 0 || h == 0) return@Runnable
89
+
90
+ val fragView = feedFragment?.view ?: return@Runnable
91
+ val wSpec = MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY)
92
+ val hSpec = MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
93
+
94
+ // Re-pin container
95
+ fragmentContainer.measure(wSpec, hSpec)
96
+ fragmentContainer.layout(0, 0, w, h)
97
+
98
+ // Re-pin fragment root, VP, and RV if they got the wrong size
99
+ if (fragView.height != h) {
100
+ fragView.measure(wSpec, hSpec)
101
+ fragView.layout(0, 0, w, h)
102
+ }
65
103
 
66
- /** The page index used for overlay transform calculations. */
67
- private var currentPage: Int = 0
104
+ val vp = findViewPager2(fragView)
105
+ if (vp != null && vp.height != h) {
106
+ vp.measure(wSpec, hSpec)
107
+ vp.layout(0, 0, w, h)
108
+ val rv = vp.getChildAt(0) as? ViewGroup
109
+ if (rv != null) {
110
+ rv.measure(wSpec, hSpec)
111
+ rv.layout(0, 0, w, h)
112
+ }
113
+ }
114
+ }
68
115
 
69
- /** Deferred page update to avoid flashing stale metadata. */
70
- private var pageUpdateRunnable: Runnable? = null
71
116
 
72
117
  // -----------------------------------------------------------------------
73
118
  // Lifecycle
@@ -75,12 +120,21 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
75
120
 
76
121
  override fun onAttachedToWindow() {
77
122
  super.onAttachedToWindow()
78
- embedFeedFragmentIfNeeded()
123
+ // Defer to the next message-loop tick. onAttachedToWindow can fire
124
+ // during a parent fragment transaction (e.g. react-native-screens
125
+ // ScreenStack.onUpdate), and FragmentManager forbids nested
126
+ // commitNow calls. Posting ensures we run after the outer
127
+ // transaction completes — still before the first draw pass.
128
+ handler.post {
129
+ if (isAttachedToWindow) embedFeedFragmentIfNeeded()
130
+ }
79
131
  }
80
132
 
81
133
  override fun onDetachedFromWindow() {
82
- teardownScrollTracking()
83
- removeFeedFragment()
134
+ synchronized(ShortKitBridge.staticPendingFeedViews) {
135
+ ShortKitBridge.staticPendingFeedViews.remove(this)
136
+ }
137
+ suspendFeedFragment()
84
138
  super.onDetachedFromWindow()
85
139
  }
86
140
 
@@ -88,233 +142,177 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
88
142
  // Fragment containment
89
143
  // -----------------------------------------------------------------------
90
144
 
91
- private fun embedFeedFragmentIfNeeded() {
92
- if (feedFragment != null) return
145
+ internal fun embedFeedFragmentIfNeeded() {
146
+ val activity = getReactActivity()
147
+ if (activity == null) {
148
+ Log.e(TAG, "embedFeedFragment: FragmentActivity not found in context chain")
149
+ return
150
+ }
93
151
 
94
- val sdk = ShortKitModule.shared?.sdk ?: run {
95
- Log.w(TAG, "ShortKit SDK not initialized. Call ShortKitModule.initialize() first.")
152
+ // Show a hidden fragment (e.g. returning to this tab).
153
+ // Using hide()/show() instead of detach()/attach() preserves the
154
+ // entire view hierarchy (ViewPager, RecyclerView, player surface)
155
+ // so there is zero flicker — matching iOS behavior exactly.
156
+ val existingFragment = feedFragment
157
+ if (existingFragment != null) {
158
+ if (existingFragment.isHidden) {
159
+ try {
160
+ activity.supportFragmentManager
161
+ .beginTransaction()
162
+ .show(existingFragment)
163
+ .commitNowAllowingStateLoss()
164
+ existingFragment.activate()
165
+ } catch (e: Exception) {
166
+ Log.e(TAG, "Failed to show feed fragment", e)
167
+ }
168
+ }
169
+ feedId?.let { id ->
170
+ ShortKitBridge.shared?.registerFeed(id)
171
+ ShortKitBridge.shared?.registerFeedFragment(id, existingFragment)
172
+ }
96
173
  return
97
174
  }
98
175
 
99
- val activity = getReactActivity() ?: run {
100
- Log.w(TAG, "Could not find hosting Activity.")
176
+ val sdk = ShortKitBridge.shared?.sdk ?: run {
177
+ synchronized(ShortKitBridge.staticPendingFeedViews) {
178
+ ShortKitBridge.staticPendingFeedViews.add(this)
179
+ }
101
180
  return
102
181
  }
103
182
 
104
- val fragment = ShortKitFeedFragment.newInstance(sdk)
183
+ var feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
184
+
185
+ preloadId?.let { id ->
186
+ val preload = ShortKitBridge.shared?.consumePreload(id)
187
+ if (preload != null) {
188
+ feedConfig = feedConfig.copy(preload = preload)
189
+ }
190
+ }
191
+
192
+ val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig, startAtItemId)
105
193
 
106
194
  try {
107
195
  activity.supportFragmentManager
108
196
  .beginTransaction()
109
197
  .replace(fragmentContainerId, fragment)
110
198
  .commitNowAllowingStateLoss()
111
- this.feedFragment = fragment // Only assign after successful commit
112
- setupScrollTracking(fragment)
199
+ this.feedFragment = fragment
200
+
201
+ // View.generateViewId() can return IDs that collide with Fabric-
202
+ // managed views (e.g. ReactSurfaceView also gets id=1). When that
203
+ // happens, FragmentManager.replace() places the fragment view in
204
+ // the wrong parent. Detect and correct this by reparenting.
205
+ val fragView = fragment.view
206
+ val fragParent = fragView?.parent
207
+ if (fragParent != null && fragParent !== fragmentContainer) {
208
+ Log.w(TAG, "embedFeedFragment: reparenting — fragment placed in ${fragParent.javaClass.simpleName} due to ID collision")
209
+ (fragParent as? ViewGroup)?.removeView(fragView)
210
+ fragmentContainer.addView(fragView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
211
+ }
212
+
213
+ // Pin the fragment container to the React Native-assigned size.
214
+ // Without this, MATCH_PARENT causes Android to re-measure the
215
+ // container to the full activity size, and the SDK's deferred
216
+ // layout forcing picks up the wrong (full-screen) dimensions.
217
+ rnWidth = measuredWidth
218
+ rnHeight = measuredHeight
219
+ fragmentContainer.layoutParams = LayoutParams(measuredWidth, measuredHeight)
220
+ fragmentContainer.measure(
221
+ MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
222
+ MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
223
+ )
224
+ fragmentContainer.layout(0, 0, measuredWidth, measuredHeight)
225
+ // Schedule a delayed re-pin: the SDK's forceLayoutIfNeeded will
226
+ // run when deferCellSetup exhausts retries (~3 post ticks later)
227
+ // and may size the VP/RV to the wrong dimensions. Re-apply our
228
+ // correct RN size after it runs.
229
+ handler.postDelayed(constrainRunnable, 500)
230
+
231
+ if (measuredWidth == 0 || measuredHeight == 0) {
232
+ Log.w(TAG, "embedFeedFragment: WARNING — container is 0x0, overlays will be invisible. Check if parent has display:none or flex:0")
233
+ }
234
+
235
+ feedId?.let { id ->
236
+ ShortKitBridge.shared?.registerFeed(id)
237
+ ShortKitBridge.shared?.registerFeedFragment(id, fragment)
238
+ }
113
239
  } catch (e: Exception) {
114
240
  Log.e(TAG, "Failed to embed feed fragment", e)
115
241
  }
116
242
  }
117
243
 
118
- private fun removeFeedFragment() {
244
+ /**
245
+ * Hide the fragment without destroying its view hierarchy (mirrors iOS
246
+ * `suspendFeedViewController`). Using hide() instead of detach() keeps
247
+ * the ViewPager, RecyclerView, player surface, and all overlay views
248
+ * alive in memory — so returning to the tab is instant with no flicker.
249
+ */
250
+ private fun suspendFeedFragment() {
251
+ feedId?.let { id ->
252
+ ShortKitBridge.shared?.unregisterFeed(id)
253
+ ShortKitBridge.shared?.unregisterFeedFragment(id)
254
+ }
119
255
  val fragment = feedFragment ?: return
120
- feedFragment = null // Clear immediately to prevent leaks on early return
121
256
 
122
257
  try {
258
+ fragment.deactivate()
123
259
  val activity = getReactActivity() ?: return
124
260
  activity.supportFragmentManager
125
261
  .beginTransaction()
126
- .remove(fragment)
262
+ .hide(fragment)
127
263
  .commitNowAllowingStateLoss()
128
264
  } catch (e: IllegalStateException) {
129
- // Expected during Activity teardown — fragment already being destroyed
265
+ // Expected during Activity teardown — fall back to full destroy
266
+ feedFragment = null
130
267
  } catch (e: Exception) {
131
- Log.e(TAG, "Unexpected error removing feed fragment", e)
132
- }
133
- }
134
-
135
- // -----------------------------------------------------------------------
136
- // Scroll tracking
137
- // -----------------------------------------------------------------------
138
-
139
- private fun setupScrollTracking(fragment: ShortKitFeedFragment) {
140
- val pager = findViewPager2(fragment.view ?: return) ?: run {
141
- Log.w(TAG, "Could not find ViewPager2 in feed fragment hierarchy.")
142
- return
143
- }
144
- viewPager = pager
145
-
146
- val callback = object : ViewPager2.OnPageChangeCallback() {
147
- override fun onPageScrolled(
148
- position: Int,
149
- positionOffset: Float,
150
- positionOffsetPixels: Int
151
- ) {
152
- handleScrollOffset(position, positionOffset, positionOffsetPixels)
153
- }
154
- }
155
- pageChangeCallback = callback
156
- pager.registerOnPageChangeCallback(callback)
157
- }
158
-
159
- private fun teardownScrollTracking() {
160
- pageUpdateRunnable?.let { handler.removeCallbacks(it) }
161
- pageUpdateRunnable = null
162
- pageChangeCallback?.let { viewPager?.unregisterOnPageChangeCallback(it) }
163
- pageChangeCallback = null
164
- viewPager = null
165
- currentOverlayView?.translationY = 0f
166
- nextOverlayView?.translationY = 0f
167
- currentCarouselOverlayView?.translationY = 0f
168
- nextCarouselOverlayView?.translationY = 0f
169
- currentOverlayView = null
170
- nextOverlayView = null
171
- currentCarouselOverlayView = null
172
- nextCarouselOverlayView = null
173
- }
174
-
175
- private fun handleScrollOffset(
176
- position: Int,
177
- positionOffset: Float,
178
- positionOffsetPixels: Int
179
- ) {
180
- val cellHeight = height.toFloat()
181
- if (cellHeight <= 0) return
182
-
183
- // Detect page change, but DEFER updating currentPage.
184
- //
185
- // Why: when the scroll settles on a new page, overlay-current still
186
- // shows the OLD page's metadata (React hasn't processed OVERLAY_ACTIVATE
187
- // yet). If we update currentPage immediately, delta snaps to 0 and
188
- // overlay-current becomes visible with stale data.
189
- //
190
- // By deferring ~80ms, overlay-next (which already shows the correct
191
- // data via NextOverlayProvider) stays visible at y=0 while React
192
- // processes the state update.
193
- if (positionOffset == 0f) {
194
- if (position != currentPage && pageUpdateRunnable == null) {
195
- val targetPage = position
196
- val runnable = Runnable {
197
- currentPage = targetPage
198
- pageUpdateRunnable = null
199
- // Reapply overlay transforms now that currentPage is updated.
200
- // Without this, overlay-next (static NextOverlayProvider state)
201
- // stays visible at y=0 while overlay-current (live state) stays
202
- // hidden — no scroll event fires to trigger handleScrollOffset.
203
- val h = height.toFloat()
204
- currentOverlayView?.translationY = 0f
205
- nextOverlayView?.translationY = h
206
- currentCarouselOverlayView?.translationY = 0f
207
- nextCarouselOverlayView?.translationY = h
208
- }
209
- pageUpdateRunnable = runnable
210
- handler.postDelayed(runnable, 80)
211
- }
212
- } else if (pageUpdateRunnable != null) {
213
- // User is scrolling again — apply pending update immediately
214
- // so transforms stay aligned for the new gesture.
215
- handler.removeCallbacks(pageUpdateRunnable!!)
216
- pageUpdateRunnable = null
217
- currentPage = position
218
- }
219
-
220
- // positionOffset is 0.0 when settled, approaches 1.0 during forward scroll.
221
- // position is the index of the page currently filling most of the screen.
222
- val delta: Float = if (position >= currentPage) {
223
- // Forward scroll (or idle)
224
- positionOffsetPixels.toFloat()
225
- } else {
226
- // Backward scroll: position dropped below currentPage
227
- -(cellHeight - positionOffsetPixels.toFloat())
228
- }
229
-
230
- // Find the overlay views if not cached
231
- if (currentOverlayView == null || nextOverlayView == null) {
232
- findOverlayViews()
233
- }
234
- if (currentCarouselOverlayView == null || nextCarouselOverlayView == null) {
235
- findCarouselOverlayViews()
236
- }
237
-
238
- // Current overlays follow the active cell
239
- currentOverlayView?.translationY = -delta
240
- currentCarouselOverlayView?.translationY = -delta
241
-
242
- // Next overlays: positioned one page ahead in the scroll direction
243
- if (delta >= 0) {
244
- // Forward scroll (or idle): next cell is below
245
- val nextY = cellHeight - delta
246
- nextOverlayView?.translationY = nextY
247
- nextCarouselOverlayView?.translationY = nextY
248
- } else {
249
- // Backward scroll: next cell is above
250
- val nextY = -cellHeight - delta
251
- nextOverlayView?.translationY = nextY
252
- nextCarouselOverlayView?.translationY = nextY
268
+ Log.e(TAG, "Unexpected error suspending feed fragment", e)
269
+ feedFragment = null
253
270
  }
254
271
  }
255
272
 
256
273
  /**
257
- * Find the sibling RN overlay views by nativeID.
258
- *
259
- * In Fabric interop, this view may be wrapped in an intermediate
260
- * ViewGroup, so `getParent()` may not be the React `<View>` container
261
- * from ShortKitFeed.tsx. We walk up the ancestor chain until we find
262
- * the overlays.
274
+ * Called by [ShortKitFeedViewManager.onDropViewInstance] when React
275
+ * unmounts the native view. Performs a full fragment teardown.
263
276
  */
264
- private fun findOverlayViews() {
265
- var ancestor = parent as? ViewGroup ?: return
266
- while (true) {
267
- for (i in 0 until ancestor.childCount) {
268
- val child = ancestor.getChildAt(i)
269
- if (child === this || isOwnAncestor(child)) continue
270
-
271
- if (currentOverlayView == null) {
272
- currentOverlayView = ReactFindViewUtil.findView(child, "overlay-current")
273
- }
274
- if (nextOverlayView == null) {
275
- nextOverlayView = ReactFindViewUtil.findView(child, "overlay-next")
276
- }
277
- }
278
- if (currentOverlayView != null && nextOverlayView != null) return
279
- ancestor = ancestor.parent as? ViewGroup ?: return
280
- }
277
+ fun destroy() {
278
+ handler.removeCallbacks(constrainRunnable)
279
+ handler.removeCallbacks(layoutRunnable)
280
+ destroyFeedFragment()
281
281
  }
282
282
 
283
283
  /**
284
- * Find the sibling RN carousel overlay views by nativeID.
284
+ * Full teardown removes the fragment from FragmentManager entirely.
285
+ * Called when the Fabric view is being destroyed (not just hidden).
285
286
  */
286
- private fun findCarouselOverlayViews() {
287
- var ancestor = parent as? ViewGroup ?: return
288
- while (true) {
289
- for (i in 0 until ancestor.childCount) {
290
- val child = ancestor.getChildAt(i)
291
- if (child === this || isOwnAncestor(child)) continue
292
-
293
- if (currentCarouselOverlayView == null) {
294
- currentCarouselOverlayView = ReactFindViewUtil.findView(child, "carousel-overlay-current")
295
- }
296
- if (nextCarouselOverlayView == null) {
297
- nextCarouselOverlayView = ReactFindViewUtil.findView(child, "carousel-overlay-next")
298
- }
299
- }
300
- if (currentCarouselOverlayView != null && nextCarouselOverlayView != null) return
301
- ancestor = ancestor.parent as? ViewGroup ?: return
287
+ private fun destroyFeedFragment() {
288
+ feedId?.let { id ->
289
+ ShortKitBridge.shared?.unregisterFeed(id)
290
+ ShortKitBridge.shared?.unregisterFeedFragment(id)
302
291
  }
303
- }
292
+ val fragment = feedFragment ?: return
293
+ fragment.deactivate()
294
+ feedFragment = null
304
295
 
305
- /** Check if the given view is an ancestor of this view. */
306
- private fun isOwnAncestor(view: View): Boolean {
307
- var current: ViewParent? = parent
308
- while (current != null) {
309
- if (current === view) return true
310
- current = current.parent
296
+ try {
297
+ val activity = getReactActivity() ?: return
298
+ activity.supportFragmentManager
299
+ .beginTransaction()
300
+ .remove(fragment)
301
+ .commitNowAllowingStateLoss()
302
+ } catch (e: IllegalStateException) {
303
+ // Expected during Activity teardown
304
+ } catch (e: Exception) {
305
+ Log.e(TAG, "Unexpected error removing feed fragment", e)
311
306
  }
312
- return false
313
307
  }
314
308
 
315
- /** Recursively find the first [ViewPager2] in a view hierarchy. */
316
- private fun findViewPager2(view: View): ViewPager2? {
317
- if (view is ViewPager2) return view
309
+ // -----------------------------------------------------------------------
310
+ // Helpers
311
+ // -----------------------------------------------------------------------
312
+
313
+ /** Recursively find the first ViewPager2 in a view hierarchy. */
314
+ private fun findViewPager2(view: View): androidx.viewpager2.widget.ViewPager2? {
315
+ if (view is androidx.viewpager2.widget.ViewPager2) return view
318
316
  if (view is ViewGroup) {
319
317
  for (i in 0 until view.childCount) {
320
318
  findViewPager2(view.getChildAt(i))?.let { return it }
@@ -323,13 +321,8 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
323
321
  return null
324
322
  }
325
323
 
326
- // -----------------------------------------------------------------------
327
- // Helpers
328
- // -----------------------------------------------------------------------
329
-
330
324
  /**
331
325
  * Walk the context wrapper chain to find the hosting [FragmentActivity].
332
- * React Native's [ThemedReactContext] wraps the Activity, so we unwrap it.
333
326
  */
334
327
  private fun getReactActivity(): FragmentActivity? {
335
328
  var ctx = context
@@ -15,16 +15,30 @@ class ShortKitFeedViewManager : SimpleViewManager<ShortKitFeedView>() {
15
15
  return ShortKitFeedView(context)
16
16
  }
17
17
 
18
+ @ReactProp(name = "feedId")
19
+ fun setFeedId(view: ShortKitFeedView, feedId: String?) {
20
+ view.feedId = feedId
21
+ }
22
+
18
23
  @ReactProp(name = "config")
19
24
  fun setConfig(view: ShortKitFeedView, config: String?) {
20
25
  view.config = config
21
26
  }
22
27
 
23
- @ReactProp(name = "overlayType", defaultString = "none")
24
- fun setOverlayType(view: ShortKitFeedView, overlayType: String?) {
25
- view.overlayType = overlayType
28
+ @ReactProp(name = "startAtItemId")
29
+ fun setStartAtItemId(view: ShortKitFeedView, startAtItemId: String?) {
30
+ view.startAtItemId = startAtItemId
26
31
  }
27
32
 
33
+ @ReactProp(name = "preloadId")
34
+ fun setPreloadId(view: ShortKitFeedView, preloadId: String?) {
35
+ view.preloadId = preloadId
36
+ }
37
+
38
+ override fun onDropViewInstance(view: ShortKitFeedView) {
39
+ view.destroy()
40
+ super.onDropViewInstance(view)
41
+ }
28
42
 
29
43
  companion object {
30
44
  const val REACT_CLASS = "ShortKitFeedView"