Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
build/
.gradle
UserInterfaceState.xcuserstate
.kotlin
.kotlin
local.properties
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ ScannerWithPermissions(
Scanner(onScanned = { println(it); true }, types = listOf(CodeType.QR))
```

## Camera zoom (Android / iOS)

Use state-based zoom with [rememberCameraZoomState](scanner/src/commonMain/kotlin/CameraZoomState.kt) and pass it to [Scanner](scanner/src/commonMain/kotlin/Scanner.kt) or [ScannerWithPermissions](scanner/src/commonMain/kotlin/Scanner.kt):

```kotlin
val cameraZoomState = rememberCameraZoomState()
ScannerWithPermissions(
onScanned = { println(it); false },
types = listOf(CodeType.QR),
cameraPosition = CameraPosition.BACK,
enableTorch = false,
cameraZoomState = cameraZoomState,
)
val range = cameraZoomState.zoomRange.value // null until the camera is ready
// e.g. Slider when range != null: valueRange = range.minZoomRatio..range.maxZoomRatio
cameraZoomState.setZoomRatio(2f)
```

`zoomRatio` follows the platform camera stack (CameraX zoom ratio on Android; `videoZoomFactor` on iOS). `zoomRange` is null until the session is bound; `setZoomRatio` clamps to the last known range (or only enforces a small positive minimum before the range exists).

Check out the [sample app](./sample-app) included in the repository.

# Code Types
Expand Down
97 changes: 68 additions & 29 deletions sample-app/shared/src/commonMain/kotlin/MainView.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package org.publicvalue.multiplatform.qrcode.sample

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
Expand All @@ -25,11 +28,11 @@ import kotlinx.coroutines.launch
import org.publicvalue.multiplatform.qrcode.CameraPosition
import org.publicvalue.multiplatform.qrcode.CodeType
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
import org.publicvalue.multiplatform.qrcode.rememberCameraUtils
import org.publicvalue.multiplatform.qrcode.rememberCameraZoomState
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import org.publicvalue.multiplatform.qrcode.rememberCameraUtils

@Composable
fun MainView() {
Expand All @@ -40,8 +43,12 @@ fun MainView() {
hostState = snackbarHostState
)
}
) {
Column(modifier = Modifier.padding(it)) {
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Text("Scan QR-Code below")
var scannerVisible by remember { mutableStateOf(false) }
var enableTorch by remember { mutableStateOf(false) }
Expand All @@ -67,41 +74,73 @@ fun MainView() {
checked = enableTorch,
onCheckedChange = {
enableTorch = it
if (!scannerVisible) { // android needs separate handling when capturing video
if (!scannerVisible) {
cameraUtils.setTorchMode(CameraPosition.BACK, it)
}
}
)
}
}
var lastCode by remember { mutableStateOf<String?>(null)}
var snackbarJob by remember { mutableStateOf<Job?>(null)}
var lastSnackbar by remember { mutableStateOf(TimeSource.Monotonic.markNow().minus(1.minutes))}

var lastCode by remember { mutableStateOf<String?>(null) }
var snackbarJob by remember { mutableStateOf<Job?>(null) }
var lastSnackbar by remember { mutableStateOf(TimeSource.Monotonic.markNow().minus(1.minutes)) }

if (scannerVisible) {
val scope = rememberCoroutineScope()
ScannerWithPermissions(
modifier = Modifier.padding(16.dp),
onScanned = {
if (lastCode == it) { return@ScannerWithPermissions false }
if (TimeSource.Monotonic.markNow().minus(lastSnackbar) < 1.seconds) {
return@ScannerWithPermissions false
}
snackbarJob?.cancel()
lastSnackbar = TimeSource.Monotonic.markNow()
snackbarJob = scope.launch {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short)
val cameraZoomState = rememberCameraZoomState()
val zoomRange by cameraZoomState.zoomRange

zoomRange?.let { range ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
) {
Text("Zoom")
Slider(
modifier = Modifier.fillMaxWidth(),
value = cameraZoomState.zoomRatio,
onValueChange = { cameraZoomState.setZoomRatio(it) },
valueRange = range.minZoomRatio..range.maxZoomRatio,
)
}
}

Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
ScannerWithPermissions(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
onScanned = {
if (lastCode == it) {
lastCode = null
return@ScannerWithPermissions false
}
}
lastCode = it
false // continue scanning
},
types = listOf(CodeType.QR),
cameraPosition = CameraPosition.BACK,
enableTorch = enableTorch,
)
if (TimeSource.Monotonic.markNow().minus(lastSnackbar) < 1.seconds) {
return@ScannerWithPermissions false
}
snackbarJob?.cancel()
lastSnackbar = TimeSource.Monotonic.markNow()
snackbarJob = scope.launch {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short)
if (lastCode == it) {
lastCode = null
}
}
lastCode = it
false
},
types = listOf(CodeType.QR),
cameraPosition = CameraPosition.BACK,
enableTorch = enableTorch,
cameraZoomState = cameraZoomState,
)
}
}
}
}
}
}
65 changes: 58 additions & 7 deletions scanner/src/androidMain/kotlin/CameraView.kt
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
package org.publicvalue.multiplatform.qrcode

import android.util.Log
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.ZoomState
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext

@Composable
fun CameraView(
modifier: Modifier = Modifier,
analyzer: BarcodeAnalyzer,
cameraPosition: CameraPosition,
enableTorch: Boolean,
cameraZoomState: CameraZoomState?,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
Expand All @@ -40,19 +49,18 @@ fun CameraView(
val imageAnalysis = remember(context, analyzer) {
ImageAnalysis.Builder()
.build()
.also { it.setAnalyzer( ContextCompat.getMainExecutor(context), analyzer ) }
.also { it.setAnalyzer(ContextCompat.getMainExecutor(context), analyzer) }
}

val camera = remember(cameraProviderFuture, imageAnalysis) {
val camera = remember(cameraProviderFuture, imageAnalysis, selector) {
runCatching {
cameraProviderFuture.get().unbindAll()
val camera1 = cameraProviderFuture.get().bindToLifecycle(
cameraProviderFuture.get().bindToLifecycle(
lifecycleOwner,
selector,
preview,
imageAnalysis
)
camera1
}.onFailure {
Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it)
}
Expand All @@ -62,6 +70,18 @@ fun CameraView(
camera.getOrNull()?.cameraControl?.enableTorch(enableTorch)
}

ObserveCameraZoom(camera, lifecycleOwner, cameraZoomState)

LaunchedEffect(camera.getOrNull(), cameraZoomState) {
val cam = camera.getOrNull() ?: return@LaunchedEffect
val zoomState = cameraZoomState ?: return@LaunchedEffect
snapshotFlow { zoomState.zoomRatio }.collectLatest { ratio ->
withContext(Dispatchers.Main.immediate) {
cam.cameraControl.setZoomRatio(ratio)
}
}
}

DisposableEffect(cameraProviderFuture) {
onDispose {
cameraProviderFuture.get().unbindAll()
Expand All @@ -72,13 +92,44 @@ fun CameraView(
modifier = modifier.fillMaxSize(),
factory = { context ->
val previewView = PreviewView(context)
preview.setSurfaceProvider(previewView.surfaceProvider)
preview.surfaceProvider = previewView.surfaceProvider
previewView
},
)
}

fun CameraPosition.toSelector() = when(this) {
@Composable
private fun ObserveCameraZoom(
camera: Result<Camera>,
lifecycleOwner: LifecycleOwner,
cameraZoomState: CameraZoomState?,
) {
DisposableEffect(camera.getOrNull(), lifecycleOwner, cameraZoomState) {
val cam = camera.getOrNull()
if (cam != null && cameraZoomState != null) {
val observer = Observer<ZoomState> { state ->
cameraZoomState.updateZoomRangeFromPlatform(
CameraZoomRange(
minZoomRatio = state.minZoomRatio,
maxZoomRatio = state.maxZoomRatio,
)
)
}
cam.cameraInfo.zoomState.observe(lifecycleOwner, observer)
onDispose {
cam.cameraInfo.zoomState.removeObserver(observer)
cameraZoomState.updateZoomRangeFromPlatform(null)
}
} else {
if (cam == null && cameraZoomState != null) {
cameraZoomState.updateZoomRangeFromPlatform(null)
}
onDispose { }
}
}
}

fun CameraPosition.toSelector() = when (this) {
CameraPosition.FRONT -> CameraSelector.LENS_FACING_FRONT
CameraPosition.BACK -> CameraSelector.LENS_FACING_BACK
}
}
3 changes: 2 additions & 1 deletion scanner/src/androidMain/kotlin/Scanner.android.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ actual fun Scanner(
types: List<CodeType>,
cameraPosition: CameraPosition,
enableTorch: Boolean,
cameraZoomState: CameraZoomState?,
) {
val analyzer = remember() {
BarcodeAnalyzer(types.toFormat(), onScanned)
}
CameraView(modifier, analyzer, cameraPosition, enableTorch)
CameraView(modifier, analyzer, cameraPosition, enableTorch, cameraZoomState)
}

@OptIn(ExperimentalPermissionsApi::class)
Expand Down
68 changes: 68 additions & 0 deletions scanner/src/commonMain/kotlin/CameraZoomState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.publicvalue.multiplatform.qrcode

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

/**
* Device-reported zoom bounds once the camera is bound. Ratios are defined by the
* underlying platform (CameraX on Android, AVFoundation on iOS); see README for details.
*/
data class CameraZoomRange(
val minZoomRatio: Float,
val maxZoomRatio: Float,
)

/**
* Snapshot-backed zoom state for [Scanner], similar in usage to state-based Compose APIs
* (hold the instance, pass it into [Scanner], read [zoomRange] / [zoomRatio], call [setZoomRatio] from UI).
*
* [zoomRange] is null until the camera session is ready. Until then, [setZoomRatio] accepts any positive
* ratio and applies clamping once the range is known.
*/
@Stable
class CameraZoomState(initialZoomRatio: Float = 1f) {

private val _zoomRatio = mutableFloatStateOf(initialZoomRatio.coerceAtLeast(MIN_ZOOM_BEFORE_RANGE))
private val _zoomRange = mutableStateOf<CameraZoomRange?>(null)

/** Current zoom ratio; always within [zoomRange] when the range is non-null. */
val zoomRatio: Float get() = _zoomRatio.floatValue

/** Null until the camera reports capabilities. */
val zoomRange: State<CameraZoomRange?> get() = _zoomRange

fun setZoomRatio(ratio: Float) {
val clamped = clampRatio(ratio)
_zoomRatio.floatValue = clamped
}

internal fun updateZoomRangeFromPlatform(range: CameraZoomRange?) {
_zoomRange.value = range
if (range != null) {
_zoomRatio.floatValue = clampRatio(_zoomRatio.floatValue)
}
}

private fun clampRatio(ratio: Float): Float {
val range = _zoomRange.value
val safe = ratio.coerceAtLeast(MIN_ZOOM_BEFORE_RANGE)
return if (range != null) {
safe.coerceIn(range.minZoomRatio, range.maxZoomRatio)
} else {
safe
}
}

private companion object {
const val MIN_ZOOM_BEFORE_RANGE = 0.01f
}
}

@Composable
fun rememberCameraZoomState(initialZoomRatio: Float = 1f): CameraZoomState {
return remember { CameraZoomState(initialZoomRatio) }
}
Loading