
Composable photo gallery UI with lazy batched asset streaming, shared bounded decoding, disk-cached thumbnails, image prefetching, video playback, permission and delete handling from common code.
The device photo library, in Compose Multiplatform.
Read photos, show thumbnails, play videos and delete assets, all from commonMain.
One Kotlin API over MediaStore on Android and PhotoKit on iOS.
Quick start • Reading photos • Showing photos • Playing video • Sample app
@Composable
fun Gallery() {
val library = remember { PhotoLibrary() }
var assets by remember { mutableStateOf(emptyList<PhotoAsset>()) }
LaunchedEffect(Unit) {
if (library.ensurePermission().canRead) {
library.collectAssets().collect { assets = it }
}
}
LazyVerticalGrid(GridCells.Adaptive(96.dp)) {
items(assets, key = { it.id }) { asset ->
PhotoImage(
assetId = asset.id,
size = ImageSize.Thumbnail(320),
contentDescription = null,
modifier = Modifier.aspectRatio(1f),
)
}
}
}That's it, on both platforms. No expect/actual of your own, no Swift file on the side.
MediaStore and PhotoKit disagree about almost everything: what an asset id is, whether sizes
are cheap, who shows the delete confirmation, what a thumbnail costs. Most apps end up writing it
twice, and the two copies drift apart.
Pikto was pulled out of a shipping photo cleaner and generalised. It's opinionated about the two things that actually break at scale:
Take only what you need. Each one is published separately.
| Artifact | What you get | Pulls in |
|---|---|---|
📚 pikto-core
|
Querying, permissions, deleting. No UI dependency, so it also works with SwiftUI or Views. | coroutines |
🖼️ pikto-images
|
Composables and ImageBitmap decoding for thumbnails and full-size images. |
pikto-core, Compose Multiplatform |
🎬 pikto-video
|
A video player for library clips, with next-clip preloading. |
pikto-core, Compose Multiplatform, Media3 on Android |
Three steps and you're showing photos.
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.daniatitienei:pikto-core:1.0.1")
implementation("io.github.daniatitienei:pikto-images:1.0.1") // optional
implementation("io.github.daniatitienei:pikto-video:1.0.1") // optional
}
}
}Targets: android, iosArm64, iosSimulatorArm64.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installPikto()
setContent { App() }
}
}The permission prompt and the delete confirmation sheet are both activity results, and Android only lets those be registered before the activity reaches STARTED. That's a call the library can't make for you. Everything unregisters on destroy, so it's safe across configuration changes and multiple activities.
[!TIP] Only reading the library? You can skip
installPikto(). It's needed only to request permission or delete.
The read permissions come from Pikto's own manifest and merge into your app, so there's nothing to add. To drop one you don't want:
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" /><key>NSPhotoLibraryUsageDescription</key>
<string>So you can browse and clean up your photos.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>So you can save photos back to your library.</string>[!WARNING] iOS terminates an app that touches the photo library without these keys.
No other setup. Nothing to call, no Swift to write. 🎉
PhotoLibrary is the whole surface. Build one and keep it. It holds no per-call state, so one
instance for the app's lifetime is the intended use.
val library = PhotoLibrary()when (library.ensurePermission()) {
PhotoPermission.GRANTED -> loadEverything()
PhotoPermission.LIMITED -> loadWhatWeCanAndOfferToWiden()
PhotoPermission.DENIED -> sendToSettings()
PhotoPermission.NOT_DETERMINED -> Unit // A prompt always settles the question.
}ensurePermission() returns the current status and prompts only if nobody has been asked yet.
For the common case, canRead is true for both GRANTED and LIMITED.
[!IMPORTANT] Use
ensurePermission(), notpermissionStatus(), when a refusal should send the user to Settings.permissionStatus()never shows UI, so it can't tell "refused" from "never asked": iOS reportsDENIED, but Android reportsNOT_DETERMINEDfor both. Only showing the prompt separates them. SoDENIEDis reliable afterensurePermission(), and iOS-only before it.
[!NOTE]
LIMITEDis a permanent state on both platforms, not a step towardsGRANTED. The user picked some photos and the rest stays invisible. Treat it as a working state and offer a way to widen the selection, never as a failure to retry.
Each emission is everything known so far, in order, with sizes filled in as they arrive. The last emission is the whole library.
library.collectAssets().collect { assets ->
// The first emission lands in a frame or two. Later ones grow it.
}val everything: List<PhotoAsset> = library.loadAll()[!CAUTION] This waits for the last asset before returning the first. Never put it in front of UI.
val query = MediaQuery(
mediaTypes = setOf(MediaType.VIDEO),
sortOrder = SortOrder.OLDEST_FIRST,
includeSizes = true,
includeAlbums = false,
batching = BatchStrategy(firstBatchSize = 20, growthFactor = 4, maxBatchSize = 500),
)
library.collectAssets(query).collect { videos -> }MediaQuery.Images and MediaQuery.Videos are ready-made presets.
Two flags have a real cost on iOS:
| Flag | Android | iOS | Advice |
|---|---|---|---|
includeSizes |
Free, it's in the same cursor row | One disk read per asset through PHAssetResource: milliseconds become seconds |
Turn it off if nothing on screen shows bytes. Sizes always arrive after the assets. |
includeAlbums |
Cheap | The most expensive pass by far: PhotoKit can't ask an asset which albums hold it, so every album gets walked | Off by default. Turn it on only when you show albums. |
collectAssets is a fold over stream(). Collect the stream directly when you want albums, or want
to handle each kind of update differently:
library.stream(MediaQuery(includeAlbums = true)).collect { update ->
when (update) {
is LibraryUpdate.Assets -> append(update.assets)
is LibraryUpdate.Sizes -> patchSizes(update.sizeBytesById)
is LibraryUpdate.Albums -> showAlbumRow(update.snapshot)
}
}Assets arrives before the Sizes and Albums that describe it.when (library.delete(selectedIds)) {
DeleteResult.Deleted -> refresh()
DeleteResult.Cancelled -> Unit // The user dismissed the system sheet. Not an error.
is DeleteResult.Failed -> showError()
}Both platforms show a system confirmation sheet first, so "the user said no" is an ordinary
outcome. That's why Cancelled sits beside Failed rather than inside it.
[!NOTE] Nothing is deleted permanently. Android moves assets to the MediaStore trash and iOS to Recently Deleted. Both are recoverable for about thirty days.
data class PhotoAsset(
val id: String, // Opaque. A MediaStore row id or a PHAsset local identifier.
val createdAt: Instant,
val width: Int,
val height: Int,
val mediaType: MediaType,
val sizeBytes: Long?, // Null until a Sizes update fills it in.
val durationMillis: Long,
val isScreenshot: Boolean,
)id is stable for as long as the asset exists on the device, and means nothing anywhere else.
Don't parse it, sort by it, or use it as a cross-device key.
PhotoImage(
assetId = asset.id,
size = ImageSize.Thumbnail(320),
contentDescription = null,
modifier = Modifier.aspectRatio(1f),
placeholder = { Box(Modifier.fillMaxSize().background(Color.LightGray)) },
)The two sizes take different paths:
| Size | How it's made | Cached |
|---|---|---|
ImageSize.Thumbnail(sidePx) |
The platform's own thumbnailing | 💾 Memory and disk, so the second cold start is a file read |
ImageSize.Full |
Rendered by the platform at screen resolution | 🧠 Memory only |
[!TIP] The size is part of the cache key, so
Thumbnail(320)andThumbnail(321)share nothing. Pick a few sizes and reuse them.
Need the bitmap itself rather than a composable that draws it?
val bitmap: ImageBitmap? = rememberPhotoImage(asset.id, ImageSize.Full)The single biggest win for a scrolling grid. Warm the ids just outside the visible window:
PrefetchPhotos(assetIds = upcomingIds, size = ImageSize.Thumbnail(320))Recomputing the window on every scroll is fine. That's the intended use: ids already cached or already in flight cost nothing.
PhotoImage uses a process-wide loader with sensible defaults. Bring your own when you want
different budgets:
val loader = remember {
PhotoImageLoader(
ImageLoaderConfig(
thumbnailMemoryBytes = 32L * 1024 * 1024,
fullImageMemoryBytes = 128L * 1024 * 1024,
diskCacheBytes = 80L * 1024 * 1024,
)
)
}
ProvidePhotoImageLoader(loader) { App() }[!WARNING] Keep one instance. A second loader means a second set of caches and a second writer to the same disk directory.
diskCache = PhotoDiskCache.None keeps nothing on disk. Pass your own PhotoDiskCache to store
it somewhere else.PhotoImageLoader through ProvidePhotoImageLoader and nothing
touches the platform.val state = rememberVideoPlaybackState(asset.id)
Box {
// The still underneath, so nothing flashes black on the way in.
PhotoImage(asset.id, ImageSize.Full, null, Modifier.fillMaxSize())
VideoPlayer(
assetId = asset.id,
state = state,
isVisible = state.hasRenderedFirstFrame,
modifier = Modifier.fillMaxSize(),
nextAssetId = nextClipId,
scale = VideoScale.CROP,
)
}
Slider(
value = state.positionMillis.toFloat(),
valueRange = 0f..state.durationMillis.coerceAtLeast(1L).toFloat(),
onValueChangeFinished = state::endScrub,
onValueChange = {
state.startScrub()
state.scrubTo(it.toLong())
},
)Three things a hand-rolled player usually gets wrong, and how Pikto handles them:
1. 📌 Keep it mounted. Creating or releasing a player is main-thread work measured in
hundreds of milliseconds on both platforms, so doing it per clip freezes the screen. A null
assetId means "nothing to play" and keeps the player alive and idle. Keep the composable in the
tree and swap the id underneath it.
2. ⏭️ Preload the next clip. Pass nextAssetId and that clip is opened and buffered while the
user is still watching, so reaching it is a hand-over rather than a cold start.
3. 👁️ Hide it with isVisible, not a modifier. The player is a native view that draws itself
on its own thread. A graphicsLayer alpha or clip is a statement about the Compose tree that the
surface is under no obligation to honour. Hiding has to happen in the view's own terms
(visibility on Android, hidden on iOS), and the same goes for scale, which is why there's no
ContentScale parameter. Pass state.hasRenderedFirstFrame and draw a still underneath.
[!NOTE] On Android the player is a
TextureViewrather than the usualSurfaceView, so it survives being translated or scaled inside agraphicsLayerwithout punching through and blinking.
A runnable gallery that uses all three artifacts lives in sample/: a grid, a
full-screen viewer, video playback, and deleting one or many. Android and iOS share one
commonMain source set.
./gradlew :sample:androidApp:installDebug # 🤖 Android
open sample/iosApp/iosApp.xcodeproj # 🍎 iOSMore in sample/README.md.
Pikto doesn't assume a DI framework. Both factories are plain functions, so wire them however you already do. With Koin:
val piktoModule = module {
single<PhotoLibrary> { PhotoLibrary() }
single<PhotoImageLoader> { PhotoImageLoader() }
}On Android both no-argument factories work from Application.onCreate onwards, because an
androidx.startup initializer captures the application context at process start. If you strip
that initializer out, use the PhotoLibrary(context) and PhotoImageLoader(context, config)
overloads in androidMain.
Pikto doesn't wrap all of MediaStore or PhotoKit, and doesn't try to. When you need something it doesn't cover, take the platform handle and go:
// androidMain
val uri: Uri = photoAssetUri(asset.id) // Hand to Coil, ExoPlayer, a share sheet.
// iosMain
val phAsset: PHAsset? = phAssetFor(asset.id) // Live photos, location, favouriting.Up front, because these are the things you'll go looking for:
Several of these could be added. Say so in an issue. 🙌
| Kotlin | 2.4.20+ |
| Android | minSdk 24, compileSdk 37 |
| iOS | 15+ |
| Compose Multiplatform | 1.12+ (for pikto-images and pikto-video) |
[!NOTE] The iOS 15 floor comes from Kotlin/Native, not Pikto: its
minVersion.iosis 15.0 as of Kotlin 2.4, after 14.0 in 2.3 and 12.0 in 2.2. Nothing Pikto does can lower it.
See CONTRIBUTING.md. Issues and pull requests are welcome, especially platform edge cases from real devices.
Apache 2.0. See LICENSE.
The device photo library, in Compose Multiplatform.
Read photos, show thumbnails, play videos and delete assets, all from commonMain.
One Kotlin API over MediaStore on Android and PhotoKit on iOS.
Quick start • Reading photos • Showing photos • Playing video • Sample app
@Composable
fun Gallery() {
val library = remember { PhotoLibrary() }
var assets by remember { mutableStateOf(emptyList<PhotoAsset>()) }
LaunchedEffect(Unit) {
if (library.ensurePermission().canRead) {
library.collectAssets().collect { assets = it }
}
}
LazyVerticalGrid(GridCells.Adaptive(96.dp)) {
items(assets, key = { it.id }) { asset ->
PhotoImage(
assetId = asset.id,
size = ImageSize.Thumbnail(320),
contentDescription = null,
modifier = Modifier.aspectRatio(1f),
)
}
}
}That's it, on both platforms. No expect/actual of your own, no Swift file on the side.
MediaStore and PhotoKit disagree about almost everything: what an asset id is, whether sizes
are cheap, who shows the delete confirmation, what a thumbnail costs. Most apps end up writing it
twice, and the two copies drift apart.
Pikto was pulled out of a shipping photo cleaner and generalised. It's opinionated about the two things that actually break at scale:
Take only what you need. Each one is published separately.
| Artifact | What you get | Pulls in |
|---|---|---|
📚 pikto-core
|
Querying, permissions, deleting. No UI dependency, so it also works with SwiftUI or Views. | coroutines |
🖼️ pikto-images
|
Composables and ImageBitmap decoding for thumbnails and full-size images. |
pikto-core, Compose Multiplatform |
🎬 pikto-video
|
A video player for library clips, with next-clip preloading. |
pikto-core, Compose Multiplatform, Media3 on Android |
Three steps and you're showing photos.
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.daniatitienei:pikto-core:1.0.1")
implementation("io.github.daniatitienei:pikto-images:1.0.1") // optional
implementation("io.github.daniatitienei:pikto-video:1.0.1") // optional
}
}
}Targets: android, iosArm64, iosSimulatorArm64.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installPikto()
setContent { App() }
}
}The permission prompt and the delete confirmation sheet are both activity results, and Android only lets those be registered before the activity reaches STARTED. That's a call the library can't make for you. Everything unregisters on destroy, so it's safe across configuration changes and multiple activities.
[!TIP] Only reading the library? You can skip
installPikto(). It's needed only to request permission or delete.
The read permissions come from Pikto's own manifest and merge into your app, so there's nothing to add. To drop one you don't want:
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" /><key>NSPhotoLibraryUsageDescription</key>
<string>So you can browse and clean up your photos.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>So you can save photos back to your library.</string>[!WARNING] iOS terminates an app that touches the photo library without these keys.
No other setup. Nothing to call, no Swift to write. 🎉
PhotoLibrary is the whole surface. Build one and keep it. It holds no per-call state, so one
instance for the app's lifetime is the intended use.
val library = PhotoLibrary()when (library.ensurePermission()) {
PhotoPermission.GRANTED -> loadEverything()
PhotoPermission.LIMITED -> loadWhatWeCanAndOfferToWiden()
PhotoPermission.DENIED -> sendToSettings()
PhotoPermission.NOT_DETERMINED -> Unit // A prompt always settles the question.
}ensurePermission() returns the current status and prompts only if nobody has been asked yet.
For the common case, canRead is true for both GRANTED and LIMITED.
[!IMPORTANT] Use
ensurePermission(), notpermissionStatus(), when a refusal should send the user to Settings.permissionStatus()never shows UI, so it can't tell "refused" from "never asked": iOS reportsDENIED, but Android reportsNOT_DETERMINEDfor both. Only showing the prompt separates them. SoDENIEDis reliable afterensurePermission(), and iOS-only before it.
[!NOTE]
LIMITEDis a permanent state on both platforms, not a step towardsGRANTED. The user picked some photos and the rest stays invisible. Treat it as a working state and offer a way to widen the selection, never as a failure to retry.
Each emission is everything known so far, in order, with sizes filled in as they arrive. The last emission is the whole library.
library.collectAssets().collect { assets ->
// The first emission lands in a frame or two. Later ones grow it.
}val everything: List<PhotoAsset> = library.loadAll()[!CAUTION] This waits for the last asset before returning the first. Never put it in front of UI.
val query = MediaQuery(
mediaTypes = setOf(MediaType.VIDEO),
sortOrder = SortOrder.OLDEST_FIRST,
includeSizes = true,
includeAlbums = false,
batching = BatchStrategy(firstBatchSize = 20, growthFactor = 4, maxBatchSize = 500),
)
library.collectAssets(query).collect { videos -> }MediaQuery.Images and MediaQuery.Videos are ready-made presets.
Two flags have a real cost on iOS:
| Flag | Android | iOS | Advice |
|---|---|---|---|
includeSizes |
Free, it's in the same cursor row | One disk read per asset through PHAssetResource: milliseconds become seconds |
Turn it off if nothing on screen shows bytes. Sizes always arrive after the assets. |
includeAlbums |
Cheap | The most expensive pass by far: PhotoKit can't ask an asset which albums hold it, so every album gets walked | Off by default. Turn it on only when you show albums. |
collectAssets is a fold over stream(). Collect the stream directly when you want albums, or want
to handle each kind of update differently:
library.stream(MediaQuery(includeAlbums = true)).collect { update ->
when (update) {
is LibraryUpdate.Assets -> append(update.assets)
is LibraryUpdate.Sizes -> patchSizes(update.sizeBytesById)
is LibraryUpdate.Albums -> showAlbumRow(update.snapshot)
}
}Assets arrives before the Sizes and Albums that describe it.when (library.delete(selectedIds)) {
DeleteResult.Deleted -> refresh()
DeleteResult.Cancelled -> Unit // The user dismissed the system sheet. Not an error.
is DeleteResult.Failed -> showError()
}Both platforms show a system confirmation sheet first, so "the user said no" is an ordinary
outcome. That's why Cancelled sits beside Failed rather than inside it.
[!NOTE] Nothing is deleted permanently. Android moves assets to the MediaStore trash and iOS to Recently Deleted. Both are recoverable for about thirty days.
data class PhotoAsset(
val id: String, // Opaque. A MediaStore row id or a PHAsset local identifier.
val createdAt: Instant,
val width: Int,
val height: Int,
val mediaType: MediaType,
val sizeBytes: Long?, // Null until a Sizes update fills it in.
val durationMillis: Long,
val isScreenshot: Boolean,
)id is stable for as long as the asset exists on the device, and means nothing anywhere else.
Don't parse it, sort by it, or use it as a cross-device key.
PhotoImage(
assetId = asset.id,
size = ImageSize.Thumbnail(320),
contentDescription = null,
modifier = Modifier.aspectRatio(1f),
placeholder = { Box(Modifier.fillMaxSize().background(Color.LightGray)) },
)The two sizes take different paths:
| Size | How it's made | Cached |
|---|---|---|
ImageSize.Thumbnail(sidePx) |
The platform's own thumbnailing | 💾 Memory and disk, so the second cold start is a file read |
ImageSize.Full |
Rendered by the platform at screen resolution | 🧠 Memory only |
[!TIP] The size is part of the cache key, so
Thumbnail(320)andThumbnail(321)share nothing. Pick a few sizes and reuse them.
Need the bitmap itself rather than a composable that draws it?
val bitmap: ImageBitmap? = rememberPhotoImage(asset.id, ImageSize.Full)The single biggest win for a scrolling grid. Warm the ids just outside the visible window:
PrefetchPhotos(assetIds = upcomingIds, size = ImageSize.Thumbnail(320))Recomputing the window on every scroll is fine. That's the intended use: ids already cached or already in flight cost nothing.
PhotoImage uses a process-wide loader with sensible defaults. Bring your own when you want
different budgets:
val loader = remember {
PhotoImageLoader(
ImageLoaderConfig(
thumbnailMemoryBytes = 32L * 1024 * 1024,
fullImageMemoryBytes = 128L * 1024 * 1024,
diskCacheBytes = 80L * 1024 * 1024,
)
)
}
ProvidePhotoImageLoader(loader) { App() }[!WARNING] Keep one instance. A second loader means a second set of caches and a second writer to the same disk directory.
diskCache = PhotoDiskCache.None keeps nothing on disk. Pass your own PhotoDiskCache to store
it somewhere else.PhotoImageLoader through ProvidePhotoImageLoader and nothing
touches the platform.val state = rememberVideoPlaybackState(asset.id)
Box {
// The still underneath, so nothing flashes black on the way in.
PhotoImage(asset.id, ImageSize.Full, null, Modifier.fillMaxSize())
VideoPlayer(
assetId = asset.id,
state = state,
isVisible = state.hasRenderedFirstFrame,
modifier = Modifier.fillMaxSize(),
nextAssetId = nextClipId,
scale = VideoScale.CROP,
)
}
Slider(
value = state.positionMillis.toFloat(),
valueRange = 0f..state.durationMillis.coerceAtLeast(1L).toFloat(),
onValueChangeFinished = state::endScrub,
onValueChange = {
state.startScrub()
state.scrubTo(it.toLong())
},
)Three things a hand-rolled player usually gets wrong, and how Pikto handles them:
1. 📌 Keep it mounted. Creating or releasing a player is main-thread work measured in
hundreds of milliseconds on both platforms, so doing it per clip freezes the screen. A null
assetId means "nothing to play" and keeps the player alive and idle. Keep the composable in the
tree and swap the id underneath it.
2. ⏭️ Preload the next clip. Pass nextAssetId and that clip is opened and buffered while the
user is still watching, so reaching it is a hand-over rather than a cold start.
3. 👁️ Hide it with isVisible, not a modifier. The player is a native view that draws itself
on its own thread. A graphicsLayer alpha or clip is a statement about the Compose tree that the
surface is under no obligation to honour. Hiding has to happen in the view's own terms
(visibility on Android, hidden on iOS), and the same goes for scale, which is why there's no
ContentScale parameter. Pass state.hasRenderedFirstFrame and draw a still underneath.
[!NOTE] On Android the player is a
TextureViewrather than the usualSurfaceView, so it survives being translated or scaled inside agraphicsLayerwithout punching through and blinking.
A runnable gallery that uses all three artifacts lives in sample/: a grid, a
full-screen viewer, video playback, and deleting one or many. Android and iOS share one
commonMain source set.
./gradlew :sample:androidApp:installDebug # 🤖 Android
open sample/iosApp/iosApp.xcodeproj # 🍎 iOSMore in sample/README.md.
Pikto doesn't assume a DI framework. Both factories are plain functions, so wire them however you already do. With Koin:
val piktoModule = module {
single<PhotoLibrary> { PhotoLibrary() }
single<PhotoImageLoader> { PhotoImageLoader() }
}On Android both no-argument factories work from Application.onCreate onwards, because an
androidx.startup initializer captures the application context at process start. If you strip
that initializer out, use the PhotoLibrary(context) and PhotoImageLoader(context, config)
overloads in androidMain.
Pikto doesn't wrap all of MediaStore or PhotoKit, and doesn't try to. When you need something it doesn't cover, take the platform handle and go:
// androidMain
val uri: Uri = photoAssetUri(asset.id) // Hand to Coil, ExoPlayer, a share sheet.
// iosMain
val phAsset: PHAsset? = phAssetFor(asset.id) // Live photos, location, favouriting.Up front, because these are the things you'll go looking for:
Several of these could be added. Say so in an issue. 🙌
| Kotlin | 2.4.20+ |
| Android | minSdk 24, compileSdk 37 |
| iOS | 15+ |
| Compose Multiplatform | 1.12+ (for pikto-images and pikto-video) |
[!NOTE] The iOS 15 floor comes from Kotlin/Native, not Pikto: its
minVersion.iosis 15.0 as of Kotlin 2.4, after 14.0 in 2.3 and 12.0 in 2.2. Nothing Pikto does can lower it.
See CONTRIBUTING.md. Issues and pull requests are welcome, especially platform edge cases from real devices.
Apache 2.0. See LICENSE.