androidkotlinandroid-studioandroid-jetpack-compose

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?


Solution

  • 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:

    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