diff --git a/README.md b/README.md
index 8dfee4a..09caee6 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,46 @@ Use `{ type: 'none' }` to apply values immediately without animation. Useful for
`onTransitionEnd` fires immediately with `{ finished: true }`.
+### Per-Property Transitions
+
+Pass a map instead of a single config to use different animation types per property category.
+
+```tsx
+
+```
+
+Available category keys:
+
+| Key | Properties |
+| ----------------- | ---------------------------------------------------------------- |
+| `default` | Fallback for categories not explicitly listed |
+| `transform` | translateX, translateY, scaleX, scaleY, rotate, rotateX, rotateY |
+| `opacity` | opacity |
+| `borderRadius` | borderRadius |
+| `backgroundColor` | backgroundColor |
+
+Use `default` as a fallback for categories not explicitly listed:
+
+```tsx
+
+```
+
+When no `default` key is provided, the library default (timing 300ms easeInOut) applies to all categories.
+
+> **Android note:** Android animates `backgroundColor` with `ValueAnimator` (timing only). If a per-property map specifies `type: 'spring'` for `backgroundColor`, it silently falls back to timing 300ms.
+
### Border Radius
`borderRadius` can be animated just like other properties. It uses hardware-accelerated platform APIs — `ViewOutlineProvider` + `clipToOutline` on Android and `layer.cornerRadius` + `layer.masksToBounds` on iOS. Unlike RN's style-based `borderRadius` (which uses a Canvas drawable on Android), this clips children properly and is GPU-accelerated.
diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt
index aebb135..5d3c7c5 100644
--- a/android/src/main/java/com/ease/EaseView.kt
+++ b/android/src/main/java/com/ease/EaseView.kt
@@ -13,6 +13,7 @@ import android.view.animation.PathInterpolator
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
+import com.facebook.react.bridge.ReadableMap
import com.facebook.react.views.view.ReactViewGroup
import kotlin.math.sqrt
@@ -34,15 +35,90 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// --- First mount tracking ---
private var isFirstMount: Boolean = true
- // --- Transition config (set by ViewManager) ---
- var transitionType: String = "timing"
- var transitionDuration: Int = 300
- var transitionEasingBezier: FloatArray = floatArrayOf(0.42f, 0f, 0.58f, 1.0f)
- var transitionDamping: Float = 15.0f
- var transitionStiffness: Float = 120.0f
- var transitionMass: Float = 1.0f
- var transitionLoop: String = "none"
- var transitionDelay: Long = 0L
+ // --- Transition configs (set by ViewManager via ReadableMap) ---
+ private var transitionConfigs: Map = emptyMap()
+
+ data class TransitionConfig(
+ val type: String,
+ val duration: Int,
+ val easingBezier: FloatArray,
+ val damping: Float,
+ val stiffness: Float,
+ val mass: Float,
+ val loop: String,
+ val delay: Long
+ )
+
+ fun setTransitionsFromMap(map: ReadableMap?) {
+ if (map == null) {
+ transitionConfigs = emptyMap()
+ return
+ }
+ val configs = mutableMapOf()
+ val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor")
+ for (key in keys) {
+ if (map.hasKey(key)) {
+ val configMap = map.getMap(key) ?: continue
+ val bezierArray = configMap.getArray("easingBezier")!!
+ configs[key] = TransitionConfig(
+ type = configMap.getString("type")!!,
+ duration = configMap.getInt("duration"),
+ easingBezier = floatArrayOf(
+ bezierArray.getDouble(0).toFloat(),
+ bezierArray.getDouble(1).toFloat(),
+ bezierArray.getDouble(2).toFloat(),
+ bezierArray.getDouble(3).toFloat()
+ ),
+ damping = configMap.getDouble("damping").toFloat(),
+ stiffness = configMap.getDouble("stiffness").toFloat(),
+ mass = configMap.getDouble("mass").toFloat(),
+ loop = configMap.getString("loop")!!,
+ delay = configMap.getInt("delay").toLong()
+ )
+ }
+ }
+ transitionConfigs = configs
+ }
+
+ /** Map property name to category key, then fall back to defaultConfig. */
+ fun getTransitionConfig(name: String): TransitionConfig {
+ val categoryKey = when (name) {
+ "opacity" -> "opacity"
+ "translateX", "translateY", "scaleX", "scaleY",
+ "rotate", "rotateX", "rotateY" -> "transform"
+ "borderRadius" -> "borderRadius"
+ "backgroundColor" -> "backgroundColor"
+ else -> null
+ }
+ if (categoryKey != null) {
+ transitionConfigs[categoryKey]?.let { return it }
+ }
+ return transitionConfigs["defaultConfig"]!!
+ }
+
+ private fun allTransitionsNone(): Boolean {
+ val defaultConfig = transitionConfigs["defaultConfig"]
+ if (defaultConfig == null || defaultConfig.type != "none") return false
+ val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor")
+ return categories.all { key ->
+ val config = transitionConfigs[key]
+ config == null || config.type == "none"
+ }
+ }
+
+ companion object {
+ // Bitmask flags — must match JS constants
+ const val MASK_OPACITY = 1 shl 0
+ const val MASK_TRANSLATE_X = 1 shl 1
+ const val MASK_TRANSLATE_Y = 1 shl 2
+ const val MASK_SCALE_X = 1 shl 3
+ const val MASK_SCALE_Y = 1 shl 4
+ const val MASK_ROTATE = 1 shl 5
+ const val MASK_ROTATE_X = 1 shl 6
+ const val MASK_ROTATE_Y = 1 shl 7
+ const val MASK_BORDER_RADIUS = 1 shl 8
+ const val MASK_BACKGROUND_COLOR = 1 shl 9
+ }
// --- Transform origin (0–1 fractions) ---
var transformOriginX: Float = 0.5f
@@ -118,21 +194,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// --- Animated properties bitmask (set by ViewManager) ---
var animatedProperties: Int = 0
- // --- Easing interpolators (lazy singletons shared across all instances) ---
- companion object {
- // Bitmask flags — must match JS constants
- const val MASK_OPACITY = 1 shl 0
- const val MASK_TRANSLATE_X = 1 shl 1
- const val MASK_TRANSLATE_Y = 1 shl 2
- const val MASK_SCALE_X = 1 shl 3
- const val MASK_SCALE_Y = 1 shl 4
- const val MASK_ROTATE = 1 shl 5
- const val MASK_ROTATE_X = 1 shl 6
- const val MASK_ROTATE_Y = 1 shl 7
- const val MASK_BORDER_RADIUS = 1 shl 8
- const val MASK_BACKGROUND_COLOR = 1 shl 9
- }
-
init {
// Set camera distance for 3D perspective rotations (rotateX/rotateY)
cameraDistance = resources.displayMetrics.density * 850f
@@ -236,34 +297,40 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// Animate properties that differ from initial to target
if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
- animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, loop = true)
+ animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, getTransitionConfig("opacity"), loop = true)
}
if (mask and MASK_TRANSLATE_X != 0 && initialAnimateTranslateX != translateX) {
- animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, loop = true)
+ animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, getTransitionConfig("translateX"), loop = true)
}
if (mask and MASK_TRANSLATE_Y != 0 && initialAnimateTranslateY != translateY) {
- animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, loop = true)
+ animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, getTransitionConfig("translateY"), loop = true)
}
if (mask and MASK_SCALE_X != 0 && initialAnimateScaleX != scaleX) {
- animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, loop = true)
+ animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, getTransitionConfig("scaleX"), loop = true)
}
if (mask and MASK_SCALE_Y != 0 && initialAnimateScaleY != scaleY) {
- animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, loop = true)
+ animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, getTransitionConfig("scaleY"), loop = true)
}
if (mask and MASK_ROTATE != 0 && initialAnimateRotate != rotate) {
- animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, loop = true)
+ animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, getTransitionConfig("rotate"), loop = true)
}
if (mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) {
- animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, loop = true)
+ animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, getTransitionConfig("rotateX"), loop = true)
}
if (mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) {
- animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, loop = true)
+ animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, getTransitionConfig("rotateY"), loop = true)
}
if (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) {
- animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
+ animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, getTransitionConfig("borderRadius"), loop = true)
}
if (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) {
- animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, loop = true)
+ animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, getTransitionConfig("backgroundColor"), loop = true)
+ }
+
+ // If all per-property configs were 'none', no animations were queued.
+ // Fire onTransitionEnd immediately to match the scalar 'none' contract.
+ if (pendingBatchAnimationCount == 0) {
+ onTransitionEnd?.invoke(true)
}
} else {
// No initial animation — set target values directly (skip non-animated)
@@ -278,8 +345,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
}
- } else if (transitionType == "none") {
- // No transition — set values immediately, cancel running animations
+ } else if (allTransitionsNone()) {
+ // No transition (scalar) — set values immediately, cancel running animations
cancelAllAnimations()
if (mask and MASK_OPACITY != 0) this.alpha = opacity
if (mask and MASK_TRANSLATE_X != 0) this.translationX = translateX
@@ -294,53 +361,149 @@ class EaseView(context: Context) : ReactViewGroup(context) {
onTransitionEnd?.invoke(true)
} else {
// Subsequent updates: animate changed properties (skip non-animated)
+ var anyPropertyChanged = false
+
if (prevOpacity != null && mask and MASK_OPACITY != 0 && prevOpacity != opacity) {
- val from = getCurrentValue("alpha")
- animateProperty("alpha", DynamicAnimation.ALPHA, from, opacity)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("opacity")
+ if (config.type == "none") {
+ cancelSpringForProperty("alpha")
+ runningAnimators["alpha"]?.cancel()
+ runningAnimators.remove("alpha")
+ this.alpha = opacity
+ } else {
+ val from = getCurrentValue("alpha")
+ animateProperty("alpha", DynamicAnimation.ALPHA, from, opacity, config)
+ }
}
if (prevTranslateX != null && mask and MASK_TRANSLATE_X != 0 && prevTranslateX != translateX) {
- val from = getCurrentValue("translationX")
- animateProperty("translationX", DynamicAnimation.TRANSLATION_X, from, translateX)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("translateX")
+ if (config.type == "none") {
+ cancelSpringForProperty("translationX")
+ runningAnimators["translationX"]?.cancel()
+ runningAnimators.remove("translationX")
+ this.translationX = translateX
+ } else {
+ val from = getCurrentValue("translationX")
+ animateProperty("translationX", DynamicAnimation.TRANSLATION_X, from, translateX, config)
+ }
}
if (prevTranslateY != null && mask and MASK_TRANSLATE_Y != 0 && prevTranslateY != translateY) {
- val from = getCurrentValue("translationY")
- animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, from, translateY)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("translateY")
+ if (config.type == "none") {
+ cancelSpringForProperty("translationY")
+ runningAnimators["translationY"]?.cancel()
+ runningAnimators.remove("translationY")
+ this.translationY = translateY
+ } else {
+ val from = getCurrentValue("translationY")
+ animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, from, translateY, config)
+ }
}
if (prevScaleX != null && mask and MASK_SCALE_X != 0 && prevScaleX != scaleX) {
- val from = getCurrentValue("scaleX")
- animateProperty("scaleX", DynamicAnimation.SCALE_X, from, scaleX)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("scaleX")
+ if (config.type == "none") {
+ cancelSpringForProperty("scaleX")
+ runningAnimators["scaleX"]?.cancel()
+ runningAnimators.remove("scaleX")
+ this.scaleX = scaleX
+ } else {
+ val from = getCurrentValue("scaleX")
+ animateProperty("scaleX", DynamicAnimation.SCALE_X, from, scaleX, config)
+ }
}
if (prevScaleY != null && mask and MASK_SCALE_Y != 0 && prevScaleY != scaleY) {
- val from = getCurrentValue("scaleY")
- animateProperty("scaleY", DynamicAnimation.SCALE_Y, from, scaleY)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("scaleY")
+ if (config.type == "none") {
+ cancelSpringForProperty("scaleY")
+ runningAnimators["scaleY"]?.cancel()
+ runningAnimators.remove("scaleY")
+ this.scaleY = scaleY
+ } else {
+ val from = getCurrentValue("scaleY")
+ animateProperty("scaleY", DynamicAnimation.SCALE_Y, from, scaleY, config)
+ }
}
if (prevRotate != null && mask and MASK_ROTATE != 0 && prevRotate != rotate) {
- val from = getCurrentValue("rotation")
- animateProperty("rotation", DynamicAnimation.ROTATION, from, rotate)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("rotate")
+ if (config.type == "none") {
+ cancelSpringForProperty("rotation")
+ runningAnimators["rotation"]?.cancel()
+ runningAnimators.remove("rotation")
+ this.rotation = rotate
+ } else {
+ val from = getCurrentValue("rotation")
+ animateProperty("rotation", DynamicAnimation.ROTATION, from, rotate, config)
+ }
}
if (prevRotateX != null && mask and MASK_ROTATE_X != 0 && prevRotateX != rotateX) {
- val from = getCurrentValue("rotationX")
- animateProperty("rotationX", DynamicAnimation.ROTATION_X, from, rotateX)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("rotateX")
+ if (config.type == "none") {
+ cancelSpringForProperty("rotationX")
+ runningAnimators["rotationX"]?.cancel()
+ runningAnimators.remove("rotationX")
+ this.rotationX = rotateX
+ } else {
+ val from = getCurrentValue("rotationX")
+ animateProperty("rotationX", DynamicAnimation.ROTATION_X, from, rotateX, config)
+ }
}
if (prevRotateY != null && mask and MASK_ROTATE_Y != 0 && prevRotateY != rotateY) {
- val from = getCurrentValue("rotationY")
- animateProperty("rotationY", DynamicAnimation.ROTATION_Y, from, rotateY)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("rotateY")
+ if (config.type == "none") {
+ cancelSpringForProperty("rotationY")
+ runningAnimators["rotationY"]?.cancel()
+ runningAnimators.remove("rotationY")
+ this.rotationY = rotateY
+ } else {
+ val from = getCurrentValue("rotationY")
+ animateProperty("rotationY", DynamicAnimation.ROTATION_Y, from, rotateY, config)
+ }
}
if (prevBorderRadius != null && mask and MASK_BORDER_RADIUS != 0 && prevBorderRadius != borderRadius) {
- val from = getCurrentValue("animateBorderRadius")
- animateProperty("animateBorderRadius", null, from, borderRadius)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("borderRadius")
+ if (config.type == "none") {
+ runningAnimators["animateBorderRadius"]?.cancel()
+ runningAnimators.remove("animateBorderRadius")
+ setAnimateBorderRadius(borderRadius)
+ } else {
+ val from = getCurrentValue("animateBorderRadius")
+ animateProperty("animateBorderRadius", null, from, borderRadius, config)
+ }
}
if (prevBackgroundColor != null && mask and MASK_BACKGROUND_COLOR != 0 && prevBackgroundColor != backgroundColor) {
- animateBackgroundColor(getCurrentBackgroundColor(), backgroundColor)
+ anyPropertyChanged = true
+ val config = getTransitionConfig("backgroundColor")
+ if (config.type == "none") {
+ runningAnimators["backgroundColor"]?.cancel()
+ runningAnimators.remove("backgroundColor")
+ applyBackgroundColor(backgroundColor)
+ } else {
+ animateBackgroundColor(getCurrentBackgroundColor(), backgroundColor, config)
+ }
+ }
+
+ // If all changed properties resolved to 'none', no animations were queued.
+ // Fire onTransitionEnd immediately.
+ if (anyPropertyChanged && pendingBatchAnimationCount == 0) {
+ onTransitionEnd?.invoke(true)
}
}
@@ -378,22 +541,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
setBackgroundColor(color)
}
- private fun animateBackgroundColor(fromColor: Int, toColor: Int, loop: Boolean = false) {
+ private fun animateBackgroundColor(fromColor: Int, toColor: Int, config: TransitionConfig, loop: Boolean = false) {
runningAnimators["backgroundColor"]?.cancel()
val batchId = animationBatchId
pendingBatchAnimationCount++
val animator = ValueAnimator.ofArgb(fromColor, toColor).apply {
- duration = transitionDuration.toLong()
- startDelay = transitionDelay
+ duration = config.duration.toLong()
+ startDelay = config.delay
+
interpolator = PathInterpolator(
- transitionEasingBezier[0], transitionEasingBezier[1],
- transitionEasingBezier[2], transitionEasingBezier[3]
+ config.easingBezier[0], config.easingBezier[1],
+ config.easingBezier[2], config.easingBezier[3]
)
- if (loop && transitionLoop != "none") {
+ if (loop && config.loop != "none") {
repeatCount = ValueAnimator.INFINITE
- repeatMode = if (transitionLoop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
+ repeatMode = if (config.loop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
}
addUpdateListener { animation ->
val color = animation.animatedValue as Int
@@ -428,16 +592,28 @@ class EaseView(context: Context) : ReactViewGroup(context) {
viewProperty: DynamicAnimation.ViewProperty?,
fromValue: Float,
toValue: Float,
+ config: TransitionConfig,
loop: Boolean = false
) {
- if (transitionType == "spring" && viewProperty != null) {
- animateSpring(viewProperty, toValue)
+ if (config.type == "none") {
+ // Set immediately — cancel any running animation for this property
+ cancelSpringForProperty(propertyName)
+ runningAnimators[propertyName]?.cancel()
+ runningAnimators.remove(propertyName)
+ ObjectAnimator.ofFloat(this, propertyName, toValue).apply {
+ duration = 0
+ start()
+ }
+ return
+ }
+ if (config.type == "spring" && viewProperty != null) {
+ animateSpring(viewProperty, toValue, config)
} else {
- animateTiming(propertyName, fromValue, toValue, loop)
+ animateTiming(propertyName, fromValue, toValue, config, loop)
}
}
- private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, loop: Boolean = false) {
+ private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, config: TransitionConfig, loop: Boolean = false) {
cancelSpringForProperty(propertyName)
runningAnimators[propertyName]?.cancel()
@@ -445,15 +621,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
pendingBatchAnimationCount++
val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
- duration = transitionDuration.toLong()
- startDelay = transitionDelay
+ duration = config.duration.toLong()
+ startDelay = config.delay
+
interpolator = PathInterpolator(
- transitionEasingBezier[0], transitionEasingBezier[1],
- transitionEasingBezier[2], transitionEasingBezier[3]
+ config.easingBezier[0], config.easingBezier[1],
+ config.easingBezier[2], config.easingBezier[3]
)
- if (loop && transitionLoop != "none") {
+ if (loop && config.loop != "none") {
repeatCount = ObjectAnimator.INFINITE
- repeatMode = if (transitionLoop == "reverse") {
+ repeatMode = if (config.loop == "reverse") {
ObjectAnimator.REVERSE
} else {
ObjectAnimator.RESTART
@@ -484,25 +661,27 @@ class EaseView(context: Context) : ReactViewGroup(context) {
animator.start()
}
- private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float) {
+ private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float, config: TransitionConfig) {
cancelTimingForViewProperty(viewProperty)
- val existingSpring = runningSpringAnimations[viewProperty]
- if (existingSpring != null && existingSpring.isRunning) {
- existingSpring.animateToFinalPosition(toValue)
- return
+ // Cancel any existing spring so we get a fresh end listener with the current batchId.
+ runningSpringAnimations[viewProperty]?.let { existing ->
+ if (existing.isRunning) {
+ existing.cancel()
+ }
}
+ runningSpringAnimations.remove(viewProperty)
val batchId = animationBatchId
pendingBatchAnimationCount++
- val dampingRatio = (transitionDamping / (2.0f * sqrt(transitionStiffness * transitionMass)))
+ 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 = transitionStiffness
+ this.stiffness = config.stiffness
}
addUpdateListener { _, _, _ ->
// First update — enable hardware layer
@@ -524,10 +703,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
onEaseAnimationStart()
runningSpringAnimations[viewProperty] = spring
- if (transitionDelay > 0) {
+ if (config.delay > 0) {
val runnable = Runnable { spring.start() }
pendingDelayedRunnables.add(runnable)
- postDelayed(runnable, transitionDelay)
+ postDelayed(runnable, config.delay)
} else {
spring.start()
}
@@ -631,6 +810,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
applyBackgroundColor(Color.TRANSPARENT)
isFirstMount = true
- transitionLoop = "none"
+ transitionConfigs = emptyMap()
}
}
diff --git a/android/src/main/java/com/ease/EaseViewManager.kt b/android/src/main/java/com/ease/EaseViewManager.kt
index 5c426e5..4282310 100644
--- a/android/src/main/java/com/ease/EaseViewManager.kt
+++ b/android/src/main/java/com/ease/EaseViewManager.kt
@@ -3,6 +3,7 @@ package com.ease
import android.graphics.Color
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.PixelUtil
@@ -126,56 +127,11 @@ class EaseViewManager : ReactViewManager() {
view.initialAnimateBorderRadius = PixelUtil.toPixelFromDIP(value)
}
- // --- Transition config setters ---
+ // --- Transitions config (single ReadableMap) ---
- @ReactProp(name = "transitionType")
- fun setTransitionType(view: EaseView, value: String?) {
- view.transitionType = value ?: "timing"
- }
-
- @ReactProp(name = "transitionDuration", defaultInt = 300)
- fun setTransitionDuration(view: EaseView, value: Int) {
- view.transitionDuration = value
- }
-
- @ReactProp(name = "transitionEasingBezier")
- fun setTransitionEasingBezier(view: EaseView, value: ReadableArray?) {
- if (value != null && value.size() == 4) {
- view.transitionEasingBezier = floatArrayOf(
- value.getDouble(0).toFloat(),
- value.getDouble(1).toFloat(),
- value.getDouble(2).toFloat(),
- value.getDouble(3).toFloat()
- )
- } else {
- // Fallback: easeInOut
- view.transitionEasingBezier = floatArrayOf(0.42f, 0f, 0.58f, 1.0f)
- }
- }
-
- @ReactProp(name = "transitionDamping", defaultFloat = 15f)
- fun setTransitionDamping(view: EaseView, value: Float) {
- view.transitionDamping = value
- }
-
- @ReactProp(name = "transitionStiffness", defaultFloat = 120f)
- fun setTransitionStiffness(view: EaseView, value: Float) {
- view.transitionStiffness = value
- }
-
- @ReactProp(name = "transitionMass", defaultFloat = 1f)
- fun setTransitionMass(view: EaseView, value: Float) {
- view.transitionMass = value
- }
-
- @ReactProp(name = "transitionLoop")
- fun setTransitionLoop(view: EaseView, value: String?) {
- view.transitionLoop = value ?: "none"
- }
-
- @ReactProp(name = "transitionDelay", defaultInt = 0)
- fun setTransitionDelay(view: EaseView, value: Int) {
- view.transitionDelay = value.toLong()
+ @ReactProp(name = "transitions")
+ fun setTransitions(view: EaseView, value: ReadableMap?) {
+ view.setTransitionsFromMap(value)
}
// --- Border radius ---
diff --git a/example/src/demos/PerPropertyDemo.tsx b/example/src/demos/PerPropertyDemo.tsx
new file mode 100644
index 0000000..e8c5a7d
--- /dev/null
+++ b/example/src/demos/PerPropertyDemo.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { Text, StyleSheet } from 'react-native';
+import { EaseView } from 'react-native-ease';
+
+import { Section } from '../components/Section';
+import { Button } from '../components/Button';
+
+export function PerPropertyDemo() {
+ const [active, setActive] = useState(false);
+ return (
+
+
+ Opacity fades slowly (1.5s timing), transforms slide with a bouncy
+ spring — each category animates independently
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ box: {
+ width: 80,
+ height: 80,
+ backgroundColor: '#4a90d9',
+ borderRadius: 12,
+ borderWidth: 2,
+ borderColor: '#7ab8ff',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ hint: {
+ fontSize: 13,
+ color: '#8888aa',
+ marginBottom: 12,
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index 7834c72..19a22e6 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -21,6 +21,7 @@ import { SlideDemo } from './SlideDemo';
import { StyleReRenderDemo } from './StyleReRenderDemo';
import { StyledCardDemo } from './StyledCardDemo';
import { TransformOriginDemo } from './TransformOriginDemo';
+import { PerPropertyDemo } from './PerPropertyDemo';
interface DemoEntry {
component: ComponentType;
@@ -80,6 +81,11 @@ export const demos: Record = {
title: 'Comparison',
section: 'Advanced',
},
+ 'per-property': {
+ component: PerPropertyDemo,
+ title: 'Per-Property',
+ section: 'Advanced',
+ },
...(Platform.OS !== 'web'
? {
benchmark: {
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index 96ae860..442deca 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -58,6 +58,92 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY,
kMaskScaleX | kMaskScaleY | kMaskRotate |
kMaskRotateX | kMaskRotateY;
+// Per-property transition config resolved from the transitions struct
+struct EaseTransitionConfig {
+ std::string type;
+ int duration;
+ float bezier[4];
+ float damping;
+ float stiffness;
+ float mass;
+ std::string loop;
+ int delay;
+};
+
+// Convert from a codegen-generated transition config struct to our local
+// EaseTransitionConfig
+template
+static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
+ EaseTransitionConfig config;
+ config.type = src.type;
+ config.duration = src.duration;
+ const auto &b = src.easingBezier;
+ if (b.size() == 4) {
+ config.bezier[0] = b[0];
+ config.bezier[1] = b[1];
+ config.bezier[2] = b[2];
+ config.bezier[3] = b[3];
+ } else {
+ config.bezier[0] = 0.42f;
+ config.bezier[1] = 0.0f;
+ config.bezier[2] = 0.58f;
+ config.bezier[3] = 1.0f;
+ }
+ config.damping = src.damping;
+ config.stiffness = src.stiffness;
+ config.mass = src.mass;
+ config.loop = src.loop;
+ config.delay = src.delay;
+ return config;
+}
+
+// Check if a category config was explicitly set (non-empty type means JS sent
+// it)
+template static bool hasConfig(const T &cfg) {
+ return !cfg.type.empty();
+}
+
+static EaseTransitionConfig
+transitionConfigForProperty(const std::string &name,
+ const EaseViewProps &props) {
+ const auto &t = props.transitions;
+
+ // Map property name to category, check if category override exists
+ if (name == "opacity" && hasConfig(t.opacity)) {
+ return transitionConfigFromStruct(t.opacity);
+ } else if ((name == "translateX" || name == "translateY" ||
+ name == "scaleX" || name == "scaleY" || name == "rotate" ||
+ name == "rotateX" || name == "rotateY") &&
+ hasConfig(t.transform)) {
+ return transitionConfigFromStruct(t.transform);
+ } else if (name == "borderRadius" && hasConfig(t.borderRadius)) {
+ return transitionConfigFromStruct(t.borderRadius);
+ } else if (name == "backgroundColor" && hasConfig(t.backgroundColor)) {
+ return transitionConfigFromStruct(t.backgroundColor);
+ }
+ // Fallback to defaultConfig
+ return transitionConfigFromStruct(t.defaultConfig);
+}
+
+// Find lowest property name with a set mask bit among transform properties
+static std::string lowestTransformPropertyName(int mask) {
+ if (mask & kMaskTranslateX)
+ return "translateX";
+ if (mask & kMaskTranslateY)
+ return "translateY";
+ if (mask & kMaskScaleX)
+ return "scaleX";
+ if (mask & kMaskScaleY)
+ return "scaleY";
+ if (mask & kMaskRotate)
+ return "rotate";
+ if (mask & kMaskRotateX)
+ return "rotateX";
+ if (mask & kMaskRotateY)
+ return "rotateY";
+ return "translateX"; // fallback
+}
+
@implementation EaseView {
BOOL _isFirstMount;
NSInteger _animationBatchId;
@@ -139,16 +225,16 @@ - (NSValue *)presentationValueForKeyPath:(NSString *)keyPath {
- (CAAnimation *)createAnimationForKeyPath:(NSString *)keyPath
fromValue:(NSValue *)fromValue
toValue:(NSValue *)toValue
- props:(const EaseViewProps &)props
+ config:(EaseTransitionConfig)config
loop:(BOOL)loop {
- if (props.transitionType == EaseViewTransitionType::Spring) {
+ if (config.type == "spring") {
CASpringAnimation *spring =
[CASpringAnimation animationWithKeyPath:keyPath];
spring.fromValue = fromValue;
spring.toValue = toValue;
- spring.damping = props.transitionDamping;
- spring.stiffness = props.transitionStiffness;
- spring.mass = props.transitionMass;
+ spring.damping = config.damping;
+ spring.stiffness = config.stiffness;
+ spring.mass = config.mass;
spring.initialVelocity = 0;
spring.duration = spring.settlingDuration;
return spring;
@@ -156,23 +242,14 @@ - (CAAnimation *)createAnimationForKeyPath:(NSString *)keyPath
CABasicAnimation *timing = [CABasicAnimation animationWithKeyPath:keyPath];
timing.fromValue = fromValue;
timing.toValue = toValue;
- timing.duration = props.transitionDuration / 1000.0;
- {
- const auto &b = props.transitionEasingBezier;
- if (b.size() == 4) {
- timing.timingFunction = [CAMediaTimingFunction
- functionWithControlPoints:(float)b[0]:(float)b[1]:(float)b[2
- ]:(float)b[3]];
- } else {
- // Fallback: easeInOut
- timing.timingFunction =
- [CAMediaTimingFunction functionWithControlPoints:0.42:0.0:0.58:1.0];
- }
- }
+ timing.duration = config.duration / 1000.0;
+ timing.timingFunction = [CAMediaTimingFunction
+ functionWithControlPoints:config.bezier[0]:config.bezier[1
+ ]:config.bezier[2]:config.bezier[3]];
if (loop) {
- if (props.transitionLoop == EaseViewTransitionLoop::Repeat) {
+ if (config.loop == "repeat") {
timing.repeatCount = HUGE_VALF;
- } else if (props.transitionLoop == EaseViewTransitionLoop::Reverse) {
+ } else if (config.loop == "reverse") {
timing.repeatCount = HUGE_VALF;
timing.autoreverses = YES;
}
@@ -185,18 +262,17 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath
animationKey:(NSString *)animationKey
fromValue:(NSValue *)fromValue
toValue:(NSValue *)toValue
- props:(const EaseViewProps &)props
+ config:(EaseTransitionConfig)config
loop:(BOOL)loop {
_pendingAnimationCount++;
CAAnimation *animation = [self createAnimationForKeyPath:keyPath
fromValue:fromValue
toValue:toValue
- props:props
+ config:config
loop:loop];
- if (props.transitionDelay > 0) {
- animation.beginTime =
- CACurrentMediaTime() + (props.transitionDelay / 1000.0);
+ if (config.delay > 0) {
+ animation.beginTime = CACurrentMediaTime() + (config.delay / 1000.0);
animation.fillMode = kCAFillModeBackwards;
}
[animation setValue:@(_animationBatchId) forKey:@"easeBatchId"];
@@ -307,40 +383,77 @@ - (void)updateProps:(const Props::Shared &)props
newViewProps.initialAnimateBackgroundColor)
.CGColor;
- // Animate from initial to target
+ // Animate from initial to target (skip if config is 'none')
if (hasInitialOpacity) {
+ EaseTransitionConfig opacityConfig =
+ transitionConfigForProperty("opacity", newViewProps);
self.layer.opacity = newViewProps.animateOpacity;
- [self applyAnimationForKeyPath:@"opacity"
- animationKey:kAnimKeyOpacity
- fromValue:@(newViewProps.initialAnimateOpacity)
- toValue:@(newViewProps.animateOpacity)
- props:newViewProps
- loop:YES];
+ if (opacityConfig.type != "none") {
+ [self applyAnimationForKeyPath:@"opacity"
+ animationKey:kAnimKeyOpacity
+ fromValue:@(newViewProps.initialAnimateOpacity)
+ toValue:@(newViewProps.animateOpacity)
+ config:opacityConfig
+ loop:YES];
+ }
}
if (hasInitialTransform) {
+ // Build mask of which transform sub-properties actually changed
+ int changedInitTransform = 0;
+ if (newViewProps.initialAnimateTranslateX !=
+ newViewProps.animateTranslateX)
+ changedInitTransform |= kMaskTranslateX;
+ if (newViewProps.initialAnimateTranslateY !=
+ newViewProps.animateTranslateY)
+ changedInitTransform |= kMaskTranslateY;
+ if (newViewProps.initialAnimateScaleX != newViewProps.animateScaleX)
+ changedInitTransform |= kMaskScaleX;
+ if (newViewProps.initialAnimateScaleY != newViewProps.animateScaleY)
+ changedInitTransform |= kMaskScaleY;
+ if (newViewProps.initialAnimateRotate != newViewProps.animateRotate)
+ changedInitTransform |= kMaskRotate;
+ if (newViewProps.initialAnimateRotateX != newViewProps.animateRotateX)
+ changedInitTransform |= kMaskRotateX;
+ if (newViewProps.initialAnimateRotateY != newViewProps.animateRotateY)
+ changedInitTransform |= kMaskRotateY;
+ std::string transformName =
+ lowestTransformPropertyName(changedInitTransform);
+ EaseTransitionConfig transformConfig =
+ transitionConfigForProperty(transformName, newViewProps);
self.layer.transform = targetT;
- [self applyAnimationForKeyPath:@"transform"
+ if (transformConfig.type != "none") {
+ [self
+ applyAnimationForKeyPath:@"transform"
animationKey:kAnimKeyTransform
fromValue:[NSValue valueWithCATransform3D:initialT]
toValue:[NSValue valueWithCATransform3D:targetT]
- props:newViewProps
+ config:transformConfig
loop:YES];
+ }
}
if (hasInitialBorderRadius) {
+ EaseTransitionConfig brConfig =
+ transitionConfigForProperty("borderRadius", newViewProps);
self.layer.cornerRadius = newViewProps.animateBorderRadius;
- [self
- applyAnimationForKeyPath:@"cornerRadius"
- animationKey:kAnimKeyCornerRadius
- fromValue:@(newViewProps.initialAnimateBorderRadius)
- toValue:@(newViewProps.animateBorderRadius)
- props:newViewProps
- loop:YES];
+ if (brConfig.type != "none") {
+ [self applyAnimationForKeyPath:@"cornerRadius"
+ animationKey:kAnimKeyCornerRadius
+ fromValue:@(newViewProps
+ .initialAnimateBorderRadius)
+ toValue:@(newViewProps.animateBorderRadius)
+ config:brConfig
+ loop:YES];
+ }
}
if (hasInitialBackgroundColor) {
+ EaseTransitionConfig bgConfig =
+ transitionConfigForProperty("backgroundColor", newViewProps);
self.layer.backgroundColor =
RCTUIColorFromSharedColor(newViewProps.animateBackgroundColor)
.CGColor;
- [self applyAnimationForKeyPath:@"backgroundColor"
+ if (bgConfig.type != "none") {
+ [self
+ applyAnimationForKeyPath:@"backgroundColor"
animationKey:kAnimKeyBackgroundColor
fromValue:(__bridge id)RCTUIColorFromSharedColor(
newViewProps
@@ -349,8 +462,19 @@ - (void)updateProps:(const Props::Shared &)props
toValue:(__bridge id)RCTUIColorFromSharedColor(
newViewProps.animateBackgroundColor)
.CGColor
- props:newViewProps
+ config:bgConfig
loop:YES];
+ }
+ }
+
+ // If all per-property configs were 'none', no animations were queued.
+ // Fire onTransitionEnd immediately to match the scalar 'none' contract.
+ if (_pendingAnimationCount == 0 && _eventEmitter) {
+ auto emitter =
+ std::static_pointer_cast(_eventEmitter);
+ emitter->onTransitionEnd(EaseViewEventEmitter::OnTransitionEnd{
+ .finished = true,
+ });
}
} else {
// No initial animation — set target values directly
@@ -367,8 +491,16 @@ - (void)updateProps:(const Props::Shared &)props
RCTUIColorFromSharedColor(newViewProps.animateBackgroundColor)
.CGColor;
}
- } else if (newViewProps.transitionType == EaseViewTransitionType::None) {
- // No transition — set values immediately
+ } else if (newViewProps.transitions.defaultConfig.type == "none" &&
+ (!hasConfig(newViewProps.transitions.transform) ||
+ newViewProps.transitions.transform.type == "none") &&
+ (!hasConfig(newViewProps.transitions.opacity) ||
+ newViewProps.transitions.opacity.type == "none") &&
+ (!hasConfig(newViewProps.transitions.borderRadius) ||
+ newViewProps.transitions.borderRadius.type == "none") &&
+ (!hasConfig(newViewProps.transitions.backgroundColor) ||
+ newViewProps.transitions.backgroundColor.type == "none")) {
+ // All transitions are 'none' — set values immediately
[self.layer removeAllAnimations];
if (mask & kMaskOpacity)
self.layer.opacity = newViewProps.animateOpacity;
@@ -391,17 +523,27 @@ - (void)updateProps:(const Props::Shared &)props
}
} else {
// Subsequent updates: animate changed properties
+ BOOL anyPropertyChanged = NO;
if ((mask & kMaskOpacity) &&
oldViewProps.animateOpacity != newViewProps.animateOpacity) {
- self.layer.opacity = newViewProps.animateOpacity;
- [self
- applyAnimationForKeyPath:@"opacity"
- animationKey:kAnimKeyOpacity
- fromValue:[self presentationValueForKeyPath:@"opacity"]
- toValue:@(newViewProps.animateOpacity)
- props:newViewProps
- loop:NO];
+ anyPropertyChanged = YES;
+ EaseTransitionConfig opacityConfig =
+ transitionConfigForProperty("opacity", newViewProps);
+ if (opacityConfig.type == "none") {
+ self.layer.opacity = newViewProps.animateOpacity;
+ [self.layer removeAnimationForKey:kAnimKeyOpacity];
+ } else {
+ self.layer.opacity = newViewProps.animateOpacity;
+ [self
+ applyAnimationForKeyPath:@"opacity"
+ animationKey:kAnimKeyOpacity
+ fromValue:[self
+ presentationValueForKeyPath:@"opacity"]
+ toValue:@(newViewProps.animateOpacity)
+ config:opacityConfig
+ loop:NO];
+ }
}
// Check if ANY transform-related property changed
@@ -416,46 +558,98 @@ - (void)updateProps:(const Props::Shared &)props
oldViewProps.animateRotateY != newViewProps.animateRotateY;
if (anyTransformChanged) {
- CATransform3D fromT = [self presentationTransform];
- CATransform3D toT = [self targetTransformFromProps:newViewProps];
- self.layer.transform = toT;
- [self applyAnimationForKeyPath:@"transform"
- animationKey:kAnimKeyTransform
- fromValue:[NSValue valueWithCATransform3D:fromT]
- toValue:[NSValue valueWithCATransform3D:toT]
- props:newViewProps
- loop:NO];
+ anyPropertyChanged = YES;
+ // Determine which transform sub-properties changed for config selection
+ int changedTransformMask = 0;
+ if (oldViewProps.animateTranslateX != newViewProps.animateTranslateX)
+ changedTransformMask |= kMaskTranslateX;
+ if (oldViewProps.animateTranslateY != newViewProps.animateTranslateY)
+ changedTransformMask |= kMaskTranslateY;
+ if (oldViewProps.animateScaleX != newViewProps.animateScaleX)
+ changedTransformMask |= kMaskScaleX;
+ if (oldViewProps.animateScaleY != newViewProps.animateScaleY)
+ changedTransformMask |= kMaskScaleY;
+ if (oldViewProps.animateRotate != newViewProps.animateRotate)
+ changedTransformMask |= kMaskRotate;
+ if (oldViewProps.animateRotateX != newViewProps.animateRotateX)
+ changedTransformMask |= kMaskRotateX;
+ if (oldViewProps.animateRotateY != newViewProps.animateRotateY)
+ changedTransformMask |= kMaskRotateY;
+
+ std::string transformName =
+ lowestTransformPropertyName(changedTransformMask);
+ EaseTransitionConfig transformConfig =
+ transitionConfigForProperty(transformName, newViewProps);
+
+ if (transformConfig.type == "none") {
+ self.layer.transform = [self targetTransformFromProps:newViewProps];
+ [self.layer removeAnimationForKey:kAnimKeyTransform];
+ } else {
+ CATransform3D fromT = [self presentationTransform];
+ CATransform3D toT = [self targetTransformFromProps:newViewProps];
+ self.layer.transform = toT;
+ [self applyAnimationForKeyPath:@"transform"
+ animationKey:kAnimKeyTransform
+ fromValue:[NSValue valueWithCATransform3D:fromT]
+ toValue:[NSValue valueWithCATransform3D:toT]
+ config:transformConfig
+ loop:NO];
+ }
}
}
if ((mask & kMaskBorderRadius) &&
oldViewProps.animateBorderRadius != newViewProps.animateBorderRadius) {
+ anyPropertyChanged = YES;
+ EaseTransitionConfig brConfig =
+ transitionConfigForProperty("borderRadius", newViewProps);
self.layer.cornerRadius = newViewProps.animateBorderRadius;
self.layer.masksToBounds = newViewProps.animateBorderRadius > 0;
- [self applyAnimationForKeyPath:@"cornerRadius"
- animationKey:kAnimKeyCornerRadius
- fromValue:[self presentationValueForKeyPath:
- @"cornerRadius"]
- toValue:@(newViewProps.animateBorderRadius)
- props:newViewProps
- loop:NO];
+ if (brConfig.type == "none") {
+ [self.layer removeAnimationForKey:kAnimKeyCornerRadius];
+ } else {
+ [self applyAnimationForKeyPath:@"cornerRadius"
+ animationKey:kAnimKeyCornerRadius
+ fromValue:[self presentationValueForKeyPath:
+ @"cornerRadius"]
+ toValue:@(newViewProps.animateBorderRadius)
+ config:brConfig
+ loop:NO];
+ }
}
if ((mask & kMaskBackgroundColor) &&
oldViewProps.animateBackgroundColor !=
newViewProps.animateBackgroundColor) {
- CGColorRef fromColor = (__bridge CGColorRef)
- [self presentationValueForKeyPath:@"backgroundColor"];
+ anyPropertyChanged = YES;
+ EaseTransitionConfig bgConfig =
+ transitionConfigForProperty("backgroundColor", newViewProps);
CGColorRef toColor =
RCTUIColorFromSharedColor(newViewProps.animateBackgroundColor)
.CGColor;
self.layer.backgroundColor = toColor;
- [self applyAnimationForKeyPath:@"backgroundColor"
- animationKey:kAnimKeyBackgroundColor
- fromValue:(__bridge id)fromColor
- toValue:(__bridge id)toColor
- props:newViewProps
- loop:NO];
+ if (bgConfig.type == "none") {
+ [self.layer removeAnimationForKey:kAnimKeyBackgroundColor];
+ } else {
+ CGColorRef fromColor = (__bridge CGColorRef)
+ [self presentationValueForKeyPath:@"backgroundColor"];
+ [self applyAnimationForKeyPath:@"backgroundColor"
+ animationKey:kAnimKeyBackgroundColor
+ fromValue:(__bridge id)fromColor
+ toValue:(__bridge id)toColor
+ config:bgConfig
+ loop:NO];
+ }
+ }
+
+ // If all changed properties resolved to 'none', no animations were queued.
+ // Fire onTransitionEnd immediately.
+ if (anyPropertyChanged && _pendingAnimationCount == 0 && _eventEmitter) {
+ auto emitter =
+ std::static_pointer_cast(_eventEmitter);
+ emitter->onTransitionEnd(EaseViewEventEmitter::OnTransitionEnd{
+ .finished = true,
+ });
}
}
diff --git a/src/EaseView.tsx b/src/EaseView.tsx
index e9e20ce..4a7b02f 100644
--- a/src/EaseView.tsx
+++ b/src/EaseView.tsx
@@ -1,8 +1,9 @@
import { StyleSheet, type ViewProps, type ViewStyle } from 'react-native';
-import NativeEaseView from './EaseViewNativeComponent';
+import NativeEaseView, { type NativeProps } from './EaseViewNativeComponent';
import type {
AnimateProps,
CubicBezier,
+ SingleTransition,
Transition,
TransitionEndEvent,
TransformOrigin,
@@ -58,6 +59,115 @@ const EASING_PRESETS: Record = {
easeInOut: [0.42, 0, 0.58, 1],
};
+/** Returns true if the transition is a SingleTransition (has a `type` field). */
+function isSingleTransition(t: Transition): t is SingleTransition {
+ return 'type' in t;
+}
+
+type NativeTransitions = NonNullable;
+type NativeTransitionConfig = NativeTransitions['defaultConfig'];
+
+/** Default config: timing 300ms easeInOut. */
+const DEFAULT_CONFIG: NativeTransitionConfig = {
+ type: 'timing',
+ duration: 300,
+ easingBezier: [0.42, 0, 0.58, 1],
+ damping: 15,
+ stiffness: 120,
+ mass: 1,
+ loop: 'none',
+ delay: 0,
+};
+
+/** Resolve a SingleTransition into a native config object. */
+function resolveSingleConfig(config: SingleTransition): NativeTransitionConfig {
+ const type = config.type as string;
+ const duration = config.type === 'timing' ? config.duration ?? 300 : 300;
+ const rawEasing =
+ config.type === 'timing' ? config.easing ?? 'easeInOut' : 'easeInOut';
+ if (__DEV__) {
+ if (Array.isArray(rawEasing)) {
+ if ((rawEasing as number[]).length !== 4) {
+ console.warn(
+ 'react-native-ease: Custom easing must be a [x1, y1, x2, y2] tuple (got length ' +
+ (rawEasing as number[]).length +
+ ').',
+ );
+ }
+ if (
+ rawEasing[0] < 0 ||
+ rawEasing[0] > 1 ||
+ rawEasing[2] < 0 ||
+ rawEasing[2] > 1
+ ) {
+ console.warn(
+ 'react-native-ease: Easing x-values (x1, x2) must be between 0 and 1.',
+ );
+ }
+ }
+ }
+ const easingBezier: number[] = Array.isArray(rawEasing)
+ ? rawEasing
+ : EASING_PRESETS[rawEasing]!;
+ const damping = config.type === 'spring' ? config.damping ?? 15 : 15;
+ const stiffness = config.type === 'spring' ? config.stiffness ?? 120 : 120;
+ const mass = config.type === 'spring' ? config.mass ?? 1 : 1;
+ const loop: string =
+ config.type === 'timing' ? config.loop ?? 'none' : 'none';
+ const delay =
+ config.type === 'timing' || config.type === 'spring'
+ ? config.delay ?? 0
+ : 0;
+ return {
+ type,
+ duration,
+ easingBezier,
+ damping,
+ stiffness,
+ mass,
+ loop,
+ delay,
+ };
+}
+
+/** Category keys that map to optional NativeTransitions fields. */
+const CATEGORY_KEYS = [
+ 'transform',
+ 'opacity',
+ 'borderRadius',
+ 'backgroundColor',
+] as const;
+
+/** Resolve the transition prop into a NativeTransitions struct. */
+function resolveTransitions(transition?: Transition): NativeTransitions {
+ // No transition: timing default for all properties
+ if (transition == null) {
+ return { defaultConfig: DEFAULT_CONFIG };
+ }
+
+ // Single transition: set as defaultConfig only
+ if (isSingleTransition(transition)) {
+ return { defaultConfig: resolveSingleConfig(transition) };
+ }
+
+ // TransitionMap: resolve defaultConfig + only specified category keys
+ const defaultConfig = transition.default
+ ? resolveSingleConfig(transition.default)
+ : DEFAULT_CONFIG;
+
+ const result: NativeTransitions = { defaultConfig };
+
+ for (const key of CATEGORY_KEYS) {
+ const specific = transition[key];
+ if (specific != null) {
+ (result as Record)[key] =
+ resolveSingleConfig(specific);
+ }
+ }
+
+ return result;
+}
+
export type EaseViewProps = ViewProps & {
/** Target values for animated properties. */
animate?: AnimateProps;
@@ -181,50 +291,8 @@ export function EaseView({
}
}
- // Resolve transition config
- const transitionType = transition?.type ?? 'timing';
- const transitionDuration =
- transition?.type === 'timing' ? transition.duration ?? 300 : 300;
- const rawEasing =
- transition?.type === 'timing'
- ? transition.easing ?? 'easeInOut'
- : 'easeInOut';
- if (__DEV__) {
- if (Array.isArray(rawEasing)) {
- if ((rawEasing as number[]).length !== 4) {
- console.warn(
- 'react-native-ease: Custom easing must be a [x1, y1, x2, y2] tuple (got length ' +
- (rawEasing as number[]).length +
- ').',
- );
- }
- if (
- rawEasing[0] < 0 ||
- rawEasing[0] > 1 ||
- rawEasing[2] < 0 ||
- rawEasing[2] > 1
- ) {
- console.warn(
- 'react-native-ease: Easing x-values (x1, x2) must be between 0 and 1.',
- );
- }
- }
- }
- const bezier: CubicBezier = Array.isArray(rawEasing)
- ? rawEasing
- : EASING_PRESETS[rawEasing]!;
- const transitionDamping =
- transition?.type === 'spring' ? transition.damping ?? 15 : 15;
- const transitionStiffness =
- transition?.type === 'spring' ? transition.stiffness ?? 120 : 120;
- const transitionMass =
- transition?.type === 'spring' ? transition.mass ?? 1 : 1;
- const transitionLoop =
- transition?.type === 'timing' ? transition.loop ?? 'none' : 'none';
- const transitionDelay =
- transition?.type === 'timing' || transition?.type === 'spring'
- ? transition.delay ?? 0
- : 0;
+ // Resolve transition config into a fully-populated struct
+ const transitions = resolveTransitions(transition);
const handleTransitionEnd = onTransitionEnd
? (event: { nativeEvent: { finished: boolean } }) =>
@@ -256,14 +324,7 @@ export function EaseView({
initialAnimateRotateY={resolvedInitial.rotateY}
initialAnimateBorderRadius={resolvedInitial.borderRadius}
initialAnimateBackgroundColor={initialBgColor}
- transitionType={transitionType}
- transitionDuration={transitionDuration}
- transitionEasingBezier={bezier}
- transitionDamping={transitionDamping}
- transitionStiffness={transitionStiffness}
- transitionMass={transitionMass}
- transitionLoop={transitionLoop}
- transitionDelay={transitionDelay}
+ transitions={transitions}
useHardwareLayer={useHardwareLayer}
transformOriginX={transformOrigin?.x ?? 0.5}
transformOriginY={transformOrigin?.y ?? 0.5}
diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx
index a1e872b..5c4d8b9 100644
--- a/src/EaseView.web.tsx
+++ b/src/EaseView.web.tsx
@@ -3,6 +3,7 @@ import { View, type ViewStyle, type StyleProp } from 'react-native';
import type {
AnimateProps,
CubicBezier,
+ SingleTransition,
Transition,
TransitionEndEvent,
TransformOrigin,
@@ -81,7 +82,78 @@ function buildTransform(vals: ReturnType): string {
return parts.length > 0 ? parts.join(' ') : 'none';
}
-function resolveEasing(transition: Transition | undefined): string {
+/** Returns true if the transition is a SingleTransition (has a `type` field). */
+function isSingleTransition(t: Transition): t is SingleTransition {
+ return 'type' in t;
+}
+
+/** Resolve a single config into CSS-ready duration/easing. */
+function resolveConfigForCss(config: SingleTransition | undefined): {
+ duration: number;
+ easing: string;
+ type: string;
+} {
+ if (!config || config.type === 'none') {
+ return { duration: 0, easing: 'linear', type: config?.type ?? 'timing' };
+ }
+ return {
+ duration: resolveDuration(config),
+ easing: resolveEasing(config),
+ type: config.type,
+ };
+}
+
+/** CSS property names for each category. */
+const CSS_PROP_MAP = {
+ opacity: 'opacity',
+ transform: 'transform',
+ borderRadius: 'border-radius',
+ backgroundColor: 'background-color',
+} as const;
+
+type CategoryKey = keyof typeof CSS_PROP_MAP;
+
+/** Resolve transition prop into per-category CSS configs. */
+function resolvePerCategoryConfigs(
+ transition: Transition | undefined,
+): Record {
+ if (!transition) {
+ const def = resolveConfigForCss(undefined);
+ return {
+ opacity: def,
+ transform: def,
+ borderRadius: def,
+ backgroundColor: def,
+ };
+ }
+ if (isSingleTransition(transition)) {
+ const def = resolveConfigForCss(transition);
+ return {
+ opacity: def,
+ transform: def,
+ borderRadius: def,
+ backgroundColor: def,
+ };
+ }
+ // TransitionMap
+ const defaultConfig = resolveConfigForCss(transition.default);
+ return {
+ opacity: transition.opacity
+ ? resolveConfigForCss(transition.opacity)
+ : defaultConfig,
+ transform: transition.transform
+ ? resolveConfigForCss(transition.transform)
+ : defaultConfig,
+ borderRadius: transition.borderRadius
+ ? resolveConfigForCss(transition.borderRadius)
+ : defaultConfig,
+ backgroundColor: transition.backgroundColor
+ ? resolveConfigForCss(transition.backgroundColor)
+ : defaultConfig,
+ };
+}
+
+function resolveEasing(transition: SingleTransition | undefined): string {
if (!transition || transition.type !== 'timing') {
return 'cubic-bezier(0.42, 0, 0.58, 1)';
}
@@ -92,7 +164,7 @@ function resolveEasing(transition: Transition | undefined): string {
return `cubic-bezier(${bezier[0]}, ${bezier[1]}, ${bezier[2]}, ${bezier[3]})`;
}
-function resolveDuration(transition: Transition | undefined): number {
+function resolveDuration(transition: SingleTransition | undefined): number {
if (!transition) return 300;
if (transition.type === 'timing') return transition.duration ?? 300;
if (transition.type === 'none') return 0;
@@ -102,14 +174,6 @@ function resolveDuration(transition: Transition | undefined): number {
return Math.round(tau * 4 * 1000);
}
-/** CSS transition properties that we animate. */
-const TRANSITION_PROPS = [
- 'opacity',
- 'transform',
- 'border-radius',
- 'background-color',
-];
-
/** Counter for unique keyframe names. */
let keyframeCounter = 0;
@@ -146,21 +210,42 @@ export function EaseView({
const displayValues =
!mounted && hasInitial ? resolveAnimateValues(initialAnimate) : resolved;
- const duration = resolveDuration(transition);
- const easing = resolveEasing(transition);
+ const categoryConfigs = resolvePerCategoryConfigs(transition);
+
+ // For loop mode, use the default/single transition config
+ const singleTransition =
+ transition && isSingleTransition(transition)
+ ? transition
+ : transition && !isSingleTransition(transition)
+ ? transition.default
+ : undefined;
+ const loopMode =
+ singleTransition?.type === 'timing' ? singleTransition.loop : undefined;
+ const loopDuration = resolveDuration(singleTransition);
+ const loopEasing = resolveEasing(singleTransition);
const originX = ((transformOrigin?.x ?? 0.5) * 100).toFixed(1);
const originY = ((transformOrigin?.y ?? 0.5) * 100).toFixed(1);
- const transitionType = transition?.type ?? 'timing';
- const loopMode = transition?.type === 'timing' ? transition.loop : undefined;
-
const transitionCss =
- transitionType === 'none' || (!mounted && hasInitial)
+ !mounted && hasInitial
? 'none'
- : TRANSITION_PROPS.map((prop) => `${prop} ${duration}ms ${easing}`).join(
- ', ',
- );
+ : (Object.keys(CSS_PROP_MAP) as CategoryKey[])
+ .filter((key) => {
+ const cfg = categoryConfigs[key];
+ return cfg.type !== 'none' && cfg.duration > 0;
+ })
+ .map((key) => {
+ const cfg = categoryConfigs[key];
+ const springEasing =
+ cfg.type === 'spring'
+ ? 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
+ : null;
+ return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${
+ springEasing ?? cfg.easing
+ }`;
+ })
+ .join(', ') || 'none';
// Apply CSS transition/animation properties imperatively (not in RN style spec).
useEffect(() => {
@@ -168,14 +253,7 @@ export function EaseView({
if (!el) return;
if (!loopMode) {
- const springTransition =
- transitionType === 'spring'
- ? TRANSITION_PROPS.map(
- (prop) =>
- `${prop} ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
- ).join(', ')
- : null;
- el.style.transition = springTransition ?? transitionCss;
+ el.style.transition = transitionCss;
}
el.style.transformOrigin = `${originX}% ${originY}%`;
});
@@ -257,27 +335,37 @@ export function EaseView({
const direction = loopMode === 'reverse' ? 'alternate' : 'normal';
el.style.transition = 'none';
- el.style.animation = `${name} ${duration}ms ${easing} infinite ${direction}`;
+ el.style.animation = `${name} ${loopDuration}ms ${loopEasing} infinite ${direction}`;
return () => {
styleEl.remove();
el.style.animation = '';
animationNameRef.current = null;
};
- }, [loopMode, animate, initialAnimate, duration, easing, getElement]);
+ }, [loopMode, animate, initialAnimate, loopDuration, loopEasing, getElement]);
// Build animated style using RN transform array format.
// react-native-web converts these to CSS transform strings.
const animatedStyle: ViewStyle = {
opacity: displayValues.opacity,
transform: [
- { translateX: displayValues.translateX },
- { translateY: displayValues.translateY },
- { scaleX: displayValues.scaleX },
- { scaleY: displayValues.scaleY },
- { rotate: `${displayValues.rotate}deg` },
- { rotateX: `${displayValues.rotateX}deg` },
- { rotateY: `${displayValues.rotateY}deg` },
+ ...(displayValues.translateX !== 0
+ ? [{ translateX: displayValues.translateX }]
+ : []),
+ ...(displayValues.translateY !== 0
+ ? [{ translateY: displayValues.translateY }]
+ : []),
+ ...(displayValues.scaleX !== 1 ? [{ scaleX: displayValues.scaleX }] : []),
+ ...(displayValues.scaleY !== 1 ? [{ scaleY: displayValues.scaleY }] : []),
+ ...(displayValues.rotate !== 0
+ ? [{ rotate: `${displayValues.rotate}deg` }]
+ : []),
+ ...(displayValues.rotateX !== 0
+ ? [{ rotateX: `${displayValues.rotateX}deg` }]
+ : []),
+ ...(displayValues.rotateY !== 0
+ ? [{ rotateY: `${displayValues.rotateY}deg` }]
+ : []),
],
...(displayValues.borderRadius > 0
? { borderRadius: displayValues.borderRadius }
diff --git a/src/EaseViewNativeComponent.ts b/src/EaseViewNativeComponent.ts
index ad9e313..48aad0e 100644
--- a/src/EaseViewNativeComponent.ts
+++ b/src/EaseViewNativeComponent.ts
@@ -6,6 +6,28 @@ import {
type ColorValue,
} from 'react-native';
+type Float = CodegenTypes.Float;
+type Int32 = CodegenTypes.Int32;
+
+type NativeTransitionConfig = Readonly<{
+ type: string;
+ duration: Int32;
+ easingBezier: ReadonlyArray;
+ damping: Float;
+ stiffness: Float;
+ mass: Float;
+ loop: string;
+ delay: Int32;
+}>;
+
+type NativeTransitions = Readonly<{
+ defaultConfig: NativeTransitionConfig;
+ transform?: NativeTransitionConfig;
+ opacity?: NativeTransitionConfig;
+ borderRadius?: NativeTransitionConfig;
+ backgroundColor?: NativeTransitionConfig;
+}>;
+
export interface NativeProps extends ViewProps {
// Bitmask of which properties are animated (0 = none, let style handle all)
animatedProperties?: CodegenTypes.WithDefault;
@@ -37,22 +59,8 @@ export interface NativeProps extends ViewProps {
>;
initialAnimateBackgroundColor?: ColorValue;
- // Transition config
- transitionType?: CodegenTypes.WithDefault<
- 'timing' | 'spring' | 'none',
- 'timing'
- >;
- transitionDuration?: CodegenTypes.WithDefault;
- // Easing cubic bezier control points [x1, y1, x2, y2] (default: easeInOut)
- transitionEasingBezier?: ReadonlyArray;
- transitionDamping?: CodegenTypes.WithDefault;
- transitionStiffness?: CodegenTypes.WithDefault;
- transitionMass?: CodegenTypes.WithDefault;
- transitionLoop?: CodegenTypes.WithDefault<
- 'none' | 'repeat' | 'reverse',
- 'none'
- >;
- transitionDelay?: CodegenTypes.WithDefault;
+ // Unified transition config — one struct with per-property configs
+ transitions?: NativeTransitions;
// Transform origin (0–1 fractions, default center)
transformOriginX?: CodegenTypes.WithDefault;
diff --git a/src/__tests__/EaseView.test.tsx b/src/__tests__/EaseView.test.tsx
index b2530e9..5689dd6 100644
--- a/src/__tests__/EaseView.test.tsx
+++ b/src/__tests__/EaseView.test.tsx
@@ -127,16 +127,22 @@ describe('EaseView', () => {
});
describe('transition defaults', () => {
- it('defaults to timing with standard values', () => {
+ it('uses timing default for all properties when no transition', () => {
render();
- const props = getNativeProps();
- expect(props.transitionType).toBe('timing');
- expect(props.transitionDuration).toBe(300);
- expect(props.transitionEasingBezier).toEqual([0.42, 0, 0.58, 1]);
- expect(props.transitionLoop).toBe('none');
- });
-
- it('resolves timing transition props', () => {
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(300);
+ expect(t.defaultConfig.easingBezier).toEqual([0.42, 0, 0.58, 1]);
+ expect(t.defaultConfig.loop).toBe('none');
+ expect(t.defaultConfig.delay).toBe(0);
+ // No category overrides
+ expect(t.opacity).toBeUndefined();
+ expect(t.transform).toBeUndefined();
+ expect(t.borderRadius).toBeUndefined();
+ expect(t.backgroundColor).toBeUndefined();
+ });
+
+ it('resolves timing transition to defaultConfig only', () => {
render(
{
}}
/>,
);
- const props = getNativeProps();
- expect(props.transitionType).toBe('timing');
- expect(props.transitionDuration).toBe(500);
- expect(props.transitionEasingBezier).toEqual([0, 0, 1, 1]);
- expect(props.transitionLoop).toBe('reverse');
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(500);
+ expect(t.defaultConfig.easingBezier).toEqual([0, 0, 1, 1]);
+ expect(t.defaultConfig.loop).toBe('reverse');
+ // No category overrides — native falls back to defaultConfig
+ expect(t.opacity).toBeUndefined();
+ expect(t.transform).toBeUndefined();
});
- it('resolves spring transition props with timing defaults for unused fields', () => {
+ it('resolves spring transition with defaults for unused fields', () => {
render(
,
);
- const props = getNativeProps();
- expect(props.transitionType).toBe('spring');
- expect(props.transitionDamping).toBe(20);
- expect(props.transitionStiffness).toBe(200);
- expect(props.transitionMass).toBe(2);
- // Timing-specific fields get defaults (easeInOut bezier)
- expect(props.transitionDuration).toBe(300);
- expect(props.transitionEasingBezier).toEqual([0.42, 0, 0.58, 1]);
- expect(props.transitionLoop).toBe('none');
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('spring');
+ expect(t.defaultConfig.damping).toBe(20);
+ expect(t.defaultConfig.stiffness).toBe(200);
+ expect(t.defaultConfig.mass).toBe(2);
+ // Timing-specific fields get defaults
+ expect(t.defaultConfig.duration).toBe(300);
+ expect(t.defaultConfig.easingBezier).toEqual([0.42, 0, 0.58, 1]);
+ expect(t.defaultConfig.loop).toBe('none');
});
it('uses spring defaults when only type is specified', () => {
render();
- const props = getNativeProps();
- expect(props.transitionDamping).toBe(15);
- expect(props.transitionStiffness).toBe(120);
- expect(props.transitionMass).toBe(1);
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.damping).toBe(15);
+ expect(t.defaultConfig.stiffness).toBe(120);
+ expect(t.defaultConfig.mass).toBe(1);
});
- it('passes none transition type', () => {
+ it('passes none transition type to defaultConfig', () => {
render();
- expect(getNativeProps().transitionType).toBe('none');
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('none');
+ // No category overrides
+ expect(t.opacity).toBeUndefined();
+ expect(t.transform).toBeUndefined();
});
it('passes custom cubic bezier control points', () => {
@@ -197,8 +210,8 @@ describe('EaseView', () => {
}}
/>,
);
- const props = getNativeProps();
- expect(props.transitionEasingBezier).toEqual([0.25, 0.1, 0.25, 1.0]);
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.easingBezier).toEqual([0.25, 0.1, 0.25, 1.0]);
});
it('warns for invalid array length', () => {
@@ -234,6 +247,22 @@ describe('EaseView', () => {
);
spy.mockRestore();
});
+
+ it('passes delay for timing transition', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.delay).toBe(200);
+ });
+
+ it('passes delay for spring transition', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.delay).toBe(150);
+ });
});
describe('useHardwareLayer', () => {
@@ -464,4 +493,135 @@ describe('EaseView', () => {
);
});
});
+
+ describe('per-property transition map', () => {
+ it('sets only defaultConfig from single transition', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('spring');
+ expect(t.defaultConfig.damping).toBe(20);
+ // No category overrides — native falls back to defaultConfig
+ expect(t.opacity).toBeUndefined();
+ expect(t.transform).toBeUndefined();
+ expect(t.backgroundColor).toBeUndefined();
+ });
+
+ it('sets defaultConfig with default-only map', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(500);
+ // No category overrides
+ expect(t.opacity).toBeUndefined();
+ expect(t.transform).toBeUndefined();
+ });
+
+ it('applies category-specific overrides', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ // opacity override
+ expect(t.opacity!.type).toBe('timing');
+ expect(t.opacity!.duration).toBe(150);
+ // transform override
+ expect(t.transform!.type).toBe('spring');
+ expect(t.transform!.damping).toBe(20);
+ expect(t.transform!.stiffness).toBe(200);
+ // defaultConfig for non-overridden categories
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(300);
+ // borderRadius/backgroundColor not set — native uses defaultConfig
+ expect(t.borderRadius).toBeUndefined();
+ expect(t.backgroundColor).toBeUndefined();
+ });
+
+ it('does not inject spring default for transforms when no default key', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ // opacity is explicitly set
+ expect(t.opacity!.type).toBe('timing');
+ expect(t.opacity!.duration).toBe(150);
+ // defaultConfig is timing 300ms (library default)
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(300);
+ // No spring injection — transform falls back to defaultConfig on native
+ expect(t.transform).toBeUndefined();
+ expect(t.borderRadius).toBeUndefined();
+ expect(t.backgroundColor).toBeUndefined();
+ });
+
+ it('does not inject spring transform when default key is provided', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ // explicit default overrides library defaults
+ expect(t.defaultConfig.type).toBe('timing');
+ expect(t.defaultConfig.duration).toBe(500);
+ // transform not injected — user opted into explicit default
+ expect(t.transform).toBeUndefined();
+ });
+
+ it('supports per-category delay in transition map', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.opacity!.delay).toBe(50);
+ // defaultConfig has delay 100 — native uses this for non-overridden categories
+ expect(t.defaultConfig.delay).toBe(100);
+ });
+
+ it('supports transform category for all transform properties', () => {
+ render(
+ ,
+ );
+ const t = getNativeProps().transitions;
+ expect(t.transform!.type).toBe('spring');
+ expect(t.transform!.damping).toBe(10);
+ expect(t.transform!.stiffness).toBe(100);
+ });
+ });
});
diff --git a/src/index.tsx b/src/index.tsx
index 739b0d6..0f260e2 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,6 +4,8 @@ export type {
AnimateProps,
CubicBezier,
Transition,
+ SingleTransition,
+ TransitionMap,
TimingTransition,
SpringTransition,
NoneTransition,
diff --git a/src/types.ts b/src/types.ts
index f1b35a2..312f727 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -42,8 +42,28 @@ export type NoneTransition = {
type: 'none';
};
-/** Animation transition configuration. */
-export type Transition = TimingTransition | SpringTransition | NoneTransition;
+/** A single animation transition configuration. */
+export type SingleTransition =
+ | TimingTransition
+ | SpringTransition
+ | NoneTransition;
+
+/** Per-property transition map with category-based keys. */
+export type TransitionMap = {
+ /** Fallback config for properties not explicitly listed. */
+ default?: SingleTransition;
+ /** Config for all transform properties (translateX/Y, scaleX/Y, rotate, rotateX/Y). */
+ transform?: SingleTransition;
+ /** Config for opacity. */
+ opacity?: SingleTransition;
+ /** Config for borderRadius. */
+ borderRadius?: SingleTransition;
+ /** Config for backgroundColor. */
+ backgroundColor?: SingleTransition;
+};
+
+/** Animation transition configuration — either a single config or a per-property map. */
+export type Transition = SingleTransition | TransitionMap;
/** Event fired when the animation ends. */
export type TransitionEndEvent = {