Giter Club home page Giter Club logo

telephoto's Introduction

logo logo

telephoto.mp4

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.

See project website for full documentation.

License

Copyright 2023 Saket Narayan.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

telephoto's People

Contributors

dsteve595 avatar fooibar avatar saket avatar simonmarquis avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

telephoto's Issues

Support overlaying content that zooms with the image.

I'm trying to overlay the image with an aspect ratio box and I'd like it to follow any zooms & translates the user makes. Unfortunately the accessed zoomFraction includes the initial scale of the image to scale to fit so I don't know what zoom factor to apply to my overlay to match. I think either of the below that would be sufficient:

  1. A user-zoom factor that goes from 1 when all the way 'zoomed out' to whatever the max zoom is.
  2. An initial image scale factor I could subtract from zoomFraction to get the value to scale my ui with.
ui screenshot

FlickToDismiss drops frames when it is removed from composition before it completes its dismiss animation

Follow up on #43 (comment)

I have implemented your library to replace the existing functionality and it seems to work well.

But I was wondering if I could remove the delay of the the dismiss action?

If I remove the delay it becomes very choppy. But I would like it to be instant like the other functionality, I already have.

    (flickState.gestureState as? FlickToDismissState.GestureState.Dismissing)?.let { gestureState ->
        LaunchedEffect(Unit) {
            delay(gestureState.animationDuration / 6) // Seems to be the lowest I can get away with.
            appState.navigateUp()
        }
    }

see choppyness

IiccX2jkJm.mp4

Difficult to zoom inside pager

VID-20240102-WA0004.mp4

While trying to pinch zoom ZoomableAsyncImage used inside a HorizontalPager/VerticalPager, most of the time the pager changes the page instead of zooming. How I overcome this issue?

Compose Multiplatform support

Migrating Modifier.zoomable() to multiplatform should be easy is done, but SubSamplingImage() will need to be decoupled from Android's BitmapRegionDecoder for lazy loading of bitmap tiles.

Target platforms

JVM

Apple

For loading of images, @colinrtwhite plans to migrate Coil to multiplatform in v3.

Double tap not working sometimes

Hi there, I noticed sometimes a double tap gesture is interpreted as something else which results in a strange looking behavior where you double tap and the zoom changes by a tiny bit (like 0.5-1%).
This is pretty easy to reproduce, at least on my device. I can record a video if that would help.

It seems to be caused by this part in PointerInputScope.detectTapAndQuickZoomGestures:

var dragged = false
verticalDrag(secondDown.id) { drag ->
  dragged = true
  val dragDelta = drag.positionChange()
  val zoomDelta = 1f + (dragDelta.y * 0.004f) // Formula copied from https://github.com/usuiat/Zoomable.
  onQuickZoom(Zooming(secondDown.position, zoomDelta))
  drag.consume()
}

if (dragged) {
  onQuickZoom(QuickZoomStopped)
} else {
  onDoubleTap(secondDown.position)
}

It calls onQuickZoom instead of onDoubleTap. Could you provide some info on why onQuickZoom() is there and what kind of gesture it covers? Thanks

Unable to capture bitmap from ZoomableAsyncImage composable

Android official guide to capture bitmap from composable is here

Using that official way, capturing bitmap from ZoomableAsyncImage or it's parent composable is not working.

My main goal is to capture bitmap which is visible to the user (Area which is zoomed out of screen should not be the part of it)
If there are other ways to crop the image by zoomed size and offsets, please let me know.

Code snippet 1 throws error java.lang.IllegalStateException: Picture already recording, must call #endRecording():

val picture = remember { Picture() }
ZoomableAsyncImage(
  modifier = Modifier
      .fillMaxSize()
      .background(MaterialTheme.colorScheme.secondaryContainer)
      .drawWithCache {
          val width = this.size.width.toInt()
          val height = this.size.height.toInt()
          onDrawWithContent {
              val pictureCanvas = Canvas(picture.beginRecording(width, height))

              draw(this, this.layoutDirection, pictureCanvas, this.size) {
                  [email protected]()
              }

              picture.endRecording()

              drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
          }
      },
  model = ImageRequest.Builder(LocalContext.current)
      .data(uriOrBitmap)
      .crossfade(true)
      .build(),
  contentScale = ContentScale.Crop,
  contentDescription = null,
)

Code snippet 2, ZoomableAsyncImage doesn't display image (Just shifted recording work to the parent composable):

val picture = remember { Picture() }
Column(
    modifier = Modifier
        .fillMaxSize()
        .background(MaterialTheme.colorScheme.secondaryContainer)
        .drawWithCache {
            val width = this.size.width.toInt()
            val height = this.size.height.toInt()
            onDrawWithContent {
                val pictureCanvas = Canvas(picture.beginRecording(width, height))

                draw(this, this.layoutDirection, pictureCanvas, this.size) {
                    [email protected]()
                }

                picture.endRecording()

                drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
            }
        }
) {
    ZoomableAsyncImage(
        modifier = Modifier
            .fillMaxSize(),
        model = ImageRequest.Builder(LocalContext.current)
            .data(uriOrBitmap)
            .crossfade(true)
            .build(),
        contentScale = ContentScale.Crop,
        contentDescription = null,
    )
}

