730wavy
730wavy

Reputation: 704

Android Studio Compose Kotlin - How to place markers on images like google maps

Hi I am working on a project where I am trying to place markers on images, sort of similar to google maps.

For each marker I will save coordinates and details in the database and whenever the user clicks on the marker, it shows the relevant data, right now I'm only using static sample data though. I'm new to android studio but have managed to put something together, but I'm having a couple of problems.

My main problem is getting the correct offset relative to the image and screen (due to different screen sizes and auto resizing of the images). The current offset on click is showing high numbers up to around 1200, which makes the placed markers appear off screen. The current emulator device only goes up to around 200F width. So I'm not sure how to handle this dynamically for all devices.

ImageScreen.kt -

 @Composable
    fun ImageScreen(
        navController: NavController
    ) {
        //val configuration = LocalConfiguration.current
        //val screenHeight = configuration.screenHeightDp.dp
        //val screenWidth = configuration.screenWidthDp.dp
    
        val context = LocalContext.current
        var xyCoordinates by remember { mutableStateOf(Offset.Zero) }
        val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
        val scope = rememberCoroutineScope()
        var showBottomSheet by remember { mutableStateOf(false) }
    
        val imgAnnotations = remember {
            mutableStateListOf<ImgAnnotation>()
                .apply {
                    add(
                        ImgAnnotation(
                            uid = "45224",
                            coordinateX = 10f,
                            coordinateY = 10f,
                            note = "Sample text 1"
                        )
                    )
                    add(
                        ImgAnnotation(
                            uid = "6454",
                            coordinateX = 50f,
                            coordinateY = 50f,
                            note = "Sample text 2"
                        )
                    )
                    add(
                        ImgAnnotation(
                            uid = "211111",
                            coordinateX = 200f,
                            coordinateY = 90f,
                            note = "Sample text 3"
                        )
                    )
                    add(
                        ImgAnnotation(
                            uid = "21555",
                            coordinateX = 32f,
                            coordinateY = 93f,
                            note = "Sample text 4"
                        )
                    )
                }
        }
    
        var currentAnnotationSelected = ImgAnnotation()
        var showAnnotation by remember { mutableStateOf(false) }
    
        Column(
            modifier = Modifier
                .fillMaxSize(),
            verticalArrangement = Arrangement.Bottom,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
    
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.fillMaxSize()
            ) {
    
                Image(
                    painter = painterResource(R.drawable.hair_picture),
                    contentDescription = "Record image",
                    contentScale = ContentScale.Fit,
                    modifier = Modifier
                        .align(Alignment.BottomCenter)
                        .pointerInput(Unit) {
                            detectTapGestures(
                                onPress = { offset ->
                                    xyCoordinates = offset
                                    showBottomSheet = true
                                }
                            )
                        }
                )
    
                Box(
                    contentAlignment = Alignment.Center,
                    modifier = Modifier.fillMaxSize()
                ) {
                    imgAnnotations.forEach { item ->                    
                        MakeShape(
                            modifier = Modifier
                                //.offset(item.coordinateX.dp, item.coordinateY.dp
                             item.coordinateX!!.toFloat().dp,
                                   item.coordinateY!!.toFloat().dp)

                                .clickable {
                                    currentAnnotationSelected = item
                                    showAnnotation = true
                                    showBottomSheet = true
                                },
                            shape = CircleShape,
                            size = 20.dp,
                            bg = Color.Yellow
                        )
                    }
                }
    
                if (showBottomSheet) {
                    ModalBottomSheet(
                        onDismissRequest = {
                            showBottomSheet = false
                            showAnnotation = false
                            currentAnnotationSelected = ImgAnnotation()
                        },
                        sheetState = sheetState,
                        windowInsets = WindowInsets(0, 0, 0, 0)
                    ) {
                        IconButton(
                            onClick = {
                                scope.launch { sheetState.hide() }.invokeOnCompletion {
                                    if (!sheetState.isVisible) {
                                        showBottomSheet = false
                                        showAnnotation = false
                                        currentAnnotationSelected = ImgAnnotation()
                                    }
                                }
                            },
                            modifier = Modifier
                                .align(Alignment.End)
                        ) {
                            Icon(
                                painterResource(R.drawable.close_icon),
                                contentDescription = "Close icon",
                                modifier = Modifier.height(18.dp)
                            )
                        }
    
                        AnnotationNote(
                            xy = xyCoordinates,
                            annotationData = currentAnnotationSelected,
                            show = showAnnotation
                        )
    
                        Spacer(modifier = Modifier.height(16.dp))
                    }
                }
            }
        }
    }

