Reputation: 704
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
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
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
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:
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.
Upvotes: 3