Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions android/src/main/java/com/ease/EaseView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,76 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}
}

// Drop every running loop the current props no longer ask for.
//
// The per-property paths in applyAnimateValues() only cancel an animator
// when the property is still in animatedProperties AND its value changed.
// Props that drop a property from `animate` entirely, or that keep
// animating it but stop asking for `loop`, hit neither gate — the infinite
// ValueAnimator keeps driving the view forever, even after the view is
// reused for different content. Treat the current props as the source of
// truth instead.
private fun removeStaleLoopAnimations(
opacity: Float,
translateX: Float,
translateY: Float,
scaleX: Float,
scaleY: Float,
rotate: Float,
rotateX: Float,
rotateY: Float,
borderRadius: Float,
borderWidth: Float,
elevation: Float
) {
if (runningAnimators.isEmpty()) return

val mask = animatedProperties
for ((animatorKey, property) in LOOPABLE_PROPERTIES) {
val animator = runningAnimators[animatorKey] as? ValueAnimator ?: continue
if (animator.repeatCount != ValueAnimator.INFINITE) continue

val (propertyMask, configName) = property
val stillAnimated = mask and propertyMask != 0
if (stillAnimated && getTransitionConfig(configName).loop.let { it == "repeat" || it == "reverse" }) {
continue
}

animator.cancel()
runningAnimators.remove(animatorKey)

// cancel() strands the property wherever the sweep happened to be,
// so write the resting state the current props ask for. Every
// animate* prop carries its identity value once JS clears the mask
// bit, so the incoming values are correct either way. Colors are
// skipped — an unset color has no identity value and the style now
// owns it.
val target: Float
val prev: Float?
val apply: (Float) -> Unit
when (animatorKey) {
"alpha" -> { target = opacity; prev = prevOpacity; apply = { this.alpha = it } }
"translationX" -> { target = translateX; prev = prevTranslateX; apply = { this.translationX = it } }
"translationY" -> { target = translateY; prev = prevTranslateY; apply = { this.translationY = it } }
"scaleX" -> { target = scaleX; prev = prevScaleX; apply = { this.scaleX = it } }
"scaleY" -> { target = scaleY; prev = prevScaleY; apply = { this.scaleY = it } }
"rotation" -> { target = rotate; prev = prevRotate; apply = { this.rotation = it } }
"rotationX" -> { target = rotateX; prev = prevRotateX; apply = { this.rotationX = it } }
"rotationY" -> { target = rotateY; prev = prevRotateY; apply = { this.rotationY = it } }
"animateBorderRadius" -> { target = borderRadius; prev = prevBorderRadius; apply = { setAnimateBorderRadius(it) } }
"animateBorderWidth" -> { target = borderWidth; prev = prevBorderWidth; apply = { setAnimateBorderWidth(it) } }
"elevation" -> { target = elevation; prev = prevElevation; apply = { this.elevation = it } }
else -> continue
}

// Skip only when a path below will animate this property anyway:
// those read the live view value as their "from", so writing the
// target first would flatten the animation into a no-op.
if (stillAnimated && prev != null && prev != target) continue
apply(target)
}
}

companion object {
// Bitmask flags — must match JS constants
const val MASK_OPACITY = 1 shl 0
Expand All @@ -140,6 +210,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
const val MASK_BORDER_COLOR = 1 shl 11
// Masks 12-15 are shadow properties (iOS only)
const val MASK_ELEVATION = 1 shl 16

// runningAnimators key → (mask bit, getTransitionConfig name)
private val LOOPABLE_PROPERTIES = listOf(
"alpha" to (MASK_OPACITY to "opacity"),
"translationX" to (MASK_TRANSLATE_X to "translateX"),
"translationY" to (MASK_TRANSLATE_Y to "translateY"),
"scaleX" to (MASK_SCALE_X to "scaleX"),
"scaleY" to (MASK_SCALE_Y to "scaleY"),
"rotation" to (MASK_ROTATE to "rotate"),
"rotationX" to (MASK_ROTATE_X to "rotateX"),
"rotationY" to (MASK_ROTATE_Y to "rotateY"),
"animateBorderRadius" to (MASK_BORDER_RADIUS to "borderRadius"),
"backgroundColor" to (MASK_BACKGROUND_COLOR to "backgroundColor"),
"animateBorderWidth" to (MASK_BORDER_WIDTH to "borderWidth"),
"borderColor" to (MASK_BORDER_COLOR to "borderColor"),
"elevation" to (MASK_ELEVATION to "elevation"),
)
}

