Skip to content
Merged
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<EaseView
initialAnimate={{ translateX: 0 }}
animate={{ translateX: -300 }}
transition={{ type: 'timing', duration: 3000, easing: 'linear', loop: 'repeat' }}
paused={touching}
onPaused={({ values }) => 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, 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. 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. `ValueAnimator` records the pause one frame after the last value it drew, so a resume can land up to one frame ahead. `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.

### Enter Animations

Use `initialAnimate` to set starting values. On mount, the view starts at `initialAnimate` values and animates to `animate` values.
Expand Down Expand Up @@ -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` |
Expand Down
170 changes: 148 additions & 22 deletions android/src/main/java/com/ease/EaseView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -332,7 +334,39 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// --- Running animations ---
private val runningAnimators = mutableMapOf<String, Animator>()
private val runningSpringAnimations = mutableMapOf<DynamicAnimation.ViewProperty, SpringAnimation>()
private val pendingDelayedRunnables = mutableListOf<Runnable>()
private val pendingDelayedRunnables = mutableMapOf<Runnable, Long>()

// --- Pause ---
var pendingPaused: Boolean = false
private var isPaused: Boolean = false
private val pausedDelayedRunnables = mutableMapOf<Runnable, Long>()
// 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<DynamicAnimation.ViewProperty, SpringState>()
private val pausedSprings = mutableMapOf<DynamicAnimation.ViewProperty, SpringState>()

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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -875,7 +909,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}

runningAnimators["borderColor"] = animator
animator.start()
startAnimator(animator)
}

private fun animateProperty(
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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--
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
Expand All @@ -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()
}
Expand Down Expand Up @@ -1147,6 +1271,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
outlineProvider = ViewOutlineProvider.BACKGROUND

transformPerspective = 1280f
isPaused = false
pendingPaused = false
isFirstMount = true
transitionConfigs = emptyMap()
}
Expand Down
Loading
Loading