DEV Community

Cover image for Kotlin Multiplatform + Compose: Unified Camera & Gallery Picker with Expect/Actual and Permission Handling
Ismoy Belizaire
Ismoy Belizaire

Posted on

Kotlin Multiplatform + Compose: Unified Camera & Gallery Picker with Expect/Actual and Permission Handling

Handling image capture and gallery access in mobile apps is a task most developers face – but doing it the Kotlin Multiplatform way, using Compose, introduces unique challenges.

In this post, I'll walk through how to create a shared, composable-based image picker using Kotlin's expect/actual mechanism. The result is a unified camera and gallery experience for Android and iOS, fully integrated with Compose Multiplatform UI.

The Problem with Platform-Specific Image Pickers
Image picking requires different APIs on Android and iOS:

• Android: ActivityResultContracts, CameraX, media permissions

• iOS: UIImagePickerController, delegate protocols, Info.plist

• Compose Multiplatform has no built-in media picker

• Permissions and file access require different logic per platform
We want to hide this complexity and give developers a clean, shared interface.

Step 1: Define expect functions in commonMain

We declare our public API with expect composables. These serve as the shared contract across platforms.

ImagePickerLauncher

@Composable expect fun ImagePickerLauncher( config: ImagePickerConfig ) 
Enter fullscreen mode Exit fullscreen mode

This picker handles capturing a photo with the camera. The config provides callbacks like onPhotoCaptured, onError, and onDismiss.

GalleryPickerLauncher

@Composable expect fun GalleryPickerLauncher( onPhotosSelected: (List<PhotoResult>) -> Unit, onError: (Exception) -> Unit, onDismiss: () -> Unit = {}, allowMultiple: Boolean = false, mimeTypes: List<String> = listOf("image/*"), selectionLimit: Long = SELECTION_LIMIT ) 
Enter fullscreen mode Exit fullscreen mode

This version supports selecting one or multiple images and filtering by MIME type.

Android Implementation
The Android actual implementation uses Compose with platform-aware context handling:

@Composable actual fun ImagePickerLauncher(config: ImagePickerConfig) { val context = LocalContext.current if (context !is ComponentActivity) { config.onError(Exception("Invalid context")) return } CameraCaptureView( activity = context, onPhotoResult = { result -> config.onPhotoCaptured(result) }, onPhotosSelected = config.onPhotosSelected, onError = config.onError, onDismiss = config.onDismiss, cameraCaptureConfig = config.cameraCaptureConfig ) } 
Enter fullscreen mode Exit fullscreen mode

For gallery access:

@Composable actual fun GalleryPickerLauncher(...) { val context = LocalContext.current if (context !is ComponentActivity) { onError(Exception("Invalid context")) return } val config = GalleryPickerConfig( context = context, onPhotosSelected = onPhotosSelected, onError = onError, onDismiss = onDismiss, allowMultiple = allowMultiple, mimeTypes = mimeTypes ) GalleryPickerLauncherContent(config) } 
Enter fullscreen mode Exit fullscreen mode

iOS Implementation
On iOS, we integrate UIKit behavior with Compose state, launching the correct picker depending on user action:

@Composable actual fun ImagePickerLauncher(config: ImagePickerConfig) { var showDialog by remember { mutableStateOf(true) } var askCameraPermission by remember { mutableStateOf(false) } var launchCamera by remember { mutableStateOf(false) } var launchGallery by remember { mutableStateOf(false) } handleImagePickerState( showDialog = showDialog, askCameraPermission = askCameraPermission, launchCamera = launchCamera, launchGallery = launchGallery, config = config, onDismissDialog = { showDialog = false }, onCancelDialog = { showDialog = false config.onDismiss() }, onRequestCameraPermission = { askCameraPermission = true }, onRequestGallery = { launchGallery = true }, onCameraPermissionGranted = { askCameraPermission = false launchCamera = true }, onCameraPermissionDenied = { askCameraPermission = false config.onDismiss() }, onCameraFinished = { launchCamera = false }, onGalleryFinished = { launchGallery = false } ) } 
Enter fullscreen mode Exit fullscreen mode

And for gallery selection:

@Composable actual fun GalleryPickerLauncher(...) { LaunchedEffect(Unit) { if (allowMultiple) { val selectedImages = mutableListOf<PhotoResult>() GalleryPickerOrchestrator.launchGallery( onPhotoSelected = { result -> selectedImages.add(result) onPhotosSelected(selectedImages.toList()) }, onError = onError, onDismiss = onDismiss, allowMultiple = true, selectionLimit = selectionLimit ) } else { GalleryPickerOrchestrator.launchGallery( onPhotoSelected = { result -> onPhotosSelected(listOf(result)) }, onError = onError, onDismiss = onDismiss, allowMultiple = false, selectionLimit = 1 ) } } } 
Enter fullscreen mode Exit fullscreen mode

How It All Comes Together in Compose
With both platform implementations hidden, your shared Compose code stays clean:

if (showCamera) { ImagePickerLauncher( config = ImagePickerConfig( onPhotoCaptured = { photo -> capturedPhoto = photo }, onError = { showError = true }, onDismiss = { showCamera = false } ) ) } 
Enter fullscreen mode Exit fullscreen mode

And for gallery:

if (showGallery) { GalleryPickerLauncher( onPhotosSelected = { photos -> selectedImages = photos }, onError = { showError = true }, onDismiss = { showGallery = false }, allowMultiple = true ) } 
Enter fullscreen mode Exit fullscreen mode

Why This Pattern Works
• Shared UI remains fully declarative and platform-agnostic

• Permissions and platform quirks are abstracted

• It respects Compose principles and KMP architecture

• You avoid boilerplate and platform channels

Conclusion

Kotlin Multiplatform offers powerful abstractions when used correctly. By leveraging expect/actual and fully composable APIs, we've built a clean, testable, and scalable image picker that works across platforms.

You can explore the full implementation in ImagePickerKMP, an open-source project that follows these principles. Whether you use it directly or as inspiration, it's a practical example of Compose and KMP working in harmony.

Top comments (3)

Collapse
 
jamey_h66 profile image
Jamey H

Nice posting, Interested in talking to you, could you share your email address?

Collapse
 
code_architect profile image
Gleb • Edited

Nice breakdown! When I was working on a cross-platform project at Modsen, we ran into the same challenges with camera/gallery abstraction. Using expect/actual with a shared Compose UI was also our go-to, but the tricky part was permissions centralizing that logic made a big difference.
One thing we noticed: permission handling can get messy if you don’t centralize it. Wrapping it into a common handler reduced duplicated logic and saved us headaches later. Also, testing across real devices (not just emulators) turned out to be crucial, since gallery/camera APIs behave differently on some OEM Android builds.
.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.