// --- Transform origin (0–1 fractions) ---
Expand Down Expand Up @@ -473,6 +560,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
onTransitionEnd?.invoke(true)
} else {
// Subsequent updates: animate changed properties (skip non-animated)
// Runs after the animationBatchId bump above so the cancellations
// can't be mistaken for this batch's animations completing.
removeStaleLoopAnimations(
opacity, translateX, translateY, scaleX, scaleY,
rotate, rotateX, rotateY, borderRadius, borderWidth, elevation
)
var anyPropertyChanged = false

if (prevOpacity != null && mask and MASK_OPACITY != 0 && prevOpacity != opacity) {
Expand Down
23 changes: 23 additions & 0 deletions example/app/issues/54/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Tabs, Stack } from 'expo-router';

export default function StaleLoopLayout() {
return (
<>
<Stack.Screen options={{ title: 'Stale loop survives props' }} />
<Tabs
screenOptions={{
tabBarActiveTintColor: '#fff',
tabBarInactiveTintColor: '#8888aa',
tabBarStyle: {
backgroundColor: '#1a1a2e',
borderTopColor: '#16213e',
},
headerShown: false,
}}
>
<Tabs.Screen name="index" options={{ title: 'Shimmer' }} />
<Tabs.Screen name="other" options={{ title: 'Other' }} />
</Tabs>
</>
);
}
195 changes: 195 additions & 0 deletions example/app/issues/54/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { EaseView } from 'react-native-ease';

// Issue #54 — a looping animation survives on an EaseView whose current props
// no longer ask for it.
// https://github.com/appandflow/react-native-ease/issues/54
//
// Both cards below keep the SAME EaseView instance across the load — no key,
// no remount — which is what a list row does when it swaps a skeleton for its
// loaded content. The teardown paths in updateProps are gated on "property is
// still in animatedProperties AND its value changed", so neither card hits
// them and the shimmer keeps sweeping over the content.
//
// Steps to reproduce:
// 1. Both cards shimmer — a band sweeps left→right on a 1.5 s linear loop.
// 2. Press "Load content". Both cards swap to their loaded state.
// 3. Expected: both bands stop.
// Without the fix: both keep sweeping forever.
// 4. Switch to the Other tab and back. Without the fix the loops are replayed
// by didMoveToWindow → reapplyLoopAnimations even after being cancelled.
// 5. "Back to skeleton" remounts both views to start the shimmer over.

const SWEEP = 64;

export default function StaleLoopTab() {
const insets = useSafeAreaInsets();
const [loaded, setLoaded] = useState(false);
// Loops are only created on first mount, so resetting has to remount the
// views. Loading must NOT — the bug needs the same EaseView instance.
const [mountKey, setMountKey] = useState(0);

return (
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
<Text style={styles.heading}>Issue #54 — stale shimmer loop</Text>
<Text style={styles.body}>
Press Load content. Both bands must stop sweeping. Then switch to the
Other tab and back — they must still be stopped.
</Text>

<Card
title="A — property leaves animate"
detail={
loaded
? 'animate={{ opacity: 1 }} · mask no longer has translateX'
: 'animate={{ translateX: 64 }} · loop: repeat'
}
>
<EaseView
key={mountKey}
initialAnimate={loaded ? undefined : { translateX: -SWEEP }}
animate={loaded ? { opacity: 1 } : { translateX: SWEEP }}
transition={{
type: 'timing',
duration: 1500,
easing: 'linear',
loop: loaded ? undefined : 'repeat',
}}
style={StyleSheet.absoluteFill}
>
<View style={styles.band} />
</EaseView>
</Card>

<Card
title="B — loop leaves the transition"
detail={
loaded
? 'animate={{ translateX: 64 }} · loop removed, value unchanged'
: 'animate={{ translateX: 64 }} · loop: repeat'
}
>
<EaseView
key={mountKey}
initialAnimate={loaded ? undefined : { translateX: -SWEEP }}
animate={{ translateX: SWEEP }}
transition={{
type: 'timing',
duration: 1500,
easing: 'linear',
loop: loaded ? undefined : 'repeat',
}}
style={StyleSheet.absoluteFill}
>
<View style={styles.band} />
</EaseView>
</Card>

<View style={styles.buttons}>
<Pressable
style={[styles.button, loaded && styles.buttonDisabled]}
disabled={loaded}
onPress={() => setLoaded(true)}
>
<Text style={styles.buttonText}>Load content</Text>
</Pressable>
<Pressable
style={[styles.button, !loaded && styles.buttonDisabled]}
disabled={!loaded}
onPress={() => {
setLoaded(false);
setMountKey((k) => k + 1);
}}
>
<Text style={styles.buttonText}>Back to skeleton</Text>
</Pressable>
</View>
</View>
);
}

function Card({
title,
detail,
children,
}: {
title: string;
detail: string;
children: React.ReactNode;
}) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>{title}</Text>
<Text style={styles.cardDetail}>{detail}</Text>
<View style={styles.track}>{children}</View>
</View>
);
}

