Skip to content

Instantly share code, notes, and snippets.

@Narayan-Dhingra
Created July 30, 2026 16:12
Show Gist options
  • Select an option

  • Save Narayan-Dhingra/20fccab6845e7b2bc30f15ec1ab7494a to your computer and use it in GitHub Desktop.

Select an option

Save Narayan-Dhingra/20fccab6845e7b2bc30f15ec1ab7494a to your computer and use it in GitHub Desktop.
iOS Camera Bridge in Compose Multiplatform
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