Above issue was observed on lib version 0.7.1

On lib version 0.8.0 the observation is different:

  • Picture recording works but the image is not displayed initially until user perform any touch action on the image. (May be first frame is not getting rendered)

Support for SVGs

Adding below lines, .gif and .svg files can be opened with coil AsyncImage.

imageLoader = ImageLoader.Builder(context)
    .components {
        add(SvgDecoder.Factory())  // svg
        add(ImageDecoderDecoder.Factory()) // gif
    }
    .build()

but with ZoomableAsyncImage, gif files can be played without any problem, but svg files not open.

Crash when clearing the cache while app is in the background

When having my app in the background and then clearing the cache through appinfo in android settings. Upon entering the app and selecting a image (which creates a ZoomableAsyncImage), it crashes. Other than that your library has been working great!

Version: 1.0.0-alpha02

stacktrace FATAL EXCEPTION: main Process: com.jerboa, PID: 8240 java.lang.IllegalStateException: Coil returned a null image from disk cache at me.saket.telephoto.zoomable.coil.Resolver.work(Unknown Source:245) at me.saket.telephoto.zoomable.coil.Resolver$work$1.invokeSuspend(Unknown Source:11) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(Unknown Source:8) at kotlinx.coroutines.internal.ScopeCoroutine.afterResume(Unknown Source:6) at kotlinx.coroutines.AbstractCoroutine.resumeWith(Unknown Source:22) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(Unknown Source:31) at kotlinx.coroutines.DispatchedTask.run(Unknown Source:109) at kotlinx.coroutines.EventLoopImplPlatform.processUnconfinedEvent(Unknown Source:23) at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(Unknown Source:116) at kotlinx.coroutines.CancellableContinuationImpl.completeResume(Unknown Source:2) at kotlinx.coroutines.channels.BufferedChannelKt.tryResume0(Unknown Source:6) at kotlinx.coroutines.channels.BufferedChannel.tryResumeReceiver(Unknown Source:94) at kotlinx.coroutines.channels.BufferedChannel.access$updateCellSend(Unknown Source:51) at kotlinx.coroutines.channels.BufferedChannel.trySend-JP2dKIU(Unknown Source:107) at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(SourceFile:12) at androidx.compose.runtime.Recomposer$recompositionRunner$2$unregisterApplyObserver$1.invoke(SourceFile:2) at androidx.compose.runtime.snapshots.SnapshotKt.advanceGlobalSnapshot(Unknown Source:73) at androidx.compose.material.Strings$Companion.sendApplyNotifications(Unknown Source:29) at androidx.compose.ui.platform.GlobalSnapshotManager$ensureStarted$1.invokeSuspend(Unknown Source:73) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(Unknown Source:8) at kotlinx.coroutines.DispatchedTask.run(Unknown Source:109) at androidx.compose.ui.platform.AndroidUiDispatcher.access$performTrampolineDispatch(Unknown Source:22) at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.run(Unknown Source:2) at android.os.Handler.handleCallback(Handler.java:938) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loopOnce(Looper.java:201) at android.os.Looper.loop(Looper.java:288) at android.app.ActivityThread.main(ActivityThread.java:7842) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003) Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@921ce6a, Dispatchers.Main.immediate]

Support `onClickOutside`

Would be useful for developing screens that have a dismiss action when clicking outside the zoomable image.

Crash while panning around

A user got a crash while panning around an image. I don't have the image to reproduce it, but hopefully the crash logs will be enough :