const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#1a1a2e',
paddingHorizontal: 20,
},
heading: {
fontSize: 22,
fontWeight: '700',
color: '#fff',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#aaaacc',
marginBottom: 28,
lineHeight: 20,
},
card: {
marginBottom: 28,
},
cardTitle: {
fontSize: 15,
fontWeight: '600',
color: '#e0e0ff',
marginBottom: 4,
},
cardDetail: {
fontSize: 11,
fontFamily: 'monospace',
color: '#6666aa',
marginBottom: 10,
},
track: {
height: 64,
borderRadius: 12,
backgroundColor: '#16213e',
overflow: 'hidden',
},
band: {
width: 56,
height: '100%',
alignSelf: 'center',
backgroundColor: '#4a90d9',
},
buttons: {
flexDirection: 'row',
gap: 12,
justifyContent: 'center',
},
button: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#16213e',
},
buttonDisabled: {
opacity: 0.4,
},
buttonText: {
color: '#e0e0ff',
fontSize: 15,
fontWeight: '600',
},
});
37 changes: 37 additions & 0 deletions example/app/issues/54/other.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

// Switching to this tab detaches the shimmer views from the window, which is
// what makes didMoveToWindow → reapplyLoopAnimations run when you come back.
export default function OtherTab() {
const insets = useSafeAreaInsets();

return (
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
<Text style={styles.heading}>Other tab</Text>
<Text style={styles.body}>
Go back to the Shimmer tab. Any card that was loaded must still be
still.
</Text>
</View>
);
}

const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: '#1a1a2e',
paddingHorizontal: 20,
},
heading: {
fontSize: 22,
fontWeight: '700',
color: '#fff',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#aaaacc',
lineHeight: 20,
},
});
5 changes: 5 additions & 0 deletions example/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ export const demos: Record<string, DemoEntry> = {
title: 'Issue #45 — Android modal background',
section: 'Issues',
},
'issue-54': {
route: '/issues/54',
title: 'Issue #54 — Stale shimmer loop',
section: 'Issues',
},
'issue-loop-cancel': {
route: '/issues/loop-cancel',
title: 'Audit — Cancelled loop resurrects',
Expand Down
Loading
Loading