@@ -15,6 +15,10 @@ import androidx.dynamicanimation.animation.SpringAnimation
1515import androidx.dynamicanimation.animation.SpringForce
1616import com.facebook.react.bridge.ReadableMap
1717import com.facebook.react.uimanager.BackgroundStyleApplicator
18+ import com.facebook.react.uimanager.LengthPercentage
19+ import com.facebook.react.uimanager.LengthPercentageType
20+ import com.facebook.react.uimanager.PixelUtil
21+ import com.facebook.react.uimanager.style.BorderRadiusProp
1822import com.facebook.react.uimanager.style.LogicalEdge
1923import com.facebook.react.views.view.ReactViewGroup
2024import kotlin.math.sqrt
@@ -38,6 +42,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
3842 private var prevBackgroundColor: Int? = null
3943 private var prevBorderWidth: Float? = null
4044 private var prevBorderColor: Int? = null
45+ private var prevElevation: Float? = null
4146 private var currentBackgroundColor: Int = Color .TRANSPARENT
4247 private var currentBorderColor: Int = Color .BLACK
4348
@@ -64,7 +69,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
6469 return
6570 }
6671 val configs = mutableMapOf<String , TransitionConfig >()
67- val keys = listOf (" defaultConfig" , " transform" , " opacity" , " borderRadius" , " backgroundColor" , " border" )
72+ val keys = listOf (" defaultConfig" , " transform" , " opacity" , " borderRadius" , " backgroundColor" , " border" , " shadow " )
6873 for (key in keys) {
6974 if (map.hasKey(key)) {
7075 val configMap = map.getMap(key) ? : continue
@@ -98,6 +103,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
98103 " borderRadius" -> " borderRadius"
99104 " backgroundColor" -> " backgroundColor"
100105 " borderWidth" , " borderColor" -> " border"
106+ " elevation" -> " shadow"
101107 else -> null
102108 }
103109 if (categoryKey != null ) {
@@ -109,7 +115,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
109115 private fun allTransitionsNone (): Boolean {
110116 val defaultConfig = transitionConfigs[" defaultConfig" ]
111117 if (defaultConfig == null || defaultConfig.type != " none" ) return false
112- val categories = listOf (" transform" , " opacity" , " borderRadius" , " backgroundColor" , " border" )
118+ val categories = listOf (" transform" , " opacity" , " borderRadius" , " backgroundColor" , " border" , " shadow " )
113119 return categories.all { key ->
114120 val config = transitionConfigs[key]
115121 config == null || config.type == " none"
@@ -130,6 +136,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
130136 const val MASK_BACKGROUND_COLOR = 1 shl 9
131137 const val MASK_BORDER_WIDTH = 1 shl 10
132138 const val MASK_BORDER_COLOR = 1 shl 11
139+ // Masks 12-15 are shadow properties (iOS only)
140+ const val MASK_ELEVATION = 1 shl 16
133141 }
134142
135143 // --- Transform origin (0–1 fractions) ---
@@ -160,6 +168,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
160168 clipToOutline = false
161169 }
162170 invalidateOutline()
171+ // Sync border drawable so borders follow the animated corner radius.
172+ // Value is in pixels; convert back to DIPs for BackgroundStyleApplicator.
173+ val dip = PixelUtil .toDIPFromPixel(value)
174+ BackgroundStyleApplicator .setBorderRadius(
175+ this , BorderRadiusProp .BORDER_RADIUS ,
176+ LengthPercentage (dip, LengthPercentageType .POINT ))
163177 }
164178 }
165179
@@ -209,6 +223,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
209223 var initialAnimateBackgroundColor: Int = Color .TRANSPARENT
210224 var initialAnimateBorderWidth: Float = 0.0f
211225 var initialAnimateBorderColor: Int = Color .BLACK
226+ var initialAnimateElevation: Float = 0.0f
212227
213228 // --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) ---
214229 var pendingOpacity: Float = 1.0f
@@ -223,6 +238,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
223238 var pendingBackgroundColor: Int = Color .TRANSPARENT
224239 var pendingBorderWidth: Float = 0.0f
225240 var pendingBorderColor: Int = Color .BLACK
241+ var pendingElevation: Float = 0.0f
226242
227243 // --- Running animations ---
228244 private val runningAnimators = mutableMapOf<String , Animator >()
@@ -246,15 +262,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
246262 cameraDistance = density * density * perspective * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER
247263 }
248264
265+ // Custom outline provider used when borderRadius is animated.
266+ // Reads _borderRadius dynamically — invalidated on each frame by setAnimateBorderRadius.
267+ private val animatedOutlineProvider = object : ViewOutlineProvider () {
268+ override fun getOutline (view : View , outline : Outline ) {
269+ outline.setRoundRect(0 , 0 , view.width, view.height, _borderRadius )
270+ }
271+ }
272+
249273 init {
250274 applyCameraDistance(1280f )
251-
252- // ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
253- outlineProvider = object : ViewOutlineProvider () {
254- override fun getOutline (view : View , outline : Outline ) {
255- outline.setRoundRect(0 , 0 , view.width, view.height, _borderRadius )
256- }
257- }
258275 }
259276
260277 // --- Hardware layer management ---
@@ -292,7 +309,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
292309 }
293310
294311 fun applyPendingAnimateValues () {
295- applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor)
312+ applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor, pendingElevation )
296313 }
297314
298315 private fun applyAnimateValues (
@@ -307,7 +324,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
307324 borderRadius : Float ,
308325 backgroundColor : Int ,
309326 borderWidth : Float ,
310- borderColor : Int
327+ borderColor : Int ,
328+ elevation : Float
311329 ) {
312330 if (pendingBatchAnimationCount > 0 ) {
313331 onTransitionEnd?.invoke(false )
@@ -320,6 +338,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
320338 // Bitmask: which properties are animated. Non-animated = let style handle.
321339 val mask = animatedProperties
322340
341+ // Use custom outline provider only when borderRadius is animated.
342+ // Otherwise fall back to BACKGROUND provider so elevation shadows
343+ // respect the style borderRadius from the background drawable.
344+ val needsCustomOutline = mask and MASK_BORDER_RADIUS != 0
345+ if (needsCustomOutline && outlineProvider != = animatedOutlineProvider) {
346+ outlineProvider = animatedOutlineProvider
347+ } else if (! needsCustomOutline && outlineProvider == = animatedOutlineProvider) {
348+ outlineProvider = ViewOutlineProvider .BACKGROUND
349+ }
350+
323351 if (isFirstMount) {
324352 isFirstMount = false
325353
@@ -335,7 +363,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
335363 (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) ||
336364 (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) ||
337365 (mask and MASK_BORDER_WIDTH != 0 && initialAnimateBorderWidth != borderWidth) ||
338- (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor)
366+ (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) ||
367+ (mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation)
339368
340369 if (hasInitialAnimation) {
341370 // Set initial values for animated properties
@@ -351,6 +380,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
351380 if (mask and MASK_BACKGROUND_COLOR != 0 ) applyBackgroundColor(initialAnimateBackgroundColor)
352381 if (mask and MASK_BORDER_WIDTH != 0 ) setAnimateBorderWidth(initialAnimateBorderWidth)
353382 if (mask and MASK_BORDER_COLOR != 0 ) applyBorderColor(initialAnimateBorderColor)
383+ if (mask and MASK_ELEVATION != 0 ) this .elevation = initialAnimateElevation
354384
355385 // Animate properties that differ from initial to target
356386 if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
@@ -389,6 +419,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
389419 if (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) {
390420 animateBorderColorTransition(initialAnimateBorderColor, borderColor, getTransitionConfig(" borderColor" ), loop = true )
391421 }
422+ if (mask and MASK_ELEVATION != 0 && initialAnimateElevation != elevation) {
423+ animateProperty(" elevation" , null , initialAnimateElevation, elevation, getTransitionConfig(" elevation" ), loop = true )
424+ }
392425
393426 // If all per-property configs were 'none', no animations were queued.
394427 // Fire onTransitionEnd immediately to match the scalar 'none' contract.
@@ -409,6 +442,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
409442 if (mask and MASK_BACKGROUND_COLOR != 0 ) applyBackgroundColor(backgroundColor)
410443 if (mask and MASK_BORDER_WIDTH != 0 ) setAnimateBorderWidth(borderWidth)
411444 if (mask and MASK_BORDER_COLOR != 0 ) applyBorderColor(borderColor)
445+ if (mask and MASK_ELEVATION != 0 ) this .elevation = elevation
412446 }
413447
414448 // Update backface visibility after setting initial rotation values.
@@ -431,6 +465,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
431465 if (mask and MASK_BACKGROUND_COLOR != 0 ) applyBackgroundColor(backgroundColor)
432466 if (mask and MASK_BORDER_WIDTH != 0 ) setAnimateBorderWidth(borderWidth)
433467 if (mask and MASK_BORDER_COLOR != 0 ) applyBorderColor(borderColor)
468+ if (mask and MASK_ELEVATION != 0 ) this .elevation = elevation
434469 onTransitionEnd?.invoke(true )
435470 } else {
436471 // Subsequent updates: animate changed properties (skip non-animated)
@@ -598,6 +633,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
598633 }
599634 }
600635
636+ if (prevElevation != null && mask and MASK_ELEVATION != 0 && prevElevation != elevation) {
637+ anyPropertyChanged = true
638+ val config = getTransitionConfig(" elevation" )
639+ if (config.type == " none" ) {
640+ runningAnimators[" elevation" ]?.cancel()
641+ runningAnimators.remove(" elevation" )
642+ this .elevation = elevation
643+ } else {
644+ val from = getCurrentValue(" elevation" )
645+ animateProperty(" elevation" , null , from, elevation, config)
646+ }
647+ }
648+
601649 // If all changed properties resolved to 'none', no animations were queued.
602650 // Fire onTransitionEnd immediately.
603651 if (anyPropertyChanged && pendingBatchAnimationCount == 0 ) {
@@ -617,6 +665,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
617665 prevBackgroundColor = backgroundColor
618666 prevBorderWidth = borderWidth
619667 prevBorderColor = borderColor
668+ prevElevation = elevation
620669 }
621670
622671 private fun getCurrentValue (propertyName : String ): Float = when (propertyName) {
@@ -630,6 +679,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
630679 " rotationY" -> this .rotationY
631680 " animateBorderRadius" -> getAnimateBorderRadius()
632681 " animateBorderWidth" -> getAnimateBorderWidth()
682+ " elevation" -> this .elevation
633683 else -> 0f
634684 }
635685
@@ -961,6 +1011,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
9611011 prevBackgroundColor = null
9621012 prevBorderWidth = null
9631013 prevBorderColor = null
1014+ prevElevation = null
9641015
9651016 this .alpha = 1f
9661017 this .translationX = 0f
@@ -974,6 +1025,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
9741025 applyBackgroundColor(Color .TRANSPARENT )
9751026 setAnimateBorderWidth(0f )
9761027 applyBorderColor(Color .BLACK )
1028+ this .elevation = 0f
1029+ outlineProvider = ViewOutlineProvider .BACKGROUND
9771030
9781031 transformPerspective = 1280f
9791032 isFirstMount = true
0 commit comments