Skip to content

Commit 7916950

Browse files
feat: add animated borderWidth and borderColor (#36)
* feat: add animated borderWidth and borderColor support Adds animatable border properties following the existing per-property pattern: bitmask flags, codegen props, native change detection, and platform-specific animation. iOS: borderWidth and borderColor via Core Animation key-path animations on the corresponding CALayer properties. Android: borderWidth via ObjectAnimator with a custom getter/setter that calls BackgroundStyleApplicator.setBorderWidth. borderColor via ValueAnimator.ofArgb calling BackgroundStyleApplicator.setBorderColor. * refactor(android): use applyBorderColor helper in border color animator * feat(web): wire borderWidth and borderColor into CSS transitions and styles * feat(example): add border demo * feat(example): cycle border demo through none, red, green * fix(android): remove double DIP conversion for borderWidth BackgroundStyleApplicator.setBorderWidth converts DIPs to pixels internally, so the ViewManager should pass the raw DIP value.
1 parent 5235a76 commit 7916950

10 files changed

Lines changed: 501 additions & 11 deletions

File tree

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

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import androidx.dynamicanimation.animation.DynamicAnimation
1414
import androidx.dynamicanimation.animation.SpringAnimation
1515
import androidx.dynamicanimation.animation.SpringForce
1616
import com.facebook.react.bridge.ReadableMap
17+
import com.facebook.react.uimanager.BackgroundStyleApplicator
18+
import com.facebook.react.uimanager.style.LogicalEdge
1719
import com.facebook.react.views.view.ReactViewGroup
1820
import kotlin.math.sqrt
1921

@@ -34,7 +36,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
3436
private var prevRotateY: Float? = null
3537
private var prevBorderRadius: Float? = null
3638
private var prevBackgroundColor: Int? = null
39+
private var prevBorderWidth: Float? = null
40+
private var prevBorderColor: Int? = null
3741
private var currentBackgroundColor: Int = Color.TRANSPARENT
42+
private var currentBorderColor: Int = Color.BLACK
3843

3944
// --- First mount tracking ---
4045
private var isFirstMount: Boolean = true
@@ -59,7 +64,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
5964
return
6065
}
6166
val configs = mutableMapOf<String, TransitionConfig>()
62-
val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor")
67+
val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor", "border")
6368
for (key in keys) {
6469
if (map.hasKey(key)) {
6570
val configMap = map.getMap(key) ?: continue
@@ -92,6 +97,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
9297
"rotate", "rotateX", "rotateY" -> "transform"
9398
"borderRadius" -> "borderRadius"
9499
"backgroundColor" -> "backgroundColor"
100+
"borderWidth", "borderColor" -> "border"
95101
else -> null
96102
}
97103
if (categoryKey != null) {
@@ -103,7 +109,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
103109
private fun allTransitionsNone(): Boolean {
104110
val defaultConfig = transitionConfigs["defaultConfig"]
105111
if (defaultConfig == null || defaultConfig.type != "none") return false
106-
val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor")
112+
val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor", "border")
107113
return categories.all { key ->
108114
val config = transitionConfigs[key]
109115
config == null || config.type == "none"
@@ -122,6 +128,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
122128
const val MASK_ROTATE_Y = 1 shl 7
123129
const val MASK_BORDER_RADIUS = 1 shl 8
124130
const val MASK_BACKGROUND_COLOR = 1 shl 9
131+
const val MASK_BORDER_WIDTH = 1 shl 10
132+
const val MASK_BORDER_COLOR = 1 shl 11
125133
}
126134

127135
// --- Transform origin (0–1 fractions) ---
@@ -155,6 +163,28 @@ class EaseView(context: Context) : ReactViewGroup(context) {
155163
}
156164
}
157165

166+
// --- Border width (animated via ObjectAnimator("animateBorderWidth")) ---
167+
private var _borderWidth: Float = 0f
168+
169+
@Suppress("unused") // Used by ObjectAnimator via reflection
170+
fun getAnimateBorderWidth(): Float = _borderWidth
171+
@Suppress("unused") // Used by ObjectAnimator via reflection
172+
fun setAnimateBorderWidth(value: Float) {
173+
if (_borderWidth != value) {
174+
_borderWidth = value
175+
BackgroundStyleApplicator.setBorderWidth(this, LogicalEdge.ALL, value)
176+
}
177+
}
178+
179+
private fun applyBorderColor(color: Int) {
180+
currentBorderColor = color
181+
BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.ALL, color)
182+
}
183+
184+
private fun getCurrentBorderColor(): Int {
185+
return currentBorderColor
186+
}
187+
158188
// --- Hardware layer ---
159189
var useHardwareLayer: Boolean = false
160190

@@ -177,6 +207,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
177207
var initialAnimateRotateY: Float = 0.0f
178208
var initialAnimateBorderRadius: Float = 0.0f
179209
var initialAnimateBackgroundColor: Int = Color.TRANSPARENT
210+
var initialAnimateBorderWidth: Float = 0.0f
211+
var initialAnimateBorderColor: Int = Color.BLACK
180212

181213
// --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) ---
182214
var pendingOpacity: Float = 1.0f
@@ -189,6 +221,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
189221
var pendingRotateY: Float = 0.0f
190222
var pendingBorderRadius: Float = 0.0f
191223
var pendingBackgroundColor: Int = Color.TRANSPARENT
224+
var pendingBorderWidth: Float = 0.0f
225+
var pendingBorderColor: Int = Color.BLACK
192226

193227
// --- Running animations ---
194228
private val runningAnimators = mutableMapOf<String, Animator>()
@@ -258,7 +292,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
258292
}
259293

