|
// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0 |
|
package com.example.myapplication |
|
|
|
import android.content.Context |
|
import android.hardware.camera2.CameraCaptureSession |
|
import android.hardware.camera2.CaptureRequest |
|
import android.hardware.camera2.CaptureResult |
|
import android.hardware.camera2.TotalCaptureResult |
|
import android.os.Bundle |
|
import androidx.activity.ComponentActivity |
|
import androidx.activity.compose.setContent |
|
import androidx.activity.enableEdgeToEdge |
|
import androidx.camera.camera2.interop.Camera2Interop |
|
import androidx.camera.compose.CameraXViewfinder |
|
import androidx.camera.core.CameraControl |
|
import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA |
|
import androidx.camera.core.FocusMeteringAction |
|
import androidx.camera.core.Preview |
|
import androidx.camera.core.SurfaceOrientedMeteringPointFactory |
|
import androidx.camera.core.SurfaceRequest |
|
import androidx.camera.lifecycle.ProcessCameraProvider |
|
import androidx.camera.lifecycle.awaitInstance |
|
import androidx.camera.viewfinder.compose.MutableCoordinateTransformer |
|
import androidx.compose.animation.AnimatedVisibility |
|
import androidx.compose.animation.ExperimentalSharedTransitionApi |
|
import androidx.compose.animation.animateBounds |
|
import androidx.compose.animation.animateColorAsState |
|
import androidx.compose.animation.fadeIn |
|
import androidx.compose.animation.fadeOut |
|
import androidx.compose.animation.slideInVertically |
|
import androidx.compose.animation.slideOutVertically |
|
import androidx.compose.foundation.Canvas |
|
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.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.Spacer |
|
import androidx.compose.foundation.layout.fillMaxSize |
|
import androidx.compose.foundation.layout.height |
|
import androidx.compose.foundation.layout.offset |
|
import androidx.compose.foundation.layout.padding |
|
import androidx.compose.foundation.layout.safeDrawingPadding |
|
import androidx.compose.foundation.layout.size |
|
import androidx.compose.foundation.layout.widthIn |
|
import androidx.compose.foundation.layout.wrapContentSize |
|
import androidx.compose.foundation.shape.CircleShape |
|
import androidx.compose.foundation.shape.RoundedCornerShape |
|
import androidx.compose.material3.Button |
|
import androidx.compose.material3.Text |
|
import androidx.compose.material3.adaptive.allHorizontalHingeBounds |
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo |
|
import androidx.compose.runtime.Composable |
|
import androidx.compose.runtime.CompositionLocalProvider |
|
import androidx.compose.runtime.LaunchedEffect |
|
import androidx.compose.runtime.compositionLocalOf |
|
import androidx.compose.runtime.derivedStateOf |
|
import androidx.compose.runtime.getValue |
|
import androidx.compose.runtime.mutableIntStateOf |
|
import androidx.compose.runtime.mutableStateOf |
|
import androidx.compose.runtime.produceState |
|
import androidx.compose.runtime.remember |
|
import androidx.compose.runtime.rememberUpdatedState |
|
import androidx.compose.runtime.saveable.rememberSaveable |
|
import androidx.compose.runtime.setValue |
|
import androidx.compose.ui.Alignment |
|
import androidx.compose.ui.Modifier |
|
import androidx.compose.ui.draw.clip |
|
import androidx.compose.ui.geometry.Offset |
|
import androidx.compose.ui.geometry.Rect |
|
import androidx.compose.ui.geometry.isSpecified |
|
import androidx.compose.ui.geometry.takeOrElse |
|
import androidx.compose.ui.graphics.BlendMode |
|
import androidx.compose.ui.graphics.Brush |
|
import androidx.compose.ui.graphics.Color |
|
import androidx.compose.ui.graphics.Matrix |
|
import androidx.compose.ui.graphics.setFrom |
|
import androidx.compose.ui.graphics.toComposeRect |
|
import androidx.compose.ui.input.pointer.pointerInput |
|
import androidx.compose.ui.layout.HorizontalRuler |
|
import androidx.compose.ui.layout.LayoutCoordinates |
|
import androidx.compose.ui.layout.LookaheadScope |
|
import androidx.compose.ui.layout.layout |
|
import androidx.compose.ui.platform.LocalContext |
|
import androidx.compose.ui.text.style.TextAlign |
|
import androidx.compose.ui.unit.Constraints |
|
import androidx.compose.ui.unit.constrain |
|
import androidx.compose.ui.unit.dp |
|
import androidx.compose.ui.unit.round |
|
import androidx.lifecycle.LifecycleOwner |
|
import androidx.lifecycle.ViewModel |
|
import androidx.lifecycle.compose.LocalLifecycleOwner |
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
import com.example.myapplication.ui.theme.MyApplicationTheme |
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi |
|
import com.google.accompanist.permissions.isGranted |
|
import com.google.accompanist.permissions.rememberPermissionState |
|
import com.google.accompanist.permissions.shouldShowRationale |
|
import kotlinx.coroutines.Runnable |
|
import kotlinx.coroutines.awaitCancellation |
|
import kotlinx.coroutines.delay |
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
import kotlinx.coroutines.flow.StateFlow |
|
import kotlinx.coroutines.flow.asStateFlow |
|
import kotlinx.coroutines.flow.update |
|
import java.util.UUID |
|
import kotlin.math.roundToInt |
|
|
|
val HorizontalHingeTopRuler = HorizontalRuler() |
|
val HorizontalHingeBottomRuler = HorizontalRuler() |
|
|
|
val LocalLookaheadScope = compositionLocalOf<LookaheadScope?> { null } |
|
|
|
class MainActivity : ComponentActivity() { |
|
override fun onCreate(savedInstanceState: Bundle?) { |
|
super.onCreate(savedInstanceState) |
|
enableEdgeToEdge() |
|
setContent { |
|
MyApplicationTheme { |
|
val viewModel = remember { CameraPreviewViewModel() } |
|
val horizontalHinge = currentWindowAdaptiveInfo().windowPosture |
|
.allHorizontalHingeBounds.firstOrNull() |
|
LookaheadScope { |
|
CompositionLocalProvider(LocalLookaheadScope provides this) { |
|
Box( |
|
Modifier.layout { measurable, constraints -> |
|
val placeable = measurable.measure(constraints) |
|
layout( |
|
width = constraints.maxWidth, |
|
height = constraints.maxHeight, |
|
rulers = { |
|
if (horizontalHinge != null) { |
|
val bounds = coordinates.windowToLocal(horizontalHinge) |
|
HorizontalHingeTopRuler provides bounds.top |
|
HorizontalHingeBottomRuler provides bounds.bottom |
|
} |
|
} |
|
) { placeable.place(0, 0) } |
|
} |
|
) { |
|
CameraPreviewScreen(viewModel) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
private fun LayoutCoordinates.windowToLocal(rect: Rect): Rect = |
|
Rect( |
|
topLeft = windowToLocal(rect.topLeft), |
|
bottomRight = windowToLocal(rect.bottomRight), |
|
) |
|
|
|
|
|
@OptIn(ExperimentalPermissionsApi::class) |
|
@Composable |
|
fun CameraPreviewScreen( |
|
viewModel: CameraPreviewViewModel, |
|
modifier: Modifier = Modifier |
|
) { |
|
val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) |
|
if (cameraPermissionState.status.isGranted) { |
|
CameraPreviewContent(viewModel, modifier) |
|
} else { |
|
Column( |
|
modifier = modifier |
|
.fillMaxSize() |
|
.wrapContentSize() |
|
.widthIn(max = 480.dp), |
|
horizontalAlignment = Alignment.CenterHorizontally |
|
) { |
|
val textToShow = if (cameraPermissionState.status.shouldShowRationale) { |
|
// If the user has denied the permission but the rationale can be shown, |
|
// then gently explain why the app requires this permission |
|
"Whoops! Looks like we need your camera to work our magic!" + |
|
"Don't worry, we just wanna see your pretty face (and maybe some cats). " + |
|
"Grant us permission and let's get this party started!" |
|
} else { |
|
// If it's the first time the user lands on this feature, or the user |
|
// doesn't want to be asked again for this permission, explain that the |
|
// permission is required |
|
"Hi there! We need your camera to work our magic! ✨\n" + |
|
"Grant us permission and let's get this party started! \uD83C\uDF89" |
|
} |
|
Text(textToShow, textAlign = TextAlign.Center) |
|
Spacer(Modifier.height(16.dp)) |
|
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { |
|
Text("Unleash the Camera!") |
|
} |
|
} |
|
} |
|
} |
|
|
|
@OptIn(ExperimentalSharedTransitionApi::class) |
|
@Composable |
|
fun CameraPreviewContent( |
|
viewModel: CameraPreviewViewModel, |
|
modifier: Modifier = Modifier, |
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current |
|
) { |
|
val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle() |
|
val sensorFaceRects by viewModel.sensorFaceRects.collectAsStateWithLifecycle() |
|
val context = LocalContext.current |
|
LaunchedEffect(lifecycleOwner) { |
|
viewModel.bindToCamera(context.applicationContext, lifecycleOwner) |
|
} |
|
val shouldHighlightFaces by remember { |
|
derivedStateOf { sensorFaceRects.isNotEmpty() } |
|
} |
|
|
|
val colors = listOf(Color(0xFF09D8E6), Color(0xFFE6C709), Color(0xFFE60991)) |
|
var pickedColorIndex by rememberSaveable { mutableIntStateOf(0) } |
|
val onColorIndexChanged = { index: Int -> pickedColorIndex = index } |
|
val spotlightColor by animateColorAsState(colors[pickedColorIndex]) |
|
|
|
val windowPosture = currentWindowAdaptiveInfo().windowPosture |
|
val isTabletop: Boolean = windowPosture.isTabletop |
|
|
|
val lookaheadScope = LocalLookaheadScope.current |
|
?: throw IllegalStateException("No LookaheadScope found") |
|
|
|
Box(modifier.safeDrawingPadding()) { |
|
ViewfinderContent( |
|
surfaceRequest, |
|
{ sensorFaceRects }, |
|
shouldHighlightFaces, |
|
viewModel::tapToFocus, |
|
Modifier |
|
.fillMaxSize() |
|
.then(if(isTabletop) Modifier.alignAboveHinge() else Modifier) |
|
.animateBounds(lookaheadScope) |
|
.padding(16.dp) |
|
.clip(RoundedCornerShape(24.dp)) |
|
.border(8.dp, spotlightColor, RoundedCornerShape(24.dp)) |
|
) |
|
|
|
AnimatedVisibility( |
|
isTabletop, |
|
enter = fadeIn() + slideInVertically { it / 2 }, |
|
exit = fadeOut() + slideOutVertically { it / 2 }, |
|
modifier = Modifier.alignBelowHinge() |
|
) { |
|
MyControlPanel( |
|
colors = colors, |
|
pickedColorIndex = pickedColorIndex, |
|
onColorPicked = onColorIndexChanged, |
|
) |
|
} |
|
} |
|
} |
|
|
|
// Place the composable above the horizontal hinge, if a hinge is present. |
|
// Ruler values are only available during the placement phase, so this modifier |
|
// *measures* with max constraints, and then *places* the content above the hinge. |
|
private fun Modifier.alignAboveHinge(): Modifier = this then |
|
Modifier.layout { measurable, constraints -> |
|
layout(constraints.maxWidth, constraints.maxHeight) { |
|
// Get current hinge top, or NaN if not available |
|
val hingeTop = HorizontalHingeTopRuler.current(defaultValue = Float.NaN) |
|
|
|
// Constrain the height of the composable to the hinge top (if available) |
|
val childConstraints = if (hingeTop.isNaN()) constraints else |
|
Constraints(maxHeight = hingeTop.roundToInt()).constrain(constraints) |
|
|
|
// Place the composable above the hinge |
|
val placeable = measurable.measure(childConstraints) |
|
placeable.place(0, 0) |
|
} |
|
} |
|
|
|
// Place the composable below the horizontal hinge, if a hinge is present. |
|
// Ruler values are only available during the placement phase, so this modifier |
|
// *measures* with max constraints, and then *places* the content below the hinge. |
|
private fun Modifier.alignBelowHinge(): Modifier = this then |
|
Modifier.layout { measurable, constraints -> |
|
layout(constraints.maxWidth, constraints.maxHeight) { |
|
// Get current hinge bottom, or default to 0 if not available |
|
val hingeBottom = HorizontalHingeBottomRuler.current(defaultValue = 0f).roundToInt() |
|
|
|
// Constrain the height of the composable to the hinge bottom (if available) |
|
val childConstraints = Constraints(maxHeight = constraints.maxHeight - hingeBottom) |
|
.constrain(constraints) |
|
|
|
// Place the composable below the hinge |
|
val placeable = measurable.measure(childConstraints) |
|
placeable.place(0, hingeBottom) |
|
} |
|
} |
|
|
|
|
|
@Composable |
|
fun ViewfinderContent( |
|
surfaceRequest: SurfaceRequest?, |
|
sensorFaceRects: () -> List<Rect>, |
|
shouldHighlightFaces: Boolean, |
|
onTapToFocus: (Offset) -> Unit, |
|
modifier: Modifier = Modifier |
|
) { |
|
val currentOnTapToFocus by rememberUpdatedState(onTapToFocus) |
|
val transformationInfo by |
|
produceState<SurfaceRequest.TransformationInfo?>(null, surfaceRequest) { |
|
try { |
|
surfaceRequest?.setTransformationInfoListener(Runnable::run) { transformationInfo -> |
|
value = transformationInfo |
|
} |
|
awaitCancellation() |
|
} finally { |
|
surfaceRequest?.clearTransformationInfoListener() |
|
} |
|
} |
|
|
|
var autofocusRequest by remember { mutableStateOf(UUID.randomUUID() to Offset.Unspecified) } |
|
|
|
val autofocusRequestId = autofocusRequest.first |
|
// Show the autofocus indicator if the offset is specified |
|
val showAutofocusIndicator = autofocusRequest.second.isSpecified |
|
// Cache the initial coords for each autofocus request |
|
val autofocusCoords = remember(autofocusRequestId) { autofocusRequest.second } |
|
|
|
// Queue hiding the request for each unique autofocus tap |
|
if (showAutofocusIndicator) { |
|
LaunchedEffect(autofocusRequestId) { |
|
delay(1000) |
|
// Clear the offset to finish the request and hide the indicator |
|
autofocusRequest = autofocusRequestId to Offset.Unspecified |
|
} |
|
} |
|
|
|
surfaceRequest?.let { request -> |
|
Box(modifier) { |
|
val coordinateTransformer = remember { MutableCoordinateTransformer() } |
|
CameraXViewfinder( |
|
surfaceRequest = request, |
|
coordinateTransformer = coordinateTransformer, |
|
modifier = Modifier.pointerInput(coordinateTransformer) { |
|
detectTapGestures { tapCoords -> |
|
with(coordinateTransformer) { |
|
currentOnTapToFocus(tapCoords.transform()) |
|
} |
|
autofocusRequest = UUID.randomUUID() to tapCoords |
|
} |
|
} |
|
) |
|
|
|
AnimatedVisibility( |
|
visible = showAutofocusIndicator, |
|
enter = fadeIn(), |
|
exit = fadeOut(), |
|
modifier = Modifier |
|
.offset { autofocusCoords.takeOrElse { Offset.Zero }.round() } |
|
.offset((-24).dp, (-24).dp) |
|
) { |
|
Spacer( |
|
Modifier |
|
.border(2.dp, Color.White, CircleShape) |
|
.size(48.dp) |
|
) |
|
} |
|
|
|
AnimatedVisibility( |
|
visible = transformationInfo != null && shouldHighlightFaces, |
|
enter = fadeIn(), exit = fadeOut() |
|
) { |
|
Canvas(Modifier.fillMaxSize()) { |
|
val uiFaceRects = sensorFaceRects().transformToUiCoords( |
|
transformationInfo = transformationInfo, |
|
uiToBufferCoordinateTransformer = coordinateTransformer |
|
) |
|
|
|
// Fill the whole space with the color |
|
drawRect(Color(0xDDE60991)) |
|
// Then extract each face and make it transparent |
|
|
|
uiFaceRects.forEach { faceRect -> |
|
drawRect( |
|
Brush.radialGradient( |
|
0.4f to Color.Black, 1f to Color.Transparent, |
|
center = faceRect.center, |
|
radius = faceRect.minDimension * 2f, |
|
), |
|
blendMode = BlendMode.DstOut |
|
) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
@Composable |
|
fun MyControlPanel( |
|
modifier: Modifier = Modifier, |
|
colors: List<Color> = listOf(Color(0xFF09D8E6), Color(0xFFE6C709), Color(0xFFE60991)), |
|
pickedColorIndex: Int = 0, |
|
onColorPicked: (colorIndex: Int) -> Unit = { } |
|
) { |
|
Row(modifier.fillMaxSize(), Arrangement.SpaceAround, Alignment.CenterVertically) { |
|
colors.forEachIndexed { colorIndex, color -> |
|
Spacer( |
|
Modifier |
|
.size(72.dp) |
|
.clip(CircleShape) |
|
.clickable { onColorPicked(colorIndex) } |
|
.background(color) |
|
.then( |
|
if (colorIndex == pickedColorIndex) |
|
Modifier.border(2.dp, Color.Black, CircleShape) |
|
else Modifier |
|
)) |
|
} |
|
} |
|
} |
|
|
|
private fun List<Rect>.transformToUiCoords( |
|
transformationInfo: SurfaceRequest.TransformationInfo?, |
|
uiToBufferCoordinateTransformer: MutableCoordinateTransformer |
|
): List<Rect> = this.map { sensorRect -> |
|
val bufferToUiTransformMatrix = Matrix().apply { |
|
setFrom(uiToBufferCoordinateTransformer.transformMatrix) |
|
invert() |
|
} |
|
|
|
val sensorToBufferTransformMatrix = Matrix().apply { |
|
transformationInfo?.let { |
|
setFrom(it.sensorToBufferTransform) |
|
} |
|
} |
|
|
|
val bufferRect = sensorToBufferTransformMatrix.map(sensorRect) |
|
val uiRect = bufferToUiTransformMatrix.map(bufferRect) |
|
|
|
uiRect |
|
} |
|
|
|
class CameraPreviewViewModel : ViewModel() { |
|
// used to set up a link between the Camera and your UI. |
|
private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null) |
|
val surfaceRequest: StateFlow<SurfaceRequest?> = _surfaceRequest |
|
private var surfaceMeteringPointFactory: SurfaceOrientedMeteringPointFactory? = null |
|
private var cameraControl: CameraControl? = null |
|
private val _sensorFaceRects = MutableStateFlow(listOf<Rect>()) |
|
val sensorFaceRects: StateFlow<List<Rect>> = _sensorFaceRects.asStateFlow() |
|
|
|
private val cameraPreviewUseCase = Preview.Builder() |
|
.apply { |
|
Camera2Interop.Extender(this) |
|
.setCaptureRequestOption( |
|
CaptureRequest.STATISTICS_FACE_DETECT_MODE, |
|
CaptureRequest.STATISTICS_FACE_DETECT_MODE_FULL |
|
) |
|
.setSessionCaptureCallback(object : CameraCaptureSession.CaptureCallback() { |
|
override fun onCaptureCompleted( |
|
session: CameraCaptureSession, |
|
request: CaptureRequest, |
|
result: TotalCaptureResult |
|
) { |
|
super.onCaptureCompleted(session, request, result) |
|
result.get(CaptureResult.STATISTICS_FACES) |
|
?.map { face -> face.bounds.toComposeRect() } |
|
?.toList() |
|
?.let { faces -> _sensorFaceRects.update { faces } } |
|
} |
|
}) |
|
} |
|
.build().apply { |
|
setSurfaceProvider { newSurfaceRequest -> |
|
_surfaceRequest.update { newSurfaceRequest } |
|
surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory( |
|
newSurfaceRequest.resolution.width.toFloat(), |
|
newSurfaceRequest.resolution.height.toFloat() |
|
) |
|
} |
|
} |
|
|
|
suspend fun bindToCamera(appContext: Context, lifecycleOwner: LifecycleOwner) { |
|
val processCameraProvider = ProcessCameraProvider.awaitInstance(appContext) |
|
val camera = processCameraProvider.bindToLifecycle( |
|
lifecycleOwner, DEFAULT_BACK_CAMERA, cameraPreviewUseCase |
|
) |
|
cameraControl = camera.cameraControl |
|
|
|
// Cancellation signals we're done with the camera |
|
try { |
|
awaitCancellation() |
|
} finally { |
|
processCameraProvider.unbindAll() |
|
cameraControl = null |
|
} |
|
} |
|
|
|
fun tapToFocus(tapCoords: Offset) { |
|
val point = surfaceMeteringPointFactory?.createPoint(tapCoords.x, tapCoords.y) |
|
if (point != null) { |
|
val meteringAction = FocusMeteringAction.Builder(point).build() |
|
cameraControl?.startFocusAndMetering(meteringAction) |
|
} |
|
} |
|
} |