Skip to content

Commit 6ce7d49

Browse files
feat: add shadow properties (iOS) and elevation (Android) (#35)
* feat: add shadow properties (iOS) and elevation (Android) animation support Adds animatable shadow/elevation properties following the existing per-property pattern: bitmask flags, codegen props, native change detection, and platform-specific animation. iOS: shadowOpacity, shadowRadius, shadowColor, shadowOffset via Core Animation key-path animations. Android: elevation via ObjectAnimator. * fix(ios): remove stray character at start of EaseView.mm clang-format introduced a stray 'l' at the beginning of the file, causing a compilation error. * feat(web): wire shadow and elevation into CSS transitions and styles * feat(example): add shadow/elevation demo * refactor: change shadowOffset API to object, update bitmask layout, remove web elevation - shadowOffset is now { width, height } matching RN style API (flattened to native props) - Consolidated MASK_SHADOW_OFFSET into single bit (1 << 15) - MASK_ELEVATION shifted to 1 << 16 - Removed elevation from web (no-op in react-native-web) * fix(example): add light surface behind shadow demo for visibility * fix(example): use animated borderRadius for correct elevation outline * fix(android): fall back to BACKGROUND outline provider when borderRadius is not animated When borderRadius is not in the animated bitmask, use ViewOutlineProvider.BACKGROUND so elevation shadows respect the style borderRadius from the background drawable. Only switch to the custom provider when borderRadius is actively animated. * fix(ios): stop setting masksToBounds for animated borderRadius cornerRadius alone rounds the view's background visually. masksToBounds clips children and shadows, which conflicts with shadow animations and doesn't match RN's default overflow:visible. Let the style system control masksToBounds via the overflow prop instead. * feat(example): add animated borderRadius to shadow demo for testing * chore(example): revert animated borderRadius from shadow demo * feat(example): add kitchen sink demo animating every supported prop * fix(android): sync animated borderRadius to BorderDrawable When borderRadius is animated via ObjectAnimator, the BorderDrawable was not updated, causing borders to render with square corners. Now setAnimateBorderRadius also calls BackgroundStyleApplicator.setBorderRadius to keep the border drawable in sync with the animated corner radius. * chore: remove stray react-native-animation-performance.md
1 parent 7916950 commit 6ce7d49

11 files changed

Lines changed: 805 additions & 30 deletions

File tree

android/src/main/java/com/ease/EaseView.kt

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import androidx.dynamicanimation.animation.SpringAnimation
1515
import androidx.dynamicanimation.animation.SpringForce
1616
import com.facebook.react.bridge.ReadableMap
1717
import 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
1822
import com.facebook.react.uimanager.style.LogicalEdge
1923
import com.facebook.react.views.view.ReactViewGroup
2024
import 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

android/src/main/java/com/ease/EaseViewManager.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ class EaseViewManager : ReactViewManager() {
178178
view.initialAnimateBorderColor = value ?: Color.BLACK
179179
}
180180

181+
// --- Elevation ---
182+
183+
@ReactProp(name = "animateElevation", defaultFloat = 0f)
184+
fun setAnimateElevation(view: EaseView, value: Float) {
185+
view.pendingElevation = PixelUtil.toPixelFromDIP(value)
186+
}
187+
188+
@ReactProp(name = "initialAnimateElevation", defaultFloat = 0f)
189+
fun setInitialAnimateElevation(view: EaseView, value: Float) {
190+
view.initialAnimateElevation = PixelUtil.toPixelFromDIP(value)
191+
}
192+
181193
// --- Hardware layer ---
182194

183195
@ReactProp(name = "useHardwareLayer", defaultBoolean = false)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useState } from 'react';
2+
import { View, Text, StyleSheet, Platform } from 'react-native';
3+
import { EaseView } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
import { Button } from '../components/Button';
7+
8+
export function KitchenSinkDemo() {
9+
const [active, setActive] = useState(false);
10+
return (
11+
<Section title="Kitchen Sink">
12+
<View style={styles.surface}>
13+
<EaseView
14+
animate={
15+
active
16+
? {
17+
opacity: 0.9,
18+
translateX: 40,
19+
translateY: -10,
20+
scale: 1.15,
21+
rotate: 8,
22+
borderRadius: 32,
23+
backgroundColor: '#6366f1',
24+
borderWidth: 3,
25+
borderColor: '#fbbf24',
26+
shadowOpacity: 0.5,
27+
shadowRadius: 20,
28+
shadowOffset: { width: 4, height: 12 },
29+
shadowColor: '#6366f1',
30+
elevation: 16,
31+
}
32+
: {
33+
opacity: 1,
34+
translateX: 0,
35+
translateY: 0,
36+
scale: 1,
37+
rotate: 0,
38+
borderRadius: 12,
39+
backgroundColor: '#fff',
40+
borderWidth: 0,
41+
borderColor: '#fbbf24',
42+
shadowOpacity: 0,
43+
shadowRadius: 0,
44+
shadowOffset: { width: 0, height: 0 },
45+
shadowColor: '#000',
46+
elevation: 0,
47+
}
48+
}
49+
transition={{ type: 'spring', damping: 14, stiffness: 100 }}
50+
style={styles.box}
51+
>
52+
<Text style={[styles.text, active && styles.textActive]}>
53+
{active ? 'Wild' : 'Calm'}
54+
</Text>
55+
<Text style={[styles.sub, active && styles.textActive]}>
56+
{Platform.select({
57+
android: 'all props',
58+
default: 'every prop',
59+
})}
60+
</Text>
61+
</EaseView>
62+
</View>
63+
<Button
64+
label={active ? 'Reset' : 'Go Wild'}
65+
onPress={() => setActive((v) => !v)}
66+
/>
67+
</Section>
68+
);
69+
}
70+
71+
const styles = StyleSheet.create({
72+
surface: {
73+
backgroundColor: '#e8eaf0',
74+
borderRadius: 12,
75+
padding: 40,
76+
alignItems: 'center',
77+
},
78+
box: {
79+
width: 110,
80+
height: 110,
81+
alignItems: 'center',
82+
justifyContent: 'center',
83+
},
84+
text: {
85+
color: '#333',
86+
fontSize: 18,
87+
fontWeight: '800',
88+
},
89+
textActive: {
90+
color: '#fff',
91+
},
92+
sub: {
93+
color: '#999',
94+
fontSize: 11,
95+
fontWeight: '600',
96+
marginTop: 2,
97+
},
98+
});

0 commit comments

Comments
 (0)