260294
fun applyPendingAnimateValues() {
261-
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor)
295+
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor, pendingBorderWidth, pendingBorderColor)
262296
}
263297

264298
private fun applyAnimateValues(
@@ -271,7 +305,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
271305
rotateX: Float,
272306
rotateY: Float,
273307
borderRadius: Float,
274-
backgroundColor: Int
308+
backgroundColor: Int,
309+
borderWidth: Float,
310+
borderColor: Int
275311
) {
276312
if (pendingBatchAnimationCount > 0) {
277313
onTransitionEnd?.invoke(false)
@@ -297,7 +333,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
297333
(mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) ||
298334
(mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) ||
299335
(mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) ||
300-
(mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor)
336+
(mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) ||
337+
(mask and MASK_BORDER_WIDTH != 0 && initialAnimateBorderWidth != borderWidth) ||
338+
(mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor)
301339

302340
if (hasInitialAnimation) {
303341
// Set initial values for animated properties
@@ -311,6 +349,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
311349
if (mask and MASK_ROTATE_Y != 0) this.rotationY = initialAnimateRotateY
312350
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(initialAnimateBorderRadius)
313351
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(initialAnimateBackgroundColor)
352+
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(initialAnimateBorderWidth)
353+
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(initialAnimateBorderColor)
314354

315355
// Animate properties that differ from initial to target
316356
if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
@@ -343,6 +383,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
343383
if (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) {
344384
animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, getTransitionConfig("backgroundColor"), loop = true)
345385
}
386+
if (mask and MASK_BORDER_WIDTH != 0 && initialAnimateBorderWidth != borderWidth) {
387+
animateProperty("animateBorderWidth", null, initialAnimateBorderWidth, borderWidth, getTransitionConfig("borderWidth"), loop = true)
388+
}
389+
if (mask and MASK_BORDER_COLOR != 0 && initialAnimateBorderColor != borderColor) {
390+
animateBorderColorTransition(initialAnimateBorderColor, borderColor, getTransitionConfig("borderColor"), loop = true)
391+
}
346392

347393
// If all per-property configs were 'none', no animations were queued.
348394
// Fire onTransitionEnd immediately to match the scalar 'none' contract.
@@ -361,6 +407,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
361407
if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
362408
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
363409
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
410+
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth)
411+
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor)
364412
}
365413

366414
// Update backface visibility after setting initial rotation values.
@@ -381,6 +429,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
381429
if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
382430
if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
383431
if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
432+
if (mask and MASK_BORDER_WIDTH != 0) setAnimateBorderWidth(borderWidth)
433+
if (mask and MASK_BORDER_COLOR != 0) applyBorderColor(borderColor)
384434
onTransitionEnd?.invoke(true)
385435
} else {
386436
// Subsequent updates: animate changed properties (skip non-animated)
@@ -523,6 +573,31 @@ class EaseView(context: Context) : ReactViewGroup(context) {
523573
}
524574
}
525575

