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

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

Backer posted Originally published at ismoy.github.io 3 min read

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
    )
}
@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 }
    )
}
@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 }
        )
    )
}
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.

0 votes
0 votes

More Posts

Simplifying Kotlin Multiplatform Setup with the New Android-KMP Plugin

Ismoy - Sep 3, 2025

BelZSpeedScan: A Kotlin Multiplatform Library for Fast Document Scanning

Ismoy - Aug 21, 2025

ImagePickerKMP 1.0.23: Controling Camera Launch on iOS with directCameraLaunch

Ismoy - Sep 3, 2025

Type-safe Kotlin Multiplatform i18n: auto-convert Android strings to cross-platform translations.

Ismoy - Oct 14, 2025

Pattern-matching across different languages

Nicolas Fränkel - Jul 24, 2025
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
3 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!