kotlinandroid-jetpack-composeandroid-jetpackjetbrains-compose

How does jetpack compose coordinate system work


I am trying to do an animated splash screen to move a logo from the center of the screen to the top left of the screen.

The code below works OK if the window size is big enough, but it seems that the coordinate system starts off screen (i.e. there is some global bounding box where the coordinate system starts off screen)

So it seems that the Modifier.offset(x,y).scale(scale) uses different coordinates and doesn't position relative to the containing BoxWithConstraints.

How can I make either the offset and scale position relative to its parent or otherwise get the global (top, left) of the bounding box so I can adjust my final target coordinates?

Its a multi-platform app so i want it to work on all platforms.

@Composable
fun SplashScreen(sizeProvider: WindowSizeProvider) {
    //val fullSize = IntSize(sizeProvider.width.toInt(), sizeProvider.height.toInt())
    val boxPosition = remember { mutableStateOf(Offset.Zero) }
    val screenSize = remember { mutableStateOf(IntSize.Zero) }
    BoxWithConstraints(modifier = Modifier
        .fillMaxSize()
        .onGloballyPositioned { coordinates ->
            boxPosition.value = coordinates.positionInWindow()
                .also { println("onGloballyPositioned: $it") }
        })
        
    {
        screenSize.value = IntSize(this.constraints.maxWidth, this.constraints.maxHeight)  // The real bounds you
        val imgSize = IntSize(1460, 320)
        val initialScale = 1f
        val animatedOffsetBoundsLeft =
            remember { Animatable((screenSize.value.width.toFloat() - imgSize.width) / initialScale) }
        val animatedOffsetBoundsTop =
            remember { Animatable((screenSize.value.height.toFloat() - imgSize.height) / initialScale) }
        val animatedOffsetBoundsScale = remember { Animatable(initialScale) }

        val animationSpec = tween<Float>(durationMillis = 2000, easing = FastOutSlowInEasing)

        val delayStart: Long = 2000
        val finalScale = 0.2f
        val finalTop = 10f + boxPosition.value.y
        val finalLeft = 10f + boxPosition.value.x
        LaunchedEffect(Unit) {
            delay(delayStart)
            animatedOffsetBoundsLeft.animateTo(
                targetValue = finalLeft - imgSize.width + imgSize.width * finalScale,
                animationSpec = animationSpec
            )
        }
        LaunchedEffect(Unit) {
            delay(delayStart)
            animatedOffsetBoundsTop.animateTo(
                targetValue = finalTop - imgSize.height + imgSize.height * finalScale,
                animationSpec = animationSpec
            )
        }
        LaunchedEffect(Unit) {
            delay(delayStart)
            animatedOffsetBoundsScale.animateTo(targetValue = finalScale, animationSpec = animationSpec)
        }

        Box(modifier = Modifier.fillMaxSize()) {
            Image(painterResource(Res.drawable.logo_light),
                contentDescription = null,
                modifier = Modifier
                    .offset {
                        IntOffset(
                            animatedOffsetBoundsLeft.value.roundToInt(),
                            animatedOffsetBoundsTop.value.roundToInt()
                        )
                    }
                    .scale(animatedOffsetBoundsScale.value) // control the size scaling of your logo
            )
        }
    }
}

