skip to Main Content

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?

2

Answers


  1. If the ContentScale you picked is ContentScale.FillBounds this is not that hard to solve. 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)
    

    Result is for a bitmap with 2000x1000px image displayed with 3/4 aspect ratio on screen

    enter image description here

    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: $painterHeightn" +
                            "positionOnPainter: $positionOnPaintern" +
                            "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
                )
            }
        }
    }
    

    You can draw shape with Modifier.drawWithContent, Shape can be converted to Outline or change this position to display other Composables using Modifier.offset{IntOffset}

    However, this works when your Bitmap fits perfectly to Image Composable that is always possible when ContentScale.FillBounds is set. In a case where you see empty spaces or in case of Crop sometimes only some section of the painter is drawn, in Image composable you also need to calculate top, left, end, and bottom spaces or get a Rectangle which is done by Image internally but is not a public parameter. You can use ImageWithConstraints where rectangle is calculated based on current ContentScale with this library
    or copy source code where rectangle is calculated.

    After getting rectangle you just need to add start and top of Rectangle into calculations to get correct positions with any ContantScale.

    Login or Signup to reply.
  2. 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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search