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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 65 additions & 12 deletions android/src/main/java/com/ease/EaseView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.LengthPercentage
import com.facebook.react.uimanager.LengthPercentageType
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.style.BorderRadiusProp
import com.facebook.react.uimanager.style.LogicalEdge
import com.facebook.react.views.view.ReactViewGroup
import kotlin.math.sqrt
Expand All @@ -38,6 +42,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
private var prevBackgroundColor: Int? = null
private var prevBorderWidth: Float? = null
private var prevBorderColor: Int? = null
private var prevElevation: Float? = null
private var currentBackgroundColor: Int = Color.TRANSPARENT
private var currentBorderColor: Int = Color.BLACK

Expand All @@ -64,7 +69,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
return
}
val configs = mutableMapOf<String, TransitionConfig>()
val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor", "border")
val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor", "border", "shadow")
for (key in keys) {
if (map.hasKey(key)) {
val configMap = map.getMap(key) ?: continue
Expand Down Expand Up @@ -98,6 +103,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
"borderRadius" -> "borderRadius"
"backgroundColor" -> "backgroundColor"
"borderWidth", "borderColor" -> "border"
"elevation" -> "shadow"
else -> null
}
if (categoryKey != null) {
Expand All @@ -109,7 +115,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
private fun allTransitionsNone(): Boolean {
val defaultConfig = transitionConfigs["defaultConfig"]
if (defaultConfig == null || defaultConfig.type != "none") return false
val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor", "border")
val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor", "border", "shadow")
return categories.all { key ->
val config = transitionConfigs[key]
config == null || config.type == "none"
Expand All @@ -130,6 +136,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
const val MASK_BACKGROUND_COLOR = 1 shl 9
const val MASK_BORDER_WIDTH = 1 shl 10
const val MASK_BORDER_COLOR = 1 shl 11
// Masks 12-15 are shadow properties (iOS only)
const val MASK_ELEVATION = 1 shl 16
}

// --- Transform origin (0–1 fractions) ---
Expand Down Expand Up @@ -160,6 +168,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
clipToOutline = false
}
invalidateOutline()
// Sync border drawable so borders follow the animated corner radius.
// Value is in pixels; convert back to DIPs for BackgroundStyleApplicator.
val dip = PixelUtil.toDIPFromPixel(value)
BackgroundStyleApplicator.setBorderRadius(
this, BorderRadiusProp.BORDER_RADIUS,
LengthPercentage(dip, LengthPercentageType.POINT))
}
}

Expand Down Expand Up @@ -209,6 +223,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
var initialAnimateBackgroundColor: Int = Color.TRANSPARENT
var initialAnimateBorderWidth: Float = 0.0f
var initialAnimateBorderColor: Int = Color.BLACK
var initialAnimateElevation: Float = 0.0f

// --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) ---
var pendingOpacity: Float = 1.0f
Expand All @@ -223,6 +238,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
var pendingBackgroundColor: Int = Color.TRANSPARENT
var pendingBorderWidth: Float = 0.0f
var pendingBorderColor: Int = Color.BLACK
var pendingElevation: Float = 0.0f

// --- Running animations ---
private val runningAnimators = mutableMapOf<String, Animator>()
Expand All @@ -246,15 +262,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER
}

// Custom outline provider used when borderRadius is animated.
// Reads _borderRadius dynamically — invalidated on each frame by setAnimateBorderRadius.
private val animatedOutlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, _borderRadius)
}
}

init {
applyCameraDistance(1280f)

// ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, _borderRadius)
}
}
}

// --- Hardware layer management ---
Expand Down Expand Up @@ -292,7 +309,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}

fun applyPendingAnimateValues() {
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor)
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor, pendingElevation)
}

private fun applyAnimateValues(
Expand All @@ -307,7 +324,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
borderRadius: Float,
backgroundColor: Int,
borderWidth: Float,
borderColor: Int
borderColor: Int,
elevation: Float
) {
if (pendingBatchAnimationCount > 0) {
onTransitionEnd?.invoke(false)
Expand All @@ -320,6 +338,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
// Bitmask: which properties are animated. Non-animated = let style handle.
val mask = animatedProperties

// Use custom outline provider only when borderRadius is animated.
// Otherwise fall back to BACKGROUND provider so elevation shadows
// respect the style borderRadius from the background drawable.
val needsCustomOutline = mask and MASK_BORDER_RADIUS != 0
if (needsCustomOutline && outlineProvider !== animatedOutlineProvider) {
outlineProvider = animatedOutlineProvider
} else if (!needsCustomOutline && outlineProvider === animatedOutlineProvider) {
outlineProvider = ViewOutlineProvider.BACKGROUND
}

if (isFirstMount) {
isFirstMount = false

Expand All @@ -335,7 +363,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
(mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) ||
(mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) ||
(mask and MASK_BORDER_WIDTH != 0 && initialAnimateBorderWidth != borderWidth) ||
(mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor)
(mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) ||
(mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation)

if (hasInitialAnimation) {
// Set initial values for animated properties
Expand All @@ -351,6 +380,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(initialAnimateBackgroundColor)
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(initialAnimateBorderWidth)
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(initialAnimateBorderColor)
if (mask and MASK_ELEVATION != 0) this.elevation = initialAnimateElevation

// Animate properties that differ from initial to target
if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
Expand Down Expand Up @@ -389,6 +419,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) {
animateBorderColorTransition(initialAnimateBorderColor, borderColor, getTransitionConfig("borderColor"), loop = true)
}
if (mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation) {
animateProperty("elevation", null, initialAnimateElevation, elevation, getTransitionConfig("elevation"), loop = true)
}

// If all per-property configs were 'none', no animations were queued.
// Fire onTransitionEnd immediately to match the scalar 'none' contract.
Expand All @@ -409,6 +442,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth)
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor)
if (mask and MASK_ELEVATION != 0) this.elevation = elevation
}

// Update backface visibility after setting initial rotation values.
Expand All @@ -431,6 +465,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth)
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor)
if (mask and MASK_ELEVATION != 0) this.elevation = elevation
onTransitionEnd?.invoke(true)
} else {
// Subsequent updates: animate changed properties (skip non-animated)
Expand Down Expand Up @@ -598,6 +633,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}
}

if (prevElevation != null && mask and MASK_ELEVATION != 0 && prevElevation != elevation) {
anyPropertyChanged = true
val config = getTransitionConfig("elevation")
if (config.type == "none") {
runningAnimators["elevation"]?.cancel()
runningAnimators.remove("elevation")
this.elevation = elevation
} else {
val from = getCurrentValue("elevation")
animateProperty("elevation", null, from, elevation, config)
}
}

// If all changed properties resolved to 'none', no animations were queued.
// Fire onTransitionEnd immediately.
if (anyPropertyChanged && pendingBatchAnimationCount == 0) {
Expand All @@ -617,6 +665,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
prevBackgroundColor = backgroundColor
prevBorderWidth = borderWidth
prevBorderColor = borderColor
prevElevation = elevation
}

private fun getCurrentValue(propertyName: String): Float = when (propertyName) {
Expand All @@ -630,6 +679,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
"rotationY" -> this.rotationY
"animateBorderRadius" -> getAnimateBorderRadius()
"animateBorderWidth" -> getAnimateBorderWidth()
"elevation" -> this.elevation
else -> 0f
}

Expand Down Expand Up @@ -961,6 +1011,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
prevBackgroundColor = null
prevBorderWidth = null
prevBorderColor = null
prevElevation = null

this.alpha = 1f
this.translationX = 0f
Expand All @@ -974,6 +1025,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
applyBackgroundColor(Color.TRANSPARENT)
setAnimateBorderWidth(0f)
applyBorderColor(Color.BLACK)
this.elevation = 0f
outlineProvider = ViewOutlineProvider.BACKGROUND

transformPerspective = 1280f
isFirstMount = true
Expand Down
12 changes: 12 additions & 0 deletions android/src/main/java/com/ease/EaseViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ class EaseViewManager : ReactViewManager() {
view.initialAnimateBorderColor = value ?: Color.BLACK
}

// --- Elevation ---

@ReactProp(name = "animateElevation", defaultFloat = 0f)
fun setAnimateElevation(view: EaseView, value: Float) {
view.pendingElevation = PixelUtil.toPixelFromDIP(value)
}

@ReactProp(name = "initialAnimateElevation", defaultFloat = 0f)
fun setInitialAnimateElevation(view: EaseView, value: Float) {
view.initialAnimateElevation = PixelUtil.toPixelFromDIP(value)
}

// --- Hardware layer ---

@ReactProp(name = "useHardwareLayer", defaultBoolean = false)
Expand Down
98 changes: 98 additions & 0 deletions example/src/demos/KitchenSinkDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { EaseView } from 'react-native-ease';

import { Section } from '../components/Section';
import { Button } from '../components/Button';

export function KitchenSinkDemo() {
const [active, setActive] = useState(false);
return (
<Section title="Kitchen Sink">
<View style={styles.surface}>
<EaseView
animate={
active
? {
opacity: 0.9,
translateX: 40,
translateY: -10,
scale: 1.15,
rotate: 8,
borderRadius: 32,
backgroundColor: '#6366f1',
borderWidth: 3,
borderColor: '#fbbf24',
shadowOpacity: 0.5,
shadowRadius: 20,
shadowOffset: { width: 4, height: 12 },
shadowColor: '#6366f1',
elevation: 16,
}
: {
opacity: 1,
translateX: 0,
translateY: 0,
scale: 1,
rotate: 0,
borderRadius: 12,
backgroundColor: '#fff',
borderWidth: 0,
borderColor: '#fbbf24',
shadowOpacity: 0,
shadowRadius: 0,
shadowOffset: { width: 0, height: 0 },
shadowColor: '#000',
elevation: 0,
}
}
transition={{ type: 'spring', damping: 14, stiffness: 100 }}
style={styles.box}
>
<Text style={[styles.text, active && styles.textActive]}>
{active ? 'Wild' : 'Calm'}
</Text>
<Text style={[styles.sub, active && styles.textActive]}>
{Platform.select({
android: 'all props',
default: 'every prop',
})}
</Text>
</EaseView>
</View>
<Button
label={active ? 'Reset' : 'Go Wild'}
onPress={() => setActive((v) => !v)}
/>
</Section>
);
}

const styles = StyleSheet.create({
surface: {
backgroundColor: '#e8eaf0',
borderRadius: 12,
padding: 40,
alignItems: 'center',
},
box: {
width: 110,
height: 110,
alignItems: 'center',
justifyContent: 'center',
},
text: {
color: '#333',
fontSize: 18,
fontWeight: '800',
},
textActive: {
color: '#fff',
},
sub: {
color: '#999',
fontSize: 11,
fontWeight: '600',
marginTop: 2,
},
});
Loading
Loading