@@ -14,6 +14,8 @@ import androidx.dynamicanimation.animation.DynamicAnimation
1414import androidx.dynamicanimation.animation.SpringAnimation
1515import androidx.dynamicanimation.animation.SpringForce
1616import com.facebook.react.bridge.ReadableMap
17+ import com.facebook.react.uimanager.BackgroundStyleApplicator
18+ import com.facebook.react.uimanager.style.LogicalEdge
1719import com.facebook.react.views.view.ReactViewGroup
1820import 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
0 commit comments