New feature: Tap-to-focus (#100)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-23 19:11:52 +01:00
parent f06e96d786
commit 185cbc0ebc
2 changed files with 66 additions and 4 deletions

View File

@@ -21,6 +21,7 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
@@ -69,6 +70,7 @@ import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.scaledTo
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@Composable
fun CameraPreview(
@@ -283,6 +285,7 @@ class CameraCaptureController {
var cameraControl: CameraControl? = null
var imageCapture: ImageCapture? = null
private val executor = Executors.newSingleThreadExecutor()
var previewView: PreviewView? = null
fun shutdown() {
executor.shutdown()
@@ -302,6 +305,20 @@ class CameraCaptureController {
}
)
}
fun tapToFocus(tapOffset: Offset) {
val view = previewView ?: return
val control = cameraControl ?: return
val factory = view.meteringPointFactory
val point = factory.createPoint(tapOffset.x, tapOffset.y)
val action = FocusMeteringAction.Builder(point)
.setAutoCancelDuration(5, TimeUnit.SECONDS)
.build()
control.startFocusAndMetering(action)
}
}
sealed interface CameraBindState {

View File

@@ -24,11 +24,13 @@ import androidx.camera.view.PreviewView
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -66,10 +68,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
@@ -156,7 +161,10 @@ fun CameraScreen(
CameraPreview(
onImageAnalyzed = onImageAnalyzed,
captureController = captureController,
onPreviewViewReady = { view -> previewView = view },
onPreviewViewReady = { view ->
previewView = view
captureController.previewView = view
},
cameraPermission = cameraPermission,
onError = { message, throwable -> cameraViewModel.logError(message, throwable) }
)
@@ -192,7 +200,8 @@ fun CameraScreen(
isTorchEnabled = !isTorchEnabled
captureController.cameraControl?.enableTorch(isTorchEnabled) },
thumbnailCoords = thumbnailCoords,
navigation = navigation
navigation = navigation,
captureController
)
}
@@ -207,7 +216,16 @@ private fun CameraScreenScaffold(
onTorchSwitched: () -> Unit,
thumbnailCoords: MutableState<Offset>,
navigation: Navigation,
captureController: CameraCaptureController,
) {
var focusPoint by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(focusPoint) {
if (focusPoint != null) {
delay(1000)
focusPoint = null
}
}
var tapCount by remember { mutableLongStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
val tapThreshold = 500L
@@ -235,9 +253,16 @@ private fun CameraScreenScaffold(
CameraPreviewBox(
cameraPreview,
cameraUiState,
focusPoint,
onCapture,
onTorchSwitched,
modifier.clickable(onClick = onPageCountClick)
modifier.pointerInput(Unit) {
detectTapGestures { offset ->
focusPoint = offset
captureController.tapToFocus(offset)
onPageCountClick()
}
}
)
}
if (cameraUiState.captureState is CaptureState.CapturePreview) {
@@ -251,6 +276,7 @@ private fun CameraScreenScaffold(
private fun CameraPreviewBox(
cameraPreview: @Composable (() -> Unit),
cameraUiState: CameraUiState,
focusPoint: Offset?,
onCapture: () -> Unit,
onTorchSwitched: () -> Unit,
modifier: Modifier,
@@ -266,6 +292,7 @@ private fun CameraPreviewBox(
if (cameraUiState.isDebugMode) {
MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
}
FocusOverlay(focusPoint)
CaptureButton(
onClick = onCapture,
modifier = Modifier
@@ -427,6 +454,23 @@ private fun CameraPreviewWithOverlay(
}
}
@Composable
fun FocusOverlay(focusPoint: Offset?) {
if (focusPoint == null) return
Canvas(modifier = Modifier.fillMaxSize()) {
val size = 80f
drawRect(
color = Color.White,
topLeft = Offset(
focusPoint.x - size / 2,
focusPoint.y - size / 2
),
size = Size(size, size),
style = Stroke(width = 3f)
)
}
}
@Composable
fun MessageBox(inferenceTime: Long) {
Text(
@@ -519,7 +563,8 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
onDebugModeSwitched = {},
onTorchSwitched = {},
thumbnailCoords = thumbnailCoords,
navigation = dummyNavigation()
navigation = dummyNavigation(),
captureController = CameraCaptureController()
)
}
}