Skip to content

Commit

Permalink
Add keyboard support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
evant authored and saket committed May 9, 2024
1 parent a9045c9 commit 7015d6e
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 15 deletions.
27 changes: 13 additions & 14 deletions sample/src/main/kotlin/me/saket/telephoto/sample/navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package me.saket.telephoto.zoomable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
Expand All @@ -12,9 +13,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
Expand Down Expand Up @@ -62,6 +66,7 @@ fun Modifier.zoomable(
onLongClick = onLongClick,
)
)
.focusable()
.thenIf(state.autoApplyTransformations) {
Modifier.applyTransformation(state.contentTransformation)
}
Expand Down Expand Up @@ -99,6 +104,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,
Expand Down Expand Up @@ -137,6 +146,24 @@ private class ZoomableNode(
}
}
}
val onKeyboardZoom: (Float) -> Unit = { factor ->
coroutineScope.launch {
state.animateZoomBy(factor)
}
}
val onKeyboardResetZoom: () -> Unit = {
coroutineScope.launch {
state.resetZoom()
}
}
val onKeyboardPan: (DpOffset) -> Unit = { delta ->
coroutineScope.launch {
state.animatePanBy(
with(requireDensity()) {
Offset(x = delta.x.toPx(), y = delta.y.toPx())
})
}
}

private val tappableAndQuickZoomableNode = TappableAndQuickZoomableElement(
gesturesEnabled = enabled,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<KeyboardActionsNode>() {
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
}
}

0 comments on commit 7015d6e

Please sign in to comment.