Annotation note composable -

@Composable
fun AnnotationNote(
    xy: Offset = Offset.Zero,
    show: Boolean = false,
    annotationData: ImgAnnotation = ImgAnnotation()
) {
    var annotationNote by remember {
        mutableStateOf(annotationData.note ?: "")
    }

    Column(
        modifier = Modifier
            .fillMaxWidth(),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        if (show) {
            Text(annotationNote)
        } else {
            //Text("$xy")
            TextField(
                modifier = Modifier.fillMaxWidth(),
                value = annotationNote,
                onValueChange = {
                    annotationNote = it
                },
                label = {
                    Text(text = "Annotation Note")
                }
            )

            Spacer(modifier = Modifier.height(24.dp))

            ActionButton(
                onClick = { // to do - need to save coordinates and note to db },
                contentColor = Color.Black,
                disabledContentColor = Color.Black,
                text = stringResource(R.string.save_btn),
            )

            Spacer(modifier = Modifier.height(24.dp))
        }
    }
}

Edit

I found another question that may be able to help with my problem, and I've tried implementing it but the example shows how to use predefined static values. For my use case I need to get the calculated coordinates' values depending where a user clicks on the image, so I'm still stuck on how to use it to get (percentage as suggested in question) offest on the click modifier of an image?

Upvotes: 3

Views: 409

Answers (2)

Thracian
Thracian

Reputation: 67248

You just need to scale position from you db or the on Bitmap to dimensions of Image composable which you can get from onSizeChanged.

You can see this answer that you can detect pixels on Image on every click

How to detect what image part was clicked in Android Compose

Let me first explain the logic how it should be done. Let's say you have a 1000x1000px bitmap and you want to display it inside an Image that covers 2000x2000px on screen. And let's say a market is placed on (500,500) on bitmap to correctly draw this on screen it should be at (1000, 1000) which is also center of Composable.

You can calculate x coordinate on screen as

position on Screen = positionX on Bitmap * (Composable width/ Bitmap width)

1000 = 500 * (2000/1000)

And let's you touched (400, 400) on Image Composable on screen it's calculated as

  position on Bitmap = positionX on Screen * (Bitmap width/ Composable width)
    200 = 400 * (1000/2000)

Demo

@Preview
@Composable
fun ImageTouchScaleTest() {
    Column(
        modifier = Modifier.fillMaxSize()
    ) {

        val painter = painterResource(R.drawable.landscape)
        val painterWidth = painter.intrinsicSize.width
        val painterHeight = painter.intrinsicSize.height


        Column(
            modifier = Modifier.verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {

            // This is the position on painter or Bitmap
            var positionOnPainter by remember {
                mutableStateOf(
                    Offset(1000f, 500f)
                )
            }

            // This is the position on screen
            var positionOnImage by remember {
                mutableStateOf(
                    Offset.Zero
                )
            }

            Text(
                "painter width: $painterWidth, " +
                        "height: $painterHeight\n" +
                        "positionOnPainter: $positionOnPainter\n" +
                        "positionOnImage: $positionOnImage"
            )

            val drawModifier = Modifier
                .drawWithContent {

                    val canvasWidth = size.width
                    val canvasHeight = size.height

                    drawContent()

                    val xOnImage = positionOnPainter.x * canvasWidth / painterWidth
                    val yOnImage = positionOnPainter.y * canvasHeight / painterHeight

                    positionOnImage = Offset(xOnImage, yOnImage)

                    drawCircle(
                        color = Color.Red,
                        radius = 5.dp.toPx(),
                        center = positionOnImage
                    )
                }
                .pointerInput(Unit) {

                    val imageWidth = size.width
                    val imageHeight = size.height

                    detectTapGestures(
                        onPress = { offset ->
                            val xOnPainter = offset.x * painterWidth / imageWidth
                            val yOnPainter = offset.y * painterHeight / imageHeight
                            positionOnPainter = Offset(xOnPainter, yOnPainter)
                        }
                    )
                }

            Image(
                modifier = drawModifier.fillMaxWidth().aspectRatio(3/4f),
                painter = painter,
                contentScale = ContentScale.FillBounds,
                contentDescription = null
            )
        }
    }
}

However, this works when your Bitmap fits perfectly to Image Composable that is always possible when ContentScale.FillBounds is set.

Result that works with any and every ContentScale and Alignment

enter image description here

In a case where you see empty spaces or in case of there are gaps around image or image Alignment is different than center you need to calculate Rect of area that bitmap is drawn in Image composable with

private fun getDrawAreaRect(
    dstSize: Size,
    scaledSrcSize: Size,
    horizontalBias: Float,
    verticalBias: Float
): Rect {
    val horizontalGap = ((dstSize.width - scaledSrcSize.width) / 2).coerceAtLeast(0f)
    val verticalGap = ((dstSize.height - scaledSrcSize.height) / 2).coerceAtLeast(0f)

    val left = when (horizontalBias) {
        -1f -> 0f
        0f -> horizontalGap
        else -> horizontalGap * 2
    }

    val top = when (verticalBias) {
        -1f -> 0f
        0f -> verticalGap
        else -> verticalGap * 2
    }

    val right = (left + scaledSrcSize.width).coerceAtMost(dstSize.width)
    val bottom = (top + scaledSrcSize.height).coerceAtMost(dstSize.height)

    val drawAreaRect = Rect(
        left, top, right, bottom
    )
    return drawAreaRect
}

And in cases where not Bitmap is drawn partially, ContentScale.Crop and some other ones, clip image to center or based on selected alignment from corners, in that case you need to calculate which section of Bitmap is visible with

/**
 * Get Rectangle of [ImageBitmap] with [bitmapWidth] and [bitmapHeight] that is drawn inside
 * Canvas with [scaledImageWidth] and [scaledImageHeight]. [containerWidth] and [containerHeight] belong
 * to [BoxWithConstraints] that contains Canvas.
 *  @param containerWidth width of the parent container
 *  @param containerHeight height of the parent container
 *  @param scaledImageWidth width of the [Canvas] that draws [ImageBitmap]
 *  @param scaledImageHeight height of the [Canvas] that draws [ImageBitmap]
 *  @param bitmapWidth intrinsic width of the [ImageBitmap]
 *  @param bitmapHeight intrinsic height of the [ImageBitmap]
 *  @return [IntRect] that covers [ImageBitmap] bounds. When image [ContentScale] is crop
 *  this rectangle might return smaller rectangle than actual [ImageBitmap] and left or top
 *  of the rectangle might be bigger than zero.
 */
internal fun getScaledBitmapRect(
    horizontalBias: Float,
    verticalBias: Float,
    containerWidth: Int,
    containerHeight: Int,
    scaledImageWidth: Float,
    scaledImageHeight: Float,
    bitmapWidth: Int,
    bitmapHeight: Int
): Rect {
    // Get scale of box to width of the image
    // We need a rect that contains Bitmap bounds to pass if any child requires it
    // For a image with 100x100 px with 300x400 px container and image with crop 400x400px
    // So we need to pass top left as 0,50 and size
    val scaledBitmapX = containerWidth / scaledImageWidth
    val scaledBitmapY = containerHeight / scaledImageHeight

    val scaledBitmapWidth = (bitmapWidth * scaledBitmapX).coerceAtMost(bitmapWidth.toFloat())
    val scaledBitmapHeight = (bitmapHeight * scaledBitmapY).coerceAtMost(bitmapHeight.toFloat())

    val horizontalGap = (bitmapWidth - scaledBitmapWidth) / 2
    val verticalGap = (bitmapHeight - scaledBitmapHeight) / 2

    val left = when (horizontalBias) {
        -1f -> 0f
        0f -> horizontalGap
        else -> horizontalGap * 2
    }

    val top = when (verticalBias) {
        -1f -> 0f
        0f -> verticalGap
        else -> verticalGap * 2
    }


    val topLeft = Offset(x = left, y = top)

    val size = Size(
        width = (bitmapWidth * scaledBitmapX).coerceAtMost(bitmapWidth.toFloat()),
        height = (bitmapHeight * scaledBitmapY).coerceAtMost(bitmapHeight.toFloat())
    )

    return Rect(offset = topLeft, size = size)
}

And getting these both with

data class ImageProperties(
    val drawAreaRect: Rect,
    val bitmapRect: Rect
) {
    companion object {

        @Stable
        val Zero: ImageProperties = ImageProperties(
            drawAreaRect = Rect.Zero,
            bitmapRect = Rect.Zero
        )
    }
}

private fun calculateImageDrawProperties(
    srcSize: Size,
    dstSize: Size,
    contentScale: ContentScale,
    alignment: Alignment
): ImageProperties {
    val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize)

    // Bitmap scaled size that might be drawn,  this size can be bigger or smaller than
    // draw area. If Bitmap is bigger than container it's cropped only to show
    // which will be on screen
    //
    val scaledSrcSize = Size(
        srcSize.width * scaleFactor.scaleX,
        srcSize.height * scaleFactor.scaleY
    )

    val biasAlignment: BiasAlignment = alignment as BiasAlignment

    // - Left, 0 Center, 1 Right
    val horizontalBias: Float = biasAlignment.horizontalBias
    // -1 Top, 0 Center, 1 Bottom
    val verticalBias: Float = biasAlignment.verticalBias

    // DrawAreaRect returns the area that bitmap is drawn in Container
    // This rectangle is useful for getting are after gaps on any side based on
    // alignment and ContentScale
    val drawAreaRect = getDrawAreaRect(
        dstSize,
        scaledSrcSize,
        horizontalBias,
        verticalBias
    )

    // BitmapRect returns that Rectangle to show which section of Bitmap is visible on screen
    val bitmapRect = getScaledBitmapRect(
        horizontalBias = horizontalBias,
        verticalBias = verticalBias,
        containerWidth = dstSize.width.toInt(),
        containerHeight = dstSize.height.toInt(),
        scaledImageWidth = scaledSrcSize.width,
        scaledImageHeight = scaledSrcSize.height,
        bitmapWidth = srcSize.width.toInt(),
        bitmapHeight = srcSize.height.toInt()
    )

    return ImageProperties(
        drawAreaRect = drawAreaRect,
        bitmapRect = bitmapRect
    )
}

And Composable that process touch positions

@Composable
private fun ImageWithMarkers(
    modifier: Modifier = Modifier,
    contentScale: ContentScale,
    alignment: Alignment = Alignment.Center,
    imgAnnotationList: SnapshotStateList<ImgAnnotation>,
    imageBitmap: ImageBitmap,
    onClick: (ImgAnnotation) -> Unit
) {

    var imageProperties by remember {
        mutableStateOf(ImageProperties.Zero)
    }

    Box(
        modifier = Modifier.padding(vertical = 16.dp)
    ) {

        Image(
            modifier = modifier
                .drawWithContent {
                    drawContent()
                    val drawAreaRect = imageProperties.drawAreaRect

                    // This is for displaying area that bitmap is drawn in Image Composable
                    drawRect(
                        color = Color.Green,
                        topLeft = drawAreaRect.topLeft,
                        size = drawAreaRect.size,
                        style = Stroke(
                            4.dp.toPx(),
                        )
                    )
                }
                .pointerInput(contentScale) {
                    detectTapGestures { offset: Offset ->

                        val drawAreaRect = imageProperties.drawAreaRect
                        val isTouchInImage = drawAreaRect.contains(offset)

                        if (isTouchInImage) {
                            val bitmapRect = imageProperties.bitmapRect

                            // Calculate touch position scaled into Bitmap
                            // using scale between area on screen / bitmap on screen
                            // Bitmap on screen might change based on crop or other
                            // ContentScales that clip image
                            val ratioX = drawAreaRect.width / bitmapRect.width
                            val ratioY = drawAreaRect.height / bitmapRect.height

                            val xOnImage =
                                bitmapRect.left + (offset.x - drawAreaRect.left) / ratioX
                            val yOnImage =
                                bitmapRect.top + (offset.y - drawAreaRect.top) / ratioY

                            imgAnnotationList.add(
                                ImgAnnotation(UUID.randomUUID().toString(), xOnImage, yOnImage)
                            )
                        }
                    }
                }
                .onSizeChanged {
                    val imageSize = it
                    val dstSize: Size = imageSize.toSize()
                    val srcSize =
                        Size(imageBitmap.width.toFloat(), imageBitmap.height.toFloat())

                    imageProperties = calculateImageDrawProperties(
                        srcSize = srcSize,
                        dstSize = dstSize,
                        contentScale = contentScale,
                        alignment = alignment
                    )
                },
            bitmap = imageBitmap,
            contentScale = contentScale,
            alignment = alignment,
            contentDescription = null
        )

        ShapesOnImage(
            list = imgAnnotationList,
            imageProperties = imageProperties,
            onClick = onClick
        )
    }
}

Composable that draws Markers

@Composable
fun ShapesOnImage(
    list: List<ImgAnnotation>,
    imageProperties: ImageProperties,
    onClick: (ImgAnnotation) -> Unit
) {
    if (imageProperties != ImageProperties.Zero) {
        list.forEachIndexed { index, imgAnnotation ->

            val coordinateX = imgAnnotation.coordinateX
            val coordinateY = imgAnnotation.coordinateY

            val drawAreaRect = imageProperties.drawAreaRect
            val bitmapRect = imageProperties.bitmapRect

            val ratioX = drawAreaRect.width / bitmapRect.width
            val ratioY = drawAreaRect.height / bitmapRect.height

            val xOnScreen = drawAreaRect.left + (coordinateX - bitmapRect.left) * ratioX
            val yOnScreen = drawAreaRect.top + (coordinateY - bitmapRect.top) * ratioY

            Box(
                modifier = Modifier
                    .layout { measurable, constraints ->
                        val placeable = measurable.measure(constraints)

                        val width = placeable.width
                        val height = placeable.height

                        val xPos = (xOnScreen - width / 2).toInt()
                        val yPos = (yOnScreen - height / 2).toInt()

                        layout(placeable.width, placeable.height) {
                            placeable.placeRelative(xPos, yPos)
                        }
                    }
                    .size(20.dp)
                    .background(Color.Red, CircleShape)
                    .clickable {
                        onClick(imgAnnotation)
                    },
                contentAlignment = Alignment.Center
            ) {
                Text("${index + 1}", color = Color.White)
            }

        }
    }
}

data class ImgAnnotation(
    val uid: String,
    val coordinateX: Float,
    val coordinateY: Float,
    val note: String = ""
)

Demo with different ContentScale and Alignments

@Preview
@Composable
fun ImageWithMarkersSample() {
    val imageBitmap: ImageBitmap = ImageBitmap.imageResource(R.drawable.landscape1)

    val imgAnnotationList = imgAnnotations()

    val context = LocalContext.current

    Column(
        modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(8.dp)
    ) {

        Text("ContentScale: ContentScale.Fit, alignment: TopCenter")
        ImageWithMarkers(
            modifier = Modifier
                .border(2.dp, Color.Red)
                .background(Color.LightGray)
                .fillMaxWidth()
                .aspectRatio(4 / 3f),
            contentScale = ContentScale.Fit,
            imgAnnotationList = imgAnnotationList,
            imageBitmap = imageBitmap
        ) {
            Toast.makeText(
                context,
                "Clicked ${it.uid.substring(0, 4)} at " +
                        "x: ${it.coordinateX}, y: ${it.coordinateY}",
                Toast.LENGTH_SHORT
            ).show()
        }

        Spacer(modifier = Modifier.height(16.dp))
        Text("ContentScale: ContentScale.FillBounds, alignment: TopCenter")
        ImageWithMarkers(
            modifier = Modifier
                .border(2.dp, Color.Red)
                .background(Color.LightGray)
                .fillMaxWidth()
                .aspectRatio(3 / 2f),
            contentScale = ContentScale.FillBounds,
            imgAnnotationList = imgAnnotationList,
            imageBitmap = imageBitmap
        ) {
            Toast.makeText(
                context,
                "Clicked ${it.uid.substring(0, 4)} " +
                        "at x: ${it.coordinateX}, y: ${it.coordinateY}",
                Toast.LENGTH_SHORT
            ).show()
        }

        Spacer(modifier = Modifier.height(16.dp))
        Text("ContentScale: ContentScale.Fit, alignment: BottomEnd")
        ImageWithMarkers(
            modifier = Modifier
                .border(2.dp, Color.Red)
                .background(Color.LightGray)
                .fillMaxWidth()
                .aspectRatio(5 / 3f),
            contentScale = ContentScale.Fit,
            alignment = Alignment.BottomEnd,
            imgAnnotationList = imgAnnotationList,
            imageBitmap = imageBitmap
        ) {
            Toast.makeText(
                context,
                "Clicked ${it.uid.substring(0, 4)} at " +
                        "x: ${it.coordinateX}, y: ${it.coordinateY}",
                Toast.LENGTH_SHORT
            ).show()
        }

        Spacer(modifier = Modifier.height(16.dp))
        Text("ContentScale: ContentScale.Crop, alignment: TopCenter")
        ImageWithMarkers(
            modifier = Modifier
                .border(2.dp, Color.Red)
                .background(Color.LightGray)
                .fillMaxWidth()
                .aspectRatio(3 / 4f),
            contentScale = ContentScale.Crop,
            imgAnnotationList = imgAnnotationList,
            imageBitmap = imageBitmap
        ) {
            Toast.makeText(
                context,
                "Clicked ${it.uid.substring(0, 4)} " +
                        "at x: ${it.coordinateX}, y: ${it.coordinateY}",
                Toast.LENGTH_SHORT
            ).show()
        }

        Spacer(modifier = Modifier.height(16.dp))
        Text("ContentScale: ContentScale.Crop, alignment: TopStart")
        ImageWithMarkers(
            modifier = Modifier
                .border(2.dp, Color.Red)
                .background(Color.LightGray)
                .fillMaxWidth()
                .aspectRatio(3 / 4f),
            contentScale = ContentScale.Crop,
            alignment = Alignment.TopStart,
            imgAnnotationList = imgAnnotationList,
            imageBitmap = imageBitmap
        ) {
            Toast.makeText(
                context,
                "Clicked ${it.uid.substring(0, 4)} " +
                        "at x: ${it.coordinateX}, y: ${it.coordinateY}",
                Toast.LENGTH_SHORT
            ).show()
        }
    }
}

@Composable
private fun imgAnnotations(): SnapshotStateList<ImgAnnotation> {
    val imgAnnotationList = remember {
        mutableStateListOf<ImgAnnotation>()
            .apply {
                add(
                    ImgAnnotation(
                        uid = "45224",
                        coordinateX = 10f,
                        coordinateY = 10f,
                        note = "Sample text 1"
                    )
                )
                add(
                    ImgAnnotation(
                        uid = "6454",
                        coordinateX = 50f,
                        coordinateY = 50f,
                        note = "Sample text 2"
                    )
                )
                add(
                    ImgAnnotation(
                        uid = "211111",
                        coordinateX = 200f,
                        coordinateY = 90f,
                        note = "Sample text 3"
                    )
                )
                add(
                    ImgAnnotation(
                        uid = "21555",
                        coordinateX = 32f,
                        coordinateY = 93f,
                        note = "Sample text 4"
                    )
                )
            }
    }
    return imgAnnotationList
}

You can use ImageWithConstraints where rectangle is calculated based on current ContentScale with this library or check ImageWithThumbNail that zooms position around are of user touch.

Upvotes: 3

Jan Itor
Jan Itor

Reputation: 4276

The exact way to map between image and composable coordinates depends on the way Image scales its content. But either way it comes down to:

  • calculating ratios between dimensions of the image and Image composable and offsets if needed
  • using the ratios and offsets to map between screen coordinates relative to Image composable and image coordinates on marker creation and on marker placement

In this example imageWidth and imageHeight are dimensions of the image loaded in Painter. windowSize stores the size of the Image composable.

Marker data class:

/**
 * @param value Arbitrary data
 * @param x offset in image coordinates
 * @param y offset in image coordinates
 */
private data class Marker(val value: String, val x: Int, val y: Int)

An example for ContentScale.Fit:

@Composable
private fun FitImage(
    @DrawableRes image: Int,
    markers: List<Marker>,
    onMarkerClick: (Marker) -> Unit,
    onAddMarker: (Int, Int) -> Unit,
    modifier: Modifier,
) {
    Box(
        modifier = modifier
            .wrapContentSize()
    ) {
        val painter = painterResource(image)
        val imageWidth = painter.intrinsicSize.width
        val imageHeight = painter.intrinsicSize.height
        var windowSize by remember { mutableStateOf(IntSize.Zero) }
        var dx by remember { mutableIntStateOf(0) }
        var dy by remember { mutableIntStateOf(0) }
        var ratio by remember { mutableFloatStateOf(0f) }
        LaunchedEffect(windowSize) {
            if (windowSize == IntSize.Zero) { return@LaunchedEffect }
            val (windowWidth, windowHeight) = windowSize
            if (windowWidth.toFloat() / windowHeight > imageWidth / imageHeight) {
                // if vertical gaps, calculate ratio with heights
                ratio = windowHeight / imageHeight
                dx = ((windowWidth - imageWidth * ratio) / 2).toInt()
                dy = 0
            } else {
                // if horizontal gaps, calculate ratio with widths
                ratio = windowWidth / imageWidth
                dx = 0
                dy = ((windowHeight - imageHeight * ratio) / 2).toInt()
            }
        }
        markers.forEach { marker ->
            Marker(marker, ratio, ratio, dx, dy) { onMarkerClick(marker) }
        }
        Image(
            painter = painter,
            contentDescription = "Record image",
            contentScale = ContentScale.Fit,
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = { offset ->
                            val imageX = ((offset.x - dx) / ratio).toInt()
                            val imageY = ((offset.y - dy) / ratio).toInt()
                            // if tap is inside image
                            if (imageX in 0..imageWidth.toInt() && imageY in 0..imageHeight.toInt()) {
                                onAddMarker(imageX, imageY)
                            }
                        }
                    )
                }
                .onGloballyPositioned { windowSize = it.size }
        )
    }
}

