Created
July 30, 2026 16:12
-
-
Save Narayan-Dhingra/20fccab6845e7b2bc30f15ec1ab7494a to your computer and use it in GitHub Desktop.
iOS Camera Bridge in Compose Multiplatform
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package app.snookalook.mobile.bridges | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import kotlinx.cinterop.ExperimentalForeignApi | |
| import kotlinx.cinterop.addressOf | |
| import kotlinx.cinterop.useContents | |
| import kotlinx.cinterop.usePinned | |
| import platform.CoreGraphics.CGRectMake | |
| import platform.CoreGraphics.CGSizeMake | |
| import platform.UIKit.UIApplication | |
| import platform.UIKit.UIGraphicsBeginImageContextWithOptions | |
| import platform.UIKit.UIGraphicsEndImageContext | |
| import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext | |
| import platform.UIKit.UIImage | |
| import platform.UIKit.UIImageJPEGRepresentation | |
| import platform.UIKit.UIImagePickerController | |
| import platform.UIKit.UIImagePickerControllerDelegateProtocol | |
| import platform.UIKit.UIImagePickerControllerOriginalImage | |
| import platform.UIKit.UIImagePickerControllerSourceType | |
| import platform.UIKit.UINavigationControllerDelegateProtocol | |
| import platform.UIKit.UIWindow | |
| import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT | |
| import platform.darwin.NSObject | |
| import platform.darwin.dispatch_async | |
| import platform.darwin.dispatch_get_global_queue | |
| import platform.posix.memcpy | |
| @OptIn(ExperimentalForeignApi::class) | |
| @Composable | |
| actual fun CameraCaptureWrapper( | |
| content: @Composable (onCapture: () -> Unit, capturedBytes: ByteArray?) -> Unit, | |
| ) { | |
| var capturedBytes by remember { mutableStateOf<ByteArray?>(null) } | |
| var retainedDelegate by remember { mutableStateOf<NSObject?>(null) } | |
| content( | |
| onCapture@{ | |
| val source = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera | |
| // Simulator + no-camera devices: graceful no-op. SourceSheet leaves gallery as the | |
| // user's alternative, auto-fallback here would steal their choice (Q2). | |
| if (!UIImagePickerController.isSourceTypeAvailable(source)) return@onCapture | |
| val picker = UIImagePickerController() | |
| picker.sourceType = source | |
| val delegate = object : NSObject(), | |
| UIImagePickerControllerDelegateProtocol, | |
| UINavigationControllerDelegateProtocol { | |
| override fun imagePickerController( | |
| picker: UIImagePickerController, | |
| didFinishPickingMediaWithInfo: Map<Any?, *>, | |
| ) { | |
| val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage | |
| // Capture image ref BEFORE dismiss; move delegate cleanup + encode INSIDE | |
| // completion so capturedBytes is only written after the dismiss animation | |
| // fully finishes, prevents UIKit "presentation in progress" collision | |
| // when Compose consent AlertDialog tries to present mid-dismiss. | |
| picker.dismissViewControllerAnimated(true) { | |
| picker.delegate = null | |
| retainedDelegate = null | |
| if (image != null) { | |
| // Downscale + JPEG encode off-main (B4.1), mirrors PhotoPickerBridge. | |
| dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { | |
| val srcW = image.size.useContents { width } | |
| val srcH = image.size.useContents { height } | |
| val maxDim = 1024.0 | |
| val ratio = if (maxOf(srcW, srcH) > maxDim) maxDim / maxOf(srcW, srcH) else 1.0 | |
| val dstW = srcW * ratio | |
| val dstH = srcH * ratio | |
| UIGraphicsBeginImageContextWithOptions(CGSizeMake(dstW, dstH), false, 1.0) | |
| image.drawInRect(CGRectMake(0.0, 0.0, dstW, dstH)) | |
| val scaled = UIGraphicsGetImageFromCurrentImageContext() ?: image | |
| UIGraphicsEndImageContext() | |
| val data = UIImageJPEGRepresentation(scaled, 0.85) | |
| if (data != null) { | |
| val bytes = ByteArray(data.length.toInt()) | |
| bytes.usePinned { pinned -> | |
| memcpy(pinned.addressOf(0), data.bytes, data.length) | |
| } | |
| capturedBytes = bytes | |
| } | |
| } | |
| } | |
| } | |
| } | |
| override fun imagePickerControllerDidCancel(picker: UIImagePickerController) { | |
| picker.dismissViewControllerAnimated(true) { | |
| picker.delegate = null | |
| retainedDelegate = null | |
| } | |
| } | |
| } | |
| retainedDelegate = delegate | |
| picker.delegate = delegate | |
| val root = UIApplication.sharedApplication.keyWindow?.rootViewController | |
| ?: UIApplication.sharedApplication.windows | |
| .filterIsInstance<UIWindow>() | |
| .firstOrNull { it.isKeyWindow() } | |
| ?.rootViewController | |
| root?.presentViewController(picker, true, null) | |
| }, | |
| capturedBytes, | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment