From 0bb4f3fea312847017c488faad825c564360b285 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ashraf Date: Thu, 30 Jul 2026 13:40:11 +0700 Subject: [PATCH 1/2] fix: keep the player alive while a view is off-window --- .changeset/lucky-pandas-listen.md | 10 +++++ .../DotlottieReactNativeView.kt | 37 +++++++++++++++++- ios/DotlottieReactNativeViewManager.swift | 38 +++++++++++++------ 3 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 .changeset/lucky-pandas-listen.md diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md new file mode 100644 index 0000000..bada82f --- /dev/null +++ b/.changeset/lucky-pandas-listen.md @@ -0,0 +1,10 @@ +--- +'@lottiefiles/dotlottie-react-native': patch +--- + +fix: don't reload the animation when a view scrolls out of view and back + +Leaving the window was treated as a teardown, so list recycling and screen +transitions destroyed the player and rebuilt it on the way back, re-reading the +source and restarting from frame 0. Playback now freezes at the current frame +and resumes on return; the player is released only on unmount. diff --git a/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt b/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt index ff1e625..27eef47 100644 --- a/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt +++ b/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -53,6 +54,7 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex private var hasActiveComposition: Boolean = false private var isReleased: Boolean = false private var hasAttachedToWindow: Boolean = false + private var frozenWhileDetached: Boolean = false private val measureAndLayout = Runnable { @@ -75,6 +77,11 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex lifecycleRegistry.currentState = Lifecycle.State.RESUMED setViewTreeLifecycleOwner(this) composeView.setViewTreeLifecycleOwner(this) + // ComposeView disposes its composition on detach by default, which would + // reload the animation whenever a list recycles the row back into view. + composeView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnLifecycleDestroyed(this) + ) addView(composeView) ensureStateMachineListener() // Composition is created once the view is attached (onAttachedToWindow / @@ -463,7 +470,7 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex super.onDetachedFromWindow() hasAttachedToWindow = false removeCallbacks(measureAndLayout) - cleanup() + freezeWhileDetached() } override fun onAttachedToWindow() { @@ -476,9 +483,36 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex if (!hasActiveComposition) { renderContent() } + unfreezeAfterReattach() scheduleMeasureAndLayout() } + private fun freezeWhileDetached() { + if (isReleased || !hasActiveComposition || frozenWhileDetached) { + return + } + try { + if (dotLottieController.isPlaying) { + dotLottieController.freeze() + frozenWhileDetached = true + } + } catch (e: Exception) { + android.util.Log.w("DotLottie", "Failed to freeze detached animation: ${e.message}") + } + } + + private fun unfreezeAfterReattach() { + if (!frozenWhileDetached) { + return + } + frozenWhileDetached = false + try { + dotLottieController.unFreeze() + } catch (e: Exception) { + android.util.Log.w("DotLottie", "Failed to unfreeze re-attached animation: ${e.message}") + } + } + override fun requestLayout() { super.requestLayout() scheduleMeasureAndLayout() @@ -551,6 +585,7 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex } catch (e: Exception) { android.util.Log.e("DotLottie", "Error during cleanup: ${e.message}") } finally { + frozenWhileDetached = false if (hasActiveComposition) { composeView.disposeComposition() hasActiveComposition = false diff --git a/ios/DotlottieReactNativeViewManager.swift b/ios/DotlottieReactNativeViewManager.swift index e1e726e..0c0f1bd 100644 --- a/ios/DotlottieReactNativeViewManager.swift +++ b/ios/DotlottieReactNativeViewManager.swift @@ -301,9 +301,6 @@ struct AnimationView: View { var body: some View { if let animation = dataStore.animation { DotLottieView(dotLottie: animation) - .onDisappear { - cleanupAnimation() - } } else { Text("Loading animation...") .onAppear { @@ -311,12 +308,6 @@ struct AnimationView: View { } } } - - - - func cleanupAnimation() { - dataStore.cleanupAnimation() - } } @@ -499,6 +490,7 @@ class DotlottieReactNativeView: UIView { private var isMountedToWindow: Bool = false private var isReleased: Bool = false private var pendingAnimationUpdate: Bool = false + private var pausedWhileDetached: Bool = false let dataStore: Datastore = .init() var _animation: DotLottieAnimation? { dataStore.animation @@ -532,11 +524,32 @@ class DotlottieReactNativeView: UIView { if currentlyMounted { setupHostingControllerIfNeeded() - scheduleAnimationUpdate() + if dataStore.animation == nil { + scheduleAnimationUpdate() + } else { + resumeAfterReattach() + } } else { - dataStore.cleanupAnimation() - tearDownHostingController() + pauseWhileDetached() + } + } + + private func pauseWhileDetached() { + guard !pausedWhileDetached, let animation = _animation, animation.isPlaying() else { + return } + + pausedWhileDetached = true + _ = animation.pause() + } + + private func resumeAfterReattach() { + guard pausedWhileDetached else { + return + } + + pausedWhileDetached = false + _ = _animation?.play() } override func layoutSubviews() { @@ -552,6 +565,7 @@ class DotlottieReactNativeView: UIView { isReleased = true isMountedToWindow = false pendingAnimationUpdate = false + pausedWhileDetached = false dataStore.cleanupAnimation() tearDownHostingController() resetEventHandlers() From cec4ec09484abc7c22ba12b8fe30f853794f96f3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ashraf Date: Thu, 30 Jul 2026 14:39:31 +0700 Subject: [PATCH 2/2] fix(android): only clear the frozen flag when unfreeze succeeds If unFreeze() threw, the flag was already cleared, so the view forgot it owed a resume: the next detach saw a non-playing player and never re-armed, leaving the animation frozen for good. Clearing on success keeps the retry on the next re-attach and matches freezeWhileDetached(). --- .../java/com/dotlottiereactnative/DotlottieReactNativeView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt b/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt index 27eef47..1f68b74 100644 --- a/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt +++ b/android/src/main/java/com/dotlottiereactnative/DotlottieReactNativeView.kt @@ -505,9 +505,9 @@ class DotlottieReactNativeView(context: ThemedReactContext) : FrameLayout(contex if (!frozenWhileDetached) { return } - frozenWhileDetached = false try { dotLottieController.unFreeze() + frozenWhileDetached = false } catch (e: Exception) { android.util.Log.w("DotLottie", "Failed to unfreeze re-attached animation: ${e.message}") }