4

I have one Image composable, then I retrieve its boundary box using onGloballyPositioned listener. When I press a button a new Image is displayed, that have the same resId and initial position and size, so it matches the size of the original Image. And the original image gets hidden, while the copy image changes it locating using absoluteOffset and its size using the width and height attributes. I am using LaunchedEffect, to generate float values from 0f to 1f, and then use them to change the position and size of the copied image.

Here is the result:

enter image description here

Everything works well, except the fact that there is some flickering, since we hide the original and show the copy image immediately, and probably there is a empty frame, when both images are recomposed at the same time. So the original image is hidden, but the copied image is still not show, so there is a frame where both images are invisible.

Is there a way I can set the the order in which the images are recomposed, so the copied image get its visible state, before the original image is hidden?

I saw that there is way to use key inside columns/rows from here. But I am not so sure it is related.

The other idea I got is to use opacity animation, so there can be a delay, something like

time   |  Original Image (opacity) | Copy Image (opacity)  
-------|---------------------------|-----------------------
0s     | 1                         | 0  
0.2s   | 0.75                      | 0.25   
0.4s   | 0.5                       | 0.5 
0.6s   | 0.25                      | 0.75
0.8s   | 0.0                       | 1 

Also I know I can use single image to achieve the same effect, but I want to have separate image, that is not part of the compose navigation. So if I transition to another destination, I want the image to be transferred to that destination with smooth animation.

enter image description here

Here is the source code:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.slaviboy.myapplication.ui.theme.MyApplicationTheme

class MainActivity : ComponentActivity() {

    val viewModel by viewModels<ViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {

            val left = with(LocalDensity.current) { 200.dp.toPx() }
            val top = with(LocalDensity.current) { 300.dp.toPx() }
            val width = with(LocalDensity.current) { 100.dp.toPx() }
            viewModel.setSharedImageToCoord(Rect(left, top, left + width, top + width))

            Box(modifier = Modifier.fillMaxSize()) {

                if (!viewModel.isSharedImageVisible.value) {
                    Image(painter = painterResource(id = viewModel.setSharedImageResId.value),
                        contentDescription = null,
                        contentScale = ContentScale.FillWidth,
                        modifier = Modifier
                            .width(130.dp)
                            .height(130.dp)
                            .onGloballyPositioned { coordinates ->
                                coordinates.parentCoordinates
                                    ?.localBoundingBoxOf(coordinates, false)
                                    ?.let {
                                        viewModel.setSharedImageFromCoord(it)
                                    }
                            })
                }
                SharedImage(viewModel)
            }


            Button(onClick = {
                viewModel.setIsSharedImageVisible(true)
                viewModel.triggerAnimation()
            }) {
            }

        }
    }
}

@Composable
fun SharedImage(viewModel: ViewModel) {

    var left by remember { mutableStateOf(0f) }
    var top by remember { mutableStateOf(0f) }
    var width by remember { mutableStateOf(330f) }
    val anim = remember {
        TargetBasedAnimation(
            animationSpec = tween(1700, 0),
            typeConverter = Float.VectorConverter,
            initialValue = 0f,
            targetValue = 1f
        )
    }
    var playTime by remember { mutableStateOf(0L) }

    LaunchedEffect(viewModel.triggerAnimation.value) {

        val from = viewModel.sharedImageFromCoord.value
        val to = viewModel.sharedImageToCoord.value
        val fromLeft = from.left
        val fromTop = from.top
        val fromSize = from.width
        val toLeft = to.left
        val toTop = to.top
        val toSize = to.width

        val startTime = withFrameNanos { it }
        do {
            playTime = withFrameNanos { it } - startTime
            val animationValue = anim.getValueFromNanos(playTime)
            left = fromLeft + animationValue * (toLeft - fromLeft)
            top = fromTop + animationValue * (toTop - fromTop)
            width = fromSize + animationValue * (toSize - fromSize)
        } while (playTime < anim.durationNanos)

    }

    if (viewModel.isSharedImageVisible.value) {
        Image(
            painterResource(id = viewModel.setSharedImageResId.value),
            contentDescription = null,
            modifier = Modifier
                .absoluteOffset {
                    IntOffset(left.toInt(), top.toInt())
                }
                .width(
                    with(LocalDensity.current) { width.toDp() }
                )
                .height(
                    with(LocalDensity.current) { width.toDp() }
                )
        )
    }

}

class ViewModel : androidx.lifecycle.ViewModel() {

    private val _isSharedImageVisible = mutableStateOf(false)
    val isSharedImageVisible: State<Boolean> = _isSharedImageVisible

    fun setIsSharedImageVisible(isSharedImageVisible: Boolean) {
        _isSharedImageVisible.value = isSharedImageVisible
    }


    private val _sharedImageFromCoord = mutableStateOf(Rect.Zero)
    val sharedImageFromCoord: State<Rect> = _sharedImageFromCoord

