/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.eyedropper.ui.touchscreen import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asComposeColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.round import androidx.compose.ui.unit.roundToIntSize import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.eyedropper.ui.utils.ARROW_PADDING_PX import com.android.eyedropper.ui.utils.LONG_PRESS_MOVE_PX import com.android.eyedropper.ui.utils.ReticleDirectionButton import com.android.eyedropper.ui.utils.ReticleProps import com.android.eyedropper.ui.utils.getArrowPositionAroundCenter import com.android.eyedropper.ui.utils.getReticleDrawProps import com.android.eyedropper.ui.utils.getReticleTotalBounds import com.android.eyedropper.ui.utils.isWithinArrow import com.android.eyedropper.ui.utils.toPx import kotlin.math.max import kotlin.math.roundToInt private const val SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR = 1.2f object ScreenshotWithReticleTestTags { const val CANVAS = "canvas" } /** * Subcomponent of [TouchscreenMainScreen] which displays the cursor tracking graphic. * * This Composable displays/handles: * (1) The screenshot magnified by [SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR] * (2) The reticle which tracks the cursor * (3) Gliding the screenshot in the opposite direction of the reticle to preserve perceived drag * while increasing true drag, allowing us to fully traverse the magnified screenshot. * * This Composable is responding for updating `TouchscreenViewModel#bitmapPointerPosition` * * A canvas is used for the same reasons detailed in [DesktopSelectColorOverlay] * * A visual/logical replica (in Compose) of: * `j/c/g/android/apps/accessibility/auditor/ui/picker/ScreenshotWithReticleFragment` */ @Composable fun ScreenshotWithReticle( modifier: Modifier = Modifier, viewModel: TouchscreenViewModel, ) { val density = LocalDensity.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() val bitmapPointerPosition by viewModel.bitmapPointerPosition.collectAsStateWithLifecycle() val screenshotBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() val imageBitmap = screenshotBitmap ?: return // Recompose until resources are available val bitmapSize = IntSize(imageBitmap.width, imageBitmap.height) val drawProps = getReticleDrawProps(density) val magnifiedPatchNumPx = drawProps.magnifiedPatchNumPx require( imageBitmap.width >= magnifiedPatchNumPx && imageBitmap.height >= magnifiedPatchNumPx ) { "ScreenshotBitmap must be at least ${magnifiedPatchNumPx}x$magnifiedPatchNumPx " + "to draw the reticle, " + "but received ${imageBitmap.width}x${imageBitmap.height}" } var initPositionSet by remember { mutableStateOf(false) } var canvasSize by remember { mutableStateOf(IntSize.Zero) } // Calculate inset pixel values val systemBarInsets = WindowInsets.systemBars.asPaddingValues() val topPaddingPx = remember(systemBarInsets) { systemBarInsets.calculateTopPadding().toPx(density) } val bottomPaddingPx = remember(systemBarInsets) { systemBarInsets.calculateBottomPadding().toPx(density) } // Define content bounds val canvasBounds by derivedStateOf { Rect( 0f, topPaddingPx, canvasSize.width.toFloat(), canvasSize.height - bottomPaddingPx ) } val canvasCenter by derivedStateOf { canvasBounds.center } var moveSpeedRatioX by remember { mutableStateOf(0f) } var moveSpeedRatioY by remember { mutableStateOf(0f) } var displayedPointerPosition by remember { mutableStateOf(canvasCenter) } var displayedScreenshotCenter by remember { mutableStateOf(canvasCenter) } /** * Convenience local setter for [displayedPointerPosition] & [displayedScreenshotCenter]. * * The core function that updates the position of the reticle and screenshot. * For a given input drag `displayedDragAmountPx`, we implement: * * (1) Scale `displayedDragAmountPx` from "display space" down to "bitmap space". * (2) Compute `newBitmapPosition` = old bitmap position + bitmapDragAmount * (3) Compute `bitmapDelta` = newBitmapPosition - bitmap origin (center) * (4) Scale `bitmapDelta` up to "display space", call this `displayedDelta` * (5) Move: reticle -> 1/(1+R) * `displayedDelta` (from origin) * screenshot -> -R/(1+R) * `displayedDelta` (from origin), * where R is the move speed ratio along the respective axis. */ fun syncDisplayPositionsToBitmapPosition(bitmapPosition: Offset, init: Boolean = false) { if (init || bitmapPosition != bitmapPointerPosition) { val newPositions = getUpdatedDisplayedPositions( bitmapPosition = bitmapPosition, canvasCenter = canvasCenter, bitmapSize = bitmapSize, moveSpeedRatioX = moveSpeedRatioX, moveSpeedRatioY = moveSpeedRatioY, scaleFactor = SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR ) displayedPointerPosition = newPositions.first displayedScreenshotCenter = newPositions.second } } // The fullscreen container that receives input and draws the screenshot and reticle Canvas( modifier = modifier .fillMaxSize() .testTag(ScreenshotWithReticleTestTags.CANVAS) .onSizeChanged { size -> if (!initPositionSet) { // Init to center only first time canvasSize = size moveSpeedRatioX = getMoveSpeedRatioX(drawProps, canvasBounds, imageBitmap.width, SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR) moveSpeedRatioY = getMoveSpeedRatioY(drawProps, canvasBounds, imageBitmap.height, SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR) initPositionSet = true } } .pointerInput(Unit) { detectDragGestures( onDragStart = { displayedOffset -> val totalBounds = getReticleTotalBounds(displayedPointerPosition, drawProps) viewModel.updateDragging(totalBounds.contains(displayedOffset)) }, // onDragEnd/onDragCancel set Reticle background to White as default state. onDragEnd = { viewModel.updateDragging(false) viewModel.updateHoveredColor(Color.White.toArgb()) // get colorInt }, onDragCancel = { viewModel.updateDragging(false) viewModel.updateHoveredColor(Color.White.toArgb()) }, // onDrag sets Reticle background color to the central pixel of the magnified view. onDrag = { change: PointerInputChange, displayedDrag: Offset -> if (uiState.isDragging) { change.consume() val newBitmapPosition = bitmapPointerPosition.convertDragToBitmapPosition( displayedDrag, bitmapSize, ) viewModel.updateBitmapPointerPosition(newBitmapPosition) syncDisplayPositionsToBitmapPosition(newBitmapPosition) } } ) } .pointerInput(Unit) { val keyMoveAmountPx = drawProps.keyMoveAmountPx detectTapGestures( onTap = { tapPosition -> // Move 1 pixel on tap val newBitmapPosition = bitmapPointerPosition.convertArrowPressToBitmapPosition( displayedPointerPosition, tapPosition, bitmapSize, drawProps, keyMoveAmountPx, ) viewModel.updateBitmapPointerPosition(newBitmapPosition) syncDisplayPositionsToBitmapPosition(newBitmapPosition) }, onLongPress = { pressPosition -> // Move greater amount on long press (not hold) val newBitmapPosition = bitmapPointerPosition.convertArrowPressToBitmapPosition( displayedPointerPosition, pressPosition, bitmapSize, drawProps, keyMoveAmountPx * LONG_PRESS_MOVE_PX, ) viewModel.updateBitmapPointerPosition(newBitmapPosition) syncDisplayPositionsToBitmapPosition(newBitmapPosition) } ) } ) { // (1) Block drawing until dependents of measure phase are initialized if (canvasSize == IntSize.Zero) return@Canvas // (2) Draw overlay components clipped to viewport. // Need to do this b/c for some reason Drawscope#drawImage draws over parent inset padding clipRect( canvasBounds.left, canvasBounds.top, canvasBounds.right, canvasBounds.bottom ) { drawScreenshot( screenshotBitmap = imageBitmap, center = displayedScreenshotCenter, ) drawTouchscreenReticle( screenshotBitmap = imageBitmap, center = displayedPointerPosition, uiState = uiState, drawProps = drawProps, bitmapPointerPositionPx = bitmapPointerPosition, ) } } // (3) Trigger initialization after Modifier#onSizeChanged, signifying measuring is done LaunchedEffect(canvasSize) { syncDisplayPositionsToBitmapPosition(bitmapPointerPosition, init = true) } } /** * Draw [screenshotBitmap] scaled by [scaleFactor] centered at [center]. */ private fun DrawScope.drawScreenshot( screenshotBitmap: ImageBitmap, center: Offset, scaleFactor: Float = SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR, ) { val scaledWidth = screenshotBitmap.width * scaleFactor val scaledHeight = screenshotBitmap.height * scaleFactor val dstTopLeft = Offset(center.x - scaledWidth / 2, center.y - scaledHeight / 2).round() val dstSize = Size(scaledWidth, scaledHeight).roundToIntSize() drawImage( image = screenshotBitmap, dstOffset = dstTopLeft, dstSize = dstSize, ) } /** * Draw reticle with a [drawProps.magnifiedPatchNumPx] sized square magnification patch. Its origin * is the center of its handle, which is set to track [center]. Its background color reflects the * central hovered over pixel. Additionally, four arrow buttons are placed on the reticle for fine * movement, which disappear on drag. * * All assets are cached and loaded as bitmaps from [ScreenshotWithReticleConfig]. The original * designs in XML are copied from the Accessibility Scanner color picker. Feature parity is on par * for the most part, but it lacks 1) Reticle drop shadow 2) Button drop shadow on click 3) Center * guide changing colors based on contrast levels. * * The magnified view is custom and inspired by the Chrome EyeDropper, chosen for a more granular * and intuitive interface (center square on 13 x 13 patch vs center cross on much larger patch). */ private fun DrawScope.drawTouchscreenReticle( screenshotBitmap: ImageBitmap, center: Offset, uiState: TouchscreenReticleState, drawProps: ReticleProps, bitmapPointerPositionPx: Offset, ) { val handleSize = Size(drawProps.handleWidthPx, drawProps.handleHeightPx).roundToIntSize() val handleLeftX = center.x - drawProps.handleWidthPx / 2 val handleTopY = center.y - drawProps.handleHeightPx / 2 val handleTopLeft = Offset(handleLeftX, handleTopY).round() val reticleBackgroundLeftX = center.x - drawProps.reticleBackgroundWidthPx / 2 val reticleBackgroundBottomY = handleTopY - drawProps.handleMarginTopPx val reticleBackgroundTopY = reticleBackgroundBottomY - drawProps.reticleBackgroundHeightPx val reticleBackgroundTopLeft = Offset(reticleBackgroundLeftX, reticleBackgroundTopY).round() val reticleBackgroundSize = Size( drawProps.reticleBackgroundWidthPx, drawProps.reticleBackgroundHeightPx ).roundToIntSize() val reticleInnerCircleLeftX = reticleBackgroundLeftX + drawProps.reticleRingWidthPx val reticleInnerCircleTopY = reticleBackgroundTopY + drawProps.reticleRingWidthPx val reticleInnerCircleTopLeft = Offset(reticleInnerCircleLeftX, reticleInnerCircleTopY).round() val reticleInnerCircleSize = Size( drawProps.reticleInnerCircleSizePx, drawProps.reticleInnerCircleSizePx ).roundToIntSize() val reticleInnerCircleCenter = Offset(center.x, reticleInnerCircleTopY + drawProps.reticleInnerCircleSizePx / 2) // Calculate the size of one pixel from the source image in the destination magnifier val magnifiedPatchNumPx = drawProps.magnifiedPatchNumPx val magnifiedPixelSize = drawProps.reticleInnerCircleSizePx / magnifiedPatchNumPx val centerPixelTopLeft = Offset( reticleInnerCircleCenter.x - magnifiedPixelSize / 2f, reticleInnerCircleCenter.y - magnifiedPixelSize / 2f ) // 1) Draw background drawImage( image = drawProps.reticleBackgroundBitmap, dstOffset = reticleBackgroundTopLeft, dstSize = reticleBackgroundSize, // Use same color filter as a11y scanner; apply beneath the source colorFilter = PorterDuffColorFilter( uiState.hoveredColor, PorterDuff.Mode.SRC_IN ).asComposeColorFilter() ) // 2) Magnified Image (clipped to inner circle) val innerCirclePath = Path().apply { addOval( Rect( center = reticleInnerCircleCenter, radius = drawProps.reticleInnerCircleSizePx / 2 ), ) } clipPath(innerCirclePath) { val patchRadiusPx = magnifiedPatchNumPx / 2.0f val magnifiedPatchTopLeft = Offset( bitmapPointerPositionPx.x - patchRadiusPx, bitmapPointerPositionPx.y - patchRadiusPx ).round() val magnifiedPatchSize = IntSize(magnifiedPatchNumPx, magnifiedPatchNumPx) // (2a) Apply a base background color that is visible when our magnified patch is not full; // i.e. at edges where reticle will display a half patch drawCircle( color = drawProps.containerColor, radius = drawProps.reticleInnerCircleSizePx / 2f, center = reticleInnerCircleCenter, ) // (2b) Draw square patch drawImage( image = screenshotBitmap, srcOffset = magnifiedPatchTopLeft, srcSize = magnifiedPatchSize, dstOffset = reticleInnerCircleTopLeft, dstSize = reticleInnerCircleSize, filterQuality = FilterQuality.None // remove blur ) } // 3) Inner circle outline drawCircle( color = drawProps.googleGrey400, radius = drawProps.reticleInnerCircleSizePx / 2f - drawProps.reticleInnerOutlineStrokeWidthPx / 2f, center = reticleInnerCircleCenter, style = Stroke(drawProps.reticleInnerOutlineStrokeWidthPx) ) // 4) Center pixel highlight drawRect( // White outline color = Color.White, topLeft = centerPixelTopLeft, size = Size(magnifiedPixelSize, magnifiedPixelSize), style = Stroke(width = 2.0f) ) drawRect( // Thin black outline on top for contrast color = Color.Black, topLeft = centerPixelTopLeft, size = Size(magnifiedPixelSize, magnifiedPixelSize), style = Stroke(width = 1.0f) ) // 6) Outermost reticle outline drawImage( image = drawProps.reticleOutlineBitmap, dstOffset = reticleBackgroundTopLeft, dstSize = reticleBackgroundSize ) // 7) Draggable handle drawImage( image = drawProps.handleBitmap, dstOffset = handleTopLeft, dstSize = handleSize ) // 8) Navigation arrows if (!uiState.isDragging) { val arcRadius = ((drawProps.reticleInnerCircleSizePx + drawProps.reticleRingWidthPx) / 2).roundToInt() drawImage( image = drawProps.arrowUpBitmap, dstOffset = reticleInnerCircleCenter.getArrowPositionAroundCenter( ReticleDirectionButton.UP, drawProps.arrowUpBitmap.width / 2, arcRadius ).round() ) drawImage( image = drawProps.arrowDownBitmap, dstOffset = reticleInnerCircleCenter.getArrowPositionAroundCenter( ReticleDirectionButton.DOWN, drawProps.arrowDownBitmap.width / 2, arcRadius ).round() ) drawImage( image = drawProps.arrowLeftBitmap, dstOffset = reticleInnerCircleCenter.getArrowPositionAroundCenter( ReticleDirectionButton.LEFT, drawProps.arrowLeftBitmap.width / 2, arcRadius ).round() ) drawImage( image = drawProps.arrowRightBitmap, dstOffset = reticleInnerCircleCenter.getArrowPositionAroundCenter( ReticleDirectionButton.RIGHT, drawProps.arrowRightBitmap.width / 2, arcRadius ).round() ) } } /** * Map a given [bitmapPosition] in "bitmap space" to two new coordinates in "display space" * representing where the displayed reticle and displayed screenshot must move to center the * reticle at [bitmapPosition] in display space. */ private fun getUpdatedDisplayedPositions( bitmapPosition: Offset, canvasCenter: Offset, bitmapSize: IntSize, moveSpeedRatioX: Float, moveSpeedRatioY: Float, scaleFactor: Float, ): Pair { val totalBitmapDisplacement = Offset( bitmapPosition.x - bitmapSize.width / 2f, bitmapPosition.y - bitmapSize.height / 2f, ) val totalDisplayDisplacement = totalBitmapDisplacement * scaleFactor // Let Z = totalDisplayDisplacement, then we distribute this through proportioned contributions // between pointerDelta and screenshotDelta. // Let R = moveSpeedRatio -> Z * 1/(1+R) - -(Z * R/(1+R)) = Z // TODO: b/438272016 - Movement speed not computed as intended val pointerDelta = Offset( totalDisplayDisplacement.x * (1 / (1 + moveSpeedRatioX)), totalDisplayDisplacement.y * (1 / (1 + moveSpeedRatioY)), ) val screenshotDelta = Offset( -totalDisplayDisplacement.x * (moveSpeedRatioX / (1 + moveSpeedRatioX)), -totalDisplayDisplacement.y * (moveSpeedRatioY / (1 + moveSpeedRatioY)), ) val newDisplayedPointerPosition = canvasCenter + pointerDelta val newDisplayedScreenshotCenter = canvasCenter + screenshotDelta return Pair(newDisplayedPointerPosition, newDisplayedScreenshotCenter) } /** * Replica of `calculateMoveSpeedRatioHorizontally` from * `j/c/g/android/apps/accessibility/auditor/ui/picker/ScreenshotWithReticleFragment`. */ private fun getMoveSpeedRatioX( drawProps: ReticleProps, canvasBounds: Rect, screenshotWidth: Int, scaleFactor: Float, ): Float { val reticleBounds = getReticleTotalBounds(canvasBounds.center, drawProps) val reticleWidth = reticleBounds.width val canvasWidth = canvasBounds.width val displayedScreenshotWidth = screenshotWidth * scaleFactor val minReticleToScreenBorderPaddingPx = drawProps.reticleToScreenBorderMinPadding // For the below descriptions, "origin" refers to the center of the canvas. // The amount of distance AVAILABLE to us as we drag a finger from the origin to screen edge. // This is simply the radius of the canvas, but we limit its range to compensate for the // width of the reticle itself. If we didn't, a portion of the reticle will escape the screen. val reticleMoveX = (canvasWidth / 2f) - (reticleWidth / 2f) - minReticleToScreenBorderPaddingPx // The amount of distance we NEED to cover from origin by the time our finger reaches screen edge. // This is simply the radius of the displayed screenshot, because we need to pan it fast enough // so that by the time we're at the screen edge, we've traversed the screenshot in its entirety. val screenshotMoveX = displayedScreenshotWidth / 2 // If we have ample physical screen distance to traverse the width of the displayed screenshot, // then we have no need to boost the speed the screenshot gets panned. // Otherwise, then for every physical pixel we have, we need to cover more ground than 1 pixel // so that we can make it across the entire screenshot just in time. if (reticleMoveX > screenshotMoveX) { return 0f } else { return (screenshotMoveX - reticleMoveX) / reticleMoveX } } /** * Replica of `calculateMoveSpeedRatioVertically` from * `j/c/g/android/apps/accessibility/auditor/ui/picker/ScreenshotWithReticleFragment`. * * Comments are short, refer to getMoveSpeedRatioX for more detail. */ private fun getMoveSpeedRatioY( drawProps: ReticleProps, canvasBounds: Rect, screenshotHeight: Int, scaleFactor: Float, ): Float { val canvasTopY = canvasBounds.top val canvasBottomY = canvasBounds.bottom val displayedScreenshotHeight = screenshotHeight * scaleFactor // Help deal with exceeding line length val minReticleToScreenBorderPaddingPx = drawProps.reticleToScreenBorderMinPadding val reticleBackgroundHeightPx = drawProps.reticleBackgroundHeightPx // Analogous to reticleMoveX. val handleTopY = canvasBounds.center.y - drawProps.handleHeightPx / 2f val reticleBackgroundTopY = handleTopY - drawProps.handleMarginTopPx - reticleBackgroundHeightPx val screenshotMoveUpY = displayedScreenshotHeight / 2f val screenshotMoveDownY = displayedScreenshotHeight / 2f // [available space for reticle to move up] = [distance from reticle top edge to top] - [pad] val reticleMoveUpY = (reticleBackgroundTopY - canvasTopY) - minReticleToScreenBorderPaddingPx val moveRatioUpY = if (reticleMoveUpY > screenshotMoveUpY) { 0f } else { (screenshotMoveUpY - reticleMoveUpY) / reticleMoveUpY } // [available space for reticle to move down] = [half height] - [handle radius] - [pad] val reticleMoveDownY = (canvasBottomY - canvasBounds.center.y) -(drawProps.handleWidthPx / 2f) - minReticleToScreenBorderPaddingPx val moveRatioDownY = if (reticleMoveDownY > screenshotMoveDownY) { 0f } else { (screenshotMoveDownY - reticleMoveDownY) / reticleMoveDownY } // Account for asymmetry due to handle not being center of reticle return max(moveRatioUpY, moveRatioDownY) } /** * Takes [this] + [displayedDelta] in display space and returns it as an offset in the bitmap space. */ private fun Offset.convertDragToBitmapPosition( displayedDelta: Offset, bitmapSize: IntSize, scaleFactor: Float = SCREENSHOT_IMAGE_VIEW_SCALE_FACTOR, ): Offset { val newBitmapPosition = this + (displayedDelta / scaleFactor) return Offset( newBitmapPosition.x.coerceIn(0f, (bitmapSize.width - 1).toFloat()), newBitmapPosition.y.coerceIn(0f, (bitmapSize.height - 1).toFloat()), ) } /** * Detects presses at [gesturePointerPosition] and returns [this] offset by [moveAmountPx] in the * correct direction, within the bitmap space. */ private fun Offset.convertArrowPressToBitmapPosition( displayedPointerPosition: Offset, gesturePointerPosition: Offset, bitmapSize: IntSize, drawProps: ReticleProps, moveAmountPx: Int = 1, ): Offset { val innerCircleRadius = drawProps.reticleInnerCircleSizePx / 2f val placementRadius = (innerCircleRadius + ARROW_PADDING_PX).roundToInt() val handleTopY = displayedPointerPosition.y - drawProps.handleHeightPx / 2 val reticleBackgroundBottomY = handleTopY - drawProps.handleMarginTopPx val reticleBackgroundTopY = reticleBackgroundBottomY - drawProps.reticleBackgroundHeightPx val reticleInnerCircleTopY = reticleBackgroundTopY + drawProps.reticleRingWidthPx val reticleInnerCircleCenter = Offset( displayedPointerPosition.x, reticleInnerCircleTopY + drawProps.reticleInnerCircleSizePx / 2 ) val bitmapOffset = when { gesturePointerPosition.isWithinArrow( ReticleDirectionButton.UP, drawProps.arrowUpBitmap, reticleInnerCircleCenter, placementRadius ) -> { Offset(0f, -moveAmountPx.toFloat()) } gesturePointerPosition.isWithinArrow( ReticleDirectionButton.DOWN, drawProps.arrowDownBitmap, reticleInnerCircleCenter, placementRadius ) -> { Offset(0f, moveAmountPx.toFloat()) } gesturePointerPosition.isWithinArrow( ReticleDirectionButton.LEFT, drawProps.arrowLeftBitmap, reticleInnerCircleCenter, placementRadius ) -> { Offset(-moveAmountPx.toFloat(), 0f) } gesturePointerPosition.isWithinArrow( ReticleDirectionButton.RIGHT, drawProps.arrowRightBitmap, reticleInnerCircleCenter, placementRadius ) -> { Offset(moveAmountPx.toFloat(), 0f) } else -> { Offset.Zero } } val newBitmapPosition = this + bitmapOffset return Offset( newBitmapPosition.x.coerceIn(0f, (bitmapSize.width - 1).toFloat()), newBitmapPosition.y.coerceIn(0f, (bitmapSize.height - 1).toFloat()), ) }