A example for ContentScale.FillBounds:

@Composable
private fun FillBoundsImage(
    @DrawableRes image: Int,
    markers: List<Marker>,
    onMarkerClick: (Marker) -> Unit,
    onAddMarker: (Int, Int) -> Unit,
    modifier: Modifier,
) {
    Box(
        modifier = modifier
            .wrapContentSize()
    ) {
        val painter = painterResource(image)
        val imageWidth = painter.intrinsicSize.width
        val imageHeight = painter.intrinsicSize.height
        var windowSize by remember { mutableStateOf(IntSize.Zero) }
        var xRatio by remember { mutableFloatStateOf(0f) }
        var yRatio by remember { mutableFloatStateOf(0f) }
        LaunchedEffect(windowSize) {
            xRatio = windowSize.width / imageWidth
            yRatio = windowSize.height / imageHeight
        }
        markers.forEach { marker ->
            Marker(marker, xRatio, yRatio, 0, 0) { onMarkerClick(marker) }
        }
        Image(
            painter = painter,
            contentDescription = "Record image",
            contentScale = ContentScale.FillBounds,
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectTapGestures(
                        onPress = { offset ->
                            val imageX = (offset.x / xRatio).toInt()
                            val imageY = (offset.y / yRatio).toInt()
                            onAddMarker(imageX, imageY)
                        }
                    )
                }
                .onGloballyPositioned { windowSize = it.size }
        )
    }
}

