Skip to content

Zoomable Image

A drop-in replacement for async Image() composables featuring support for pan & zoom gestures and automatic sub-sampling of large images. This ensures that images maintain their intricate details even when fully zoomed in, without causing any OutOfMemory exceptions.

Features

  • Sub-sampling of bitmaps
  • Pinch to zoom and flings
  • Double tap to zoom
  • Single finger zoom (double tap and hold)
  • Haptic feedback for over/under zoom
  • Compatibility with nested scrolling
  • Click listeners

Installation

implementation("me.saket.telephoto:zoomable-image-coil:0.11.2")
implementation("me.saket.telephoto:zoomable-image-glide:0.11.2")
- AsyncImage(
+ ZoomableAsyncImage(
    model = "https://example.com/image.jpg",
    contentDescription = …
  )
- GlideImage(
+ ZoomableGlideImage(
    model = "https://example.com/image.jpg",
    contentDescription = …
  )

Image requests

For complex scenarios, ZoomableImage can also take full image requests:

ZoomableAsyncImage(
  model = ImageRequest.Builder(LocalContext.current)
    .data("https://example.com/image.jpg")
    .listener(
      remember {
        object : ImageRequest.Listener {
          override fun onSuccess() {}
          override fun onError() {}
        }
      }    
    )
    .crossfade(1_000)
    .memoryCachePolicy(CachePolicy.DISABLED)
    .build(),
  imageLoader = LocalContext.current.imageLoader, // Optional.
  contentDescription = 
)
ZoomableGlideImage(
  model = "https://example.com/image.jpg",
  contentDescription = 
) {
  it.addListener(object : RequestListener<Drawable> {
      override fun onResourceReady(): Boolean = TODO()
      override fun onLoadFailed(): Boolean = TODO()
    })
    .transition(withCrossFade(1_000))
    .skipMemoryCache(true)
    .disallowHardwareConfig()
    .timeout(30_000),
}

Placeholders

If your images are available in multiple resolutions, telephoto highly recommends using their lower resolutions as placeholders while their full quality equivalents are loaded in the background.

When combined with a cross-fade transition, ZoomableImage will smoothly swap out placeholders when their full quality versions are ready to be displayed.

ZoomableAsyncImage(
  modifier = Modifier.fillMaxSize(),
  model = ImageRequest.Builder(LocalContext.current)
    .data("https://example.com/image.jpg")
    .placeholderMemoryCacheKey()
    .crossfade(1_000)
    .build(),
  contentDescription = 
)
More details about placeholderMemoryCacheKey() can be found on Coil's website.

ZoomableGlideImage(
  modifier = Modifier.fillMaxSize(),
  model = "https://example.com/image.jpg",
  contentDescription = 
) {
  it.thumbnail()   // or placeholder()
    .transition(withCrossFade(1_000)),
}
More details about thumbnail() can be found on Glide's website.

Content alignment

Alignment.TopCenter Alignment.BottomCenter

When images are zoomed, they're scaled with respect to their alignment until they're large enough to fill all available space. After that, they're scaled uniformly. The default alignment is Alignment.Center.

ZoomableAsyncImage(
  modifier = Modifier.fillMaxSize(),
  model = "https://example.com/image.jpg",
  alignment = Alignment.TopCenter
)
ZoomableGlideImage(
  modifier = Modifier.fillMaxSize(),
  model = "https://example.com/image.jpg",
  alignment = Alignment.TopCenter
)

Content scale

ContentScale.Inside ContentScale.Crop

Images are scaled using ContentScale.Fit by default, but can be customized. A visual guide of all possible values can be found here.

Unlike Image(), ZoomableImage can pan images even when they're cropped. This can be useful for applications like wallpaper apps that may want to use ContentScale.Crop to ensure that images always fill the screen.

ZoomableAsyncImage(
  modifier = Modifier.fillMaxSize(),
  model = "https://example.com/image.jpg",
  contentScale = ContentScale.Crop
)
ZoomableGlideImage(
  modifier = Modifier.fillMaxSize(),
  model = "https://example.com/image.jpg",
  contentScale = ContentScale.Crop
)

Warning

Placeholders are visually incompatible with ContentScale.Inside.

Click listeners

For detecting double taps, ZoomableImage consumes all tap gestures making it incompatible with Modifier.clickable() and Modifier.combinedClickable(). As an alternative, its onClick and onLongClick parameters can be used.

ZoomableAsyncImage(
  modifier = Modifier.clickable { error("This will not work") },
  model = "https://example.com/image.jpg",
  onClick = {  },
  onLongClick = {  },
)
ZoomableGlideImage(
  modifier = Modifier.clickable { error("This will not work") },
  model = "https://example.com/image.jpg",
  onClick = {  },
  onLongClick = {  },
)

Sharing hoisted state

For handling zoom gestures, Zoomablemage uses Modifier.zoomable() underneath. If your app displays different kinds of media, it is recommended to hoist the ZoomableState outside so that it can be shared with all zoomable composables:

val zoomableState = rememberZoomableState()

when (media) {
 is Image -> {
    ZoomableAsyncImage(
     model = media.imageUrl,
     state = rememberZoomableImageState(zoomableState),
    )
  }
  is Video -> {
    ZoomableVideoPlayer(
      model = media.videoUrl,
      state = rememberZoomableExoState(zoomableState),
    )
  }
}
val zoomableState = rememberZoomableState()

when (media) {
 is Image -> {
    ZoomableGlideImage(
     model = media.imageUrl,
     state = rememberZoomableImageState(zoomableState),
    )
  }
  is Video -> {
    ZoomableVideoPlayer(
      model = media.videoUrl,
      state = rememberZoomableExoState(zoomableState),
    )
  }
}