Skip to content

Image Gallery

Maven Central

The landscapist-image-gallery package provides high level, Compose Multiplatform UI components for building photo gallery experiences on top of Landscapist:

  • ImageGallery: a thumbnail grid built on LazyVerticalGrid with optional multi select, header and footer slots, and a custom content slot.
  • ImageViewer: a full screen pager with pinch to zoom, swipe to dismiss, and top bar, bottom bar, and page indicator overlays.
  • ImageSharedTransitionConfig: drop in configuration for shared element transitions between gallery items and viewer pages.

By default, both components render their images using LandscapistImage, so they work on Android, iOS, Desktop, and Web without extra setup. You can swap in Glide, Coil, Fresco, or any custom loader through the content slot.

To use the gallery components, add the dependency below:

dependencies {
    implementation("com.github.skydoves:landscapist-image-gallery:$version")
}

For Kotlin Multiplatform:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.github.skydoves:landscapist-image-gallery:$version")
        }
    }
}

Note: landscapist-image-gallery transitively exposes landscapist, landscapist-image, and landscapist-zoomable.

ImageGallery

ImageGallery displays a list of image models in a LazyVerticalGrid. The list items can be anything accepted by Landscapist (URL strings, Uri, File, ByteArray, drawable resources, and so on), as long as they have a stable equals/hashCode for grid keying.

import com.skydoves.landscapist.gallery.ImageGallery

ImageGallery(
  images = imageUrls,
  onImageClick = { index, imageModel ->
    navController.navigate("viewer/$index")
  },
)

Grid layout

Tune the grid with columns, contentPadding, and arrangements. aspectRatio controls the shape of each cell. The default is 1f, a square.

ImageGallery(
  images = imageUrls,
  columns = GridCells.Adaptive(minSize = 120.dp),
  contentPadding = PaddingValues(4.dp),
  horizontalArrangement = Arrangement.spacedBy(4.dp),
  verticalArrangement = Arrangement.spacedBy(4.dp),
  aspectRatio = 1f,
)

Plugins and ImageOptions

Because each item is rendered by LandscapistImage under the hood, the full Landscapist plugin ecosystem is available. Pass an ImageComponent and an ImageOptions:

ImageGallery(
  images = imageUrls,
  imageOptions = ImageOptions(contentScale = ContentScale.Crop),
  component = rememberImageComponent {
    +ShimmerPlugin(
      shimmer = Shimmer.Resonate(
        baseColor = Color.DarkGray,
        highlightColor = Color.LightGray,
      ),
    )
  },
)

Selection mode

Enable multi select by flipping selectable and tracking the selected indices in your own state. Long pressing any item enters selection mode, tapping items while in selection mode toggles them, and onImageClick is not fired during an active selection.

var selectedIndices by remember { mutableStateOf(emptySet<Int>()) }

ImageGallery(
  images = imageUrls,
  selectable = true,
  selectedIndices = selectedIndices,
  onSelectionChanged = { selectedIndices = it },
  selectionOverlay = { _, selected ->
    if (selected) {
      Box(
        modifier = Modifier
          .fillMaxSize()
          .background(Color.Black.copy(alpha = 0.3f)),
        contentAlignment = Alignment.TopEnd,
      ) {
        Icon(
          imageVector = Icons.Default.CheckCircle,
          contentDescription = null,
          tint = MaterialTheme.colors.primary,
          modifier = Modifier.padding(6.dp).size(24.dp).clip(CircleShape),
        )
      }
    }
  },
)

Both header and footer span the full grid width (GridItemSpan(maxLineSpan)), making them a good place to drop a TopAppBar, section title, or load more button.

ImageGallery(
  images = imageUrls,
  header = {
    TopAppBar(title = { Text("Gallery") })
  },
  footer = {
    TextButton(onClick = onLoadMore, modifier = Modifier.fillMaxWidth()) {
      Text("Load more")
    }
  },
)

Custom content (Glide, Coil, Fresco)

Override the default renderer by providing a content lambda. When content is set, component and imageOptions are ignored, and you own the rendering.

