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?
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:
Image
composable and offsets if neededImage
composable and image coordinates on marker creation and on marker placementIn 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.