Building a Gallery App With Jetpack Compose and Material3
How I built a local media gallery app for Android using Jetpack Compose, Material3, and the MediaStore API — with lazy grids, photo details, and a clean architecture.
title: "Building a Gallery App With Jetpack Compose and Material3" date: "2024-06-01" description: "How I built a local media gallery app for Android using Jetpack Compose, Material3, and the MediaStore API — with lazy grids, photo details, and a clean architecture." tags: ["kotlin", "android", "compose"] published: true image: ""
There's a particular kind of Android project that's useful precisely because it touches several unrelated platform APIs at once. A local gallery app is that kind of project — it requires MediaStore queries, runtime permissions, image loading, navigation, and a UI that needs to feel fluid at 60 fps. Building one from scratch with Jetpack Compose taught me more about the current state of Android development than any single tutorial could.
Why Build This?
I'd been using Compose for a few months on smaller screens, but I wanted to push it with something that had real performance requirements. A photo grid with hundreds of items, smooth scrolling, and fast full-screen transitions is a genuine test. I also wanted to practice clean architecture — the pattern of Repository → ViewModel → UI is easy to understand conceptually and surprisingly easy to get wrong in practice.
Architecture
The app follows a unidirectional data flow:
The MediaRepository is the only class that knows about ContentResolver and MediaStore. The ViewModel knows nothing about Android platform APIs — it holds StateFlows that the UI collects. This separation made it easy to test the ViewModel with a fake repository.
The key data class:
Keeping Uri in the data class rather than a file path is important — on Android 10+, file paths to external storage are not directly accessible, but content URIs always work.
Querying the MediaStore
The MediaStore.Images.Media content provider returns exactly what you ask for via a projection. Asking for everything is slow; asking for only the columns you need is fast:
The .use {} block on the cursor ensures it's always closed, even if an exception is thrown. I've seen more than one production app with cursor leaks causing intermittent CursorWindowAllocationException crashes — the .use extension is the right habit.
Building the Grid
LazyVerticalGrid with a GridCells.Adaptive specification gives you a responsive grid that adjusts column count based on available width:
The key = { it.id } parameter on items() is critical for correct animations and scroll position preservation when the list updates. Without it, Compose can't track which item is which when the data changes.
The .size(256) on the ImageRequest tells Coil to decode the image at thumbnail resolution rather than full resolution. Without this, scrolling through a large gallery would consume enormous amounts of memory loading multi-megapixel images into a 120dp grid cell.
Navigation and the Detail Screen
I used the Navigation Compose library with a simple two-destination graph. Rather than passing the full MediaItem as a navigation argument (serialisation overhead), I pass only the image ID and re-fetch from the ViewModel:
The detail screen shows the full-resolution image in a zoomable viewer (using the accompanist zoomable modifier at the time, though the Compose foundation library now has pointer input APIs for this), plus metadata and share/delete actions.
Handling Permissions
Runtime permission handling across API levels is the most brittle part of this app. On Android 13 (API 33) and above, READ_EXTERNAL_STORAGE was replaced by granular media permissions (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO). On older versions you still need the old permission.
The shouldShowRationale flag is Android's way of telling you whether the user has previously denied the permission. If they have, you should show an explanation before requesting again — just silently re-requesting gets you ignored.
Lessons
Compose state hoisting is worth the ceremony. Moving state up to the ViewModel and having the composables react to it makes the UI trivially easy to test and reason about. Early on I had some state living in remember {} inside composables, and it caused subtle bugs when navigating back.
MediaStore queries on the main thread will freeze the UI. This should be obvious but it's easy to miss early on. Everything in the repository runs on Dispatchers.IO.
Image loading at the right resolution matters more than caching strategy. Coil's caching is excellent, but loading 12MP images into 120dp thumbnails will exhaust your memory pool regardless of cache. Specify a size in every ImageRequest for thumbnail use cases.
The biggest productivity gain from Compose wasn't the declarative syntax — it was the previews. Being able to see a
@Previewof aGridThumbnailwith mock data without running the app cut my iteration time dramatically.
What I'd Add Next
The obvious missing feature is video support — the same MediaStore approach works for MediaStore.Video.Media. I'd also add folder grouping (querying by BUCKET_DISPLAY_NAME), pinch-to-zoom on the detail screen with proper bounds, and a search bar that filters by filename or date. The architecture is already set up to handle these; they're just a matter of time.