From 38f1c55c7a089a1992bf1f45ea3890409de83dee Mon Sep 17 00:00:00 2001 From: Eva Tatarka Date: Thu, 28 Mar 2024 09:58:00 -0700 Subject: [PATCH] Add keyboard support Intial stab at this, I'm not sure all the code lives in the right place and looking for feedback on that. Additional issues/questions: - Is there a way to get the 'standard' system keyboard shortcuts for this? - How to detech ctrl/meta + key press? I never saw them detected though I'm testing with the android emulator which does werid things with external keybaords. - keyboard input enabled by default? note: view needs to be focused to start receiving events - configure zoom & pan steps? Fixes #78 --- .../me/saket/telephoto/sample/navigation.kt | 27 ++-- .../sample/viewer/MediaViewerScreen.kt | 3 +- .../saket/telephoto/zoomable/ZoomableImage.kt | 1 + .../telephoto/zoomable/RealZoomableState.kt | 53 ++++++- .../me/saket/telephoto/zoomable/Zoomable.kt | 45 ++++++ .../zoomable/internal/keyboardActions.kt | 139 ++++++++++++++++++ 6 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/internal/keyboardActions.kt diff --git a/sample/src/main/kotlin/me/saket/telephoto/sample/navigation.kt b/sample/src/main/kotlin/me/saket/telephoto/sample/navigation.kt index df5f493f..ceb1e2f7 100644 --- a/sample/src/main/kotlin/me/saket/telephoto/sample/navigation.kt +++ b/sample/src/main/kotlin/me/saket/telephoto/sample/navigation.kt @@ -23,20 +23,19 @@ internal fun Navigation( val navigator = rememberCircuitNavigator(backstack) Box(Modifier.fillMaxSize()) { - for (record in backstack.take(2).asReversed()) { - key(record.key) { - when (val screen = record.screen) { - is GalleryScreenKey -> { - GalleryScreen( - key = screen, - navigator = navigator - ) - } - is MediaViewerScreenKey -> { - MediaViewerScreen( - key = screen - ) - } + val record = backstack.first() + key(record.key) { + when (val screen = record.screen) { + is GalleryScreenKey -> { + GalleryScreen( + key = screen, + navigator = navigator + ) + } + is MediaViewerScreenKey -> { + MediaViewerScreen( + key = screen + ) } } } diff --git a/sample/src/main/kotlin/me/saket/telephoto/sample/viewer/MediaViewerScreen.kt b/sample/src/main/kotlin/me/saket/telephoto/sample/viewer/MediaViewerScreen.kt index e280923d..b75f44b4 100644 --- a/sample/src/main/kotlin/me/saket/telephoto/sample/viewer/MediaViewerScreen.kt +++ b/sample/src/main/kotlin/me/saket/telephoto/sample/viewer/MediaViewerScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -28,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import coil.request.ImageRequest import kotlinx.coroutines.delay @@ -96,7 +98,6 @@ private fun MediaPage( val zoomableState = rememberZoomableState() val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.05f) CloseScreenOnFlickDismissEffect(flickState) - FlickToDismiss( state = flickState, modifier = Modifier.background(backgroundColorFor(flickState.gestureState)), diff --git a/zoomable-image/core/src/main/kotlin/me/saket/telephoto/zoomable/ZoomableImage.kt b/zoomable-image/core/src/main/kotlin/me/saket/telephoto/zoomable/ZoomableImage.kt index bf6a0dab..88e306f0 100644 --- a/zoomable-image/core/src/main/kotlin/me/saket/telephoto/zoomable/ZoomableImage.kt +++ b/zoomable-image/core/src/main/kotlin/me/saket/telephoto/zoomable/ZoomableImage.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/RealZoomableState.kt b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/RealZoomableState.kt index 13cb850f..dd21c443 100644 --- a/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/RealZoomableState.kt +++ b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/RealZoomableState.kt @@ -19,10 +19,12 @@ 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.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center import androidx.compose.ui.geometry.isFinite import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.lerp @@ -30,6 +32,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ScaleFactor import androidx.compose.ui.layout.times import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.toOffset @@ -264,6 +267,11 @@ internal class RealZoomableState internal constructor( ) } + internal fun canConsumeKeyboardPan(): Boolean { + val zoomFactor = gestureState?.userZoomFactor ?: return false + return zoomFactor.value > 1f + } + internal fun canConsumePanChange(panDelta: Offset): Boolean { val baseZoomFactor = baseZoomFactor ?: return false // Content is probably not ready yet. Ignore this gesture. val current = gestureState ?: return false @@ -409,19 +417,54 @@ internal class RealZoomableState internal constructor( ) } + internal suspend fun zoomBy(factor: Float) { + val startTransformation = gestureState ?: return + val baseZoomFactor = baseZoomFactor ?: return + val targetZoom = ContentZoomFactor(baseZoomFactor, startTransformation.userZoomFactor * factor) + .coerceUserZoomIn(zoomSpec.range) + val visualCenter = contentLayoutSize.center + smoothlyZoomTo( + targetZoom = targetZoom, + centroid = visualCenter + ) + } + + internal suspend fun panBy(delta: Offset) { + transformableState.transform(MutatePriority.UserInput) { + var previous = Offset.Zero + AnimationState( + typeConverter = Offset.VectorConverter, + initialValue = Offset.Zero, + ).animateTo(delta) { + transformBy(panChange = previous - value) + previous = value + } + } + } + private suspend fun smoothlyToggleZoom( shouldZoomIn: Boolean, centroid: Offset + ) { + val baseZoomFactor = baseZoomFactor ?: return + smoothlyZoomTo( + targetZoom = if (shouldZoomIn) { + ContentZoomFactor.maximum(baseZoomFactor, zoomSpec.range) + } else { + ContentZoomFactor.minimum(baseZoomFactor, zoomSpec.range) + }, + centroid = centroid, + ) + } + + private suspend fun smoothlyZoomTo( + targetZoom: ContentZoomFactor, + centroid: Offset ) { val startTransformation = gestureState ?: return val baseZoomFactor = baseZoomFactor ?: return val startZoom = ContentZoomFactor(baseZoomFactor, startTransformation.userZoomFactor) - val targetZoom = if (shouldZoomIn) { - ContentZoomFactor.maximum(baseZoomFactor, zoomSpec.range) - } else { - ContentZoomFactor.minimum(baseZoomFactor, zoomSpec.range) - } val targetOffset = startTransformation.offset .retainCentroidPositionAfterZoom( diff --git a/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/Zoomable.kt b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/Zoomable.kt index ac96755b..75c7200c 100644 --- a/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/Zoomable.kt +++ b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/Zoomable.kt @@ -3,8 +3,12 @@ package me.saket.telephoto.zoomable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.node.CompositionLocalConsumerModifierNode @@ -12,9 +16,12 @@ import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.requireDensity import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import kotlinx.coroutines.launch +import me.saket.telephoto.zoomable.internal.KeyboardActionsElement import me.saket.telephoto.zoomable.internal.MutatePriorities import me.saket.telephoto.zoomable.internal.TappableAndQuickZoomableElement import me.saket.telephoto.zoomable.internal.TransformableElement @@ -62,6 +69,7 @@ fun Modifier.zoomable( onLongClick = onLongClick, ) ) + .focusable() .thenIf(state.autoApplyTransformations) { Modifier.applyTransformation(state.contentTransformation) } @@ -99,6 +107,10 @@ private data class ZoomableElement( } } +// TODO: make configurable? +private const val KeyboardZoomStep = 1.2f +private val KeyboardPanStep = 50.dp + @OptIn(ExperimentalFoundationApi::class) private class ZoomableNode( private var state: RealZoomableState, @@ -137,6 +149,21 @@ private class ZoomableNode( } } } + val onKeyboardZoom: (Float) -> Unit = { factor -> + coroutineScope.launch { + state.zoomBy(factor) + } + } + val onKeyboardResetZoom: () -> Unit = { + coroutineScope.launch { + state.resetZoom() + } + } + val onKeyboardPan: (DpOffset) -> Unit = { delta -> + coroutineScope.launch { + state.panBy(with(requireDensity()) { Offset(x = delta.x.toPx(), y = delta.y.toPx()) }) + } + } private val tappableAndQuickZoomableNode = TappableAndQuickZoomableElement( gesturesEnabled = enabled, @@ -156,10 +183,20 @@ private class ZoomableNode( lockRotationOnZoomPan = false, ).create() + private val keyboardActionsNode = KeyboardActionsElement( + zoomStep = KeyboardZoomStep, + panStep = KeyboardPanStep, + canPan = state::canConsumeKeyboardPan, + onZoom = onKeyboardZoom, + onResetZoom = onKeyboardResetZoom, + onPan = onKeyboardPan, + ).create() + init { // Note to self: the order in which these nodes are delegated is important. delegate(tappableAndQuickZoomableNode) delegate(transformableNode) + delegate(keyboardActionsNode) } fun update( @@ -189,6 +226,14 @@ private class ZoomableNode( transformableState = state.transformableState, gesturesEnabled = enabled, ) + keyboardActionsNode.update( + zoomStep = KeyboardZoomStep, + panStep = KeyboardPanStep, + canPan = state::canConsumeKeyboardPan, + onZoom = onKeyboardZoom, + onResetZoom = onKeyboardResetZoom, + onPan = onKeyboardPan, + ) } } diff --git a/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/internal/keyboardActions.kt b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/internal/keyboardActions.kt new file mode 100644 index 00000000..5eadae45 --- /dev/null +++ b/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/internal/keyboardActions.kt @@ -0,0 +1,139 @@ +package me.saket.telephoto.zoomable.internal + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.KeyInputModifierNode +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +/** + * Responds to keyboard events to zoom and pan + */ +internal data class KeyboardActionsElement( + private var zoomStep: Float, + private var panStep: Dp, + private var canPan: () -> Boolean, + private var onZoom: (Float) -> Unit, + private var onResetZoom: () -> Unit, + private var onPan: (DpOffset) -> Unit, +) : ModifierNodeElement() { + override fun create(): KeyboardActionsNode { + return KeyboardActionsNode( + zoomStep = zoomStep, + panStep = panStep, + canPan = canPan, + onZoom = onZoom, + onResetZoom = onResetZoom, + onPan = onPan, + ) + } + + override fun update(node: KeyboardActionsNode) { + node.update( + zoomStep = zoomStep, + panStep = panStep, + canPan = canPan, + onZoom = onZoom, + onResetZoom = onResetZoom, + onPan = onPan, + ) + } +} + +internal class KeyboardActionsNode( + private var zoomStep: Float, + private var panStep: Dp, + private var canPan: () -> Boolean, + private var onZoom: (Float) -> Unit, + private var onResetZoom: () -> Unit, + private var onPan: (DpOffset) -> Unit, +) : Modifier.Node(), KeyInputModifierNode { + override fun onKeyEvent(event: KeyEvent): Boolean { + //TODO: how to detect the correct key combos? + println("onKeyEvent: ${event.key} isCtrlPressed=${event.isCtrlPressed} isMetaPressed:${event.isMetaPressed}") + when { + event.key == Key.ZoomIn || event.key == Key.Plus || event.key == Key.Equals -> { + if (event.type == KeyEventType.KeyDown) { + onZoom(zoomStep) + } + return true + } + event.key == Key.ZoomOut || event.key == Key.Minus -> { + if (event.type == KeyEventType.KeyDown) { + onZoom(1 / zoomStep) + } + return true + } + event.key == Key.Zero -> { + if (event.type == KeyEventType.KeyDown) { + onResetZoom() + } + return true + } + } + if (canPan()) { + when (event.key) { + Key.DirectionUp -> { + val offset = DpOffset(x = 0.dp, y = -panStep) + if (event.type == KeyEventType.KeyDown) { + onPan(offset) + } + return true + } + Key.DirectionDown -> { + val offset = DpOffset(x = 0.dp, y = panStep) + if (event.type == KeyEventType.KeyDown) { + onPan(offset) + } + return true + } + Key.DirectionLeft -> { + val offset = DpOffset(x = -panStep, y = 0.dp) + if (event.type == KeyEventType.KeyDown) { + onPan(offset) + } + return true + } + Key.DirectionRight -> { + val offset = DpOffset(x = panStep, y = 0.dp) + if (event.type == KeyEventType.KeyDown) { + onPan(offset) + } + return true + } + } + } + return false + } + + override fun onPreKeyEvent(event: KeyEvent): Boolean { + return false + } + + fun update( + zoomStep: Float, + panStep: Dp, + canPan: () -> Boolean, + onZoom: (Float) -> Unit, + onResetZoom: () -> Unit, + onPan: (DpOffset) -> Unit, + ) { + this.zoomStep = zoomStep + this.panStep = panStep + this.canPan = canPan + this.zoomStep = zoomStep + this.onZoom = onZoom + this.onResetZoom = onResetZoom + this.onPan = onPan + } +}