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 )
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 )
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 ) }
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) }
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 } ) }
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 ) } } }
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 } ) ) }
And for gallery:
if (showGallery) { GalleryPickerLauncher( onPhotosSelected = { photos -> selectedImages = photos }, onError = { showError = true }, onDismiss = { showGallery = false }, allowMultiple = true ) }
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)
Nice posting, Interested in talking to you, could you share your email address?
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.