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];