diff --git a/example/app/issues/66/index.tsx b/example/app/issues/66/index.tsx
new file mode 100644
index 0000000..0329607
--- /dev/null
+++ b/example/app/issues/66/index.tsx
@@ -0,0 +1,81 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { Stack } from 'expo-router';
+import { EaseView } from 'react-native-ease';
+
+// Issue #66 — iOS loops stop after the app returns from the background.
+// https://github.com/appandflow/react-native-ease/issues/66
+//
+// Steps to reproduce:
+// 1. Both loops below run.
+// 2. Press Home, then reopen the app.
+// 3. Expected: both keep running from where they were, and the mount id is
+// unchanged. Without the fix they stay frozen at their resting value.
+
+export default function BackgroundLoopsScreen() {
+ const [mountId] = useState(() => Math.random().toString(36).slice(2, 8));
+
+ return (
+
+
+
+ Background the app (Home), then reopen it. Both loops must keep running.
+ Mount {mountId} must not change.
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ padding: 20,
+ backgroundColor: '#1a1a2e',
+ },
+ body: {
+ color: '#a0a0c0',
+ fontSize: 14,
+ marginBottom: 32,
+ },
+ row: {
+ flexDirection: 'row',
+ gap: 40,
+ alignItems: 'center',
+ },
+ spinner: {
+ width: 64,
+ height: 64,
+ borderRadius: 12,
+ backgroundColor: '#4a90d9',
+ },
+ pulse: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ backgroundColor: '#e94560',
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index d46a16a..9768482 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -158,6 +158,11 @@ export const demos: Record = {
title: 'Issue #54 — Stale shimmer loop',
section: 'Issues',
},
+ 'issue-66': {
+ route: '/issues/66',
+ title: 'Issue #66 — Background loops',
+ section: 'Issues',
+ },
'issue-loop-cancel': {
route: '/issues/loop-cancel',
title: 'Audit — Cancelled loop resurrects',
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index 04358ba..b794358 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -285,6 +285,11 @@ - (instancetype)initWithFrame:(CGRect)frame {
_loopAnimations = [NSMutableDictionary dictionary];
_delegateProxy = [EaseAnimationDelegateProxy new];
_delegateProxy.target = self;
+ [[NSNotificationCenter defaultCenter]
+ addObserver:self
+ selector:@selector(applicationDidBecomeActive)
+ name:UIApplicationDidBecomeActiveNotification
+ object:nil];
}
return self;
}
@@ -517,6 +522,9 @@ - (void)reapplyLoopAnimations {
[CATransaction begin];
[CATransaction setDisableActions:YES];
for (NSString *key in _loopAnimations) {
+ if ([self.layer animationForKey:key] != nil) {
+ continue;
+ }
// Increment to balance the eventual animationDidStop callback when the
// view detaches again (or the loop is replaced).
_pendingAnimationCount++;
@@ -1391,6 +1399,20 @@ - (void)didMoveToWindow {
}
}
+// Core Animation drops a layer's animations while the app is in the
+// background, and only accepts them again once the app is active. The view
+// never leaves its window, so didMoveToWindow doesn't replay the loops.
+- (void)applicationDidBecomeActive {
+ if (self.window == nil || _isFirstMount || _loopAnimations.count == 0) {
+ return;
+ }
+ if (_props) {
+ [self removeStaleLoopAnimationsForProps:*std::static_pointer_cast<
+ const EaseViewProps>(_props)];
+ }
+ [self reapplyLoopAnimations];
+}
+
- (void)invalidateLayer {
[super invalidateLayer];