Telephoto

repository·trunk·Indexed 23 days ago

https://github.com/saket/telephoto

Building blocks for Jetpack Compose UI to create cohesive media experiences on Android, specifically focusing on zoom and pan gestures. It provides ZoomableImage as a replacement for the standard Image() composable with automatic sub-sampling for large images, Modifier.zoomable() for adding gesture detection to any composable (including videos), and a Zoomable Peek Overlay for transient, overlaid zoom effects.

Tokens
12K
Snippets
36
Records
60
Agent score
80%

What's inside Telephoto

  1. Overview of Telephoto building blocks

    trunk

    Telephoto provides building blocks for creating cohesive media experiences in Android Compose UI. It offers three primary ways to implement zoom and pan functionality:

    1. Zoomable Image: A drop-in replacement for the standard Image() composable. It supports pan and zoom gestures and includes automatic sub-sampling for large images to prevent memory issues.
    2. Modifier.zoomable(): A standalone Modifier that provides the gesture detection logic used by ZoomableImage. This can be applied to videos or any other non-image composables to enable zoom and pan.
    3. Zoomable Peek Overlay: A transient, overlaid zoom effect inspired by Instagram's UI patterns.
  2. Hoist ZoomableState for Shared Media

    trunk

    If your application displays different types of media (e.g., both Images and Videos) and you want consistent zoom behavior, you should hoist the ZoomableState using rememberZoomableState(). This allows the same state to be shared across different 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),
        )
      }
    }
  3. How widgets and connectors work together in InstantSearch

    trunk

    In InstantSearch, a widget is a self-contained UI component that you register on a search instance using search.addWidgets([...]). Each widget declares the search parameters it needs and renders part of the UI.

    InstantSearch merges all widget parameters into a single query and re-renders every widget whenever the results change.

    Every built-in widget is built on a connector (e.g., connectX). A connector exposes the underlying logic without rendering any UI. You can create a custom widget by passing your own render function to a connector and registering the resulting component with addWidgets.

  4. Enable keyboard and mouse shortcuts for zooming

    trunk

    To support keyboard and mouse shortcuts, the zoomable component must be focused. You can use a FocusRequester to request focus, for example, when the component is first displayed.

    Shortcuts can be customized or disabled by passing a custom HardwareShortcutsSpec to rememberZoomableState().

    val focusRequester = remember { FocusRequester() }
    LaunchedEffect(Unit) {
      // Automatically request focus when the image is displayed.
      focusRequester.requestFocus()
    }
    
    Box(
      Modifier
        .focusRequester(focusRequester)
        .zoomable(),
    )
  5. Convert between viewport and image coordinates

    trunk

    To map a touch location from the UI viewport to the actual coordinate space of the image (the zoomable content), use the coordinateSystem provided by the ZoomableState.

    1. Wrap the raw Offset in a SpatialOffset with CoordinateSpace.Viewport.
    2. Use coordinateSystem.offsetIn(CoordinateSpace.ZoomableContent) to perform the conversion.
    val zoomableState = rememberZoomableState()
    
    ZoomableAsyncImage(
      state = rememberZoomableImageState(zoomableState),
      model = "https://example.com/image.jpg",
      contentDescription = "…",
      onClick = { clickedAt: Offset ->
        val clickedAt = SpatialOffset(clickedAt, CoordinateSpace.Viewport)
        val offsetInImage = with(zoomableState.coordinateSystem) {
          clickedAt.offsetIn(CoordinateSpace.ZoomableContent)
        }
      }
    )
  6. Grab downloaded images (Low vs Full Resolution)

    trunk

    Low resolution

    Access down-sampled drawables (suitable for color extraction) by using request listeners provided by your image loading library (Coil or Glide).

    Full resolution

    ZoomableImage streams full-resolution images directly from disk. To obtain the full-resolution file, you should load it again from the cache using the library's specific mechanisms.

    Coil Example (Full Res):

    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.           
        }
      }
    }

    Glide Example (Full Res):

    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
        })
    }
  7. Observe image loading state

    trunk

    Use imageState.isImageDisplayed to determine if the full-quality image has finished loading. This property returns false while placeholders or thumbnails are still being shown. You can use this to toggle loading indicators like a CircularProgressIndicator.

    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()    
    }
  8. Configure Placeholders in ZoomableImage

    trunk

    To improve perceived performance, use lower-resolution images as placeholders. When combined with a cross-fade transition, ZoomableImage will smoothly swap the placeholder for the full-quality image.

    Warning: Placeholders are visually incompatible with Modifier.wrapContentSize() and ContentScale.Inside.

    // Coil example
    ZoomableAsyncImage(
      modifier = Modifier.fillMaxSize(),
      model = ImageRequest.Builder(LocalContext.current)
        .data("https://example.com/image.jpg")
        .placeholderMemoryCacheKey(…) // Use Coil's memory cache key for placeholder
        .crossfade(1_000)
        .build(),
      contentDescription = …
    )
    
    // Glide example
    ZoomableGlideImage(
      modifier = Modifier.fillMaxSize(),
      model = "https://example.com/image.jpg",
      contentDescription = …
    ) {
      it.thumbnail(…)   // or placeholder()
        .transition(withCrossFade(1_000))
    }
  9. Use ZoomableImage for pan & zoom support

    trunk
    Use ZoomableImage as a drop-in replacement for the standard Compose Image() composable. It provides built-in support for pan and zoom gestures and includes automatic sub-sampling for large images to prevent memory issues.
  10. Enable Keyboard and Mouse Shortcuts

    trunk

    To allow users to pan and zoom via keyboard or mouse, the ZoomableImage must be focused. You can use a FocusRequester to request focus automatically.

    Default Shortcuts

    ActionShortcut
    Zoom inControl + =
    Zoom outControl + -
    PanArrow keys
    Extra panAlt + arrow keys

    Custom shortcuts can be configured by passing a HardwareShortcutsSpec to rememberZoomableState().

    val focusRequester = remember { FocusRequester() }
    LaunchedEffect(Unit) {
      focusRequester.requestFocus()
    }
    
    ZoomableAsyncImage(
      modifier = Modifier.focusRequester(focusRequester),
      model = "https://example.com/image.jpg",
    )