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 = {