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

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.