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 android.widget.LinearLayout
import androidx.camera.core.CameraControl import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
@@ -69,6 +70,7 @@ import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@Composable @Composable
fun CameraPreview( fun CameraPreview(
@@ -283,6 +285,7 @@ class CameraCaptureController {
var cameraControl: CameraControl? = null var cameraControl: CameraControl? = null
var imageCapture: ImageCapture? = null var imageCapture: ImageCapture? = null
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
var previewView: PreviewView? = null
fun shutdown() { fun shutdown() {
executor.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 { 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.animateFloat
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -66,10 +68,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@@ -156,7 +161,10 @@ fun CameraScreen(
CameraPreview( CameraPreview(
onImageAnalyzed = onImageAnalyzed, onImageAnalyzed = onImageAnalyzed,
captureController = captureController, captureController = captureController,
onPreviewViewReady = { view -> previewView = view }, onPreviewViewReady = { view ->
previewView = view
captureController.previewView = view
},
cameraPermission = cameraPermission, cameraPermission = cameraPermission,
onError = { message, throwable -> cameraViewModel.logError(message, throwable) } onError = { message, throwable -> cameraViewModel.logError(message, throwable) }
) )
@@ -192,7 +200,8 @@ fun CameraScreen(
isTorchEnabled = !isTorchEnabled isTorchEnabled = !isTorchEnabled
captureController.cameraControl?.enableTorch(isTorchEnabled) }, captureController.cameraControl?.enableTorch(isTorchEnabled) },
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
navigation = navigation navigation = navigation,
captureController
) )
} }
@@ -207,7 +216,16 @@ private fun CameraScreenScaffold(
onTorchSwitched: () -> Unit, onTorchSwitched: () -> Unit,
thumbnailCoords: MutableState<Offset>, thumbnailCoords: MutableState<Offset>,
navigation: Navigation, 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 tapCount by remember { mutableLongStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) } var lastTapTime by remember { mutableLongStateOf(0L) }
val tapThreshold = 500L val tapThreshold = 500L
@@ -235,9 +253,16 @@ private fun CameraScreenScaffold(
CameraPreviewBox( CameraPreviewBox(
cameraPreview, cameraPreview,
cameraUiState, cameraUiState,
focusPoint,
onCapture, onCapture,
onTorchSwitched, onTorchSwitched,
modifier.clickable(onClick = onPageCountClick) modifier.pointerInput(Unit) {
detectTapGestures { offset ->
focusPoint = offset
captureController.tapToFocus(offset)
onPageCountClick()
}
}
) )
} }
if (cameraUiState.captureState is CaptureState.CapturePreview) { if (cameraUiState.captureState is CaptureState.CapturePreview) {
@@ -251,6 +276,7 @@ private fun CameraScreenScaffold(
private fun CameraPreviewBox( private fun CameraPreviewBox(
cameraPreview: @Composable (() -> Unit), cameraPreview: @Composable (() -> Unit),
cameraUiState: CameraUiState, cameraUiState: CameraUiState,
focusPoint: Offset?,
onCapture: () -> Unit, onCapture: () -> Unit,
onTorchSwitched: () -> Unit, onTorchSwitched: () -> Unit,
modifier: Modifier, modifier: Modifier,
@@ -266,6 +292,7 @@ private fun CameraPreviewBox(
if (cameraUiState.isDebugMode) { if (cameraUiState.isDebugMode) {
MessageBox(cameraUiState.liveAnalysisState.inferenceTime) MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
} }
FocusOverlay(focusPoint)
CaptureButton( CaptureButton(
onClick = onCapture, onClick = onCapture,
modifier = Modifier 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 @Composable
fun MessageBox(inferenceTime: Long) { fun MessageBox(inferenceTime: Long) {
Text( Text(
@@ -519,7 +563,8 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
onDebugModeSwitched = {}, onDebugModeSwitched = {},
onTorchSwitched = {}, onTorchSwitched = {},
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
navigation = dummyNavigation() navigation = dummyNavigation(),
captureController = CameraCaptureController()
) )
} }
} }