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 saket#78
  • Loading branch information
evant committed Mar 28, 2024
1 parent 8f93f62 commit 38f1c55
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 20 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 @@ -19,17 +19,20 @@ 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
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ 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
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 +69,7 @@ fun Modifier.zoomable(
onLongClick = onLongClick,
)
)
.focusable()
.thenIf(state.autoApplyTransformations) {
Modifier.applyTransformation(state.contentTransformation)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
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
Loading

0 comments on commit 38f1c55

Please sign in to comment.