Skip to content

Commit c22c4eb

Browse files
feat: add animatable borderRadius with hardware-accelerated clipping
Uses ViewOutlineProvider + clipToOutline on Android and layer.cornerRadius + layer.masksToBounds on iOS. Animated via ObjectAnimator (Android) and CAAnimation (iOS) like other properties. Includes codegen props, bitmask flag, style conflict stripping, view recycling reset, tests, README docs, and eslint-disable for existing no-bitwise warnings.
1 parent e442169 commit c22c4eb

8 files changed

Lines changed: 210 additions & 7 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,23 @@ Use `{ type: 'none' }` to apply values immediately without animation. Useful for
147147

148148
`onTransitionEnd` fires immediately with `{ finished: true }`.
149149

150+
### Border Radius
151+
152+
`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.
153+
154+
```tsx
155+
<EaseView
156+
animate={{ borderRadius: expanded ? 0 : 16 }}
157+
transition={{ type: 'timing', duration: 300 }}
158+
style={styles.card}
159+
>
160+
<Image source={heroImage} style={styles.image} />
161+
<Text>Content is clipped to rounded corners</Text>
162+
</EaseView>
163+
```
164+
165+
When `borderRadius` is in `animate`, any `borderRadius` in `style` is automatically stripped to avoid conflicts.
166+
150167
### Animatable Properties
151168

152169
All properties are set in the `animate` prop as flat values (no transform array).
@@ -163,6 +180,7 @@ All properties are set in the `animate` prop as flat values (no transform array)
163180
rotate: 0, // Z-axis rotation in degrees
164181
rotateX: 0, // X-axis rotation in degrees (3D)
165182
rotateY: 0, // Y-axis rotation in degrees (3D)
183+
borderRadius: 0, // pixels (hardware-accelerated, clips children)
166184
}}
167185
/>
168186
```
@@ -306,6 +324,7 @@ A `View` that animates property changes using native platform APIs.
306324
| `rotate` | `number` | `0` | Z-axis rotation in degrees |
307325
| `rotateX` | `number` | `0` | X-axis rotation in degrees (3D) |
308326
| `rotateY` | `number` | `0` | Y-axis rotation in degrees (3D) |
327+
| `borderRadius` | `number` | `0` | Border radius in pixels (hardware-accelerated, clips children) |
309328

310329
Properties not specified in `animate` default to their identity values.
311330

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

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.animation.Animator
44
import android.animation.AnimatorListenerAdapter
55
import android.animation.ObjectAnimator
66
import android.content.Context
7+
import android.graphics.Outline
78
import android.view.View
9+
import android.view.ViewOutlineProvider
810
import android.view.animation.PathInterpolator
911
import androidx.dynamicanimation.animation.DynamicAnimation
1012
import androidx.dynamicanimation.animation.SpringAnimation
@@ -23,6 +25,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
2325
private var prevRotate: Float? = null
2426
private var prevRotateX: Float? = null
2527
private var prevRotateY: Float? = null
28+
private var prevBorderRadius: Float? = null
2629

2730
// --- First mount tracking ---
2831
private var isFirstMount: Boolean = true
@@ -48,6 +51,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
4851
applyTransformOrigin()
4952
}
5053

54+
// --- Border radius (hardware-accelerated via outline clipping) ---
55+
// Animated via ObjectAnimator("borderRadius") — setter invalidates outline each frame.
56+
private var _borderRadius: Float = 0f
57+
58+
fun getBorderRadius(): Float = _borderRadius
59+
fun setBorderRadius(value: Float) {
60+
if (_borderRadius != value) {
61+
_borderRadius = value
62+
if (value > 0f) {
63+
clipToOutline = true
64+
} else {
65+
clipToOutline = false
66+
}
67+
invalidateOutline()
68+
}
69+
}
70+
5171
// --- Hardware layer ---
5272
var useHardwareLayer: Boolean = false
5373

@@ -68,6 +88,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
6888
var initialAnimateRotate: Float = 0.0f
6989
var initialAnimateRotateX: Float = 0.0f
7090
var initialAnimateRotateY: Float = 0.0f
91+
var initialAnimateBorderRadius: Float = 0.0f
7192