Thread: main, Exception: java.lang.IllegalStateException: Size is unspecified
at androidx.compose.ui.geometry.Size.getWidth-impl(Size.kt:48)
at me.saket.telephoto.zoomable.internal.DimensKt.roundToIntSize-uvyYCjk(dimens.kt:13)
at me.saket.telephoto.zoomable.internal.ContentPlacementKt$calculateTopLeftToOverlapWith$alignedOffset$2.invoke-nOcc-ac(contentPlacement.kt:33)
at me.saket.telephoto.zoomable.internal.ContentPlacementKt$calculateTopLeftToOverlapWith$alignedOffset$2.invoke(contentPlacement.kt:28)
at kotlin.UnsafeLazyImpl.getValue(Lazy.kt:81)
at me.saket.telephoto.zoomable.internal.ContentPlacementKt.calculateTopLeftToOverlapWith_x_KDEd0$lambda$1(contentPlacement.kt:28)
at me.saket.telephoto.zoomable.internal.ContentPlacementKt.calculateTopLeftToOverlapWith-x_KDEd0(contentPlacement.kt:45)
at me.saket.telephoto.zoomable.ZoomableState$coerceWithinBounds$1.invoke-MK-Hz9U(ZoomableState.kt:331)
at me.saket.telephoto.zoomable.ZoomableState$coerceWithinBounds$1.invoke(ZoomableState.kt:329)
at me.saket.telephoto.zoomable.internal.DimensKt.withZoomAndTranslate-aysBKyA(dimens.kt:54)
at me.saket.telephoto.zoomable.ZoomableState.coerceWithinBounds-8S9VItk(ZoomableState.kt:329)
at me.saket.telephoto.zoomable.ZoomableState.access$coerceWithinBounds-8S9VItk(ZoomableState.kt:88)
at me.saket.telephoto.zoomable.ZoomableState$transformableState$1.invoke-0DeBYlg(ZoomableState.kt:260)
at me.saket.telephoto.zoomable.ZoomableState$transformableState$1.invoke(ZoomableState.kt:197)
at me.saket.telephoto.zoomable.internal.DefaultTransformableState$transformScope$1.transformBy-0DeBYlg(transformableState.kt:111)
at me.saket.telephoto.zoomable.internal.TransformScope$-CC.transformBy-0DeBYlg$default(transformableState.kt:66)
at me.saket.telephoto.zoomable.ZoomableState$fling$2$1.invoke(ZoomableState.kt:470)
at me.saket.telephoto.zoomable.ZoomableState$fling$2$1.invoke(ZoomableState.kt:469)
at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrame(SuspendAnimation.kt:361)
at androidx.compose.animation.core.SuspendAnimationKt.doAnimationFrameWithScale(SuspendAnimation.kt:339)
at androidx.compose.animation.core.SuspendAnimationKt.access$doAnimationFrameWithScale(SuspendAnimation.kt:1)
at androidx.compose.animation.core.SuspendAnimationKt$animate$6.invoke(SuspendAnimation.kt:251)
at androidx.compose.animation.core.SuspendAnimationKt$animate$6.invoke(SuspendAnimation.kt:239)
at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:304)
at androidx.compose.animation.core.SuspendAnimationKt$callWithFrameNanos$2.invoke(SuspendAnimation.kt:303)
at androidx.compose.runtime.BroadcastFrameClock$FrameAwaiter.resume(BroadcastFrameClock.kt:42)
at androidx.compose.runtime.BroadcastFrameClock.sendFrame(BroadcastFrameClock.kt:71)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$2.invoke(Recomposer.kt:517)
at androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$2.invoke(Recomposer.kt:510)
at androidx.compose.ui.platform.AndroidUiFrameClock$withFrameNanos$2$callback$1.doFrame(AndroidUiFrameClock.android.kt:34)
at androidx.compose.ui.platform.AndroidUiDispatcher.performFrameDispatch(AndroidUiDispatcher.android.kt:109)
at androidx.compose.ui.platform.AndroidUiDispatcher.access$performFrameDispatch(AndroidUiDispatcher.android.kt:41)
at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.doFrame(AndroidUiDispatcher.android.kt:69)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1035)
at android.view.Choreographer.doCallbacks(Choreographer.java:845)
at android.view.Choreographer.doFrame(Choreographer.java:775)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1022)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7870)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@b094cc0, androidx.compose.runtime.BroadcastFrameClock@4d85df9, StandaloneCoroutine{Cancelling}@458113e, AndroidUiDispatcher@2b46b9f]

IllegalStateException: Invalid velocity = (-Infinity, -Infinity) px/sec

We have quite a few crashes like this from Google Play:

Exception java.lang.IllegalStateException: Invalid velocity = (-Infinity, -Infinity) px/sec
  at me.saket.telephoto.zoomable.ZoomableState.fling-BMRW4eQ$zoomable_release (ZoomableState.java:498)
  at me.saket.telephoto.zoomable.ZoomableKt$zoomable$1$zoomableModifier$2$1.invokeSuspend (Zoomable.kt:65)
  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
  at kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:108)
  at androidx.compose.ui.platform.AndroidUiDispatcher.performTrampolineDispatch (AndroidUiDispatcher.android.kt:81)
  at androidx.compose.ui.platform.AndroidUiDispatcher.access$performTrampolineDispatch (AndroidUiDispatcher.android.kt:41)
  at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.run (AndroidUiDispatcher.android.kt:57)
  at android.os.Handler.handleCallback (Handler.java:958)
  at android.os.Handler.dispatchMessage (Handler.java:99)
  at android.os.Looper.loopOnce (Looper.java:205)
  at android.os.Looper.loop (Looper.java:294)
  at android.app.ActivityThread.main (ActivityThread.java:8177)
  at java.lang.reflect.Method.invoke
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:552)
  at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:971)

Not sure how to reproduce, but would be good if we could somehow handle it gracefully without a crash.

Handle overscroll?

In the pager sample, horizontal overscroll is achieved automatically via the pager hitting its bounds. This doesn't give vertical overscroll though.

Should zoomable things trigger their own overscroll when panning at the edges? I would think so, as Modifier.scrollable() gives things overscroll at their scroll edges. Is it possible to do that without consuming the touches, thereby allowing e.g. parent pagers to still work?

Missing APK certificate in release 0.3.0

I wanted to try and install the APK you provided for the 0.3.0 release. But it seems the APK has been generated without signing it, making it impossible to install (without manually signing it). Do you mind uploading it signed with a default debug keystore?

FWIW it could be automated in the build pipeline.

OnClick stops being called after rapidly clicking

I have a few cases where onClick stopped being called. This case, where I rapidly click the ZoomableAsyncImage is the one I can reliable reproduce.

I have a onClick functionality that enables immersive mode. In some weird cases it gets stuck in this mode.

