All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.me.saket.telephoto.zoomable.ZoomableContentLocation.kt Maven / Gradle / Ivy

There is a newer version: 0.14.0
Show newest version
package me.saket.telephoto.zoomable

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isUnspecified
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.times
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.toOffset
import me.saket.telephoto.zoomable.internal.discardFractionalParts

/**
 * [Modifier.zoomable] uses [ZoomableContentLocation] to understand the content's visual size and position
 * in order to prevent it from going out of bounds during pan & zoom gestures.
 *
 * The default value is [ZoomableContentLocation.SameAsLayoutBounds].
 *
 * [Modifier.zoomable] can't calculate this on its own by relying on the layout bounds of the content.
 * because they may be smaller or larger than the content's visual size. For example, an `Image` composable
 * that uses `Modifier.fillMaxSize()` could actually be drawing an image that only fills half its height.
 * Another example is a sub-sampled composable such as a map whose full sized content could be at an
 * order of magnitude larger than its layout bounds.
 */
interface ZoomableContentLocation {
  companion object {
    /**
     * Describes a zoomable content's location that is positioned in the center of its layout
     * and is downscaled only if its size exceeds its layout bounds while maintaining its
     * original aspect ratio.
     *
     * That is, its alignment is [Alignment.Center] and scale is [ContentScale.Inside].
     */
    @Stable
    fun scaledInsideAndCenterAligned(size: Size?): ZoomableContentLocation {
      return when {
        size == null || size.isUnspecified -> Unspecified
        else -> RelativeContentLocation(
          size = size,
          scale = ContentScale.Inside,
          alignment = Alignment.Center,
        )
      }
    }

    /**
     * Describes a zoomable content's location that is positioned at 0,0 of its layout
     * and is never scaled.
     *
     * That is, its alignment is [Alignment.TopStart] and scale is [ContentScale.None].
     */
    @Stable
    fun unscaledAndTopStartAligned(size: Size?): ZoomableContentLocation {
      return when {
        size == null || size.isUnspecified -> Unspecified
        else -> RelativeContentLocation(
          size = size,
          scale = ContentScale.None,
          alignment = Alignment.TopStart,
        )
      }
    }
  }

  /**
   * A placeholder value for indicating that the zoomable content's location
   * isn't calculated yet. The content will stay hidden until this is replaced.
   */
  object Unspecified : ZoomableContentLocation {
    override fun size(layoutSize: Size) = throw UnsupportedOperationException()
    override fun location(layoutSize: Size, direction: LayoutDirection) = throw UnsupportedOperationException()
    override fun toString(): String = this::class.simpleName!!
  }

  /**
   * The default value of [ZoomableContentLocation], intended to be used for content that
   * fills every pixel of its layout size.
   *
   * For richer content such as images whose visual size may not always match its layout
   * size, you should provide a different value using [ZoomableState.setContentLocation].
   */
  object SameAsLayoutBounds : ZoomableContentLocation {
    override fun size(layoutSize: Size): Size = layoutSize
    override fun location(layoutSize: Size, direction: LayoutDirection) = Rect(Offset.Zero, layoutSize)
  }

  fun size(layoutSize: Size): Size

  fun location(layoutSize: Size, direction: LayoutDirection): Rect
}

internal val ZoomableContentLocation.isSpecified
  get() = this !is ZoomableContentLocation.Unspecified

/**
 * This isn't public because only a few combinations of [ContentScale]
 * and [Alignment] work perfectly for all kinds of content.
 */
@Immutable
internal data class RelativeContentLocation(
  private val size: Size,
  private val scale: ContentScale,
  private val alignment: Alignment,
) : ZoomableContentLocation {
  override fun size(layoutSize: Size): Size = size

  override fun location(layoutSize: Size, direction: LayoutDirection): Rect {
    val scaleFactor = scale.computeScaleFactor(
      srcSize = size,
      dstSize = layoutSize,
    )
    val scaledSize = size * scaleFactor
    val alignedOffset = alignment.align(
      size = scaledSize.discardFractionalParts(),
      space = layoutSize.discardFractionalParts(),
      layoutDirection = direction,
    )
    return Rect(
      offset = alignedOffset.toOffset(),
      size = scaledSize
    )
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy