From b94ae4178e05e04e18e861858c58eef0496e5886 Mon Sep 17 00:00:00 2001 From: Vladimir Mazunin <124871594+mazunin-v-jb@users.noreply.github.com> Date: Wed, 15 Mar 2023 09:51:30 +0400 Subject: [PATCH] Image Viewer Camera (#2864) --- .../examples/imageviewer/gradle.properties | 2 +- .../imageviewer/iosApp/iosApp/Info.plist | 4 + .../examples/imageviewer/settings.gradle.kts | 1 + .../imageviewer/view/CameraView.android.kt | 21 +++ .../example/imageviewer/ImageViewer.common.kt | 7 +- .../example/imageviewer/view/CameraScreen.kt | 32 ++-- .../imageviewer/view/CameraView.common.kt | 7 + .../imageviewer/view/CircularButton.kt | 8 +- .../example/imageviewer/view/GalleryScreen.kt | 38 +--- .../example/imageviewer/view/MemoryScreen.kt | 2 +- .../example/imageviewer/view/TopLayout.kt | 2 +- .../imageviewer/view/CameraView.desktop.kt | 21 +++ .../imageviewer/view/CameraView.ios.kt | 176 ++++++++++++++++++ 13 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt create mode 100644 experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt create mode 100644 experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt create mode 100644 experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt diff --git a/experimental/examples/imageviewer/gradle.properties b/experimental/examples/imageviewer/gradle.properties index a8fffb3cf8..527b0762d5 100644 --- a/experimental/examples/imageviewer/gradle.properties +++ b/experimental/examples/imageviewer/gradle.properties @@ -13,5 +13,5 @@ kotlin.native.useEmbeddableCompilerJar=true kotlin.native.binary.memoryModel=experimental kotlin.version=1.8.0 agp.version=7.1.3 -compose.version=1.4.0-alpha01-dev972 +compose.version=1.4.0-alpha01-dev975 ktor.version=2.2.1 diff --git a/experimental/examples/imageviewer/iosApp/iosApp/Info.plist b/experimental/examples/imageviewer/iosApp/iosApp/Info.plist index 9a269f5eaa..5b28bf5219 100644 --- a/experimental/examples/imageviewer/iosApp/iosApp/Info.plist +++ b/experimental/examples/imageviewer/iosApp/iosApp/Info.plist @@ -2,6 +2,8 @@ + NSLocationWhenInUseUsageDescription + This app uses location data to show taken photos on a map CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -20,6 +22,8 @@ 1 LSRequiresIPhoneOS + NSCameraUsageDescription + This app uses camera for capturing photos UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/experimental/examples/imageviewer/settings.gradle.kts b/experimental/examples/imageviewer/settings.gradle.kts index a2c5064a25..735eb89802 100644 --- a/experimental/examples/imageviewer/settings.gradle.kts +++ b/experimental/examples/imageviewer/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { gradlePluginPortal() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") google() + mavenLocal() } plugins { diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt new file mode 100644 index 0000000000..0909f1d68b --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt @@ -0,0 +1,21 @@ +package example.imageviewer.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun CameraView(modifier: Modifier) { + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + text = "Camera is not available on Android for now.", + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt index e6b61d16dd..d6af7958c8 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/ImageViewer.common.kt @@ -102,9 +102,10 @@ internal fun ImageViewerCommon( } is CameraPage -> { - CameraScreen(onBack = { - navigationStack.back() - }) + CameraScreen( + localization = dependencies.localization, + onBack = { navigationStack.back() } + ) } } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt index 5d496d6266..afbd19eee8 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraScreen.kt @@ -1,19 +1,27 @@ package example.imageviewer.view -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign +import example.imageviewer.Localization +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +@OptIn(ExperimentalResourceApi::class) @Composable -internal fun CameraScreen(onBack: () -> Unit) { - Box(Modifier.fillMaxSize().background(Color.Black).clickable { onBack() }, contentAlignment = Alignment.Center) { - Text("Nothing here yet 📸", textAlign = TextAlign.Center, color = Color.White) +internal fun CameraScreen(localization: Localization, onBack: () -> Unit) { + Box(Modifier.fillMaxSize()) { + CameraView(Modifier.fillMaxSize()) + TopLayout( + alignLeftContent = { + Tooltip(localization.back) { + CircularButton( + painterResource("arrowleft.png"), + onClick = { onBack() } + ) + } + }, + alignRightContent = {}, + ) } -} \ No newline at end of file +} diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt new file mode 100644 index 0000000000..1d5b5a65bb --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CameraView.common.kt @@ -0,0 +1,7 @@ +package example.imageviewer.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal expect fun CameraView(modifier: Modifier) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt index 890a905ba5..6d9b60d6ef 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/CircularButton.kt @@ -15,9 +15,13 @@ import androidx.compose.ui.unit.dp import example.imageviewer.style.ImageviewerColors @Composable -internal fun CircularButton(image: Painter, onClick: () -> Unit) { +internal fun CircularButton( + image: Painter, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { Box( - Modifier.size(40.dp).clip(CircleShape).background(ImageviewerColors.uiLightBlack) + modifier.size(50.dp).clip(CircleShape).background(ImageviewerColors.uiLightBlack) .clickable { onClick() }, contentAlignment = Alignment.Center ) { Image( diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt index d8f5145db3..7229636fbc 100755 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/GalleryScreen.kt @@ -11,10 +11,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -89,7 +86,11 @@ internal fun GalleryScreen( onFullScreen = { onClickPreviewPicture(it) } ) } - MakeNewMemoryMiniature(onMakeNewMemory) + CircularButton( + image = painterResource("plus.png"), + modifier = Modifier.align(Alignment.BottomCenter).padding(48.dp), + onClick = onMakeNewMemory, + ) } } if (pictures.isEmpty()) { @@ -123,33 +124,6 @@ private fun SquaresGalleryView( } } -@OptIn(ExperimentalResourceApi::class) -@Composable -private fun BoxScope.MakeNewMemoryMiniature(onClick: () -> Unit) { - Column(modifier = Modifier.align(Alignment.BottomCenter)) { - Box( - Modifier - .clip(CircleShape) - .width(52.dp) - .background(ImageviewerColors.uiLightBlack) - .aspectRatio(1.0f) - .clickable { - onClick() - }, - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource("plus.png"), - contentDescription = null, - modifier = Modifier - .width(18.dp) - .height(18.dp), - ) - } - Spacer(Modifier.height(32.dp)) - } -} - @OptIn(ExperimentalResourceApi::class) @Composable internal fun SquareMiniature(image: ImageBitmap, isHighlighted: Boolean, onClick: () -> Unit) { diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt index febce28e77..d279345b82 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/MemoryScreen.kt @@ -155,7 +155,7 @@ internal fun BoxScope.MagicButtonOverlay(onClick: () -> Unit) { Column( modifier = Modifier.align(Alignment.BottomEnd).padding(end = 12.dp, bottom = 16.dp) ) { - CircularButton(painterResource("magic.png"), onClick) + CircularButton(painterResource("magic.png"), onClick = onClick) } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/TopLayout.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/TopLayout.kt index b1d3af686d..27ae0d03c3 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/TopLayout.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/TopLayout.kt @@ -19,7 +19,7 @@ internal fun TopLayout( Modifier .fillMaxWidth() .notchPadding() - .padding(horizontal = 12.dp) + .padding(12.dp) ) { Row(Modifier.align(Alignment.CenterStart)) { alignLeftContent() diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt new file mode 100644 index 0000000000..0051dd3be6 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/CameraView.desktop.kt @@ -0,0 +1,21 @@ +package example.imageviewer.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +internal actual fun CameraView(modifier: Modifier) { + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + text = "Camera is not available on Desktop for now.", + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } +} diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt new file mode 100644 index 0000000000..f614c98c83 --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/CameraView.ios.kt @@ -0,0 +1,176 @@ +package example.imageviewer.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.interop.UIKitInteropView +import androidx.compose.ui.unit.dp +import kotlinx.cinterop.CValue +import platform.AVFoundation.* +import platform.AVFoundation.AVCaptureDeviceDiscoverySession.Companion.discoverySessionWithDeviceTypes +import platform.AVFoundation.AVCaptureDeviceInput.Companion.deviceInputWithDevice +import platform.CoreGraphics.CGRect +import platform.Foundation.NSError +import platform.QuartzCore.CATransaction +import platform.QuartzCore.kCATransactionDisableActions +import platform.UIKit.UIDevice +import platform.UIKit.UIDeviceOrientation +import platform.UIKit.UIImage +import platform.UIKit.UIView +import platform.darwin.NSObject + +private sealed interface CameraAccess { + object Undefined : CameraAccess + object Denied : CameraAccess + object Authorized : CameraAccess +} + +@Composable +internal actual fun CameraView(modifier: Modifier) { + var cameraAccess: CameraAccess by remember { mutableStateOf(CameraAccess.Undefined) } + LaunchedEffect(Unit) { + when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) { + AVAuthorizationStatusAuthorized -> { + cameraAccess = CameraAccess.Authorized + } + + AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> { + cameraAccess = CameraAccess.Denied + } + + AVAuthorizationStatusNotDetermined -> { + AVCaptureDevice.requestAccessForMediaType( + mediaType = AVMediaTypeVideo + ) { success -> + cameraAccess = if (success) CameraAccess.Authorized else CameraAccess.Denied + } + } + } + } + Box( + Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center + ) { + when (cameraAccess) { + CameraAccess.Undefined -> { + // Waiting for the user to accept permission + } + + CameraAccess.Denied -> { + Text("Camera access denied", color = Color.White) + } + + CameraAccess.Authorized -> { + AuthorizedCamera() + } + } + } +} + +@Composable +private fun BoxScope.AuthorizedCamera() { + val capturePhotoOutput = remember { AVCapturePhotoOutput() } + val photoCaptureDelegate = remember { + object : NSObject(), AVCapturePhotoCaptureDelegateProtocol { + override fun captureOutput( + output: AVCapturePhotoOutput, + didFinishProcessingPhoto: AVCapturePhoto, + error: NSError? + ) { + val photoData = didFinishProcessingPhoto.fileDataRepresentation() + ?: error("fileDataRepresentation is null") + val uiImage = UIImage(photoData) + //todo pass image to gallery page + } + } + } + val camera: AVCaptureDevice? = remember { + discoverySessionWithDeviceTypes( + deviceTypes = listOf(AVCaptureDeviceTypeBuiltInWideAngleCamera), + mediaType = AVMediaTypeVideo, + position = AVCaptureDevicePositionFront, + ).devices.firstOrNull() as? AVCaptureDevice + } + if (camera != null) { + val captureSession: AVCaptureSession = remember { + AVCaptureSession().also { captureSession -> + captureSession.sessionPreset = AVCaptureSessionPresetPhoto + val captureDeviceInput: AVCaptureDeviceInput = + deviceInputWithDevice(device = camera, error = null)!! + captureSession.addInput(captureDeviceInput) + captureSession.addOutput(capturePhotoOutput) + } + } + val cameraPreviewLayer = remember { + AVCaptureVideoPreviewLayer(session = captureSession) + } + UIKitInteropView( + modifier = Modifier.fillMaxSize(), + background = Color.Black, + resize = { view: UIView, rect: CValue -> + cameraPreviewLayer.connection?.apply { + videoOrientation = when (UIDevice.currentDevice.orientation) { + UIDeviceOrientation.UIDeviceOrientationPortrait -> + AVCaptureVideoOrientationPortrait + + UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> + AVCaptureVideoOrientationLandscapeRight + + UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> + AVCaptureVideoOrientationLandscapeLeft + + UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> + AVCaptureVideoOrientationPortraitUpsideDown + + else -> videoOrientation + } + } + CATransaction.begin() + CATransaction.setValue(true, kCATransactionDisableActions) + view.layer.setFrame(rect) + cameraPreviewLayer.setFrame(rect) + CATransaction.commit() + }, + ) { + val cameraContainer = UIView() + cameraContainer.layer.addSublayer(cameraPreviewLayer) + cameraPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill + captureSession.startRunning() + cameraContainer + } + Button( + modifier = Modifier.align(Alignment.BottomCenter).padding(20.dp), + onClick = { + val photoSettings = AVCapturePhotoSettings.photoSettingsWithFormat( + format = mapOf(AVVideoCodecKey to AVVideoCodecTypeJPEG) + ) + photoSettings.setHighResolutionPhotoEnabled(true) + capturePhotoOutput.setHighResolutionCaptureEnabled(true) + capturePhotoOutput.capturePhotoWithSettings( + settings = photoSettings, + delegate = photoCaptureDelegate + ) + }) { + Text("Compose Button - take a photo 📸") + } + } else { + SimulatorStub() + } +} + +@Composable +private fun SimulatorStub() { + Text( + """ + Camera is not available on simulator. + Please try to run on a real iOS device. + """.trimIndent(), color = Color.White + ) +}