androidkotlinandroid-jetpack-compose

Android compose: offset at a fraction of parent size


I want to place some composables at specific positions on top of an image. However, the image will be responsive (it will resize), so I can't use Modifier.offset(some_fixed_offset.dp).

Modifier.offset(10.percent) would be ideal, but that doesn't work (or does it?). How can I implement this? These are the possible paths that I examined briefly:

Before I dive into the custom solution, is there an easier way, similar to .offset(10.percent)?

Here's some test code that I started with:

@Composable
fun GeneralPlaceHolder(modifier: Modifier = Modifier) {
    Box (
        modifier
            .background(Color.Red)
            .size(300.dp) //<-- if this size changes, 
                          //    the yellow dot should hold it's relative position
            .padding(10.dp)
    ) {
        Box(modifier = Modifier
            .background(Color.Blue)
            .fillMaxSize()) {
            DaThing()
        }
    }
}

@Composable
fun DaThing() {
    Box {
        Image(painter = painterResource(id = R.drawable.testgrid10x10), 
           contentDescription = "", 
           modifier = Modifier.fillMaxSize())
        Box(modifier = Modifier
            .size(5.dp)
            .offset(x = 10.dp, y=20.dp) //<-- percentage no can do?
            .background(Color.Yellow)
        )
    }
}

And a screenshot with a grid as the "image". It's a 10x10 grid, so offset(10.percent, 10.percent) would place the yellow dot at the first grid point.

enter image description here


Solution

  • Modifier.offset() changes placeable placement based on fixed values via parameters as

    private class OffsetNode(
        var x: Dp,
        var y: Dp,
        var rtlAware: Boolean
    ) : LayoutModifierNode, Modifier.Node() {
    
        override fun MeasureScope.measure(
            measurable: Measurable,
            constraints: Constraints
        ): MeasureResult {
            val placeable = measurable.measure(constraints)
            return layout(placeable.width, placeable.height) {
                if (rtlAware) {
                    placeable.placeRelative(x.roundToPx(), y.roundToPx())
                } else {
                    placeable.place(x.roundToPx(), y.roundToPx())
                }
            }
        }
    }
    

    By changing placeable.placeRelative(x.roundToPx(), y.roundToPx()) to percentage of parent Constraints with Modifier.layout{} you can place your Composable to value in percentage based on parent.

    However the thing you need to pay attention is it should be the Constraints coming from parent which means where you put Modifier.layout{} matters. If you set it after any size, offset or padding Modifier, Constraints might change since size and padding Modifiers do exact same thing based on their inner logic. If you wish to learn about how Constraints, layout and size modifiers work you can refer this answer.

    enter image description here

    @Preview
    @Composable
    fun GeneralPlaceHolderTest() {
        GeneralPlaceHolder()
    }
    
    @Composable
    fun GeneralPlaceHolder(modifier: Modifier = Modifier) {
        Column {
    
            var percentage by remember {
                mutableStateOf(0f)
            }
    
            Slider(
                value = percentage,
                onValueChange = {
                    percentage = it
                }
            )
    
            Text("Percentage: $percentage")
    
            Box(
                modifier
                    .background(Color.Red)
                    .size(300.dp) //<-- if this size changes,
                    //    the yellow dot should hold it's relative position
                    .padding(10.dp)
            ) {
                Box(
                    modifier = Modifier
                        .background(Color.Blue)
                        .fillMaxSize()
                ) {
                    DaThing(percentage)
                }
            }
        }
    }
    
    @Composable
    fun DaThing(percentage: Float) {
        Box {
            Box(
                modifier = Modifier.fillMaxSize().drawBehind {
                    drawRect(color = Color.Blue)
                }
            )
    
            Box(
                modifier = Modifier
                    .layout { measurable, constraints ->
                        val placeable = measurable.measure(constraints)
                        layout(placeable.width, placeable.height) {
                            placeable.placeRelative(
                                x = (constraints.maxWidth * percentage).toInt(),
                                y = (constraints.maxHeight * percentage).toInt()
                            )
                        }
                    }
                    .size(5.dp)
                    .background(Color.Yellow)
            )
        }
    }
    

    This layout also can be refactored as Modifier

    fun Modifier.offsetByPercent(percentage: Float) = this.then(
        Modifier.layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(
                    x = (constraints.maxWidth * percentage).toInt(),
                    y = (constraints.maxHeight * percentage).toInt()
                )
            }
        }
    )
    

    And can be used as

    Box(
        modifier = Modifier
            .offsetByPercent(percentage)
            .size(5.dp)
            .background(Color.Yellow)
    )