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.