ImageGallery(
  images = imageUrls,
  content = { index, imageModel ->
    GlideImage(
      imageModel = { imageModel },
      modifier = Modifier.fillMaxSize(),
      imageOptions = ImageOptions(contentScale = ContentScale.Crop),
    )
  },
)

ImageGalleryState

rememberImageGalleryState() wraps a LazyGridState so you can observe scroll state, scroll programmatically, or share a grid state across recompositions:

val galleryState = rememberImageGalleryState()

ImageGallery(
  images = imageUrls,
  state = galleryState,
)

// Scroll to an index:
LaunchedEffect(targetIndex) {
  galleryState.lazyGridState.animateScrollToItem(targetIndex)
}

ImageGalleryDefaults

Property Default Description
Columns GridCells.Fixed(3) Grid column configuration
AspectRatio 1f Cell aspect ratio (width / height)
HorizontalSpacing 2.dp Gap between columns
VerticalSpacing 2.dp Gap between rows

ImageViewer

ImageViewer is a full screen pager for viewing images one at a time. It ships with:

  • Horizontal paging via HorizontalPager
  • Pinch and double tap to zoom and pan via ZoomablePlugin
  • Swipe to dismiss with a configurable threshold and background alpha animation
  • Overlay slots for a top bar, bottom bar, and page indicator
import com.skydoves.landscapist.gallery.ImageViewer
import com.skydoves.landscapist.gallery.rememberImageViewerState

val viewerState = rememberImageViewerState(
  initialPage = startIndex,
  pageCount = { imageUrls.size },
)

ImageViewer(
  images = imageUrls,
  state = viewerState,
  onDismiss = { navController.popBackStack() },
  onPageChanged = { page -> currentPage = page },
)

Zoom configuration

ImageViewer routes its zoomableConfig to the underlying ZoomablePlugin, so all Zoomable options apply: minZoom, maxZoom, doubleTapZoom, sub sampling, and more.

ImageViewer(
  images = imageUrls,
  zoomableConfig = ZoomableConfig(
    minZoom = 1f,
    maxZoom = 5f,
    doubleTapZoom = 2f,
    enableSubSampling = true,
  ),
)

Top bar, bottom bar, and indicator

All three overlay slots receive (currentPage, totalPages) so you can build position aware UI. The indicator is stacked above bottomBar.

ImageViewer(
  images = imageUrls,
  topBar = { current, total ->
    TopAppBar(
      backgroundColor = Color.Black.copy(alpha = 0.5f),
      title = { Text("${current + 1} / $total", color = Color.White) },
    )
  },
  indicator = { current, total ->
    PageIndicator(current = current, total = total)
  },
  bottomBar = { _, _ ->
    BottomAppBar(backgroundColor = Color.Black.copy(alpha = 0.5f)) {
      // actions...
    }
  },
)

Swipe to dismiss

Vertical swipe to dismiss is enabled by default. It is automatically disabled while the current page is zoomed, so zoom and pan gestures are not interrupted.

ImageViewer(
  images = imageUrls,
  enableSwipeToDismiss = true,
  dismissThreshold = 0.25f,  // fraction of screen height
  onDismiss = { onClose() },
)

Custom content (Glide, Coil, Fresco)

Use the content slot to render each page with your image loader of choice:

ImageViewer(
  images = imageUrls,
  content = { page, imageModel ->
    CoilImage(
      imageModel = { imageModel },
      modifier = Modifier.fillMaxSize(),
      imageOptions = ImageOptions(contentScale = ContentScale.Fit),
      component = rememberImageComponent {
        +ShimmerPlugin(shimmer = Shimmer.Flash())
      },
    )
  },
)

Zoom, paging, swipe to dismiss, and shared transition registration are still provided for you. The content slot only replaces the image rendering.

ImageViewerState

rememberImageViewerState returns an ImageViewerState that exposes the underlying PagerState, along with helpers:

val viewerState = rememberImageViewerState(
  initialPage = 0,
  pageCount = { imageUrls.size },
)

// Observe state
viewerState.currentPage       // current page index
viewerState.isZoomed          // whether the current page is zoomed in
viewerState.isDismissing      // whether a dismiss drag is in progress