576+
if (prevBorderWidth != null && mask and MASK_BORDER_WIDTH != 0 && prevBorderWidth != borderWidth) {
577+
anyPropertyChanged = true
578+
val config = getTransitionConfig("borderWidth")
579+
if (config.type == "none") {
580+
runningAnimators["animateBorderWidth"]?.cancel()
581+
runningAnimators.remove("animateBorderWidth")
582+
setAnimateBorderWidth(borderWidth)
583+
} else {
584+
val from = getCurrentValue("animateBorderWidth")
585+
animateProperty("animateBorderWidth", null, from, borderWidth, config)
586+
}
587+
}
588+
589+
if (prevBorderColor != null && mask and MASK_BORDER_COLOR != 0 && prevBorderColor != borderColor) {
590+
anyPropertyChanged = true
591+
val config = getTransitionConfig("borderColor")
592+
if (config.type == "none") {
593+
runningAnimators["borderColor"]?.cancel()
594+
runningAnimators.remove("borderColor")
595+
applyBorderColor(borderColor)
596+
} else {
597+
animateBorderColorTransition(getCurrentBorderColor(), borderColor, config)
598+
}
599+
}
600+
526601
// If all changed properties resolved to 'none', no animations were queued.
527602
// Fire onTransitionEnd immediately.
528603
if (anyPropertyChanged && pendingBatchAnimationCount == 0) {
@@ -540,6 +615,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
540615
prevRotateY = rotateY
541616
prevBorderRadius = borderRadius
542617
prevBackgroundColor = backgroundColor
618+
prevBorderWidth = borderWidth
619+
prevBorderColor = borderColor
543620
}
544621

545622
private fun getCurrentValue(propertyName: String): Float = when (propertyName) {
@@ -552,6 +629,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
552629
"rotationX" -> this.rotationX
553630
"rotationY" -> this.rotationY
554631
"animateBorderRadius" -> getAnimateBorderRadius()
632+
"animateBorderWidth" -> getAnimateBorderWidth()
555633
else -> 0f
556634
}
557635

@@ -610,6 +688,50 @@ class EaseView(context: Context) : ReactViewGroup(context) {
610688
animator.start()
611689
}
612690

