diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/404.html b/404.html new file mode 100644 index 00000000..5fbe7a36 --- /dev/null +++ b/404.html @@ -0,0 +1,463 @@ + + + +
+ + + + + + + + + + + + + + + + + +Designing a cohesive media experience for Android can be a lot of work. Telephoto aims to make that easier by offering some building blocks for Compose UI.
+Drop-in replacement for Image()
composables featuring support for pan & zoom gestures and automatic sub‑sampling of large images that'd otherwise not fit into memory.
ZoomableImage
's gesture detector, packaged as a standalone Modifier
that can be used with non-image composables.
Prepare to release vX.X.X
. Do not push yet.g clean publish --no-parallel --no-daemon
dependency-watch await me.saket.telephoto:zoomable:{version}
:sample
Prepare next development version
by bumping version and changing library version to SNAPSHOT.Designing a cohesive media experience for Android can be a lot of work. Telephoto aims to make that easier by offering some building blocks for Compose UI.
"},{"location":"#zoomable-image","title":"Zoomable Image","text":"Drop-in replacement for Image()
composables featuring support for pan & zoom gestures and automatic sub\u2011sampling of large images that'd otherwise not fit into memory.
ZoomableImage
's gesture detector, packaged as a standalone Modifier
that can be used with non-image composables.
Prepare to release vX.X.X
. Do not push yet.g clean publish --no-parallel --no-daemon
dependency-watch await me.saket.telephoto:zoomable:{version}
:sample
Prepare next development version
by bumping version and changing library version to SNAPSHOT.A Modifier
for handling pan & zoom gestures, designed to be shared across all your media composables so that your users can use the same familiar gestures throughout your app.
Features
implementation(\"me.saket.telephoto:zoomable:0.14.0\")\n
Box(\nModifier\n.size(200.dp)\n.zoomable(rememberZoomableState())\n.background(\nbrush = Brush.linearGradient(listOf(Color.Cyan, Color.Blue)),\nshape = RoundedCornerShape(4.dp)\n)\n)\n
While Modifier.zoomable()
was primarily written with images & videos in mind, it can be used for anything such as text, canvas drawings, etc.
For preventing your content from over-zooming or over-panning, Modifier.zoomable()
will use your content's layout size by default. This is good enough for composables that fill every pixel of their drawing space.
For richer content such as an Image()
whose visual size may not always match its layout size, Modifier.zoomable()
will need your assistance.
val painter = resourcePainter(R.drawable.example)\nval zoomableState = rememberZoomableState().apply {\nsetContentLocation(\nZoomableContentLocation.scaledInsideAndCenterAligned(painter.intrinsicSize)\n)\n}\nImage(\nmodifier = Modifier\n.fillMaxSize()\n.background(Color.Orange)\n.zoomable(zoomableState),\npainter = painter,\ncontentDescription = \u2026,\ncontentScale = ContentScale.Inside,\nalignment = Alignment.Center,\n)\n
"},{"location":"zoomable/#click-listeners","title":"Click listeners","text":"For detecting double clicks, Modifier.zoomable()
consumes all tap gestures making it incompatible with Modifier.clickable()
and Modifier.combinedClickable()
. As an alternative, its onClick
and onLongClick
parameters can be used.
Modifier.zoomable(\nstate = rememberZoomableState(),\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n
The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick
parameter:
Modifier.zoomable(\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
"},{"location":"zoomable/#applying-gesture-transformations","title":"Applying gesture transformations","text":"When pan & zoom gestures are received, Modifier.zoomable()
automatically applies their resulting scale
and translation
onto your content using Modifier.graphicsLayer()
.
This can be disabled if your content prefers applying the transformations in a bespoke manner.
val state = rememberZoomableState(\nautoApplyTransformations = false\n)\nText(\nmodifier = Modifier\n.fillMaxSize()\n.zoomable(state),\ntext = \"Nicolas Cage\",\nstyle = state.contentTransformation.let {\nval weightMultiplier = if (it.isUnspecified) 1f else it.scale.scaleX\nTextStyle(\nfontSize = 36.sp,\nfontWeight = FontWeight(400 * weightMultiplier),\n)\n}\n)\n
"},{"location":"zoomable/#keyboard-shortcuts","title":"Keyboard shortcuts","text":"ZoomableImage()
can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester
:
val focusRequester = remember { FocusRequester() }\nLaunchedEffect(Unit) {\n// Automatically request focus when the image is displayed. This assumes there \n// is only one zoomable image present in the hierarchy. If you're displaying \n// multiple images in a pager, apply this only for the active page. \nfocusRequester.requestFocus()\n}\nBox(\nModifier\n.focusRequester(focusRequester)\n.zoomable(),\n)\n
By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec
to rememberZoomableState()
.
Control
+ =
Meta
+ =
Zoom out Control
+ -
Meta
+ -
Pan Arrow keys Arrow keys Extra pan Alt
+ arrow keys Option
+ arrow keys"},{"location":"zoomable/recipes/","title":"Recipes","text":""},{"location":"zoomable/recipes/#observing-pan-zoom","title":"Observing pan & zoom","text":"val state = rememberZoomableState()\nBox(\nModifier.zoomable(state)\n)\nLaunchedEffect(state.contentTransformation) {\nprintln(\"Pan = ${state.contentTransformation.offset}\")\nprintln(\"Zoom = ${state.contentTransformation.scale}\")\nprintln(\"Zoom fraction = ${state.zoomFraction}\")\n}\n// Example use case: Hide system bars when image is zoomed in.\nval systemUi = rememberSystemUiController()\nval isZoomedOut = (zoomState.zoomFraction ?: 0f) < 0.1f\nLaunchedEffect(isZoomedOut) {\nsystemUi.isSystemBarsVisible = isZoomedOut\n}\n
"},{"location":"zoomable/recipes/#controlling-pan-zoom","title":"Controlling pan & zoom","text":"val state = rememberZoomableState()\nBox(\nModifier.zoomable(state)\n)\nButton(onClick = { state.zoomBy(zoomFactor = 1.2f) }) {\nText(\"+\")\n}\nButton(onClick = { state.zoomBy(zoomFactor = 1 / 1.2f) }) {\nText(\"-\")\n}\nButton(onClick = { state.panBy(offset = 50.dp) }) {\nText(\">\")\n}\nButton(onClick = { state.panBy(offset = -50.dp) }) {\nText(\"<\")\n}\n
"},{"location":"zoomable/recipes/#resetting-zoom","title":"Resetting zoom","text":"Modifier.zoomable()
will automatically retain its pan & zoom across state restorations. You may want to prevent this in lazy layouts such as a Pager()
, where each page is restored every time it becomes visible.
val pagerState = rememberPagerState()\nHorizontalPager(\nstate = pagerState,\npageCount = 3,\n) { pageNum ->\nval zoomableState = rememberZoomableState()\nZoomableContent(\nstate = zoomableState\n)\nif (pagerState.settledPage != pageNum) {\n// Page is now off-screen. Prevent restoration of \n// current zoom when this page becomes visible again.\nLaunchedEffect(Unit) {\nzoomableState.resetZoom(animationSpec = SnapSpec())\n}\n}\n}\n
Warning
A bug in Pager()
previously caused settledPage
to reset to 0
upon state restoration. This issue has been resolved in androidx.compose.foundation:foundation:1.5.0-alpha02
.
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
// For Coil 2.x\nimplementation(\"me.saket.telephoto:zoomable-image-coil:0.14.0\")\n// For Coil 3.x\nimplementation(\"me.saket.telephoto:zoomable-image-coil3:0.14.0\")\n
implementation(\"me.saket.telephoto:zoomable-image-glide:0.14.0\")\n
CoilGlide - AsyncImage(\n+ ZoomableAsyncImage(\n model = \"https://example.com/image.jpg\",\n contentDescription = \u2026\n )\n
- GlideImage(\n+ ZoomableGlideImage(\n model = \"https://example.com/image.jpg\",\n contentDescription = \u2026\n )\n
"},{"location":"zoomableimage/#image-requests","title":"Image requests","text":"For complex scenarios, ZoomableImage
can also take full image requests:
ZoomableAsyncImage(\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.listener(\nonSuccess = { \u2026 },\nonError = { \u2026 },\n)\n.crossfade(1_000)\n.memoryCachePolicy(CachePolicy.DISABLED)\n.build(),\nimageLoader = LocalContext.current.imageLoader, // Optional.\ncontentDescription = \u2026\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.addListener(object : RequestListener<Drawable> {\noverride fun onResourceReady(\u2026): Boolean = TODO()\noverride fun onLoadFailed(\u2026): Boolean = TODO()\n})\n.transition(withCrossFade(1_000))\n.skipMemoryCache(true)\n.disallowHardwareConfig()\n.timeout(30_000),\n}\n
"},{"location":"zoomableimage/#placeholders","title":"Placeholders","text":"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(\nmodifier = Modifier.fillMaxSize(),\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.placeholderMemoryCacheKey(\u2026)\n.crossfade(1_000)\n.build(),\ncontentDescription = \u2026\n)\n
More details about placeholderMemoryCacheKey()
can be found on Coil's website. ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.thumbnail(\u2026) // or placeholder()\n.transition(withCrossFade(1_000)),\n}\n
More details about thumbnail()
can be found on Glide's website. Warning
Placeholders are visually incompatible with Modifier.wrapContentSize()
.
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(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\nalignment = Alignment.TopCenter\n)\n
ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\nalignment = Alignment.TopCenter\n)\n
"},{"location":"zoomableimage/#content-scale","title":"Content scale","text":"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(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentScale = ContentScale.Crop\n)\n
ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentScale = ContentScale.Crop\n)\n
Warning
Placeholders are visually incompatible with ContentScale.Inside
.
For detecting double clicks, 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(\nmodifier = Modifier.clickable { error(\"This will not work\") },\nmodel = \"https://example.com/image.jpg\",\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n
ZoomableGlideImage(\nmodifier = Modifier.clickable { error(\"This will not work\") },\nmodel = \"https://example.com/image.jpg\",\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n
The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick
parameter:
ZoomableAsyncImage(\nmodel = \"https://example.com/image.jpg\",\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
"},{"location":"zoomableimage/#keyboard-shortcuts","title":"Keyboard shortcuts","text":"ZoomableImage()
can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester
:
val focusRequester = remember { FocusRequester() }\nLaunchedEffect(Unit) {\n// Automatically request focus when the image is displayed. This assumes there \n// is only one zoomable image present in the hierarchy. If you're displaying \n// multiple images in a pager, apply this only for the active page. \nfocusRequester.requestFocus()\n}\n
CoilGlide ZoomableAsyncImage(\nmodifier = Modifier.focusRequester(focusRequester),\nmodel = \"https://example.com/image.jpg\",\n)\n
ZoomableGlideImage(\nmodifier = Modifier.focusRequester(focusRequester),\nmodel = \"https://example.com/image.jpg\",\n)\n
By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec
to rememberZoomableState()
.
Control
+ =
Zoom out Control
+ -
Pan Arrow keys Extra pan Alt
+ arrow keys"},{"location":"zoomableimage/#sharing-hoisted-state","title":"Sharing hoisted state","text":"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()\nwhen (media) {\nis Image -> {\nZoomableAsyncImage(\nmodel = media.imageUrl,\nstate = rememberZoomableImageState(zoomableState),\n)\n}\nis Video -> {\nZoomableVideoPlayer(\nmodel = media.videoUrl,\nstate = rememberZoomableExoState(zoomableState),\n)\n}\n}\n
val zoomableState = rememberZoomableState()\nwhen (media) {\nis Image -> {\nZoomableGlideImage(\nmodel = media.imageUrl,\nstate = rememberZoomableImageState(zoomableState),\n)\n}\nis Video -> {\nZoomableVideoPlayer(\nmodel = media.videoUrl,\nstate = rememberZoomableExoState(zoomableState),\n)\n}\n}\n
"},{"location":"zoomableimage/custom-image-loaders/","title":"Custom image loaders","text":""},{"location":"zoomableimage/custom-image-loaders/#custom-image-loaders","title":"Custom image loaders","text":"In its essence, ZoomableImage
is simply an abstraction over an image loading library. If your preferred library isn't supported by telephoto
out of the box, you can create your own by implementing ZoomableImageSource
.
@Composable\nfun ZoomablePicassoImage(\nmodel: Any?,\ncontentDescription: String?,\n) {\nZoomableImage(\nimage = ZoomableImageSource.picasso(model),\ncontentDescription = contentDescription,\n)\n}\n@Composable\nprivate fun ZoomableImageSource.Companion.picasso(\nmodel: Any?,\npicasso: Picasso = Picasso\n.Builder(LocalContext.current)\n.build(),\n): ZoomableImageSource {\nreturn remember(model, picasso) {\nTODO(\"See ZoomableImageSource.coil() or glide() for an example.\")\n}\n}\n
ZoomableImageSource.picasso()
will be responsible for loading images and determining whether they can be displayed as-is or should be presented in a sub-sampled image viewer to prevent OOM errors. Here are two examples:
val zoomableState = rememberZoomableState(\nzoomSpec = ZoomSpec(maxZoomFactor = 4f)\n)\nZoomableAsyncImage(\nstate = rememberZoomableImageState(zoomableState),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026,\n)\n
val zoomableState = rememberZoomableState(\nzoomSpec = ZoomSpec(maxZoomFactor = 4f)\n)\nZoomableGlideImage(\nstate = rememberZoomableImageState(zoomableState),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026,\n)\n
"},{"location":"zoomableimage/recipes/#observing-image-loads","title":"Observing image loads","text":"val imageState = rememberZoomableImageState()\n// Whether the full quality image is loaded. This will be false for placeholders\n// or thumbnails, in which case isPlaceholderDisplayed can be used instead.\nval showLoadingIndicator = imageState.isImageDisplayed\nAnimatedVisibility(visible = showLoadingIndicator) {\nCircularProgressIndicator() }\n
"},{"location":"zoomableimage/recipes/#grabbing-downloaded-images","title":"Grabbing downloaded images","text":"Low resolution drawables can be accessed by using request listeners. These images are down-sampled by your image loading library to fit in memory and are suitable for simple use-cases such as color extraction.
CoilGlideZoomableAsyncImage(\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.listener(onSuccess = { _, result ->\n// TODO: do something with result.drawable.\n})\n.build(),\ncontentDescription = \u2026\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.addListener(object : RequestListener<Drawable> {\noverride fun onResourceReady(resource: Drawable, \u2026): Boolean {\n// TODO: do something with resource.\n}\n})\n}\n
Full resolutions must be obtained as files because ZoomableImage
streams them directly from disk. The easiest way to do this is to load them again from cache.
val state = rememberZoomableImageState()\nZoomableAsyncImage(\nmodel = imageUrl,\nstate = state,\ncontentDescription = \u2026,\n)\nif (state.isImageDisplayed) {\nButton(onClick = { downloadImage(context, imageUrl) }) {\nText(\"Download image\")\n}\n}\n
suspend fun downloadImage(context: Context, imageUrl: HttpUrl) {\nval result = context.imageLoader.execute(\nImageRequest.Builder(context)\n.data(imageUrl)\n.build()\n)\nif (result is SuccessResult) {\nval cacheKey = result.diskCacheKey ?: error(\"image wasn't saved to disk\")\nval diskCache = context.imageLoader.diskCache!!\ndiskCache.openSnapshot(cacheKey)!!.use { // TODO: copy to Downloads directory. \n}\n}\n}\n
val state = rememberZoomableImageState()\nZoomableGlideImage(\nmodel = imageUrl,\nstate = state,\ncontentDescription = \u2026,\n)\nif (state.isImageDisplayed) {\nButton(onClick = { downloadImage(context, imageUrl) }) {\nText(\"Download image\")\n}\n}\n
fun downloadImage(context: Context, imageUrl: Uri) {\nGlide.with(context)\n.download(imageUrl)\n.into(object : CustomTarget<File>() {\noverride fun onResourceReady(resource: File, \u2026) {\n// TODO: copy file to Downloads directory.\n}\noverride fun onLoadCleared(placeholder: Drawable?) = Unit\n)\n}\n
"},{"location":"zoomableimage/sub-sampling/","title":"Sub-sampling","text":""},{"location":"zoomableimage/sub-sampling/#sub-sampling","title":"Sub-sampling","text":"For displaying large images that may not fit into memory, ZoomableImage
automatically divides them into tiles so that they can be loaded lazily.
If ZoomableImage
can't be used or if sub-sampling of images is always desired, you could potentially use SubSamplingImage()
directly.
implementation(\"me.saket.telephoto:sub-sampling-image:0.14.0\")\n
val zoomableState = rememberZoomableState()\nval imageState = rememberSubSamplingImageState(\nzoomableState = zoomableState,\nimageSource = SubSamplingImageSource.asset(\"fox.jpg\")\n)\nSubSamplingImage(\nmodifier = Modifier\n.fillMaxSize()\n.zoomable(zoomableState),\nstate = imageState,\ncontentDescription = \u2026,\n)\n
SubSamplingImage()
is an adaptation of the excellent subsampling-scale-image-view by Dave Morrissey.
A Modifier
for handling pan & zoom gestures, designed to be shared across all your media composables so that your users can use the same familiar gestures throughout your app.
Features
+Box(
+ Modifier
+ .size(200.dp)
+ .zoomable(rememberZoomableState())
+ .background(
+ brush = Brush.linearGradient(listOf(Color.Cyan, Color.Blue)),
+ shape = RoundedCornerShape(4.dp)
+ )
+)
+
While Modifier.zoomable()
was primarily written with images & videos in mind, it can be used for anything such as text, canvas drawings, etc.
+ | + |
---|---|
Without edge detection | +With edge detection | +
For preventing your content from over-zooming or over-panning, Modifier.zoomable()
will use your content's layout size by default. This is good enough for composables that fill every pixel of their drawing space.
For richer content such as an Image()
whose visual size may not always match its layout size, Modifier.zoomable()
will need your assistance.
val painter = resourcePainter(R.drawable.example)
+val zoomableState = rememberZoomableState().apply {
+ setContentLocation(
+ ZoomableContentLocation.scaledInsideAndCenterAligned(painter.intrinsicSize)
+ )
+}
+
+Image(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Orange)
+ .zoomable(zoomableState),
+ painter = painter,
+ contentDescription = …,
+ contentScale = ContentScale.Inside,
+ alignment = Alignment.Center,
+)
+
For detecting double clicks, Modifier.zoomable()
consumes all tap gestures making it incompatible with Modifier.clickable()
and Modifier.combinedClickable()
. As an alternative, its onClick
and onLongClick
parameters can be used.
Modifier.zoomable(
+ state = rememberZoomableState(),
+ onClick = { … },
+ onLongClick = { … },
+)
+
The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick
parameter:
When pan & zoom gestures are received, Modifier.zoomable()
automatically applies their resulting scale
and translation
onto your content using Modifier.graphicsLayer()
.
This can be disabled if your content prefers applying the transformations in a bespoke manner.
+val state = rememberZoomableState(
+ autoApplyTransformations = false
+)
+
+Text(
+ modifier = Modifier
+ .fillMaxSize()
+ .zoomable(state),
+ text = "Nicolas Cage",
+ style = state.contentTransformation.let {
+ val weightMultiplier = if (it.isUnspecified) 1f else it.scale.scaleX
+ TextStyle(
+ fontSize = 36.sp,
+ fontWeight = FontWeight(400 * weightMultiplier),
+ )
+ }
+)
+
ZoomableImage()
can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the
+user or using a FocusRequester
:
val focusRequester = remember { FocusRequester() }
+LaunchedEffect(Unit) {
+ // Automatically request focus when the image is displayed. This assumes there
+ // is only one zoomable image present in the hierarchy. If you're displaying
+ // multiple images in a pager, apply this only for the active page.
+ focusRequester.requestFocus()
+}
+
+Box(
+ Modifier
+ .focusRequester(focusRequester)
+ .zoomable(),
+)
+
By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a
+custom HardwareShortcutsSpec
to rememberZoomableState()
.
+ | Android | +Desktop | +
---|---|---|
Zoom in | +Control + = |
+Meta + = |
+
Zoom out | +Control + - |
+Meta + - |
+
Pan | +Arrow keys | +Arrow keys | +
Extra pan | +Alt + arrow keys |
+Option + arrow keys |
+
val state = rememberZoomableState()
+Box(
+ Modifier.zoomable(state)
+)
+
+LaunchedEffect(state.contentTransformation) {
+ println("Pan = ${state.contentTransformation.offset}")
+ println("Zoom = ${state.contentTransformation.scale}")
+ println("Zoom fraction = ${state.zoomFraction}")
+}
+
+// Example use case: Hide system bars when image is zoomed in.
+val systemUi = rememberSystemUiController()
+val isZoomedOut = (zoomState.zoomFraction ?: 0f) < 0.1f
+LaunchedEffect(isZoomedOut) {
+ systemUi.isSystemBarsVisible = isZoomedOut
+}
+
val state = rememberZoomableState()
+Box(
+ Modifier.zoomable(state)
+)
+
+Button(onClick = { state.zoomBy(zoomFactor = 1.2f) }) {
+ Text("+")
+}
+Button(onClick = { state.zoomBy(zoomFactor = 1 / 1.2f) }) {
+ Text("-")
+}
+Button(onClick = { state.panBy(offset = 50.dp) }) {
+ Text(">")
+}
+Button(onClick = { state.panBy(offset = -50.dp) }) {
+ Text("<")
+}
+
Modifier.zoomable()
will automatically retain its pan & zoom across state restorations. You may want to prevent this in lazy layouts such as a Pager()
, where each page is restored every time it becomes visible.
val pagerState = rememberPagerState()
+HorizontalPager(
+ state = pagerState,
+ pageCount = 3,
+) { pageNum ->
+ val zoomableState = rememberZoomableState()
+ ZoomableContent(
+ state = zoomableState
+ )
+
+ if (pagerState.settledPage != pageNum) {
+ // Page is now off-screen. Prevent restoration of
+ // current zoom when this page becomes visible again.
+ LaunchedEffect(Unit) {
+ zoomableState.resetZoom(animationSpec = SnapSpec())
+ }
+ }
+}
+
Warning
+A bug in Pager()
previously caused settledPage
to reset to 0
upon state restoration. This issue has been resolved in androidx.compose.foundation:foundation:1.5.0-alpha02
.
In its essence, ZoomableImage
is simply an abstraction over an image loading library. If your preferred library isn't supported by telephoto
out of the box, you can create your own by implementing ZoomableImageSource
.
@Composable
+fun ZoomablePicassoImage(
+ model: Any?,
+ contentDescription: String?,
+) {
+ ZoomableImage(
+ image = ZoomableImageSource.picasso(model),
+ contentDescription = contentDescription,
+ )
+}
+
+@Composable
+private fun ZoomableImageSource.Companion.picasso(
+ model: Any?,
+ picasso: Picasso = Picasso
+ .Builder(LocalContext.current)
+ .build(),
+): ZoomableImageSource {
+ return remember(model, picasso) {
+ TODO("See ZoomableImageSource.coil() or glide() for an example.")
+ }
+}
+
ZoomableImageSource.picasso()
will be responsible for loading images and determining whether they can be displayed as-is or should be presented in a sub-sampled image viewer to prevent OOM errors. Here are two examples:
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
+For complex scenarios, ZoomableImage
can also take full image requests:
ZoomableAsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data("https://example.com/image.jpg")
+ .listener(
+ onSuccess = { … },
+ 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),
+}
+
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 = …
+)
+
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)),
+}
+
thumbnail()
can be found on Glide's website.
+Warning
+Placeholders are visually incompatible with Modifier.wrapContentSize()
.
+ | + |
---|---|
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
.
+ | + |
---|---|
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.
Warning
+Placeholders are visually incompatible with ContentScale.Inside
.
For detecting double clicks, 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.
The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick
parameter:
ZoomableImage()
can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester
:
val focusRequester = remember { FocusRequester() }
+LaunchedEffect(Unit) {
+ // Automatically request focus when the image is displayed. This assumes there
+ // is only one zoomable image present in the hierarchy. If you're displaying
+ // multiple images in a pager, apply this only for the active page.
+ focusRequester.requestFocus()
+}
+
By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec
to rememberZoomableState()
.
+ | Android | +
---|---|
Zoom in | +Control + = |
+
Zoom out | +Control + - |
+
Pan | +Arrow keys | +
Extra pan | +Alt + arrow keys |
+
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),
+ )
+ }
+}
+
val imageState = rememberZoomableImageState()
+
+// Whether the full quality image is loaded. This will be false for placeholders
+// or thumbnails, in which case isPlaceholderDisplayed can be used instead.
+val showLoadingIndicator = imageState.isImageDisplayed
+
+AnimatedVisibility(visible = showLoadingIndicator) {
+ CircularProgressIndicator()
+}
+
Low resolution drawables can be accessed by using request listeners. These images are down-sampled by your image loading library to fit in memory and are suitable for simple use-cases such as color extraction.
+Full resolutions must be obtained as files because ZoomableImage
streams them directly from disk. The easiest way to do this is to load them again from cache.
val state = rememberZoomableImageState()
+ZoomableAsyncImage(
+ model = imageUrl,
+ state = state,
+ contentDescription = …,
+)
+
+if (state.isImageDisplayed) {
+ Button(onClick = { downloadImage(context, imageUrl) }) {
+ Text("Download image")
+ }
+}
+
suspend fun downloadImage(context: Context, imageUrl: HttpUrl) {
+ val result = context.imageLoader.execute(
+ ImageRequest.Builder(context)
+ .data(imageUrl)
+ .build()
+ )
+ if (result is SuccessResult) {
+ val cacheKey = result.diskCacheKey ?: error("image wasn't saved to disk")
+ val diskCache = context.imageLoader.diskCache!!
+ diskCache.openSnapshot(cacheKey)!!.use {
+ // TODO: copy to Downloads directory.
+ }
+ }
+}
+
val state = rememberZoomableImageState()
+ZoomableGlideImage(
+ model = imageUrl,
+ state = state,
+ contentDescription = …,
+)
+
+if (state.isImageDisplayed) {
+ Button(onClick = { downloadImage(context, imageUrl) }) {
+ Text("Download image")
+ }
+}
+
fun downloadImage(context: Context, imageUrl: Uri) {
+ Glide.with(context)
+ .download(imageUrl)
+ .into(object : CustomTarget<File>() {
+ override fun onResourceReady(resource: File, …) {
+ // TODO: copy file to Downloads directory.
+ }
+
+ override fun onLoadCleared(placeholder: Drawable?) = Unit
+ )
+}
+
For displaying large images that may not fit into memory, ZoomableImage
automatically divides them into tiles so that they can be loaded lazily.
If ZoomableImage
can't be used or if sub-sampling of images is always desired, you could potentially use SubSamplingImage()
directly.
val zoomableState = rememberZoomableState()
+val imageState = rememberSubSamplingImageState(
+ zoomableState = zoomableState,
+ imageSource = SubSamplingImageSource.asset("fox.jpg")
+)
+
+SubSamplingImage(
+ modifier = Modifier
+ .fillMaxSize()
+ .zoomable(zoomableState),
+ state = imageState,
+ contentDescription = …,
+)
+
SubSamplingImage()
is an adaptation of the excellent subsampling-scale-image-view by Dave Morrissey.