androidandroid-jetpack-composejetpack-compose-animationandroid-jetpack-compose-animation

Jetpack compose - how to use a value animation to directly control other animations


I have a case in which it would be useful to use a single animated float value to control the animations of other elements, including fading between two colors. Is there a suggested way of doing this in general, like declaring an Animatable whose current state value is directly controlled by an external mutable float state? For example, if the mutable float is 0.25 at a particular instance, then all animations that it controls would be 25% of the way between one state and another state.

The reason I want this behavior is to force multiple animations to be perfectly in sync, even when leaving and reentering the composition. I know that transitions are commonly used to control multiple animations, but it is my understanding that this does not ensure all child animations are perfectly synced, i.e. at the exact same percent of the way completed.

It should be possible to accomplish this by brute force by having a single animatable float value, and using that value to directly set the position or colors of UI elements. Is this the best solution? If I use this method, I still need to calculate the interpolation between two colors, and I'm not entirely sure how to do this. I tried digging into the Compose source code to find how this is done by the animateColorAsState() composable. It seems that the colors are converted into 4D vectors, and I imagine they are linearly interpolated from there but I couldn't find the exact code that does this. Is there a built in function to interpolate between colors or vectors? Otherwise, I could just compute the value myself, but I want to try to find a cleaner way to implement all of this.

Any thoughts are appreciated!


Solution

  • Jetpack compose has linear interpolation function defined, lerp, for various classes including Rect, Color, FontSize, Size, Offset, Shadow and many classes except for Float, Int and Long.

    For the last three you need to add

    implementation "androidx.compose.ui:ui-util:$compose_version"
    

    or you can copy paste them as

    /**
     * Linearly interpolate between [start] and [stop] with [fraction] fraction between them.
     */
    fun lerp(start: Float, stop: Float, fraction: Float): Float {
        return (1 - fraction) * start + fraction * stop
    }
    
    /**
     * Linearly interpolate between [start] and [stop] with [fraction] fraction between them.
     */
    fun lerp(start: Int, stop: Int, fraction: Float): Int {
        return start + ((stop - start) * fraction.toDouble()).roundToInt()
    }
    
    /**
     * Linearly interpolate between [start] and [stop] with [fraction] fraction between them.
     */
    fun lerp(start: Long, stop: Long, fraction: Float): Long {
        return start + ((stop - start) * fraction.toDouble()).roundToLong()
    }
    

    In addition to linear interpolation sometimes scaling function which lets you change range from 0f, 1f to any range you want can be defined as

    // Scale x1 from a1..b1 range to a2..b2 range
    private fun scale(a1: Float, b1: Float, x1: Float, a2: Float, b2: Float) =
        androidx.compose.ui.util.lerp(a2, b2, calcFraction(a1, b1, x1))
    
    
    // Calculate the 0..1 fraction that `pos` value represents between `a` and `b`
    private fun calcFraction(a: Float, b: Float, pos: Float) =
        (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f)
    

    Using these 2 functions and with one Animatable or any animateFloatAsState you can synchronise many aniamtions with one value.

    In this example below, lerp and scale is used for changing position of Rect, text size, color and offset of a card.

    Linear Interpolation Animation

    @Composable
    fun SnackCard(
        modifier: Modifier = Modifier,
        snack: Snack,
        progress: Float = 0f,
        textColor: Color,
        onClick: () -> Unit
    ) {
    
        Box(
            modifier = modifier
                // πŸ”₯ Interpolate corner radius
                .clip(RoundedCornerShape(lerp(20.dp, 0.dp, progress)))
                .background(Color.White)
                .clickable(
                    onClick = onClick,
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null
                ),
            contentAlignment = Alignment.TopEnd
        ) {
    
    
            // πŸ”₯ This is lerping between .6f and 1f by changing start from 0f to .6f
            val fraction = scale(0f, 1f, progress, .6f, 1f)
    
            Image(
                contentScale = ContentScale.Crop,
                modifier = Modifier.fillMaxWidth()
                    .fillMaxHeight(fraction),
                painter = rememberAsyncImagePainter(
                    ImageRequest.Builder(LocalContext.current).data(data = snack.imageUrl)
                        .apply(block = fun ImageRequest.Builder.() {
                            crossfade(true)
                            placeholder(drawableResId = R.drawable.placeholder)
                        }).build()
                ),
                contentDescription = null
            )
    
            Column(
                modifier = Modifier
                    .padding(16.dp)
                    .align(Alignment.BottomStart)
    
            ) {
                Text(
                    // πŸ”₯ Interpolate Font size
                    fontSize = lerp(18.sp, 40.sp, progress),
                    // πŸ”₯ Interpolate Color
                    color = lerp(textColor, Color.Black, progress),
                    fontWeight = FontWeight.Bold,
                    text = snack.name
                )
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                    Text(
                        // πŸ”₯ Interpolate Font size
                        fontSize = lerp(12.sp, 24.sp, progress),
                        // πŸ”₯ Interpolate Color
                        color = lerp(textColor, Color.Black, progress),
                        text = "$${snack.price}"
                    )
                }
            }
    
            FavoriteButton(
                modifier = Modifier.graphicsLayer {
                    alpha = 1 - progress
                }
                    .padding(12.dp),
                color = textColor
            )
        }
    }
    

    Full code is available here

    https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_30LinearInterpolation.kt