code if relevant
 ZoomableAsyncImage(
                    contentScale = ContentScale.Fit,
                    model = image,
                    imageLoader = imageGifLoader,
                    contentDescription = null,
                    onClick = {
                        showTopBar = !showTopBar
                        systemUiController.isSystemBarsVisible = showTopBar

                        // Default behavior is that if navigation bar is hidden, the system will "steal" touches
                        // and show it again upon user's touch. We just want the user to be able to show the
                        // navigation bar by swipe, touches are handled by custom code -> change system bar behavior.
                        // Alternative to deprecated SYSTEM_UI_FLAG_IMMERSIVE.
                        systemUiController.systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
                    },
                    modifier = Modifier.fillMaxSize(),
                )

(v1.0.0-alpha02)

https://github.com/MV-GH/jerboa/blob/feb5954c6c0d96763aef4822b4623a66c620713e/app/src/main/java/com/jerboa/ui/components/imageviewer/ImageViewerActivity.kt#L153

The video below illustrates the problem:

In the first part I show the correct behaviour.

In the second part, It attempt to reproduce the problem. You cant really see it but i am rapidly clicking until it gets stuck.

In the third part I am stuck in immersive mode.

mwhxFkhJKx.mp4

Restricting Zoom and Scroll Direction (X/Y Axis Only) and Callback for Current ZoomableState Updates.

Hello, first off, thanks for your work on this library. I'm currently integrating it into a project where I need to restrict zoom and scroll functionality to either the X or Y axis. Additionally, I'm looking for a way to access and update the current ZoomableState during zoom events via a callback. Is there an existing method in the library to handle these requirements, or are these features something that could be considered for future development?

Possible resource leak

I have enabled StrictMode with LeakedClosable detection. To search for resources leaks that are reported in the logs.

Possibly one is coming from this library.

StrictMode policy violation: android.os.strictmode.LeakedClosableViolation: A resource was acquired at attached stack trace but never released. See java.io.Closeable for information on avoiding resource leaks.
	at android.os.StrictMode$AndroidCloseGuardReporter.report(StrictMode.java:1924)
	at dalvik.system.CloseGuard.warnIfOpen(CloseGuard.java:303)
	at java.io.FileInputStream.finalize(FileInputStream.java:500)
	at java.lang.Daemons$FinalizerDaemon.doFinalize(Daemons.java:292)
	at java.lang.Daemons$FinalizerDaemon.runInternal(Daemons.java:279)
	at java.lang.Daemons$Daemon.run(Daemons.java:140)
	at java.lang.Thread.run(Thread.java:923)
Caused by: java.lang.Throwable: Explicit termination method 'close' not called
	at dalvik.system.CloseGuard.openWithCallSite(CloseGuard.java:259)
	at dalvik.system.CloseGuard.open(CloseGuard.java:230)
	at java.io.FileInputStream.<init>(FileInputStream.java:176)
	at okio.Okio__JvmOkioKt.source(JvmOkio.kt:181)
	at okio.Okio.source(Unknown Source:1)
	at okio.JvmSystemFileSystem.source(JvmSystemFileSystem.kt:96)
	at me.saket.telephoto.subsamplingimage.internal.ExifMetadata$Companion$read$2.invokeSuspend(ExifMetadata.kt:32)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

I get this pretty consistent on opening and closing a composable with ZoomableAsyncImage

Looked further into it. It passes a inputStream to ExifInterface that is not cleaned up.

  • Reads Exif tags from the specified image input stream. Attribute mutation is not supported
    * for input streams. The given input stream will proceed from its current position. Developers
    * should close the input stream after use. This constructor is not intended to be used with
    * an input stream that performs any networking operations.

This says consumers should close the resource and this is not done. It seems that a simple inputStream.close after exif should be sufficient. (at ExifMetadata$read)

[Improvement] Supports loading file URI

So, we are trying to use telephoto to render some images from internal storage.

Basically we were just using this with Coil, and it was working correctly :

 AsyncImage(
        modifier = Modifier.fillMaxSize(),
        model = uri,
        contentDescription = "MediaImage"
    )

And now we use the same way :

 ZoomableAsyncImage(
        modifier = Modifier.fillMaxSize(),
        model = uri,
        contentDescription = "MediaImage"
    )

The uri has this form : /data/user/0/appId/cache/.tmpy7ImGl.jpeg

After some investigation I found that this code is failing :

try {
        decoder.value = factory.create(context, imageSource, imageOptions)
      } catch (e: IOException) {
        errorReporter.onImageLoadingFailed(e, imageSource)
      }

Where the imageSource is coming from this method :

private fun ImageResult.toSubSamplingImageSource(imageLoader: ImageLoader): SubSamplingImageSource? {
    val result = this
    val requestData = result.request.data
    val preview = (result.drawable as? BitmapDrawable)?.bitmap?.asImageBitmap()

    if (result is SuccessResult && result.drawable is BitmapDrawable) {
      // Prefer reading of images directly from files whenever possible because
      // that is significantly faster than reading from their input streams.
      val imageSource = when {
        result.diskCacheKey != null -> {
          val diskCache = imageLoader.diskCache!!
          val cached = diskCache[result.diskCacheKey!!] ?: error("Coil returned a null image from disk cache")
          SubSamplingImageSource.file(cached.data, preview)
        }
        result.dataSource.let { it == DataSource.DISK || it == DataSource.MEMORY_CACHE } -> when {
          requestData is Uri -> SubSamplingImageSource.contentUri(requestData, preview)
          requestData is String -> SubSamplingImageSource.contentUri(Uri.parse(requestData), preview)
          result.request.context.isResourceId(requestData) -> SubSamplingImageSource.resource(requestData, preview)
          else -> null
        }
        else -> null
      }

      if (imageSource != null) {
        return imageSource
      }
    }

    return null
  }