7293
// --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) ---
7394
var pendingOpacity: Float = 1.0f
@@ -78,6 +99,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
7899
var pendingRotate: Float = 0.0f
79100
var pendingRotateX: Float = 0.0f
80101
var pendingRotateY: Float = 0.0f
102+
var pendingBorderRadius: Float = 0.0f
81103

82104
// --- Running animations ---
83105
private val runningAnimators = mutableMapOf<String, ObjectAnimator>()
@@ -97,12 +119,19 @@ class EaseView(context: Context) : ReactViewGroup(context) {
97119
const val MASK_ROTATE = 1 shl 5
98120
const val MASK_ROTATE_X = 1 shl 6
99121
const val MASK_ROTATE_Y = 1 shl 7
100-
122+
const val MASK_BORDER_RADIUS = 1 shl 8
101123
}
102124

103125
init {
104126
// Set camera distance for 3D perspective rotations (rotateX/rotateY)
105127
cameraDistance = resources.displayMetrics.density * 850f
128+
129+
// ViewOutlineProvider reads _borderRadius dynamically — set once, invalidated on each frame.
130+
outlineProvider = object : ViewOutlineProvider() {
131+
override fun getOutline(view: View, outline: Outline) {
132+
outline.setRoundRect(0, 0, view.width, view.height, _borderRadius)
133+
}
134+
}
106135
}
107136

108137
// --- Hardware layer management ---
@@ -140,7 +169,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
140169
}
141170

142171
fun applyPendingAnimateValues() {
143-
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY)
172+
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius)
144173
}
145174

146175
private fun applyAnimateValues(
@@ -151,7 +180,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
151180
scaleY: Float,
152181
rotate: Float,
153182
rotateX: Float,
154-
rotateY: Float
183+
rotateY: Float,
184+
borderRadius: Float
155185
) {
156186
if (pendingBatchAnimationCount > 0) {
157187
onTransitionEnd?.invoke(false)
@@ -175,7 +205,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
175205
(mask and MASK_SCALE_Y != 0 && initialAnimateScaleY != scaleY) ||
176206
(mask and MASK_ROTATE != 0 && initialAnimateRotate != rotate) ||
177207
(mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) ||
178-
(mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY)
208+
(mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) ||
209+
(mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius)
179210

180211
if (hasInitialAnimation) {
181212
// Set initial values for animated properties
@@ -187,6 +218,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
187218
if (mask and MASK_ROTATE != 0) this.rotation = initialAnimateRotate
188219
if (mask and MASK_ROTATE_X != 0) this.rotationX = initialAnimateRotateX
189220
if (mask and MASK_ROTATE_Y != 0) this.rotationY = initialAnimateRotateY
221+
if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(initialAnimateBorderRadius)
190222

191223
// Animate properties that differ from initial to target
192224
if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
@@ -213,6 +245,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
213245
if (mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) {
214246
animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, loop = true)
215247
}
248+
if (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) {
249+
animateProperty("borderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
250+
}
216251
} else {
217252
// No initial animation — set target values directly (skip non-animated)
218253
if (mask and MASK_OPACITY != 0) this.alpha = opacity
@@ -223,6 +258,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
223258
if (mask and MASK_ROTATE != 0) this.rotation = rotate
224259
if (mask and MASK_ROTATE_X != 0) this.rotationX = rotateX
225260
if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
261+
if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(borderRadius)
226262
}
227263
} else if (transitionType == "none") {
228264
// No transition — set values immediately, cancel running animations
@@ -235,6 +271,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
235271
if (mask and MASK_ROTATE != 0) this.rotation = rotate
236272
if (mask and MASK_ROTATE_X != 0) this.rotationX = rotateX
237273
if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
274+
if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(borderRadius)
238275
onTransitionEnd?.invoke(true)
239276
} else {
240277
// Subsequent updates: animate changed properties (skip non-animated)
@@ -277,6 +314,11 @@ class EaseView(context: Context) : ReactViewGroup(context) {
277314
val from = getCurrentValue("rotationY")
278315
animateProperty("rotationY", DynamicAnimation.ROTATION_Y, from, rotateY)
279316
}
317+
318+
if (prevBorderRadius != null && mask and MASK_BORDER_RADIUS != 0 && prevBorderRadius != borderRadius) {
319+
val from = getCurrentValue("borderRadius")
320+
animateProperty("borderRadius", null, from, borderRadius)
321+
}
280322
}
281323

282324
prevOpacity = opacity
@@ -287,6 +329,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
287329
prevRotate = rotate
288330
prevRotateX = rotateX
289331
prevRotateY = rotateY
332+
prevBorderRadius = borderRadius
290333
}
291334

