Skip to content

Commit ed34f4e

Browse files
committed
Convert to CameraX ViewFinder
1 parent 05658f1 commit ed34f4e

4 files changed

Lines changed: 130 additions & 90 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ dependencies {
8686
implementation(libs.androidx.camera.camera2)
8787
implementation(libs.androidx.camera.lifecycle)
8888
implementation(libs.androidx.camera.view)
89+
implementation(libs.androidx.camera.core)
90+
implementation(libs.androidx.camera.compose)
8991
implementation(libs.androidx.foundation)
9092
implementation(platform(libs.androidx.compose.bom))
9193
implementation(libs.androidx.ui)

app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraPreview.kt

Lines changed: 84 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,126 @@
11
package com.darkrockstudios.app.securecamera.camera
22

3+
import androidx.camera.compose.CameraXViewfinder
34
import androidx.camera.core.CameraSelector
5+
import androidx.camera.core.ImageCapture
46
import androidx.camera.lifecycle.ProcessCameraProvider
5-
import androidx.camera.view.PreviewView
67
import androidx.compose.animation.core.animateFloatAsState
78
import androidx.compose.animation.core.tween
89
import androidx.compose.foundation.Canvas
910
import androidx.compose.foundation.gestures.detectTapGestures
1011
import androidx.compose.foundation.gestures.rememberTransformableState
1112
import androidx.compose.foundation.gestures.transformable
1213
import androidx.compose.foundation.layout.Box
14+
import androidx.compose.foundation.layout.BoxWithConstraints
1315
import androidx.compose.foundation.layout.fillMaxSize
1416
import androidx.compose.runtime.*
1517
import androidx.compose.ui.Modifier
1618
import androidx.compose.ui.graphics.Color
1719
import androidx.compose.ui.input.pointer.pointerInput
18-
import androidx.compose.ui.platform.LocalContext
19-
import androidx.compose.ui.viewinterop.AndroidView
20-
import androidx.lifecycle.compose.LocalLifecycleOwner
2120
import kotlinx.coroutines.delay
2221
import kotlinx.coroutines.launch
2322

24-
/** Create and remember a [CameraState] inside composition. */
25-
@Composable
26-
fun rememberCameraState(initialLensFacing: Int = CameraSelector.LENS_FACING_BACK): CameraState {
27-
val context = LocalContext.current
28-
val lifecycleOwner = LocalLifecycleOwner.current
2923

30-
val provider = remember { ProcessCameraProvider.getInstance(context).get() }
31-
val previewView = remember {
32-
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FIT_CENTER }
33-
}
24+
@Composable
25+
fun rememberCameraState(
26+
initialLensFacing: Int = CameraSelector.LENS_FACING_BACK,
27+
initialFlashMode: Int = ImageCapture.FLASH_MODE_OFF,
28+
): CameraState {
29+
val context = androidx.compose.ui.platform.LocalContext.current
30+
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
3431

35-
val state = remember {
32+
return remember {
33+
val providerFuture = ProcessCameraProvider.getInstance(context).get()
3634
CameraState(
37-
previewView = previewView,
3835
lifecycleOwner = lifecycleOwner,
39-
providerFuture = provider,
40-
initialLensFacing = initialLensFacing
41-
)
42-
}
43-
44-
// (Re)bind camera whenever lens changes.
45-
DisposableEffect(state.lensFacing) {
46-
state.bindCamera()
47-
onDispose { provider.unbindAll() }
48-
}
49-
50-
DisposableEffect(Unit) {
51-
onDispose { state.shutdown() }
36+
providerFuture = providerFuture,
37+
initialLensFacing = initialLensFacing,
38+
initialFlashMode = initialFlashMode
39+
).also { cameraState ->
40+
cameraState.bindCamera()
41+
}
42+
}.also { cameraState ->
43+
DisposableEffect(cameraState) {
44+
onDispose {
45+
cameraState.cleanup()
46+
}
47+
}
5248
}
53-
54-
return state
5549
}
5650

57-
/** Composable that renders the camera preview and UI using the provided [CameraState]. */
5851
@Composable
5952
fun CameraPreview(
6053
state: CameraState,
61-
modifier: Modifier = Modifier.fillMaxSize()
54+
modifier: Modifier = Modifier
6255
) {
6356
val scope = rememberCoroutineScope()
64-
// Pinch‑to‑zoom transformable
65-
val zoomState = rememberTransformableState { zoomChange, _, _ ->
66-
state.camera?.let { cam ->
67-
val current = cam.cameraInfo.zoomState.value?.zoomRatio ?: 1f
68-
val newZoom = (current * zoomChange).coerceIn(state.minZoom, state.maxZoom)
69-
state.setZoomRatio(newZoom)
57+
58+
// Track the display size for focus calculations
59+
BoxWithConstraints(
60+
modifier = modifier
61+
) {
62+
val displaySize = androidx.compose.ui.unit.IntSize(
63+
width = constraints.maxWidth,
64+
height = constraints.maxHeight
65+
)
66+
67+
// Update the camera state with current display size
68+
LaunchedEffect(displaySize) {
69+
state.displaySize = displaySize
7070
}
71-
}
7271

73-
// focus indicator fade animation
74-
val indicatorAlpha by animateFloatAsState(
75-
targetValue = if (state.focusOffset != null) 1f else 0f,
76-
animationSpec = tween(durationMillis = 300)
77-
)
72+
// Pinch‑to‑zoom transformable
73+
val zoomState = rememberTransformableState { zoomChange, _, _ ->
74+
state.camera?.let { cam ->
75+
val current = cam.cameraInfo.zoomState.value?.zoomRatio ?: 1f
76+
val newZoom = (current * zoomChange).coerceIn(state.minZoom, state.maxZoom)
77+
state.setZoomRatio(newZoom)
78+
}
79+
}
7880

79-
Box(
80-
modifier = modifier
81-
.transformable(zoomState)
82-
.pointerInput(Unit) {
83-
detectTapGestures { offset ->
84-
state.focusAt(offset)
81+
// focus indicator fade animation
82+
val indicatorAlpha by animateFloatAsState(
83+
targetValue = if (state.focusOffset != null) 1f else 0f,
84+
animationSpec = tween(durationMillis = 300),
85+
label = "focus_alpha"
86+
)
87+
88+
Box(
89+
modifier = Modifier
90+
.fillMaxSize()
91+
.transformable(zoomState)
92+
.pointerInput(Unit) {
93+
detectTapGestures { offset ->
94+
state.focusAt(offset)
8595

86-
scope.launch {
87-
delay(800)
88-
state.clearFocusOffset()
96+
scope.launch {
97+
delay(800)
98+
state.clearFocusOffset()
99+
}
89100
}
90101
}
102+
) {
103+
state.surfaceRequest?.let { request ->
104+
CameraXViewfinder(
105+
surfaceRequest = request,
106+
modifier = Modifier.fillMaxSize()
107+
)
91108
}
92-
) {
93-
// Camera preview
94-
AndroidView(
95-
factory = { state.previewView },
96-
modifier = Modifier.fillMaxSize()
97-
)
98109

99-
// Draw focus ring
100-
state.focusOffset?.let { pos ->
101-
Canvas(
102-
modifier = Modifier
103-
.fillMaxSize()
104-
.pointerInput(Unit) {} // intercept taps
105-
) {
106-
drawCircle(
107-
color = Color.White.copy(alpha = indicatorAlpha),
108-
radius = 40f,
109-
center = pos
110-
)
110+
// Draw focus ring
111+
state.focusOffset?.let { pos ->
112+
Canvas(
113+
modifier = Modifier
114+
.fillMaxSize()
115+
.pointerInput(Unit) {} // intercept taps
116+
) {
117+
drawCircle(
118+
color = Color.White.copy(alpha = indicatorAlpha),
119+
radius = 40f,
120+
center = pos,
121+
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 4f)
122+
)
123+
}
111124
}
112125
}
113126
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,33 @@ package com.darkrockstudios.app.securecamera.camera
33
import android.annotation.SuppressLint
44
import androidx.camera.core.*
55
import androidx.camera.lifecycle.ProcessCameraProvider
6-
import androidx.camera.view.PreviewView
76
import androidx.compose.runtime.*
87
import androidx.compose.ui.geometry.Offset
8+
import androidx.compose.ui.unit.IntSize
99
import androidx.lifecycle.LifecycleOwner
10+
import kotlinx.coroutines.suspendCancellableCoroutine
11+
import timber.log.Timber
1012
import java.util.concurrent.ExecutorService
1113
import java.util.concurrent.Executors
1214
import java.util.concurrent.TimeUnit
1315
import kotlin.coroutines.resume
14-
import kotlin.coroutines.suspendCoroutine
1516
import kotlin.time.Clock
1617

1718
/**
1819
* Holds the mutable state and low‑level camera plumbing so that the UI composable is lightweight.
1920
*/
2021
@Stable
2122
class CameraState internal constructor(
22-
val previewView: PreviewView,
2323
private val lifecycleOwner: LifecycleOwner,
2424
private val providerFuture: ProcessCameraProvider,
2525
initialLensFacing: Int = CameraSelector.LENS_FACING_BACK,
2626
initialFlashMode: Int = ImageCapture.FLASH_MODE_OFF,
2727
) {
2828
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
2929

30+
var surfaceRequest by mutableStateOf<SurfaceRequest?>(null)
31+
private set
32+
3033
var lensFacing by mutableIntStateOf(initialLensFacing)
3134
private set
3235

@@ -35,12 +38,16 @@ class CameraState internal constructor(
3538

3639
var minZoom by mutableFloatStateOf(1f)
3740
private set
41+
3842
var maxZoom by mutableFloatStateOf(1f)
3943
private set
4044

4145
var focusOffset by mutableStateOf<Offset?>(null)
4246
private set
4347

48+
var displaySize by mutableStateOf<IntSize?>(null)
49+
internal set
50+
4451
fun clearFocusOffset() {
4552
focusOffset = null
4653
}
@@ -62,7 +69,6 @@ class CameraState internal constructor(
6269
imageCapture?.flashMode = value
6370
}
6471

65-
/** Toggle between front and back lenses. */
6672
fun toggleLens() {
6773
switchLens(
6874
if (lensFacing == CameraSelector.LENS_FACING_BACK)
@@ -90,14 +96,21 @@ class CameraState internal constructor(
9096
/** Focus + meter at the given px location from Compose coordinates. */
9197
fun focusAt(offset: Offset) {
9298
camera?.let { cam ->
93-
val factory = previewView.meteringPointFactory
99+
val displaySize = this.displaySize ?: return
100+
101+
// Create metering point factory for the display size
102+
val factory = SurfaceOrientedMeteringPointFactory(
103+
displaySize.width.toFloat(),
104+
displaySize.height.toFloat()
105+
)
106+
94107
val point = factory.createPoint(offset.x, offset.y)
95108
val action = FocusMeteringAction.Builder(
96109
point,
97110
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE or FocusMeteringAction.FLAG_AWB
98111
).setAutoCancelDuration(3, TimeUnit.SECONDS).build()
99-
cam.cameraControl.startFocusAndMetering(action)
100112

113+
cam.cameraControl.startFocusAndMetering(action)
101114
focusOffset = offset
102115
}
103116
}
@@ -107,10 +120,10 @@ class CameraState internal constructor(
107120
* or an exception on failure.
108121
*/
109122
@SuppressLint("MissingPermission")
110-
suspend fun capturePhoto(): Result<CapturedImage> = suspendCoroutine { continuation ->
123+
suspend fun capturePhoto(): Result<CapturedImage> = suspendCancellableCoroutine { continuation ->
111124
val capture = imageCapture ?: run {
112125
continuation.resume(Result.failure(IllegalStateException("ImageCapture not ready")))
113-
return@suspendCoroutine
126+
return@suspendCancellableCoroutine
114127
}
115128

116129
capture.flashMode = flashMode
@@ -143,8 +156,10 @@ class CameraState internal constructor(
143156
internal fun bindCamera() {
144157
val provider = providerFuture
145158

146-
val preview = Preview.Builder().build().also {
147-
it.surfaceProvider = previewView.surfaceProvider
159+
val preview = Preview.Builder().build().also { preview ->
160+
preview.setSurfaceProvider { surfaceRequest ->
161+
this.surfaceRequest = surfaceRequest
162+
}
148163
}
149164

150165
imageCapture = ImageCapture.Builder()
@@ -156,17 +171,25 @@ class CameraState internal constructor(
156171
.build()
157172

158173
provider.unbindAll()
159-
camera = provider.bindToLifecycle(lifecycleOwner, selector, preview, imageCapture)
160174

161-
// Observe zoom bounds
162-
camera?.cameraInfo?.zoomState?.observe(lifecycleOwner) {
163-
minZoom = it.minZoomRatio
164-
maxZoom = it.maxZoomRatio
175+
try {
176+
camera = provider.bindToLifecycle(
177+
lifecycleOwner,
178+
selector,
179+
preview,
180+
imageCapture
181+
)
182+
183+
camera?.cameraInfo?.zoomState?.value?.let { zoomState ->
184+
minZoom = zoomState.minZoomRatio
185+
maxZoom = zoomState.maxZoomRatio
186+
}
187+
} catch (e: Exception) {
188+
Timber.e(e)
165189
}
166190
}
167191

168-
// Call when your app/session finishes with the camera
169-
fun shutdown() {
192+
internal fun cleanup() {
170193
cameraExecutor.shutdown()
171194
}
172-
}
195+
}

gradle/libs.versions.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ compileSdk = "36"
77
minSdk = "29"
88

99
javaVersion = "17"
10-
agp = "8.11.1"
10+
agp = "8.11.2"
1111
workManager = "2.10.5"
1212
argon2kt = "1.6.0"
1313
bcrypt = "0.10.2"
@@ -44,6 +44,8 @@ foundation = "1.9.2"
4444
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
4545
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCamera2" }
4646
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraView" }
47+
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraView" }
48+
androidx-camera-core = { module = "androidx.camera.viewfinder:viewfinder-core", version.ref = "cameraView" }
4749
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCamera2" }
4850
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
4951
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }

0 commit comments

Comments
 (0)