    fun setSharedImageFromCoord(sharedImageFromCoord: Rect) {
        _sharedImageFromCoord.value = sharedImageFromCoord
    }


    private val _sharedImageToCoord = mutableStateOf(Rect.Zero)
    val sharedImageToCoord: State<Rect> = _sharedImageToCoord

    fun setSharedImageToCoord(sharedImageToCoord: Rect) {
        _sharedImageToCoord.value = sharedImageToCoord
    }


    private val _setSharedImageResId = mutableStateOf(R.drawable.ic_launcher_background)
    val setSharedImageResId: State<Int> = _setSharedImageResId

    fun setSharedImageResId(setSharedImageResId: Int) {
        _setSharedImageResId.value = setSharedImageResId
    }

    private val _triggerAnimation = mutableStateOf(false)
    val triggerAnimation: State<Boolean> = _triggerAnimation

    fun triggerAnimation() {
        _triggerAnimation.value = !_triggerAnimation.value
    }
}
slaviboy
  • 1,407
  • 17
  • 27

1 Answers1

1

Well I manage to fix that problem by applying 200ms delay to the transition animation and also apply the same delay to the navigation transition animation!

During those 200ms, I start another animation that changes the opacity of the shared (copied) image from [0,1]. So basically I show the shared image, during those 200ms, and it get drawn on top of the item (original) image. Then on the last frame I hide the item (original) image, and only the transition image it displayed.

Then after those 200ms delay, I start the transition of the shared(copied) image to its new location. Here is simple graph to demonstrate the animations, during the 200ms delay and the 700ms duration.

enter image description here

@Composable
fun SharedImage(viewModel: ViewModel) {

    // opacity animation for the shared image
    // if triggered from Home -> change the opacity of the shared image [0,1]
    // if triggered from Detail -> change the opacity of the shared image [1,0]
    LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {

        val duration: Int
        val delay: Int
        val opacityFrom: Float
        val opacityTo: Float

        if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
            duration = 200
            delay = 0
            opacityFrom = 0f
            opacityTo = 1f
        } else {
            duration = 200
            delay = 700 + 200
            opacityFrom = 1f
            opacityTo = 0f
        }

        val animation = TargetBasedAnimation(
            animationSpec = tween(duration, delay),
            typeConverter = Float.VectorConverter,
            initialValue = opacityFrom,
            targetValue = opacityTo
        )

        var playTime = 0L
        val startTime = withFrameNanos { it }
        do {
            playTime = withFrameNanos { it } - startTime
            val animationValue = animation.getValueFromNanos(playTime)
            viewModel.setSharedImageOpacity(animationValue)

        } while (playTime <= animation.durationNanos)

        // on last frame set item opacity to 0
        if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
            viewModel.setItemImageOpacity(0f)
        }
    }

    var left by remember { mutableStateOf(0f) }
    var top by remember { mutableStateOf(0f) }
    var width by remember { mutableStateOf(0f) }

    // transition animation for the shared image
    // it changes the position and size of the shared image
    LaunchedEffect(viewModel.changeSharedImagePositionFrom.value) {

        val animation = TargetBasedAnimation(
            animationSpec = tween(700, 200),
            typeConverter = Float.VectorConverter,
            initialValue = 0f,
            targetValue = 1f
        )

        val from = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
            viewModel.sharedImageFromCoord.value
        } else viewModel.sharedImageToCoord.value

        val to = if (viewModel.changeSharedImagePositionFrom.value is Screen.Home) {
            viewModel.sharedImageToCoord.value
        } else viewModel.sharedImageFromCoord.value

        // offset and size for changing the shared image position and size
        val fromLeft = from.left
        val fromTop = from.top
        val fromSize = from.width
        val toLeft = to.left
        val toTop = to.top
        val toSize = to.width

        var playTime = 0L
        val startTime = withFrameNanos { it }
        do {
            playTime = withFrameNanos { it } - startTime
            val animationValue = animation.getValueFromNanos(playTime)
            left = fromLeft + animationValue * (toLeft - fromLeft)
            top = fromTop + animationValue * (toTop - fromTop)
            width = fromSize + animationValue * (toSize - fromSize)

        } while (playTime <= animation.durationNanos)

        // on last frame set item opacity to 1
        if (viewModel.changeSharedImagePositionFrom.value is Screen.Detail) {
            viewModel.setItemImageOpacity(1f)
            viewModel.setEnableItemsScroll(true)
        }
    }

    Image(
        painterResource(id = viewModel.setSharedImageResId.value),
        contentDescription = null,
        modifier = Modifier
            .absoluteOffset { IntOffset(left.toInt(), top.toInt()) }
            .width(with(LocalDensity.current) { width.toDp() })
            .height(with(LocalDensity.current) { width.toDp() }),
        alpha = viewModel.sharedImageOpacity.value
    )

}

Here is the result

enter image description here

slaviboy
  • 1,407
  • 17
  • 27