292335
private fun getCurrentValue(propertyName: String): Float = when (propertyName) {
@@ -298,17 +341,18 @@ class EaseView(context: Context) : ReactViewGroup(context) {
298341
"rotation" -> this.rotation
299342
"rotationX" -> this.rotationX
300343
"rotationY" -> this.rotationY
344+
"borderRadius" -> getBorderRadius()
301345
else -> 0f
302346
}
303347

304348
private fun animateProperty(
305349
propertyName: String,
306-
viewProperty: DynamicAnimation.ViewProperty,
350+
viewProperty: DynamicAnimation.ViewProperty?,
307351
fromValue: Float,
308352
toValue: Float,
309353
loop: Boolean = false
310354
) {
311-
if (transitionType == "spring") {
355+
if (transitionType == "spring" && viewProperty != null) {
312356
animateSpring(viewProperty, toValue)
313357
} else {
314358
animateTiming(propertyName, fromValue, toValue, loop)
@@ -479,6 +523,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
479523
prevRotate = null
480524
prevRotateX = null
481525
prevRotateY = null
526+
prevBorderRadius = null
482527

483528
this.alpha = 1f
484529
this.translationX = 0f
@@ -488,6 +533,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
488533
this.rotation = 0f
489534
this.rotationX = 0f
490535
this.rotationY = 0f
536+
setBorderRadius(0f)
491537

492538
isFirstMount = true
493539
transitionLoop = "none"

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ class EaseViewManager : ReactViewManager() {
120120
view.initialAnimateRotateY = value
121121
}
122122

123+
@ReactProp(name = "initialAnimateBorderRadius", defaultFloat = 0f)
124+
fun setInitialAnimateBorderRadius(view: EaseView, value: Float) {
125+
view.initialAnimateBorderRadius = PixelUtil.toPixelFromDIP(value)
126+
}
127+
123128
// --- Transition config setters ---
124129

125130
@ReactProp(name = "transitionType")
@@ -167,6 +172,13 @@ class EaseViewManager : ReactViewManager() {
167172
view.transitionLoop = value ?: "none"
168173
}
169174

175+
// --- Border radius ---
176+
177+
@ReactProp(name = "animateBorderRadius", defaultFloat = 0f)
178+
fun setAnimateBorderRadius(view: EaseView, value: Float) {
179+
view.pendingBorderRadius = PixelUtil.toPixelFromDIP(value)
180+
}
181+
170182
// --- Hardware layer ---
171183

172184
@ReactProp(name = "useHardwareLayer", defaultBoolean = false)

ios/EaseView.mm

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// Animation key constants
1515
static NSString *const kAnimKeyOpacity = @"ease_opacity";
1616
static NSString *const kAnimKeyTransform = @"ease_transform";
17+
static NSString *const kAnimKeyCornerRadius = @"ease_cornerRadius";
1718

1819
static inline CGFloat degreesToRadians(CGFloat degrees) {
1920
return degrees * M_PI / 180.0;
@@ -45,6 +46,7 @@ static CATransform3D composeTransform(CGFloat scaleX, CGFloat scaleY,
4546
static const int kMaskRotate = 1 << 5;
4647
static const int kMaskRotateX = 1 << 6;
4748
static const int kMaskRotateY = 1 << 7;
49+
static const int kMaskBorderRadius = 1 << 8;
4850
static const int kMaskAnyTransform = kMaskTranslateX | kMaskTranslateY |
4951
kMaskScaleX | kMaskScaleY | kMaskRotate |
5052
kMaskRotateX | kMaskRotateY;
@@ -249,6 +251,10 @@ - (void)updateProps:(const Props::Shared &)props
249251
(mask & kMaskOpacity) &&
250252
newViewProps.initialAnimateOpacity != newViewProps.animateOpacity;
251253

254+
BOOL hasInitialBorderRadius =
255+
(mask & kMaskBorderRadius) && newViewProps.initialAnimateBorderRadius !=
256+
newViewProps.animateBorderRadius;
257+
252258
BOOL hasInitialTransform = NO;
253259
CATransform3D initialT = CATransform3DIdentity;
254260
CATransform3D targetT = CATransform3DIdentity;
@@ -259,12 +265,18 @@ - (void)updateProps:(const Props::Shared &)props
259265
hasInitialTransform = !CATransform3DEqualToTransform(initialT, targetT);
260266
}
261267

262-
if (hasInitialOpacity || hasInitialTransform) {
268+
if (hasInitialOpacity || hasInitialTransform || hasInitialBorderRadius) {
263269
// Set initial values
264270
if (mask & kMaskOpacity)
265271
self.layer.opacity = newViewProps.initialAnimateOpacity;
266272
if (hasTransform)
267273
self.layer.transform = initialT;
274+
if (mask & kMaskBorderRadius) {
275+
self.layer.cornerRadius = newViewProps.initialAnimateBorderRadius;
276+
self.layer.masksToBounds =
277+
newViewProps.initialAnimateBorderRadius > 0 ||
278+
newViewProps.animateBorderRadius > 0;
279+
}
268280

269281
// Animate from initial to target
270282
if (hasInitialOpacity) {
@@ -285,12 +297,26 @@ - (void)updateProps:(const Props::Shared &)props
285297
props:newViewProps
286298
loop:YES];
287299
}
300+
if (hasInitialBorderRadius) {
301+
self.layer.cornerRadius = newViewProps.animateBorderRadius;
302+
[self
303+
applyAnimationForKeyPath:@"cornerRadius"
304+
animationKey:kAnimKeyCornerRadius
305+
fromValue:@(newViewProps.initialAnimateBorderRadius)
306+
toValue:@(newViewProps.animateBorderRadius)
307+
props:newViewProps
308+
loop:YES];
309+
}
288310
} else {
289311
// No initial animation — set target values directly
290312
if (mask & kMaskOpacity)
291313
self.layer.opacity = newViewProps.animateOpacity;
292314
if (hasTransform)
293315
self.layer.transform = targetT;
316+
if (mask & kMaskBorderRadius) {
317+
self.layer.cornerRadius = newViewProps.animateBorderRadius;
318+
self.layer.masksToBounds = newViewProps.animateBorderRadius > 0;
319+
}
294320
}
295321
} else if (newViewProps.transitionType == EaseViewTransitionType::None) {
296322
// No transition — set values immediately
@@ -299,6 +325,10 @@ - (void)updateProps:(const Props::Shared &)props
299325
self.layer.opacity = newViewProps.animateOpacity;
300326
if (hasTransform)
301327
self.layer.transform = [self targetTransformFromProps:newViewProps];
328+
if (mask & kMaskBorderRadius) {
329+
self.layer.cornerRadius = newViewProps.animateBorderRadius;
330+
self.layer.masksToBounds = newViewProps.animateBorderRadius > 0;
331+
}
302332
if (_eventEmitter) {
303333
auto emitter =
304334
std::static_pointer_cast<const EaseViewEventEmitter>(_eventEmitter);
@@ -346,6 +376,19 @@ - (void)updateProps:(const Props::Shared &)props
346376
loop:NO];
347377
}
348378
}
379+
380+
if ((mask & kMaskBorderRadius) &&
381+
oldViewProps.animateBorderRadius != newViewProps.animateBorderRadius) {
382+
self.layer.cornerRadius = newViewProps.animateBorderRadius;
383+
self.layer.masksToBounds = newViewProps.animateBorderRadius > 0;
384+
[self applyAnimationForKeyPath:@"cornerRadius"
385+
animationKey:kAnimKeyCornerRadius
386+
fromValue:[self presentationValueForKeyPath:
387+
@"cornerRadius"]
388+
toValue:@(newViewProps.animateBorderRadius)
389+
props:newViewProps
390+
loop:NO];
391+
}
349392
}
350393

351394
[CATransaction commit];
@@ -385,6 +428,8 @@ - (void)prepareForRecycle {
385428
self.layer.anchorPoint = CGPointMake(0.5, 0.5);
386429
self.layer.opacity = 1.0;
387430
self.layer.transform = CATransform3DIdentity;
431+
self.layer.cornerRadius = 0;
432+
self.layer.masksToBounds = NO;
388433
}
389434

390435
@end

0 commit comments

Comments
 (0)