Skip to content

Commit

Permalink
Retain pan across image changes
Browse files Browse the repository at this point in the history
Fixes #104
  • Loading branch information
saket committed Dec 10, 2024
1 parent f743f3e commit 1274e79
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ class ZoomableImageTest {
modifier = Modifier
.fillMaxSize()
.testTag("image"),
image = ZoomableImageSource.asset(assetName, subSample = true),
image = ZoomableImageSource.subSampledAssetWithPreview(assetName),
contentDescription = null,
state = rememberZoomableImageState(
rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 5f))
Expand All @@ -286,12 +286,11 @@ class ZoomableImageTest {

assetName = "fox_1500.jpg"
rule.waitUntil {
val isTargetImage = state.zoomableState.contentTransformation.contentSize == Size(1500f, 1000f)
state.isImageDisplayedInFullQuality && isTargetImage
}
rule.runOnIdle {
dropshots.assertSnapshot(rule.activity, testName.methodName + "_[after]")
state.zoomableState.contentTransformation.contentSize == Size(1500f, 1000f)
}
// This does not use runOnIdle() because the image's
// centroid should be retained immediately on the next frame.
dropshots.assertSnapshot(rule.activity, testName.methodName + "_[after]")
}

@Test fun various_image_sizes_and_alignments(
Expand Down Expand Up @@ -1950,6 +1949,26 @@ internal fun ZoomableImageSource.Companion.asset(assetName: String, subSample: B
}
}

@Composable
internal fun ZoomableImageSource.Companion.subSampledAssetWithPreview(assetName: String): ZoomableImageSource {
return remember(assetName) {
object : ZoomableImageSource {
@Composable
override fun resolve(canvasSize: Flow<Size>): ResolveResult {
val context = LocalContext.current
val assetBitmap = remember {
context.assets.open(assetName).use(BitmapFactory::decodeStream)!!.asImageBitmap()
}
return ResolveResult(
delegate = ZoomableImageSource.SubSamplingDelegate(
SubSamplingImageSource.asset(assetName, preview = assetBitmap)
),
)
}
}
}
}

@Composable
internal fun ZoomableImageSource.withDelay(duration: Duration): ZoomableImageSource {
val delegate = this
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.MutatePriority
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
Expand All @@ -36,14 +39,18 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.util.lerp
import me.saket.telephoto.zoomable.ContentZoomFactor.Companion.ZoomDeltaEpsilon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import me.saket.telephoto.zoomable.ZoomableContentLocation.SameAsLayoutBounds
import me.saket.telephoto.zoomable.internal.MutatePriorities
import me.saket.telephoto.zoomable.internal.PlaceholderBoundsProvider
import me.saket.telephoto.zoomable.internal.RealZoomableContentTransformation
import me.saket.telephoto.zoomable.internal.TransformableState
import me.saket.telephoto.zoomable.internal.Zero
import me.saket.telephoto.zoomable.internal.ZoomableSavedState
import me.saket.telephoto.zoomable.internal.aspectRatio
import me.saket.telephoto.zoomable.internal.calculateTopLeftToOverlapWith
import me.saket.telephoto.zoomable.internal.coerceIn
import me.saket.telephoto.zoomable.internal.copy
Expand All @@ -59,6 +66,7 @@ import me.saket.telephoto.zoomable.internal.times
import me.saket.telephoto.zoomable.internal.unaryMinus
import me.saket.telephoto.zoomable.internal.withOrigin
import me.saket.telephoto.zoomable.internal.withZoomAndTranslate
import me.saket.telephoto.zoomable.internal.zipWithPrevious
import kotlin.jvm.JvmInline
import kotlin.math.abs

Expand Down Expand Up @@ -513,6 +521,7 @@ internal class RealZoomableState internal constructor(
) / animatedZoom
)
)
// Note to self: this can't use transformableState#transformBy() to bypass its offset-locking system.
gestureState = GestureStateCalculator {
startGestureState.copy(
userOffset = animatedOffsetForUi.userOffset,
Expand Down Expand Up @@ -585,6 +594,36 @@ internal class RealZoomableState internal constructor(
}
}

@Composable
fun RetainPanAcrossImageChangesEffect() {
LaunchedEffect(this) {
withContext(Dispatchers.Main.immediate) { // To avoid flickers.
snapshotFlow { currentGestureStateInputs }
.mapNotNull { it?.unscaledContentBounds?.size }
.zipWithPrevious(::Pair)
.filter { (previous, current) ->
abs(current.aspectRatio() - previous.aspectRatio()) < ZoomDeltaEpsilon
}
.collect { (previous, current) ->
val scale = ScaleFactor(
scaleX = current.width / previous.width,
scaleY = current.height / previous.height,
)
// This unfortunately cancels any ongoing zoom/pan animations. It would be excellent
// to support updating the offset without interrupting animations in the future.
val currentGestureState = calculateGestureState()!!
transformableState.transform(MutatePriority.PreventUserInput) {
gestureState = GestureStateCalculator {
currentGestureState.copy(
userOffset = currentGestureState.userOffset * scale
)
}
}
}
}
}
}

private fun calculateGestureState(): GestureState? {
return currentGestureStateInputs?.let(gestureState::calculate)
}
Expand Down Expand Up @@ -702,9 +741,6 @@ internal data class ContentZoomFactor(
}

companion object {
/** Differences below this value are ignored when comparing two zoom values. */
const val ZoomDeltaEpsilon = 0.001f

fun minimum(baseZoom: BaseZoomFactor, range: ZoomRange): ContentZoomFactor {
return ContentZoomFactor(
baseZoom = baseZoom,
Expand Down Expand Up @@ -735,12 +771,18 @@ internal data class ContentZoomFactor(
}
}

/** Differences below this value are ignored when comparing two zoom values. */
private const val ZoomDeltaEpsilon = 0.001f

/** Offset applied by the user on top of a base offset. Similar to [UserZoomFactor]. */
@JvmInline
@Immutable
internal value class UserOffset(val value: Offset) {
operator fun minus(other: Offset): UserOffset =
UserOffset(value.minus(other))

operator fun times(factor: ScaleFactor): UserOffset =
UserOffset(value.times(factor))
}

internal data class ContentOffset(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ fun rememberZoomableState(
it.zoomSpec = zoomSpec
it.hardwareShortcutsSpec = hardwareShortcutsSpec
it.layoutDirection = LocalLayoutDirection.current
it.RetainPanAcrossImageChangesEffect()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ internal operator fun Offset.times(zoom: ContentZoomFactor): Offset =
internal operator fun Size.times(zoom: ContentZoomFactor): Size =
times(zoom.finalZoom())

internal fun Size.aspectRatio(): Float =
width / height

internal operator fun UserZoomFactor.times(operand: Float): UserZoomFactor =
UserZoomFactor(value.times(operand))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package me.saket.telephoto.zoomable.internal

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

internal fun <T, R> Flow<T>.zipWithPrevious(
mapper: (previous: T, current: T) -> R,
): Flow<R> = flow {
// Mutex locking isn't needed for UI, which is single threaded.
var previousValue: T? = null
collect { currentValue ->
previousValue?.let { previousValue ->
emit(mapper(previousValue, currentValue))
}
previousValue = currentValue
}
}

0 comments on commit 1274e79

Please sign in to comment.