From dfcbb45fc3f2d97609be9d1e5f8a1f43188b77cb Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 15 Oct 2026 06:45:17 -0400 Subject: [PATCH 1/4] feat: pause animations in place with paused and report the frozen values with onPaused paused freezes a view's running animations, loops included, and resumes them from the same point. onPaused reports the on-screen values of the animate keys when the pause takes effect. iOS stops the layer clock (speed 0 + timeOffset), so explicit beginTimes are now written in layer time. Android pauses animators and restarts springs from their last value and velocity. Web pauses the element's CSS transitions and animations, and restarts a loop only when its keyframes change instead of on every render. Closes #64 --- README.md | 30 ++++ android/src/main/java/com/ease/EaseView.kt | 170 +++++++++++++++--- .../src/main/java/com/ease/EaseViewManager.kt | 50 +++++- docs/docs/api-reference.mdx | 2 + docs/docs/usage.mdx | 28 +++ example/src/demos/PauseDemo.tsx | 159 ++++++++++++++++ example/src/demos/index.ts | 2 + ios/EaseView.mm | 143 ++++++++++++++- skills/react-native-ease-refactor/SKILL.md | 3 + src/EaseView.tsx | 87 ++++++++- src/EaseView.web.tsx | 148 ++++++++++++--- src/EaseViewNativeComponent.ts | 25 +++ src/__tests__/EaseView.test.tsx | 92 ++++++++++ src/index.tsx | 2 + src/types.ts | 19 ++ 15 files changed, 905 insertions(+), 55 deletions(-) create mode 100644 example/src/demos/PauseDemo.tsx diff --git a/README.md b/README.md index 2cb9c12..2fc2e7f 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,34 @@ Timing animations can loop infinitely. Use `'repeat'` to restart from the beginn Loop requires `initialAnimate` to define the starting value. Spring animations do not support looping. +### Pausing Animations + +Set `paused` to freeze a view's running animations, loops included, where they are. Setting it back to `false` resumes them from the same point. Nothing is remounted, so children (and their images) are untouched. + +```tsx + console.log(values.translateX)} +/> +``` + +`onPaused` fires each time `paused` becomes `true`, with the values on screen at that moment for the keys set in `animate` (colors as `rgba()` strings). + +While paused: + +- Changing `animate` starts the new animations from the frozen values; they play once resumed. A `delay` counts from the resume. +- `transition: { type: 'none' }` still applies its values immediately, as does removing a property from `animate`. +- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted. + +Platform notes: + +- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. +- **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. +- **Web** pauses the element's own CSS transitions and animations. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. + ### Enter Animations Use `initialAnimate` to set starting values. On mount, the view starts at `initialAnimate` values and animates to `animate` values. @@ -539,6 +567,8 @@ A `View` that animates property changes using native platform APIs. | `initialAnimate` | `AnimateProps` | Starting values for enter animations (animates to `animate` on mount) | | `transition` | `Transition` | Animation configuration — a single config (timing, spring, or none) or a [per-property map](#per-property-transitions) | | `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` | +| `paused` | `boolean` | Freezes running animations in place and resumes them from there when `false`. See [Pausing Animations](#pausing-animations). Default: `false` | +| `onPaused` | `(event) => void` | Called when `paused` takes effect, with `{ values }`: the on-screen values of the keys set in `animate` | | `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) | | `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). See [Transform Perspective](#transform-perspective). Default: `1280` | | `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` | diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt index 6bc6835..0cccf1e 100644 --- a/android/src/main/java/com/ease/EaseView.kt +++ b/android/src/main/java/com/ease/EaseView.kt @@ -7,6 +7,7 @@ import android.animation.ValueAnimator import android.content.Context import android.graphics.Color import android.graphics.Outline +import android.os.SystemClock import android.view.View import android.view.ViewOutlineProvider import android.view.animation.PathInterpolator @@ -293,6 +294,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { // --- Event callback --- var onTransitionEnd: ((finished: Boolean) -> Unit)? = null + var onPaused: ((values: PausedValues) -> Unit)? = null private var activeAnimationCount: Int = 0 private var animationBatchId: Int = 0 private var pendingBatchAnimationCount: Int = 0 @@ -332,7 +334,39 @@ class EaseView(context: Context) : ReactViewGroup(context) { // --- Running animations --- private val runningAnimators = mutableMapOf() private val runningSpringAnimations = mutableMapOf() - private val pendingDelayedRunnables = mutableListOf() + private val pendingDelayedRunnables = mutableMapOf() + + // --- Pause --- + var pendingPaused: Boolean = false + private var isPaused: Boolean = false + private val pausedDelayedRunnables = mutableMapOf() + // SpringAnimation can't pause: a paused spring is cancelled and restarted + // from its last value and velocity, which fully determine its motion. + private class SpringState( + val toValue: Float, + val config: TransitionConfig, + val batchId: Int, + var value: Float?, + var velocity: Float + ) + private val springStates = mutableMapOf() + private val pausedSprings = mutableMapOf() + + data class PausedValues( + val opacity: Float, + val translateX: Float, + val translateY: Float, + val scaleX: Float, + val scaleY: Float, + val rotate: Float, + val rotateX: Float, + val rotateY: Float, + val borderRadius: Float, + val backgroundColor: Int, + val borderWidth: Float, + val borderColor: Int, + val elevation: Float + ) // --- Animated properties bitmask (set by ViewManager) --- var animatedProperties: Int = 0 @@ -831,7 +865,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { } runningAnimators["backgroundColor"] = animator - animator.start() + startAnimator(animator) } private fun animateBorderColorTransition(fromColor: Int, toColor: Int, config: TransitionConfig, loop: Boolean = false) { @@ -875,7 +909,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { } runningAnimators["borderColor"] = animator - animator.start() + startAnimator(animator) } private fun animateProperty( @@ -962,7 +996,7 @@ class EaseView(context: Context) : ReactViewGroup(context) { } runningAnimators[propertyName] = animator - animator.start() + startAnimator(animator) } private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float, config: TransitionConfig) { @@ -975,18 +1009,12 @@ class EaseView(context: Context) : ReactViewGroup(context) { } } runningSpringAnimations.remove(viewProperty) + springStates.remove(viewProperty) + pausedSprings.remove(viewProperty) val batchId = animationBatchId pendingBatchAnimationCount++ - val needsBackfaceUpdate = (viewProperty == DynamicAnimation.ROTATION || - viewProperty == DynamicAnimation.ROTATION_X || - viewProperty == DynamicAnimation.ROTATION_Y) && - (animatedProperties and MASK_OPACITY == 0) - - val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass))) - .coerceAtLeast(0.01f) - // translationX/Y animate in pixels, so a DIP/s velocity has to be converted // the same way the target value is in EaseViewManager. Every other property // (scale, rotation, alpha) already shares its unit with the JS side. @@ -995,16 +1023,54 @@ class EaseView(context: Context) : ReactViewGroup(context) { PixelUtil.toPixelFromDIP(config.velocity) else -> config.velocity } + val state = SpringState(toValue, config, batchId, null, startVelocity) + springStates[viewProperty] = state + + if (config.delay > 0) { + val runnable = object : Runnable { + override fun run() { + pendingDelayedRunnables.remove(this) + if (springStates[viewProperty] === state) startSpring(viewProperty, state) + } + } + if (isPaused) { + pausedDelayedRunnables[runnable] = config.delay + } else { + pendingDelayedRunnables[runnable] = SystemClock.uptimeMillis() + config.delay + postDelayed(runnable, config.delay) + } + } else if (isPaused) { + pausedSprings[viewProperty] = state + } else { + startSpring(viewProperty, state) + } + } + + private fun startSpring(viewProperty: DynamicAnimation.ViewProperty, state: SpringState) { + val config = state.config + val toValue = state.toValue + val batchId = state.batchId + + val needsBackfaceUpdate = (viewProperty == DynamicAnimation.ROTATION || + viewProperty == DynamicAnimation.ROTATION_X || + viewProperty == DynamicAnimation.ROTATION_Y) && + (animatedProperties and MASK_OPACITY == 0) + + val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass))) + .coerceAtLeast(0.01f) val spring = SpringAnimation(this, viewProperty).apply { spring = SpringForce(toValue).apply { this.dampingRatio = dampingRatio this.stiffness = config.stiffness } - if (startVelocity != 0f) { - setStartVelocity(startVelocity) + state.value?.let { setStartValue(it) } + if (state.velocity != 0f) { + setStartVelocity(state.velocity) } - addUpdateListener { _, _, _ -> + addUpdateListener { _, value, velocity -> + state.value = value + state.velocity = velocity // First update — enable hardware layer if (activeAnimationCount == 0) { this@EaseView.onEaseAnimationStart() @@ -1015,6 +1081,9 @@ class EaseView(context: Context) : ReactViewGroup(context) { } addEndListener { _, canceled, _, _ -> this@EaseView.onEaseAnimationEnd() + // cancel() reports here; a pause cancels only to restart on resume. + if (canceled && pausedSprings[viewProperty] === state) return@addEndListener + if (springStates[viewProperty] === state) springStates.remove(viewProperty) if (batchId == animationBatchId) { if (canceled) anyInterrupted = true pendingBatchAnimationCount-- @@ -1027,20 +1096,70 @@ class EaseView(context: Context) : ReactViewGroup(context) { onEaseAnimationStart() runningSpringAnimations[viewProperty] = spring - if (config.delay > 0) { - val runnable = Runnable { spring.start() } - pendingDelayedRunnables.add(runnable) - postDelayed(runnable, config.delay) + spring.start() + } + + private fun startAnimator(animator: Animator) { + animator.start() + if (isPaused) animator.pause() + } + + fun applyPaused(paused: Boolean) { + if (paused == isPaused) return + isPaused = paused + val now = SystemClock.uptimeMillis() + if (paused) { + for (animator in runningAnimators.values) animator.pause() + for ((runnable, dueAt) in pendingDelayedRunnables) { + removeCallbacks(runnable) + pausedDelayedRunnables[runnable] = (dueAt - now).coerceAtLeast(0) + } + pendingDelayedRunnables.clear() + for ((viewProperty, spring) in runningSpringAnimations) { + val state = springStates[viewProperty] ?: continue + if (!spring.isRunning) continue + state.value = viewProperty.getValue(this) + pausedSprings[viewProperty] = state + spring.cancel() + } + onPaused?.invoke(currentValues()) } else { - spring.start() + for (animator in runningAnimators.values) animator.resume() + for ((runnable, remaining) in pausedDelayedRunnables) { + pendingDelayedRunnables[runnable] = now + remaining + postDelayed(runnable, remaining) + } + pausedDelayedRunnables.clear() + val resumed = pausedSprings.toMap() + pausedSprings.clear() + for ((viewProperty, state) in resumed) startSpring(viewProperty, state) } } + private fun currentValues(): PausedValues = PausedValues( + opacity = alpha, + translateX = PixelUtil.toDIPFromPixel(translationX), + translateY = PixelUtil.toDIPFromPixel(translationY), + scaleX = scaleX, + scaleY = scaleY, + rotate = rotation, + rotateX = rotationX, + rotateY = rotationY, + borderRadius = PixelUtil.toDIPFromPixel(_borderRadius), + backgroundColor = currentBackgroundColor, + borderWidth = _borderWidth, + borderColor = currentBorderColor, + elevation = PixelUtil.toDIPFromPixel(elevation) + ) + private fun cancelAllAnimations() { - for (runnable in pendingDelayedRunnables) { + for (runnable in pendingDelayedRunnables.keys) { removeCallbacks(runnable) } pendingDelayedRunnables.clear() + pausedDelayedRunnables.clear() + springStates.clear() + pausedSprings.clear() for (animator in runningAnimators.values) { animator.cancel() } @@ -1081,6 +1200,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { "rotationY" -> DynamicAnimation.ROTATION_Y else -> return } + springStates.remove(viewProperty) + pausedSprings.remove(viewProperty) runningSpringAnimations[viewProperty]?.let { spring -> if (spring.isRunning) { spring.cancel() @@ -1090,10 +1211,13 @@ class EaseView(context: Context) : ReactViewGroup(context) { } fun stopAnimations() { - for (runnable in pendingDelayedRunnables) { + for (runnable in pendingDelayedRunnables.keys) { removeCallbacks(runnable) } pendingDelayedRunnables.clear() + pausedDelayedRunnables.clear() + springStates.clear() + pausedSprings.clear() for (animator in runningAnimators.values) { animator.cancel() } @@ -1147,6 +1271,8 @@ class EaseView(context: Context) : ReactViewGroup(context) { outlineProvider = ViewOutlineProvider.BACKGROUND transformPerspective = 1280f + isPaused = false + pendingPaused = false isFirstMount = true transitionConfigs = emptyMap() } diff --git a/android/src/main/java/com/ease/EaseViewManager.kt b/android/src/main/java/com/ease/EaseViewManager.kt index 5d59a57..b79b7f9 100644 --- a/android/src/main/java/com/ease/EaseViewManager.kt +++ b/android/src/main/java/com/ease/EaseViewManager.kt @@ -28,6 +28,13 @@ class EaseViewManager : ReactViewManager() { TransitionEndEvent(surfaceId, view.id, finished) ) } + view.onPaused = { values -> + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id) + val surfaceId = UIManagerHelper.getSurfaceId(context) + eventDispatcher?.dispatchEvent( + PausedEvent(surfaceId, view.id, values) + ) + } return view } @@ -190,6 +197,13 @@ class EaseViewManager : ReactViewManager() { view.initialAnimateElevation = PixelUtil.toPixelFromDIP(value) } + // --- Pause --- + + @ReactProp(name = "paused", defaultBoolean = false) + fun setPaused(view: EaseView, value: Boolean) { + view.pendingPaused = value + } + // --- Hardware layer --- @ReactProp(name = "useHardwareLayer", defaultBoolean = false) @@ -220,7 +234,10 @@ class EaseViewManager : ReactViewManager() { override fun onAfterUpdateTransaction(view: ReactViewGroup) { super.onAfterUpdateTransaction(view) - (view as? EaseView)?.applyPendingAnimateValues() + (view as? EaseView)?.let { + it.applyPendingAnimateValues() + it.applyPaused(it.pendingPaused) + } } override fun onDropViewInstance(view: ReactViewGroup) { @@ -240,7 +257,8 @@ class EaseViewManager : ReactViewManager() { override fun getExportedCustomDirectEventTypeConstants(): Map? { return mapOf( - "onTransitionEnd" to mapOf("registrationName" to "onTransitionEnd") + "onTransitionEnd" to mapOf("registrationName" to "onTransitionEnd"), + "onPaused" to mapOf("registrationName" to "onPaused") ) } @@ -255,6 +273,34 @@ class EaseViewManager : ReactViewManager() { } } + private class PausedEvent( + surfaceId: Int, + viewId: Int, + private val values: EaseView.PausedValues + ) : Event(surfaceId, viewId) { + override fun getEventName() = "onPaused" + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putDouble("opacity", values.opacity.toDouble()) + putDouble("translateX", values.translateX.toDouble()) + putDouble("translateY", values.translateY.toDouble()) + putDouble("scaleX", values.scaleX.toDouble()) + putDouble("scaleY", values.scaleY.toDouble()) + putDouble("rotate", values.rotate.toDouble()) + putDouble("rotateX", values.rotateX.toDouble()) + putDouble("rotateY", values.rotateY.toDouble()) + putDouble("borderRadius", values.borderRadius.toDouble()) + putInt("backgroundColor", values.backgroundColor) + putDouble("borderWidth", values.borderWidth.toDouble()) + putInt("borderColor", values.borderColor) + putDouble("shadowOpacity", 0.0) + putDouble("shadowRadius", 0.0) + putInt("shadowColor", 0) + putDouble("shadowOffsetX", 0.0) + putDouble("shadowOffsetY", 0.0) + putDouble("elevation", values.elevation.toDouble()) + } + } + companion object { const val NAME = "EaseView" } diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx index 9711d6f..49aafc3 100644 --- a/docs/docs/api-reference.mdx +++ b/docs/docs/api-reference.mdx @@ -13,6 +13,8 @@ A `View` that animates property changes using native platform APIs. | `initialAnimate` | `AnimateProps` | Starting values for enter animations | | `transition` | `Transition` | Single config or per-property map | | `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` | +| `paused` | `boolean` | Freezes running animations in place; resumes them from there when `false`. Default: `false` | +| `onPaused` | `(event) => void` | Called when `paused` takes effect, with `{ values }`: the on-screen values of the keys set in `animate` | | `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions | | `transformPerspective` | `number` | Camera distance for 3D transforms (`rotateX`, `rotateY`). Default: `1280` | | `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations | diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index 1d18fbe..c08028e 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -215,6 +215,34 @@ Shadow properties are iOS-only. On Android, use `elevation` for material shadows Loop requires `initialAnimate` to define the starting value. Spring animations do not support looping. +## Pausing animations + +Set `paused` to freeze a view's running animations, loops included, where they are. Setting it back to `false` resumes them from the same point. Nothing is remounted, so children (and their images) are untouched. + +```tsx + console.log(values.translateX)} +/> +``` + +`onPaused` fires each time `paused` becomes `true`, with the values on screen at that moment for the keys set in `animate` (colors as `rgba()` strings). + +While paused: + +- Changing `animate` starts the new animations from the frozen values; they play once resumed. A `delay` counts from the resume. +- `transition: { type: 'none' }` still applies its values immediately, as does removing a property from `animate`. +- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted. + +Platform notes: + +- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. +- **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. +- **Web** pauses the element's own CSS transitions and animations. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. + ## Enter animations ```tsx diff --git a/example/src/demos/PauseDemo.tsx b/example/src/demos/PauseDemo.tsx new file mode 100644 index 0000000..32952d7 --- /dev/null +++ b/example/src/demos/PauseDemo.tsx @@ -0,0 +1,159 @@ +import { useCallback, useState } from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import { EaseView, type AnimatedValues } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +const TILE = 100; +const GAP = 12; +const PITCH = TILE + GAP; +const COLORS = ['#e94560', '#4a90d9', '#f5a623', '#7ed321', '#9013fe']; +const ICON = require('../../assets/icon.png'); +const SET_WIDTH = PITCH * COLORS.length; +const SPEED_PT_PER_S = 40; +const LOOP_MS = (SET_WIDTH / SPEED_PT_PER_S) * 1000; +const TILES = [...COLORS, ...COLORS, ...COLORS]; + +function format(values: AnimatedValues | null) { + if (!values) return '—'; + return Object.entries(values) + .map(([key, value]) => + typeof value === 'number' ? `${key}: ${value.toFixed(2)}` : key, + ) + .join(', '); +} + +export function PauseDemo() { + const [paused, setPaused] = useState(false); + const [pausedValues, setPausedValues] = useState(null); + const [loadStarts, setLoadStarts] = useState(0); + const [mountId] = useState(() => Math.random().toString(36).slice(2, 8)); + const [pauseEvents, setPauseEvents] = useState(0); + // react-native-web re-reports a load start whenever this callback changes. + const countLoadStart = useCallback(() => setLoadStarts((n) => n + 1), []); + + const [moved, setMoved] = useState(false); + const [boxValues, setBoxValues] = useState(null); + + return ( + <> +
+ + { + setPauseEvents((n) => n + 1); + setPausedValues(values); + }} + style={styles.track} + testID="pause-marquee" + > + {TILES.map((color, index) => ( + + + {index % COLORS.length} + + ))} + + + + onPaused #{pauseEvents}: {format(pausedValues)} + + + mount {mountId} · image load starts {loadStarts} (stays at{' '} + {TILES.length} if nothing reloads) + +
+ +
+ setBoxValues(values)} + style={[styles.box, styles.timingBox]} + /> + + + Pause mid-move, wait, then resume: both boxes continue from where they + stopped. Moving while paused starts once resumed. + + timing box: {format(boxValues)} +
+ + ); +} + +const styles = StyleSheet.create({ + viewport: { + height: TILE, + overflow: 'hidden', + borderRadius: 12, + }, + track: { + flexDirection: 'row', + gap: GAP, + width: PITCH * TILES.length, + }, + tile: { + width: TILE, + height: TILE, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + icon: { + width: 56, + height: 56, + }, + tileLabel: { + position: 'absolute', + left: 8, + top: 4, + color: 'white', + fontWeight: '800', + }, + readout: { + marginTop: 12, + color: '#a0a0c0', + fontSize: 13, + }, + box: { + width: 50, + height: 50, + borderRadius: 10, + marginBottom: 8, + }, + timingBox: { + backgroundColor: '#4a90d9', + }, + springBox: { + backgroundColor: '#e94560', + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index d46a16a..51eef69 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -17,6 +17,7 @@ import { ExitDemo } from './ExitDemo'; import { FadeDemo } from './FadeDemo'; import { InterruptDemo } from './InterruptDemo'; import { KitchenSinkDemo } from './KitchenSinkDemo'; +import { PauseDemo } from './PauseDemo'; import { PulseDemo } from './PulseDemo'; import { RotateDemo } from './RotateDemo'; import { ScaleDemo } from './ScaleDemo'; @@ -111,6 +112,7 @@ export const demos: Record = { 'spin': { component: SpinDemo, title: 'Spin', section: 'Loop' }, 'pulse': { component: PulseDemo, title: 'Pulse', section: 'Loop' }, 'banner': { component: BannerDemo, title: 'Banner', section: 'Loop' }, + 'pause': { component: PauseDemo, title: 'Pause / Resume', section: 'Loop' }, 'interrupt': { component: InterruptDemo, title: 'Interrupt', diff --git a/ios/EaseView.mm b/ios/EaseView.mm index 04358ba..c401098 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -39,6 +39,23 @@ static inline CGFloat degreesToRadians(CGFloat degrees) { return degrees * M_PI / 180.0; } +static inline CGFloat radiansToDegrees(CGFloat radians) { + return radians * 180.0 / M_PI; +} + +static int32_t argbFromCGColor(CGColorRef color) { + if (color == nil) { + return 0; + } + CGFloat r = 0, g = 0, b = 0, a = 0; + [[UIColor colorWithCGColor:color] getRed:&r green:&g blue:&b alpha:&a]; + auto channel = [](CGFloat v) -> uint32_t { + return (uint32_t)lround(fmin(fmax(v, 0.0), 1.0) * 255.0); + }; + return (int32_t)((channel(a) << 24) | (channel(r) << 16) | (channel(g) << 8) | + channel(b)); +} + // Compose a full CATransform3D from individual animate values. // Order: Scale → RotateY → RotateX → RotateZ → Translate. // Default perspective (1280) matches React Native's default. @@ -267,6 +284,11 @@ @implementation EaseView { // (currentMediaTime - beginTime) mod period. NSMutableDictionary *_loopAnimations; EaseAnimationDelegateProxy *_delegateProxy; + // `paused` stops the layer's local clock (speed 0). Explicit beginTimes + // are therefore written in layer time, never raw CACurrentMediaTime(). + BOOL _paused; + NSInteger _pauseGeneration; + BOOL _pausedEventAwaitsFirstMount; } + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -330,6 +352,10 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics #pragma mark - Animation helpers +- (CFTimeInterval)layerTime { + return [self.layer convertTime:CACurrentMediaTime() fromLayer:nil]; +} + - (CATransform3D)presentationTransform { CALayer *pl = self.layer.presentationLayer; return pl ? pl.transform : self.layer.transform; @@ -408,13 +434,13 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath BOOL isLooping = loop && (config.loop == "repeat" || config.loop == "reverse"); if (config.delay > 0) { - animation.beginTime = CACurrentMediaTime() + (config.delay / 1000.0); + animation.beginTime = [self layerTime] + (config.delay / 1000.0); animation.fillMode = kCAFillModeBackwards; } else if (isLooping) { // Set explicit beginTime so the phase survives the addAnimation copy. // Without this, re-adding the saved animation later would reset to a // fresh "now" and visually restart the loop from the start. - animation.beginTime = CACurrentMediaTime(); + animation.beginTime = [self layerTime]; } [animation setValue:@(_animationBatchId) forKey:@"easeBatchId"]; animation.delegate = _delegateProxy; @@ -932,6 +958,111 @@ - (void)tryApplyPendingFirstMountProps { _hasPendingFirstMountUpdate = NO; _isFirstMount = NO; [CATransaction commit]; + + if (_pausedEventAwaitsFirstMount) { + _pausedEventAwaitsFirstMount = NO; + [self schedulePausedEvent]; + } +} + +#pragma mark - Pause + +// Core Animation pause/resume (Technical Q&A QA1673): stopping the layer's +// clock freezes every animation on it, loops included, at their current +// phase. Descendant layers share that clock, so their animations stop too. +- (void)applyPaused:(BOOL)paused { + if (paused == _paused) { + return; + } + _paused = paused; + _pauseGeneration++; + CALayer *layer = self.layer; + if (paused) { + CFTimeInterval now = [self layerTime]; + layer.speed = 0; + layer.timeOffset = now; + [self schedulePausedEvent]; + } else { + CFTimeInterval pausedTime = layer.timeOffset; + layer.speed = 1; + layer.timeOffset = 0; + layer.beginTime = 0; + layer.beginTime = [self layerTime] - pausedTime; + _pausedEventAwaitsFirstMount = NO; + } +} + +// Animations added in this transaction are only visible to the presentation +// layer once it commits, so read the frozen values on the next turn. +- (void)schedulePausedEvent { + NSInteger generation = _pauseGeneration; + __weak EaseView *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + EaseView *strongSelf = weakSelf; + if (strongSelf == nil || !strongSelf->_paused || + strongSelf->_pauseGeneration != generation) { + return; + } + if (strongSelf->_isFirstMount) { + strongSelf->_pausedEventAwaitsFirstMount = YES; + return; + } + [strongSelf emitPausedEvent]; + }); +} + +- (void)emitPausedEvent { + if (!_eventEmitter) { + return; + } + const auto &p = *std::static_pointer_cast(_props); + CALayer *layer = self.layer; + CALayer *pl = layer.presentationLayer ?: layer; + // A composed transform carrying m34 can't be reliably decomposed, so read + // a transform component from the presentation layer only while its own + // animation drives it; otherwise it rests at the prop value. + auto component = [&](NSString *key, NSString *keyPath, + CGFloat restingValue) -> CGFloat { + if ([layer animationForKey:key] == nil) { + return restingValue; + } + return [[pl valueForKeyPath:keyPath] doubleValue]; + }; + CGSize shadowOffset = pl.shadowOffset; + auto emitter = + std::static_pointer_cast(_eventEmitter); + emitter->onPaused(EaseViewEventEmitter::OnPaused{ + .opacity = pl.opacity, + .translateX = + (Float)component(kAnimKeyTransformTransX, @"transform.translation.x", + p.animateTranslateX), + .translateY = + (Float)component(kAnimKeyTransformTransY, @"transform.translation.y", + p.animateTranslateY), + .scaleX = (Float)component(kAnimKeyTransformScaleX, @"transform.scale.x", + p.animateScaleX), + .scaleY = (Float)component(kAnimKeyTransformScaleY, @"transform.scale.y", + p.animateScaleY), + .rotate = (Float)radiansToDegrees( + component(kAnimKeyTransformRotateZ, @"transform.rotation.z", + degreesToRadians(p.animateRotate))), + .rotateX = (Float)radiansToDegrees( + component(kAnimKeyTransformRotateX, @"transform.rotation.x", + degreesToRadians(p.animateRotateX))), + .rotateY = (Float)radiansToDegrees( + component(kAnimKeyTransformRotateY, @"transform.rotation.y", + degreesToRadians(p.animateRotateY))), + .borderRadius = (Float)pl.cornerRadius, + .backgroundColor = argbFromCGColor(pl.backgroundColor), + .borderWidth = (Float)pl.borderWidth, + .borderColor = argbFromCGColor(pl.borderColor), + .shadowOpacity = pl.shadowOpacity, + .shadowRadius = (Float)pl.shadowRadius, + .shadowColor = argbFromCGColor(pl.shadowColor), + .shadowOffsetX = (Float)shadowOffset.width, + .shadowOffsetY = (Float)shadowOffset.height, + .elevation = p.animateElevation, + }); } #pragma mark - Props update @@ -1363,6 +1494,8 @@ - (void)updateProps:(const Props::Shared &)props } } + [self applyPaused:newViewProps.paused]; + [CATransaction commit]; } @@ -1492,6 +1625,12 @@ - (void)prepareForRecycle { [super prepareForRecycle]; [self.layer removeAllAnimations]; [_loopAnimations removeAllObjects]; + _paused = NO; + _pauseGeneration++; + _pausedEventAwaitsFirstMount = NO; + self.layer.speed = 1; + self.layer.timeOffset = 0; + self.layer.beginTime = 0; _isFirstMount = YES; _hasPendingFirstMountUpdate = NO; _pendingAnimationCount = 0; diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md index 7fc3170..01def70 100644 --- a/skills/react-native-ease-refactor/SKILL.md +++ b/skills/react-native-ease-refactor/SKILL.md @@ -98,6 +98,7 @@ Use this table to convert Reanimated/Animated patterns to EaseView: | `Animated.Value` + `Animated.timing` | Same `animate` + `transition` pattern — convert to state-driven | | `Animated.Value` + `Animated.spring` | `animate` + `transition={{ type: 'spring' }}` — convert to state-driven | | `withDelay(ms, withTiming(...))` or `withDelay(ms, withSpring(...))` | `transition={{ ..., delay: ms }}` — add `delay` to the transition config | +| `cancelAnimation(sv)` to freeze, then restart from `sv.value` | `paused` prop (resumes in place); read the frozen values from `onPaused` | | `entering={FadeIn.delay(ms)}` / any entering preset with `.delay()` | `initialAnimate` + `animate` + `transition={{ ..., delay: ms }}` | | Different `withTiming`/`withSpring` per property in `useAnimatedStyle` | `transition={{ opacity: { type: 'timing', ... }, transform: { type: 'spring', ... } }}` (per-property map) | @@ -442,6 +443,8 @@ transition={{ type: 'none' }} - `initialAnimate` — starting values (animates to `animate` on mount) - `transition` — animation config: a single `SingleTransition` (timing/spring/none) OR a `TransitionMap` with category keys (`default`, `transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`) - `onTransitionEnd` — callback with `{ finished: boolean }` +- `paused` — freezes running animations (loops included) in place; `false` resumes them from the same point +- `onPaused` — callback with `{ values }`, the on-screen values of the `animate` keys when `paused` took effect - `transformOrigin` — pivot point as `{ x: 0-1, y: 0-1 }`, default center - `useHardwareLayer` — Android GPU optimization (boolean, default false) - `className` — NativeWind / Tailwind CSS class string (requires NativeWind in the project) diff --git a/src/EaseView.tsx b/src/EaseView.tsx index aadc03f..d491fed 100644 --- a/src/EaseView.tsx +++ b/src/EaseView.tsx @@ -1,8 +1,15 @@ -import { StyleSheet, type ViewProps, type ViewStyle } from 'react-native'; +import { + Platform, + StyleSheet, + type ViewProps, + type ViewStyle, +} from 'react-native'; import NativeEaseView, { type NativeProps } from './EaseViewNativeComponent'; import type { AnimateProps, + AnimatedValues, CubicBezier, + PausedEvent, SingleTransition, Transition, TransitionEndEvent, @@ -194,6 +201,62 @@ function resolveTransitions(transition?: Transition): NativeTransitions { return result; } +type NativePausedEvent = Parameters< + NonNullable +>[0]['nativeEvent']; + +function argbToRgba(argb: number): string { + /* eslint-disable no-bitwise */ + const c = argb >>> 0; + const a = Math.round(((c >>> 24) / 255) * 1000) / 1000; + return `rgba(${(c >>> 16) & 0xff}, ${(c >>> 8) & 0xff}, ${c & 0xff}, ${a})`; + /* eslint-enable no-bitwise */ +} + +/** iOS has no elevation and Android no shadow* animation: those report their target. */ +const IOS_ONLY_KEYS = new Set([ + 'shadowOpacity', + 'shadowRadius', + 'shadowColor', + 'shadowOffset', +]); + +function resolvePausedValues( + native: NativePausedEvent, + animate: AnimateProps | undefined, +): AnimatedValues { + const values: Record = {}; + if (!animate) return values; + for (const key of Object.keys(animate) as (keyof AnimateProps)[]) { + if (animate[key] == null) continue; + const unsupported = + Platform.OS === 'android' ? IOS_ONLY_KEYS.has(key) : key === 'elevation'; + if (unsupported) { + values[key] = animate[key]; + continue; + } + switch (key) { + case 'scale': + values.scale = native.scaleX; + break; + case 'backgroundColor': + case 'borderColor': + case 'shadowColor': + values[key] = argbToRgba(native[key]); + break; + case 'shadowOffset': + values.shadowOffset = { + width: native.shadowOffsetX, + height: native.shadowOffsetY, + }; + break; + default: + values[key] = native[key]; + } + } + return values; +} + export type EaseViewProps = ViewProps & { /** Target values for animated properties. */ animate?: AnimateProps; @@ -203,6 +266,19 @@ export type EaseViewProps = ViewProps & { transition?: Transition; /** Called when all animations complete. Reports whether they finished naturally or were interrupted. */ onTransitionEnd?: (event: TransitionEndEvent) => void; + /** + * Freezes the view's running animations (including loops) where they are, + * and resumes them from the same point when set back to `false`. Changes to + * `animate` while paused start from the frozen values and play once + * resumed; `transition: { type: 'none' }` still applies immediately. + * @default false + */ + paused?: boolean; + /** + * Called each time `paused` becomes `true`, with the values on screen at + * the moment the animations froze (only the keys set in `animate`). + */ + onPaused?: (event: PausedEvent) => void; /** * Enable Android hardware layer during animations. The view is rasterized to * a GPU texture so animated property changes (opacity, scale, rotation) are @@ -243,6 +319,8 @@ export function EaseView({ initialAnimate, transition, onTransitionEnd, + paused = false, + onPaused, useHardwareLayer = false, transformOrigin, transformPerspective, @@ -354,10 +432,17 @@ export function EaseView({ onTransitionEnd(event.nativeEvent) : undefined; + const handlePaused = onPaused + ? (event: { nativeEvent: NativePausedEvent }) => + onPaused({ values: resolvePausedValues(event.nativeEvent, animate) }) + : undefined; + return ( void; + paused?: boolean; + onPaused?: (event: PausedEvent) => void; /** No-op on web. */ useHardwareLayer?: boolean; transformOrigin?: TransformOrigin; @@ -311,6 +321,64 @@ function resolveDuration(transition: SingleTransition | undefined): number { return getSpringEasing(d, s, m).duration; } +function setAnimationsPaused(el: HTMLElement, paused: boolean) { + if (typeof el.getAnimations !== 'function') return; + for (const animation of el.getAnimations()) { + if (paused) { + if (animation.playState === 'running') animation.pause(); + } else if (animation.playState === 'paused') { + animation.play(); + } + } +} + +function readComputedValues( + el: HTMLElement, + animate: AnimateProps | undefined, +): AnimatedValues { + const values: Record = {}; + if (!animate) return values; + const computed = getComputedStyle(el); + const matrix = new DOMMatrixReadOnly( + computed.transform === 'none' ? undefined : computed.transform, + ); + // A 3D (perspective) matrix doesn't decompose into these components, so + // transforms then report their targets. + const m = matrix.is2D ? matrix : null; + const scaleX = m ? Math.hypot(m.a, m.b) : undefined; + const transform: Partial> = { + translateX: m?.e, + translateY: m?.f, + scaleX, + scaleY: m && scaleX ? (m.a * m.d - m.b * m.c) / scaleX : undefined, + scale: scaleX, + rotate: m ? (Math.atan2(m.b, m.a) * 180) / Math.PI : undefined, + }; + for (const key of Object.keys(animate) as (keyof AnimateProps)[]) { + if (animate[key] == null) continue; + switch (key) { + case 'opacity': + values.opacity = parseFloat(computed.opacity); + break; + case 'borderRadius': + values.borderRadius = parseFloat(computed.borderTopLeftRadius); + break; + case 'borderWidth': + values.borderWidth = parseFloat(computed.borderTopWidth); + break; + case 'backgroundColor': + values.backgroundColor = computed.backgroundColor; + break; + case 'borderColor': + values.borderColor = computed.borderTopColor; + break; + default: + values[key] = transform[key] ?? animate[key]; + } + } + return values; +} + /** Counter for unique keyframe names. */ let keyframeCounter = 0; @@ -319,6 +387,8 @@ export function EaseView({ initialAnimate, transition, onTransitionEnd, + paused = false, + onPaused, useHardwareLayer: _useHardwareLayer, transformOrigin, transformPerspective = 1280, @@ -338,6 +408,9 @@ export function EaseView({ // On web, View ref gives us the underlying DOM element. const viewRef = useRef>(null); const animationNameRef = useRef(null); + const pausedRef = useRef(paused); + pausedRef.current = paused; + const wasPausedRef = useRef(false); const getElement = useCallback( () => viewRef.current as unknown as HTMLElement | null, @@ -413,18 +486,10 @@ export function EaseView({ return () => el.removeEventListener('transitionend', handler); }, [onTransitionEnd, getElement]); - // Handle loop animations via CSS @keyframes. - useEffect(() => { - const el = getElement(); - if (!loopMode || !el) { - if (animationNameRef.current) { - const cleanEl = getElement(); - if (cleanEl) cleanEl.style.animation = ''; - animationNameRef.current = null; - } - return; - } - + // Keyframes for loop mode, as a string so the effect below only restarts + // the loop when its content changes, not on every render. + let loopFrames: string | null = null; + if (loopMode) { const fromValues = initialAnimate ? resolveAnimateValues(initialAnimate) : resolveAnimateValues(undefined); @@ -439,9 +504,6 @@ export function EaseView({ uses3D && transformPerspective, ); - const name = `ease-loop-${++keyframeCounter}`; - animationNameRef.current = name; - // Only include border-radius/background-color in keyframes when explicitly // set by the user, to avoid overriding values from the style prop. const hasBorderRadius = @@ -472,31 +534,61 @@ export function EaseView({ .filter(Boolean) .join('; '); - const keyframes = `@keyframes ${name} { from { ${fromBlock} } to { ${toBlock} } }`; + loopFrames = `from { ${fromBlock} } to { ${toBlock} }`; + } + + // Handle loop animations via CSS @keyframes. + useEffect(() => { + const el = getElement(); + if (!loopMode || !loopFrames || !el) { + if (animationNameRef.current) { + const cleanEl = getElement(); + if (cleanEl) cleanEl.style.animation = ''; + animationNameRef.current = null; + } + return; + } + + const name = `ease-loop-${++keyframeCounter}`; + animationNameRef.current = name; const styleEl = document.createElement('style'); - styleEl.textContent = keyframes; + styleEl.textContent = `@keyframes ${name} { ${loopFrames} }`; document.head.appendChild(styleEl); const direction = loopMode === 'reverse' ? 'alternate' : 'normal'; el.style.transition = 'none'; el.style.animation = `${name} ${loopDuration}ms ${loopEasing} infinite ${direction}`; + if (pausedRef.current) setAnimationsPaused(el, true); return () => { styleEl.remove(); el.style.animation = ''; animationNameRef.current = null; }; - }, [ - loopMode, - animate, - initialAnimate, - loopDuration, - loopEasing, - getElement, - uses3D, - transformPerspective, - ]); + }, [loopMode, loopFrames, loopDuration, loopEasing, getElement]); + + // A layout effect, so transitions started by this render are paused before + // the browser paints a frame of them. + useLayoutEffect(() => { + const el = getElement(); + if (!el) return; + setAnimationsPaused(el, paused); + const justPaused = paused && !wasPausedRef.current; + wasPausedRef.current = paused; + if (justPaused && onPaused) { + // pause() takes effect on the next frame; read the values it holds. + const pending = + typeof el.getAnimations === 'function' + ? el.getAnimations().map((animation) => animation.ready) + : []; + Promise.allSettled(pending).then(() => { + if (pausedRef.current) { + onPaused({ values: readComputedValues(el, animate) }); + } + }); + } + }); // Build animated style using RN transform array format. // react-native-web converts these to CSS transform strings. diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts index 76e61fc..6dd6f71 100644 --- a/src/EaseViewNativeComponent.ts +++ b/src/EaseViewNativeComponent.ts @@ -31,6 +31,27 @@ type NativeTransitions = Readonly<{ shadow?: NativeTransitionConfig; }>; +type NativePausedEvent = Readonly<{ + opacity: Float; + translateX: Float; + translateY: Float; + scaleX: Float; + scaleY: Float; + rotate: Float; + rotateX: Float; + rotateY: Float; + borderRadius: Float; + backgroundColor: Int32; + borderWidth: Float; + borderColor: Int32; + shadowOpacity: Float; + shadowRadius: Float; + shadowColor: Int32; + shadowOffsetX: Float; + shadowOffsetY: Float; + elevation: Float; +}>; + export interface NativeProps extends ViewProps { // Bitmask of which properties are animated (0 = none, let style handle all) animatedProperties?: CodegenTypes.WithDefault; @@ -100,10 +121,14 @@ export interface NativeProps extends ViewProps { // 3D perspective distance (default 1280, matches RN default) transformPerspective?: CodegenTypes.WithDefault; + paused?: CodegenTypes.WithDefault; + // Events onTransitionEnd?: CodegenTypes.DirectEventHandler< Readonly<{ finished: boolean }> >; + // Colors are ARGB integers + onPaused?: CodegenTypes.DirectEventHandler; // Android hardware layer optimization (no-op on iOS) useHardwareLayer?: CodegenTypes.WithDefault; diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx index 073ec68..ee3d40d 100644 --- a/src/__tests__/EaseView.test.tsx +++ b/src/__tests__/EaseView.test.tsx @@ -1013,4 +1013,96 @@ describe('EaseView', () => { expect(props.transitions.defaultConfig.loop).toBe('reverse'); }); }); + + describe('paused', () => { + const nativeValues = { + opacity: 0.4, + translateX: -123.5, + translateY: 7, + scaleX: 1.25, + scaleY: 0.75, + rotate: 90, + rotateX: 0, + rotateY: 0, + borderRadius: 12, + backgroundColor: 0x80ff0000 - 0x100000000, + borderWidth: 2, + borderColor: 0xff00ff00 - 0x100000000, + shadowOpacity: 0.3, + shadowRadius: 4, + shadowColor: 0xff0000ff - 0x100000000, + shadowOffsetX: 1, + shadowOffsetY: 2, + elevation: 6, + }; + + it('defaults to false', () => { + render(); + expect(getNativeProps().paused).toBe(false); + }); + + it('passes paused through', () => { + render(); + expect(getNativeProps().paused).toBe(true); + }); + + it('does not register onPaused when not provided', () => { + render(); + expect(getNativeProps().onPaused).toBeUndefined(); + }); + + it('reports only the keys set in animate', () => { + const onPaused = jest.fn(); + render( + , + ); + getNativeProps().onPaused({ nativeEvent: nativeValues }); + expect(onPaused).toHaveBeenCalledWith({ + values: { translateX: -123.5, opacity: 0.4 }, + }); + }); + + it('maps scale, shadowOffset and colors', () => { + const onPaused = jest.fn(); + render( + , + ); + getNativeProps().onPaused({ nativeEvent: nativeValues }); + expect(onPaused.mock.calls[0][0].values).toEqual({ + scale: 1.25, + backgroundColor: 'rgba(255, 0, 0, 0.502)', + borderColor: 'rgba(0, 255, 0, 1)', + shadowOffset: { width: 1, height: 2 }, + }); + }); + + it('reports the target for properties iOS does not animate', () => { + const onPaused = jest.fn(); + render( + , + ); + getNativeProps().onPaused({ nativeEvent: nativeValues }); + expect(onPaused.mock.calls[0][0].values).toEqual({ + elevation: 10, + opacity: 0.4, + }); + }); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 612c5da..8d55f99 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,8 @@ export type { NoneTransition, EasingType, TransitionEndEvent, + PausedEvent, + AnimatedValues, TransformOrigin, TransformPerspective, } from './types'; diff --git a/src/types.ts b/src/types.ts index 6d37ef6..95c7790 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,25 @@ export type TransitionEndEvent = { finished: boolean; }; +/** + * Values of a view's animated properties as rendered. Keys mirror the + * `animate` prop that was set; colors are reported as `rgba()` strings. + */ +export type AnimatedValues = Omit< + AnimateProps, + 'backgroundColor' | 'borderColor' | 'shadowColor' +> & { + backgroundColor?: string; + borderColor?: string; + shadowColor?: string; +}; + +/** Event fired once `paused` has frozen the view's animations. */ +export type PausedEvent = { + /** The values on screen at the moment the animations were paused. */ + values: AnimatedValues; +}; + /** Transform origin as 0–1 fractions. Default is center (0.5, 0.5). */ export type TransformOrigin = { /** Horizontal origin. 0 = left, 0.5 = center, 1 = right. @default 0.5 */ From bd1010bf009f6d89f5c318c59681b360642cfefa Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 15 Oct 2026 07:15:42 -0400 Subject: [PATCH 2/4] fix(web): keep enter transitions and decompose scale before rotate in onPaused --- README.md | 2 +- docs/docs/usage.mdx | 2 +- src/EaseView.web.tsx | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2fc2e7f..33532e5 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ Platform notes: - **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. - **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. -- **Web** pauses the element's own CSS transitions and animations. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. +- **Web** pauses the element's own CSS transitions and animations. Changing a loop's `animate` restarts it from `initialAnimate`, paused or not. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. ### Enter Animations diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index c08028e..c1434ae 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -241,7 +241,7 @@ Platform notes: - **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. - **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. -- **Web** pauses the element's own CSS transitions and animations. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. +- **Web** pauses the element's own CSS transitions and animations. Changing a loop's `animate` restarts it from `initialAnimate`, paused or not. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. ## Enter animations diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index eada0eb..cd66cb5 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -345,14 +345,15 @@ function readComputedValues( // A 3D (perspective) matrix doesn't decompose into these components, so // transforms then report their targets. const m = matrix.is2D ? matrix : null; - const scaleX = m ? Math.hypot(m.a, m.b) : undefined; + // The style lists scale before rotate, so the linear part is S·R. + const scaleX = m ? Math.hypot(m.a, m.c) : undefined; const transform: Partial> = { translateX: m?.e, translateY: m?.f, scaleX, - scaleY: m && scaleX ? (m.a * m.d - m.b * m.c) / scaleX : undefined, + scaleY: m ? Math.hypot(m.b, m.d) : undefined, scale: scaleX, - rotate: m ? (Math.atan2(m.b, m.a) * 180) / Math.PI : undefined, + rotate: m ? (Math.atan2(-m.c, m.a) * 180) / Math.PI : undefined, }; for (const key of Object.keys(animate) as (keyof AnimateProps)[]) { if (animate[key] == null) continue; @@ -573,10 +574,13 @@ export function EaseView({ useLayoutEffect(() => { const el = getElement(); if (!el) return; - setAnimationsPaused(el, paused); - const justPaused = paused && !wasPausedRef.current; + const wasPaused = wasPausedRef.current; wasPausedRef.current = paused; - if (justPaused && onPaused) { + // getAnimations() flushes styles, which would start this render's + // transitions before the effect below has applied their `transition`. + if (!paused && !wasPaused) return; + setAnimationsPaused(el, paused); + if (paused && !wasPaused && onPaused) { // pause() takes effect on the next frame; read the values it holds. const pending = typeof el.getAnimations === 'function' From bbe161ebcf5d4f1158dba0a235e1465b660becc2 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 15 Oct 2026 07:25:57 -0400 Subject: [PATCH 3/4] fix: apply web transitions before pausing and read committed values in onPaused --- README.md | 4 ++-- docs/docs/usage.mdx | 4 ++-- example/src/demos/PauseDemo.tsx | 20 ++++++++++++++++++++ ios/EaseView.mm | 6 +++++- src/EaseView.web.tsx | 17 +++++++++++++++-- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 33532e5..55ff6f2 100644 --- a/README.md +++ b/README.md @@ -428,11 +428,11 @@ While paused: - Changing `animate` starts the new animations from the frozen values; they play once resumed. A `delay` counts from the resume. - `transition: { type: 'none' }` still applies its values immediately, as does removing a property from `animate`. -- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted. +- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted, and their completion after the resume is not reported. Platform notes: -- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. +- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. After a resume that clock runs behind `CACurrentMediaTime()`, so a descendant animation that sets its `beginTime` from `CACurrentMediaTime()` instead of its layer's time starts late. `rotate` values come from the presentation transform, normalized to (-180, 180]. - **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. - **Web** pauses the element's own CSS transitions and animations. Changing a loop's `animate` restarts it from `initialAnimate`, paused or not. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index c1434ae..00b9429 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -235,11 +235,11 @@ While paused: - Changing `animate` starts the new animations from the frozen values; they play once resumed. A `delay` counts from the resume. - `transition: { type: 'none' }` still applies its values immediately, as does removing a property from `animate`. -- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted. +- Toggling `paused` is a prop update, so like any re-render it reports in-flight non-looping animations to `onTransitionEnd` as interrupted, and their completion after the resume is not reported. Platform notes: -- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. `rotate` values come from the presentation transform, normalized to (-180, 180]. +- **iOS** stops the view layer's Core Animation clock, so Core Animation animations of descendant views pause too. After a resume that clock runs behind `CACurrentMediaTime()`, so a descendant animation that sets its `beginTime` from `CACurrentMediaTime()` instead of its layer's time starts late. `rotate` values come from the presentation transform, normalized to (-180, 180]. - **Android** pauses timing animators in place. `SpringAnimation` can't pause, so a spring stops and restarts from the same value and velocity on resume. - **Web** pauses the element's own CSS transitions and animations. Changing a loop's `animate` restarts it from `initialAnimate`, paused or not. With `rotateX`/`rotateY` (3D transforms), transform values in `onPaused` report their targets. diff --git a/example/src/demos/PauseDemo.tsx b/example/src/demos/PauseDemo.tsx index 32952d7..8a7db7b 100644 --- a/example/src/demos/PauseDemo.tsx +++ b/example/src/demos/PauseDemo.tsx @@ -34,6 +34,7 @@ export function PauseDemo() { const countLoadStart = useCallback(() => setLoadStarts((n) => n + 1), []); const [moved, setMoved] = useState(false); + const [enterKey, setEnterKey] = useState(0); const [boxValues, setBoxValues] = useState(null); return ( @@ -106,6 +107,22 @@ export function PauseDemo() { timing box: {format(boxValues)}