691+
private fun animateBorderColorTransition(fromColor: Int, toColor: Int, config: TransitionConfig, loop: Boolean = false) {
692+
runningAnimators["borderColor"]?.cancel()
693+
694+
val batchId = animationBatchId
695+
pendingBatchAnimationCount++
696+
697+
val animator = ValueAnimator.ofArgb(fromColor, toColor).apply {
698+
duration = config.duration.toLong()
699+
startDelay = config.delay
700+
701+
interpolator = PathInterpolator(
702+
config.easingBezier[0], config.easingBezier[1],
703+
config.easingBezier[2], config.easingBezier[3]
704+
)
705+
if (loop && config.loop != "none") {
706+
repeatCount = ValueAnimator.INFINITE
707+
repeatMode = if (config.loop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
708+
}
709+
addUpdateListener { animation ->
710+
this@EaseView.applyBorderColor(animation.animatedValue as Int)
711+
}
712+
addListener(object : AnimatorListenerAdapter() {
713+
private var cancelled = false
714+
override fun onAnimationStart(animation: Animator) {
715+
this@EaseView.onEaseAnimationStart()
716+
}
717+
override fun onAnimationCancel(animation: Animator) { cancelled = true }
718+
override fun onAnimationEnd(animation: Animator) {
719+
this@EaseView.onEaseAnimationEnd()
720+
if (batchId == animationBatchId) {
721+
if (cancelled) anyInterrupted = true
722+
pendingBatchAnimationCount--
723+
if (pendingBatchAnimationCount <= 0) {
724+
onTransitionEnd?.invoke(!anyInterrupted)
725+
}
726+
}
727+
}
728+
})
729+
}
730+
731+
runningAnimators["borderColor"] = animator
732+
animator.start()
733+
}
734+
613735
private fun animateProperty(
614736
propertyName: String,
615737
viewProperty: DynamicAnimation.ViewProperty?,
@@ -837,6 +959,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
837959
prevRotateY = null
838960
prevBorderRadius = null
839961
prevBackgroundColor = null
962+
prevBorderWidth = null
963+
prevBorderColor = null
840964

841965
this.alpha = 1f
842966
this.translationX = 0f
@@ -848,6 +972,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
848972
this.rotationY = 0f
849973
setAnimateBorderRadius(0f)
850974
applyBackgroundColor(Color.TRANSPARENT)
975+
setAnimateBorderWidth(0f)
976+
applyBorderColor(Color.BLACK)
851977

852978
transformPerspective = 1280f
853979
isFirstMount = true

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,31 @@ class EaseViewManager : ReactViewManager() {
153153
view.initialAnimateBackgroundColor = value ?: Color.TRANSPARENT
154154
}
155155

156+
// --- Border width ---
157+
158+
@ReactProp(name = "animateBorderWidth", defaultFloat = 0f)
159+
fun setAnimateBorderWidth(view: EaseView, value: Float) {
160+
// BackgroundStyleApplicator.setBorderWidth expects DIPs (converts internally)
161+
view.pendingBorderWidth = value
162+
}
163+
164+
@ReactProp(name = "initialAnimateBorderWidth", defaultFloat = 0f)
165+
fun setInitialAnimateBorderWidth(view: EaseView, value: Float) {
166+
view.initialAnimateBorderWidth = value
167+
}
168+
169+
// --- Border color ---
170+
171+
@ReactProp(name = "animateBorderColor", customType = "Color")
172+
fun setAnimateBorderColor(view: EaseView, value: Int?) {
173+
view.pendingBorderColor = value ?: Color.BLACK
174+
}
175+
176+
@ReactProp(name = "initialAnimateBorderColor", customType = "Color")
177+
fun setInitialAnimateBorderColor(view: EaseView, value: Int?) {
178+
view.initialAnimateBorderColor = value ?: Color.BLACK
179+
}
180+
156181
// --- Hardware layer ---
157182

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

example/src/demos/BorderDemo.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState } from 'react';
2+
import { Text, StyleSheet } 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+
const states = [
9+
{ borderWidth: 0, borderColor: '#4a90d9', label: 'None' },
10+
{ borderWidth: 4, borderColor: '#e74c3c', label: 'Red' },
11+
{ borderWidth: 4, borderColor: '#4ade80', label: 'Green' },
12+
] as const;
13+
14+
const nextLabel = ['Add Red', 'Go Green', 'Remove'] as const;
15+
16+
export function BorderDemo() {
17+
const [index, setIndex] = useState(0);
18+
const state = states[index]!;
19+
return (
20+
<Section title="Border">
21+
<EaseView
22+
animate={{
23+
borderWidth: state.borderWidth,
24+
borderColor: state.borderColor,
25+
}}
26+
transition={{
27+
border: { type: 'spring', damping: 15, stiffness: 120 },
28+
}}
29+
style={styles.box}
30+
>
31+
<Text style={styles.text}>{state.label}</Text>
32+
</EaseView>
33+
<Button
34+
label={nextLabel[index]!}
35+
onPress={() => setIndex((i) => (i + 1) % states.length)}
36+
/>
37+
</Section>
38+
);
39+
}
40+
41+
const styles = StyleSheet.create({
42+
box: {
43+
width: 100,
44+
height: 100,
45+
borderRadius: 16,
46+
backgroundColor: '#f0f0f0',
47+
alignItems: 'center',
48+
justifyContent: 'center',
49+
},
50+
text: {
51+
color: '#333',
52+
fontSize: 13,
53+
fontWeight: '700',
54+
},
55+
});

example/src/demos/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BackgroundColorDemo } from './BackgroundColorDemo';
55
import { BenchmarkDemo } from './BenchmarkDemo';
66
import { BannerDemo } from './BannerDemo';
77
import { CardFlipDemo } from './CardFlipDemo';
8+
import { BorderDemo } from './BorderDemo';
89
import { BorderRadiusDemo } from './BorderRadiusDemo';
910
import { ButtonDemo } from './ButtonDemo';
1011
import { CombinedDemo } from './CombinedDemo';
@@ -62,6 +63,11 @@ export const demos: Record<string, DemoEntry> = {
6263
title: 'Styled Card',
6364
section: 'Style',
6465
},
66+
'border': {
67+
component: BorderDemo,
68+
title: 'Border',
69+
section: 'Style',
70+
},
6571
'border-radius': {
6672
component: BorderRadiusDemo,
6773
title: 'Border Radius',

0 commit comments

Comments
 (0)