Solution

  • Coordinate system in Jetpack Compose

    In jetpack Compose there are different positions you can get from Modifier.onGloballyPositioned

    val positionInParent: Offset = it.positionInParent()
    val positionInRoot: Offset = it.positionInRoot()
    val positionInWindow: Offset = it.positionInWindow()
    

    position in parent always returns (0,0) if child Composable is at top left of parent.

    position in root depends on parent's position in root. This doesn't take status bar height into consideration. On your root Composable if child composable is at at top left it returns (0, 0)

    position in window adds statusBar height to y position.

    @Preview
    @Composable
    private fun MyComposable() {
    
        var text by remember { mutableStateOf("") }
        Column(modifier = Modifier
            .padding(horizontal = 20.dp)
            .fillMaxWidth()
            .height(300.dp)
            .verticalScroll(rememberScrollState())
            .border(2.dp, Color.Red)
            .onGloballyPositioned {
                val positionInParent: Offset = it.positionInParent()
                val positionInRoot: Offset = it.positionInRoot()
                val positionInWindow: Offset = it.positionInWindow()
                val boundsInParent: Rect = it.boundsInParent()
                val boundsInRoot: Rect = it.boundsInRoot()
                val boundsInWindow: Rect = it.boundsInWindow()
                val parentCoordinates = it.parentCoordinates
                val parentLayoutCoordinates = it.parentLayoutCoordinates
    
                text =
                    "positionInParent: $positionInParent\n" +
                            "positionInRoot: $positionInRoot\n" +
                            "positionInWindow: $positionInWindow\n" +
                            "boundsInParent: $boundsInParent\n" +
                            "boundsInRoot: $boundsInRoot\n" +
                            "boundsInWindow: $boundsInWindow\n"
    
                parentCoordinates?.let { parent ->
                    text +=
                        "parentCoordinates:\n" +
                                "positionInParent: ${parent.positionInParent()}\n" +
                                "positionInRoot: ${parent.positionInRoot()}\n" +
                                "positionInWindow: ${parent.positionInWindow()}\n\n"
                }
    
                parentLayoutCoordinates?.let { parent ->
                    text +=
                        "parentLayoutCoordinates:\n" +
                                "positionInParent: ${parent.positionInParent()}\n" +
                                "positionInRoot: ${parent.positionInRoot()}\n" +
                                "positionInWindow: ${parent.positionInWindow()}\n"
                }
            }
        ) {
            Text(text = text)
        }
    }
    

    enter image description here

    difference between parentCoordinates and parentLayoutCoordinates is

    parentCoordinates returns coordinates after layout modifiers if any available

    parentLayoutCoordinates return parent layout coordinates

    As can be seen in image parentLayoutCoordinates does not take horizontal 20.dp padding but parentCoordinates measure coordinates after padding, layout or offset modifiers. This is basically how coordinate system works in Jetpack Compose.

    Animating and scaling a Composable from center to start of parent

    There are 2 things wrong with your calculation. First one is getting box position from coordinates.positionInWindow() which adds status bar height to its center position in parent. you should get it with positionInParent.

    Second one is, instead of getting Image composable size you are getting a static size val imgSize = IntSize(1460, 320) which is size of actual bitmap but this should only be true for Image composable on a device with 1.0 density and only when bitmap fits Image perfectly.

    One of the ways to animate is setting initial aligment of Image to center in a Box, getting initial offset and size and animating to top left of parent Box with graphicsLayer only.

    Which doesn't need a BoxWithConstraints, and measures and triggers recomposition only twice, first then after getting offset and size, because of animating inside Modifier.graphicsLayer{}

    @Preview
    @Composable
    fun AnimationTest() {
    
        Box(
            modifier = Modifier.fillMaxSize().border(2.dp, Color.Red),
            contentAlignment = Alignment.Center
        ) {
    
            var initialSize by remember {
                mutableStateOf(IntSize.Zero)
            }
    
    
            var initialOffset by remember {
                mutableStateOf(IntOffset.Zero)
            }
    
            val animatable by remember {
                mutableStateOf<Animatable<IntOffset, AnimationVector2D>>(
                    Animatable(
                        IntOffset.Zero,
                        IntOffset.VectorConverter
                    )
                )
            }
    
            val scale = remember {
                Animatable(1f)
            }
    
            LaunchedEffect(Unit) {
                delay(2000)
                val animationSpec =
                    tween<IntOffset>(durationMillis = 2000, easing = FastOutSlowInEasing)
                val animationSpecScale =
                    tween<Float>(durationMillis = 2000, easing = FastOutSlowInEasing)
    
                launch {
                    scale.animateTo(
                        targetValue = .2f,
                        animationSpec = animationSpecScale
                    )
                }
    
                launch {
                    animatable.snapTo(IntOffset.Zero)
                    animatable.animateTo(
                        targetValue = IntOffset(
    
                            // .4f comes from (initial width - final width)/2 = (1-.2f)/2
                            (-initialOffset.x - initialSize.width * .4f).toInt(),
                            (-initialOffset.y - initialSize.height * .4f).toInt()
                        ),
                        animationSpec = animationSpec
                    )
                }
            }
    
            Text(
                modifier = Modifier.align(Alignment.BottomEnd),
                text = "initialSize: $initialSize, initialOffset: $initialOffset"
            )
    
            Image(
                modifier = Modifier
                    .graphicsLayer {
                        translationX = animatable.value.x.toFloat()
                        translationY = animatable.value.y.toFloat()
                        scaleX = scale.value
                        scaleY = scale.value
                    }
                    .onGloballyPositioned {
                        if (initialOffset == IntOffset.Zero) {
                            initialOffset = it.positionInParent().round()
                        }
                        if (initialSize == IntSize.Zero) {
                            initialSize = it.size
                        }
                    }
                    // This is optional, it can be any size
                    // but what we actually need to get is size of Composable
                    .size(150.dp)
                ,
                painter = painterResource(R.drawable.avatar_1_raster),
                contentDescription = null
            )
        }
    }
    

    enter image description here