// Drive state
scope.launch { viewerState.animateToPage(3) }
scope.launch { viewerState.resetZoom() }

ImageViewerDefaults

Property Default Description
BackgroundColor Color.Black Viewer background color
PageSpacing 16.dp Gap between pager pages
BeyondViewportPageCount 1 Pages to pre compose beyond the viewport
DismissThreshold 0.25f Fraction of screen height to trigger dismiss
DismissVelocityThreshold 1000f Fling velocity (px/s) to trigger dismiss

Shared element transition

ImageSharedTransitionConfig wires up Compose's shared element transitions, so a tapped thumbnail animates into the full screen viewer and collapses back on dismiss. Pass the same config (or one that produces the same keys) to both ImageGallery and ImageViewer.

SharedTransitionLayout {
  AnimatedContent(
    targetState = showViewer,
    transitionSpec = { fadeIn() togetherWith fadeOut() },
    label = "gallery-viewer",
  ) { viewerVisible ->
    val animatedContentScope = this
    if (!viewerVisible) {
      ImageGallery(
        images = imageUrls,
        onImageClick = { index, _ ->
          selectedPage = index
          showViewer = true
        },
        sharedTransition = ImageSharedTransitionConfig(
          sharedTransitionScope = this@SharedTransitionLayout,
          animatedContentScope = animatedContentScope,
        ),
      )
    } else {
      ImageViewer(
        images = imageUrls,
        state = rememberImageViewerState(
          initialPage = selectedPage,
          pageCount = { imageUrls.size },
        ),
        onDismiss = { showViewer = false },
        sharedTransition = ImageSharedTransitionConfig(
          sharedTransitionScope = this@SharedTransitionLayout,
          animatedContentScope = animatedContentScope,
        ),
      )
    }
  }
}

The same pattern works with NavHost. Use the AnimatedContentScope from each composable { ... } destination as the animatedContentScope.

Give both sides the same content scale

A shared element animates bounds. It does not animate framing, and ContentScale is framing.

Give the grid ContentScale.Crop and the viewer ContentScale.Fit and the two screens draw the same photo at two different sizes inside the same bounds. On the frame the viewer takes over, the picture is redrawn at the smaller of the two and then grows back over the length of the animation. Measured on a 600x400 photo opening from a 353px square cell: 529px wide in the grid, 353px wide on the viewer's first frame, a third smaller in one frame. It reads as the image flinching.

val scale = ContentScale.Crop

ImageGallery(images = urls, imageOptions = ImageOptions(contentScale = scale), ...)
ImageViewer(images = urls, imageOptions = ImageOptions(contentScale = scale), ...)

Crop on both keeps the grid filled and the transition still, and costs the edges of the photo on the viewer. Fit on both keeps the whole photo and letterboxes the grid cells instead. Giving up neither is what produces the reframe.

The sample in this repository keeps the mismatch on purpose: a grid reads better filled and a viewer is there to show the whole photo, so it takes the reframe rather than give up either. Match the two when the transition matters more than the framing.

Custom key provider

By default, keys are "landscapist-shared-$index-$imageModel". Override keyProvider when:

  • Gallery indices don't match viewer page indices (for example, filtered lists)
  • The same image model appears multiple times and you need another discriminator
  • You want to namespace keys across multiple galleries on the same screen
ImageSharedTransitionConfig(
  sharedTransitionScope = this@SharedTransitionLayout,
  animatedContentScope = this,
  keyProvider = { index, imageModel -> "photo-$index-${imageModel.hashCode()}" },
)

Enabling and disabling at runtime

Toggle enabled to turn the transition on or off without restructuring the composable tree:

ImageSharedTransitionConfig(
  sharedTransitionScope = scope,
  animatedContentScope = animatedScope,
  enabled = reduceMotionSetting.not(),
)

Kotlin Multiplatform support

landscapist-image-gallery is a Compose Multiplatform module, and both ImageGallery and ImageViewer work on every Compose target supported by Landscapist: Android, iOS, Desktop (JVM), and Web, without any platform specific glue.

See also