Marker composable:

/**
 * @param xRatio x ratio
 * @param yRatio y ratio
 * @param dx x offset in screen coordinates
 * @param dy y offset in screen coordinates
 */
@Composable
private fun Marker(
    marker: Marker,
    xRatio: Float,
    yRatio: Float,
    dx: Int,
    dy: Int,
    onClick: () -> Unit,
) {
    Text(
        text = marker.value,
        modifier = Modifier
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                layout(placeable.width, placeable.height) {
                    val x = (marker.x * xRatio).toInt() + dx - placeable.width / 2
                    val y = (marker.y * yRatio).toInt() + dy - placeable.height / 2
                    placeable.placeRelative(x, y, 1f)
                }
            }
            .size(20.dp)
            .background(Color.Magenta)
            .clickable { onClick() }
    )
}

Usage:

@Composable
fun ImageMarkers() {
    val markers = remember { mutableStateListOf<Marker>() }
    var clickedMarkerText by remember { mutableStateOf("") }

    Column(
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = Modifier
            .background(Color.Black)
            .fillMaxSize()
    ) {
        Text("Marker: $clickedMarkerText")
        val onMarkerClick: (Marker) -> Unit = { marker ->
            clickedMarkerText = "${marker.value} [x: ${marker.x}, y: ${marker.y}]"
        }
        val onAddMarker: (Int, Int) -> Unit = { x, y ->
            markers.add(Marker(markers.size.toString(), x, y))
        }

        FitImage(
            image = R.drawable.vertical_background,
            markers = markers,
            onMarkerClick = onMarkerClick,
            onAddMarker = onAddMarker,
            modifier = Modifier
                .background(Color.DarkGray)
                .weight(1f)
        )
        FillBoundsImage(
            image = R.drawable.vertical_background,
            markers = markers,
            onMarkerClick = onMarkerClick,
            onAddMarker = onAddMarker,
            modifier = Modifier
                .weight(1f)
        )
    }
}

Markers can be placed by clicking on either of the images. A marker can be clicked to display its info on top. vertical_background drawable is a 400 x 1000 bitmap.

screen capture

Upvotes: 3

Related Questions