So I guess we are missing some check on the RequestData.Uri so it can be used with SubSamplingImageSource.file() instead.

Coil is doing some mapping internally like that :

internal class FileUriMapper : Mapper<Uri, File> {

    override fun map(data: Uri, options: Options): File? {
        if (!isApplicable(data)) return null
        if (data.scheme == SCHEME_FILE) {
            return data.path?.let(::File)
        } else {
            // If the scheme is not "file", it's null, representing a literal path on disk.
            // Assume the entire input, regardless of any reserved characters, is valid.
            return File(data.toString())
        }
    }

    private fun isApplicable(data: Uri): Boolean {
        return !isAssetUri(data) &&
            data.scheme.let { it == null || it == SCHEME_FILE } &&
            data.path.orEmpty().startsWith('/') && data.firstPathSegment != null
    }
}

Let me know if this is something you want to fix or if I've to do the conversion on my side.
Thanks!

Exif image rotation for ZoomableAsyncImage w/ coil

When swapping out AsyncImage with ZoomableAsyncImage exif rotation seems to be ignored. AsyncImage shows my image in the correct orientation, while ZoomableAsyncImage does not.

I saw #30 marked as fixed so this might have regressed (or never worked for Coil)?

Crash when opening portrait photos in landscape

After updating from 0.5.0 to 0.6.1 I get crashes when opening portrait photos in landscape. Im using the following code (works with 0.5.0):

ZoomableAsyncImage(
    model = photoUrl,
    contentDescription = null,
    modifier = Modifier
        .fillMaxSize()
        .background(Color.Black)
)

The crash:

java.lang.IllegalArgumentException: Cannot coerce value to an empty range: maximum 1080 is less than minimum 1102.
at kotlin.ranges.RangesKt___RangesKt.coerceIn(_Ranges.kt:1413)
at me.saket.telephoto.subsamplingimage.internal.DimensKt.coerceIn-dFh3_74(dimens.kt:16)
at me.saket.telephoto.subsamplingimage.internal.TileGridGeneratorKt.generate-JPKKBEo(tileGridGenerator.kt:32)
at me.saket.telephoto.subsamplingimage.SubSamplingImageStateKt$rememberSubSamplingImageState$5$1$invokeSuspend$$inlined$flatMapLatest$1.invokeSuspend(Merge.kt:216)
at me.saket.telephoto.subsamplingimage.SubSamplingImageStateKt$rememberSubSamplingImageState$5$1$invokeSuspend$$inlined$flatMapLatest$1.invoke(Unknown Source:21)
at me.saket.telephoto.subsamplingimage.SubSamplingImageStateKt$rememberSubSamplingImageState$5$1$invokeSuspend$$inlined$flatMapLatest$1.invoke(Unknown Source:4)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3$1$2.invokeSuspend(Merge.kt:34)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3$1$2.invoke(Unknown Source:8)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3$1$2.invoke(Unknown Source:4)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startCoroutineUndispatched(Undispatched.kt:44)
at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:112)
at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:126)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56)
at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:47)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3$1.emit(Merge.kt:33)
at me.saket.telephoto.subsamplingimage.SubSamplingImageStateKt$rememberSubSamplingImageState$5$1$invokeSuspend$$inlined$filter$1$2.emit(Emitters.kt:223)
at kotlinx.coroutines.flow.FlowKt__TransformKt$filterNotNull$$inlined$unsafeTransform$1$2.emit(Emitters.kt:223)
at kotlinx.coroutines.flow.internal.SafeCollectorKt$emitFun$1.invoke(SafeCollector.kt:15)
at kotlinx.coroutines.flow.internal.SafeCollectorKt$emitFun$1.invoke(SafeCollector.kt:15)
at kotlinx.coroutines.flow.internal.SafeCollector.emit(SafeCollector.kt:87)
at kotlinx.coroutines.flow.internal.SafeCollector.emit(SafeCollector.kt:66)
at androidx.compose.runtime.SnapshotStateKt__SnapshotFlowKt$snapshotFlow$1.invokeSuspend(SnapshotFlow.kt:133)
at androidx.compose.runtime.SnapshotStateKt__SnapshotFlowKt$snapshotFlow$1.invoke(Unknown Source:8)
at androidx.compose.runtime.SnapshotStateKt__SnapshotFlowKt$snapshotFlow$1.invoke(Unknown Source:4)
at kotlinx.coroutines.flow.SafeFlow.collectSafely(Builders.kt:61)
at kotlinx.coroutines.flow.AbstractFlow.collect(Flow.kt:230)
at kotlinx.coroutines.flow.FlowKt__TransformKt$filterNotNull$$inlined$unsafeTransform$1.collect(SafeCollector.common.kt:114)
at me.saket.telephoto.subsamplingimage.SubSamplingImageStateKt$rememberSubSamplingImageState$5$1$invokeSuspend$$inlined$filter$1.collect(SafeCollector.common.kt:113)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3.invokeSuspend(Merge.kt:27)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3.invoke(Unknown Source:8)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest$flowCollect$3.invoke(Unknown Source:4)
at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:78)
at kotlinx.coroutines.CoroutineScopeKt.coroutineScope(CoroutineScope.kt:264)
at kotlinx.coroutines.flow.internal.ChannelFlowTransformLatest.flowCollect(Merge.kt:25)
at kotlinx.coroutines.flow.internal.ChannelFlowOperator.collectTo$suspendImpl(ChannelFlow.kt:157)
at kotlinx.coroutines.flow.internal.ChannelFlowOperator.collectTo(Unknown Source:0)
at kotlinx.coroutines.flow.internal.ChannelFlow$collectToFun$1.invokeSuspend(ChannelFlow.kt:60)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:103)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@8f2a5b1, androidx.compose.runtime.BroadcastFrameClock@5b2aa96, StandaloneCoroutine{Cancelling}@3108617, AndroidUiDispatcher@3ae4a04]
```

Support disabling user pan/zoom

In our image viewer, we want the image to not be pannable/zoomable while certain UI elements are active. It would be great to be able to specify that.

New Feature: Min & Max Zoom Level

I suggest adding min and max zoom options to image component for better UX.
This would let us control zoom levels, ensuring a smoother experience.

Ex:
minZoom: Float = 1f,
maxZoom: Float = 5f

Handle encrypted (maybe also encoded) files

Hi,

many thanks for creating and sharing this library. I tried migrating from SSIV (subsampling-scale-image-view) and failed because in my use case the files are encrypted. The access happens thru SAF. In SSIV I could provide a BitmapRegionDecoder that handled the decryption.

I think I could make it work by implementing the interface SubSamplingImageSource that is currently sealed.

In a second step I would like to use ZoomableAsyncImage instead of SubSamplingImage. I pass a custom data type into the ImageRequest.Builder and register a custom Fetcher for Coil that handles decryption. Because of that your ZoomableAsyncImage is already able to display my encrypted images however without subsampling. In the file CoilImageSource.kt the method ImageResult.toSubSamplingImageSource(...) would need to be adjusted to be able to map custom request data to custom SubSamplingImageSource.

Likely this is just one possible solution.

Many thanks in advance. I know that my use case might be quite rare.

Crash when using custom Uris

In our app, we are using uris with custom scheme to fetch a specific type of images. They are of this form:

localdb://poiimageprovider/image-data?requestid=...

We then have a Fetcher for coil that loads the image data from database, something like

class DbUriFetcherFactory : Fetcher.Factory<Uri> {
  override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
    return Fetcher {
      if (data.scheme == "localdb") {
        // load data and return `SourceResult`
      } else null
    }
  }
}

This works fine with coil, but telephoto tries to access the uri using ContentResolver and crashes:

java.io.FileNotFoundException: No content provider: localdb://poiimageprovider/image-data?requestid=...
at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2029)
at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1858)
at android.content.ContentResolver.openInputStream(ContentResolver.java:1528)
at me.saket.telephoto.subsamplingimage.UriImageSource.peek(SubSamplingImageSource.kt:187)
at me.saket.telephoto.zoomable.coil.SubSamplingEligibilityKt.isSvg(subSamplingEligibility.kt:56)
at me.saket.telephoto.zoomable.coil.SubSamplingEligibilityKt.canBeSubSampled(subSamplingEligibility.kt:26)
at me.saket.telephoto.zoomable.coil.Resolver.toSubSamplingImageSource(CoilImageSource.kt:153)
at me.saket.telephoto.zoomable.coil.Resolver.work(CoilImageSource.kt:109)

I'm not quite sure whether this should work or not, our solution is probably not great and maybe we should actually use ContentProvider for our uris instead of Fetcher, but it worked so far 😀
For now we fixed that by using model class instead of plain Uri.

Question: Edge detection

val state = rememberZoomableState()
val painter = resourcePainter(R.drawable.example)

LaunchedEffect(painter.intrinsicSize) {
  state.setContentLocation(
    ZoomableContentLocation.scaledInsideAndCenterAligned(painter.intrinsicSize)
  )
}

I'm wondering if this (Edge detection Function) only works with painter or others like ( AsyncImage ) ?

Support lower sdk version

Hello and thanks for this project!
However, would that be possible to use a lower sdk version? Or are you relying on some API not available before?
Would appreciate a minSdk = 23 (even 21 if nothing prevents it) :-)

EDIT ./gradlew check with minSdk=23 is passing!

Reading of images from Coil's disk cache is leaking snapshot editors

Coil's documentation states that usages of DiskCache#get will leak snapshot editors if they aren't closed.

IMPORTANT: You must call either [Snapshot.close] or [Snapshot.closeAndEdit] when finished
reading the snapshot. An open snapshot prevents editing the entry or deleting it on disk.

Telephoto will need to find a way to dispose cache snapshots when the image is no longer being displayed.

val cached = diskCache[result.diskCacheKey!!] ?: error("Coil returned a null image from disk cache")

Modifier.zoomable() applied separately from ZoomableAsyncImage doesn't force the image to stay in bounds when zoomed-in

I tried to apply zoomable to a video player and found out that there is this bug.

MRE:

val zoomSpec = remember { ZoomSpec(maxZoomFactor = 3f) }
val zoomableState = rememberZoomableState(zoomSpec = zoomSpec)

// AsyncImage from coil
AsyncImage(
  modifier = modifier
    .zoomable(state = zoomableState),
  model = ImageRequest.Builder(LocalContext.current)
    .data(imageUrl)
    .crossfade(300)
    .build(),
  contentDescription = "",
)
device-2023-05-27-155256.webm

It works when using ZoomableAsyncImage, though. Only doesn't work when using Modifier.zoomable() separately.
Can't tell for sure what might be the cause.

Crash when refreshContentTransformation is called

Using ZoomableAsyncImages inside HorizontalPager. If I use animateScrollToPage on PagerState to second next page (suppose not loaded yet) i get crash:

java.lang.IllegalStateException: Check failed.
	at me.saket.telephoto.zoomable.ZoomableState.refreshContentTransformation$zoomable_release(ZoomableState.kt:367)
	at me.saket.telephoto.zoomable.ZoomableStateKt$rememberZoomableState$1$1.invokeSuspend(ZoomableState.kt:82)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at androidx.compose.ui.platform.AndroidUiDispatcher.performTrampolineDispatch(AndroidUiDispatcher.android.kt:81)
	at androidx.compose.ui.platform.AndroidUiDispatcher.access$performTrampolineDispatch(AndroidUiDispatcher.android.kt:41)
	at androidx.compose.ui.platform.AndroidUiDispatcher$dispatchCallback$1.run(AndroidUiDispatcher.android.kt:57)
	at android.os.Handler.handleCallback(Handler.java:938)
	at android.os.Handler.dispatchMessage(Handler.java:99)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.app.ActivityThread.main(ActivityThread.java:7839)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@c0693d9, androidx.compose.runtime.BroadcastFrameClock@2af419e, StandaloneCoroutine{Cancelling}@8d1447f, AndroidUiDispatcher@d330c4c]

Occurs every time.

Support for listening to scale changes

I am currently using https://github.com/Baseflow/PhotoView in our app and we have a requirement to log analytics event if an user interacted with the image (zoom/pan etc.)

For this, we use the provided setOnScaleChangeListener method which gets a callback from https://github.com/Baseflow/PhotoView/blob/565505d5cb84f5977771b5d2ccb7726338e77224/photoview/src/main/java/com/github/chrisbanes/photoview/PhotoViewAttacher.java#LL151C18-L151C18

It would be great if telephoto could support something similar.

Expose `RelativeContentLocation` as `ZoomableContentLocation.relative`

In our use case our Image has a ContentScale.Fit using that with either ZoomableContentLocation.scaledInsideAndCenterAligned(canvasSize) or ZoomableContentLocation.unscaledAndTopStartAligned(canvasSize) in ZoomableState.setContentLocation gave a weirdly scaled image in case the image is smaller than screen resolution.

Copying the RelativeContentLocation from the source code and using RelativeContentLocation(canvasSize, ContentScale.Fit, Alignment.Center) gave me the same result i would expect from using ContentScale.Fit. This leads me to believe that it is worth to expose a ZoomableContentLocation.relative companion function that passes the variables to RelativeContentLocation and returns it back as ZoomableContentLocation

If accepted I can make a PR

Support placeholder attribute for ZoomableAsyncImage

Coil's AsyncImage has a placeholder attribute: placeholder: Painter? = null.

Please add support for it in ZoomableAsyncImage.


In the docs there's mention of using placeholders using placeholderMemoryCacheKey() but in Coil's website this is used for regular views, not Compose. And even if it can be used with Compose it would be much easier to support the placeholder attribute directly.

[Bug] onClick does not work after a double tap zoom gesture

When using Modifier.zoomable(), the onClick is not called if zoomed using double tap.

Sample code:

Image(
    modifier = Modifier
        .fillMaxSize()
        .zoomable(
            state = zoomableState,
            onClick = { _ ->
                Log.d(TAG, "click")
            },
        ),
    painter = it,
    contentDescription = null,
)

The above onClick log is printed initially, but after doing a double tap it stop logging a single tap. On debugging it seems the variable isQuickZooming remains true even after the zoom animation is completed.

Crash when caching is disabled by HTTP header `Cache-Control`

My server is returning images with these HTTP headers.

Cache-Control: private, no-cache, no-store, must-revalidate
Expires: -1
Pragma: no-cache

This causes crash when used in Telephoto with Coil. The crash does not happen when using only Coil.

In my app (can't share), using me.saket.telephoto:zoomable-image-coil:0.5.0, the image shows up for the first time, and the crash occurs when the screen is re-opened and Telephoto attempts to access is from the cache.

FATAL EXCEPTION: main @coroutine#3232
Process: my.epic.app, PID: 11053
java.io.FileNotFoundException: No content provider: http://10.0.2.2:3000/my-fking-image
	at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2013)
	at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1842)
	at android.content.ContentResolver.openInputStream(ContentResolver.java:1518)
	at me.saket.telephoto.subsamplingimage.UriImageSource.peek(SubSamplingImageSource.kt:187)
	at me.saket.telephoto.zoomable.coil.SubSamplingEligibilityKt.isSvg(subSamplingEligibility.kt:56)
	at me.saket.telephoto.zoomable.coil.SubSamplingEligibilityKt.canBeSubSampled(subSamplingEligibility.kt:26)
	at me.saket.telephoto.zoomable.coil.Resolver.toSubSamplingImageSource(CoilImageSource.kt:153)
	at me.saket.telephoto.zoomable.coil.Resolver.work(CoilImageSource.kt:109)
	at me.saket.telephoto.zoomable.coil.Resolver$work$1.invokeSuspend(Unknown Source:14)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)

In the telephoto:sample app, at the latest commit, it crashes right away.

FATAL EXCEPTION: main
Process: me.saket.telephoto.sample, PID: 20902
java.io.FileNotFoundException: No content provider: http://10.0.2.2:3000/my-fking-image
	at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2013)
	at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1842)
	at android.content.ContentResolver.openInputStream(ContentResolver.java:1518)
	at me.saket.telephoto.subsamplingimage.UriImageSource.peek(SubSamplingImageSource.kt:187)
	at me.saket.telephoto.subsamplingimage.internal.ExifMetadata$Companion$read$2.invokeSuspend(ExifMetadata.kt:36)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)
	Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [androidx.compose.ui.platform.MotionDurationScaleImpl@8104426, androidx.compose.runtime.BroadcastFrameClock@f14c867, StandaloneCoroutine{Cancelling}@792e614, AndroidUiDispatcher@31e3ebd]

Reproduction steps

  1. Let's mock a server somehow - using Mockoon, open this environment, change the body file to any image on your disk, and launch it.
  2. In emulator, the endpoint should be accessible at http://10.0.2.2:3000/my-fking-image
  3. In the telephoto project, apply these changes containg the URL and other stuff - patch file
  4. Launch the sample app and open the Breakfast item. Observe the crash.
  5. (bonus) If you uncomment respectCacheHeaders(false), the app no longer crashes, but it gets stuck in cycle, observe "Start" being printed over and over in the log, image never showing up.

Expected: App works with "no-cache" HTTP headers, not attempting to save to cache, either memory or disk, and not crashing. Furthermore, respectCacheHeaders(false) should allow me to use cache, see.

Support programmatically changing pan/zoom

It would be nice to be able to programmatically manipulate the transformation of the image, initially and over time.
Use cases:

  • A comic reader wants to start at the top, probably one of the corners
  • A map wants to start viewing a specific location

Crash: Channel is unrecoverably broken and will be disposed

Sometimes when I rapidly zoom in a large image (~30MB) it crashes with the error below and my background turns black.

The stacktrace is one line:
channel '54b5b73 com.jerboa.debug/com.jerboa.MainActivity (server)' ~ Channel is unrecoverably broken and will be disposed!

The images were if relevant:

Swapping between multiple images

Hi! I've been trying to use telephoto with a slightly specific need: I have multiple images that get swapped between different zoom levels (basically, more zoomed: more detail available on the image). Previous code used TileView (https://github.com/moagrius/TileView) which required the image to be split in many, many tiles. My current attempt swaps just the model depending on ZoomableState.zoomFraction. However, telephoto's code emits events as the zoom goes on, and ends up calling .location on an Unspecified state. Which is not surprising, it'll be the first time that image gets loaded, so no re-init can be done, so it crashes directly.

(Along with other undesired behaviors that happen with double taps, etc, but then again, that's probably not an expected use case anyways)

java.lang.UnsupportedOperationException
at me.saket.telephoto.zoomable.ZoomableContentLocation$Unspecified.location-TmRCtEA(ZoomableContentLocation.kt:75)
at me.saket.telephoto.zoomable.ZoomableContentLocation$Unspecified.location-TmRCtEA(ZoomableContentLocation.kt:73)
at me.saket.telephoto.zoomable.ZoomableState$unscaledContentBounds$2.invoke(ZoomableState.kt:184)
at me.saket.telephoto.zoomable.ZoomableState$unscaledContentBounds$2.invoke(ZoomableState.kt:183)
at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:2200)
at androidx.compose.runtime.DerivedSnapshotState.currentRecord(DerivedState.kt:161)
at androidx.compose.runtime.DerivedSnapshotState.getValue(DerivedState.kt:224)
at me.saket.telephoto.zoomable.ZoomableState.getUnscaledContentBounds(ZoomableState.kt:582)
at me.saket.telephoto.zoomable.ZoomableState.access$getUnscaledContentBounds(ZoomableState.kt:88)
at me.saket.telephoto.zoomable.ZoomableState$transformableState$1.invoke-0DeBYlg(ZoomableState.kt:201)
at me.saket.telephoto.zoomable.ZoomableState$transformableState$1.invoke(ZoomableState.kt:197)
at me.saket.telephoto.zoomable.internal.DefaultTransformableState$transformScope$1.transformBy-0DeBYlg(transformableState.kt:111)
at me.saket.telephoto.zoomable.internal.TransformableKt$transformable$3$1$1.invokeSuspend(transformable.kt:103)
...

Is this something you've already considered / do not plan on supporting ? I could probably have one ZoomableAsyncImage per layer, and attempt to transfer various state info as we transition from layer to layer, but I just wanted to check in here before. Absolutely understand if this usecase is not something you plan on supporting.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.