Merge branch 'main' of git.arirex.me:iris/ai-app
The drag-right-to-go-back gesture was removed on the other side while the file explorer was being built on this one, so the explorer's own use of it goes with it: back inside the explorer is the arrow and the platform's own gesture, which is what the rest of the app now has too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9c4d33273b
8 files changed
+130
-109
No files matched your search
@@ -358,6 +358,10 @@ first if a remote spawn ever mangles an argument.
|
|||||||
reply into the tool output under it -- and a container per row leaves
|
reply into the tool output under it -- and a container per row leaves
|
||||||
whatever was drawn without one silently unselectable, which nothing on
|
whatever was drawn without one silently unselectable, which nothing on
|
||||||
screen reports. Rows keep their tap handlers; selection is a long press.
|
screen reports. Rows keep their tap handlers; selection is a long press.
|
||||||
|
**An inline code chip is drawn behind the text** rather than as the
|
||||||
|
renderer's span background, because a span background is part of the
|
||||||
|
text's own drawing and hid the selection under it -- see
|
||||||
|
`appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md.
|
||||||
- **A session can be moved to another directory** from the settings dialog
|
- **A session can be moved to another directory** from the settings dialog
|
||||||
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
||||||
directory is settled at spawn; the next message starts it in the new one.
|
directory is settled at spawn; the next message starts it in the new one.
|
||||||
|
|||||||
+1
-1
@@ -215,7 +215,7 @@ which highlighting is on -- see "Numbers to measure".
|
|||||||
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
||||||
the session stays composed under it: its event stream keeps flowing, its
|
the session stays composed under it: its event stream keeps flowing, its
|
||||||
scroll position and draft stay where they were, and returning from a file
|
scroll position and draft stay where they were, and returning from a file
|
||||||
costs nothing. Back -- the button, the platform gesture and `swipeBack` --
|
costs nothing. Back -- the button and the platform gesture --
|
||||||
clears `files` when it is set and goes to the list otherwise. Inside the
|
clears `files` when it is set and goes to the list otherwise. Inside the
|
||||||
explorer the same back steps one level: editor → viewer (with the unsaved
|
explorer the same back steps one level: editor → viewer (with the unsaved
|
||||||
question), viewer → listing, listing → parent directory it came from, and
|
question), viewer → listing, listing → parent directory it came from, and
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ the inline builder draws nothing for a node type it does not know (a week
|
|||||||
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
|
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
|
||||||
cells get the same treatment.
|
cells get the same treatment.
|
||||||
|
|
||||||
|
**An inline code chip is drawn behind the text, not as a span
|
||||||
|
background.** A `SpanStyle` background is part of the text's own drawing
|
||||||
|
and the text node draws the selection *under* the glyphs, so an opaque
|
||||||
|
chip hid the selection: selecting a sentence highlighted every word of it
|
||||||
|
except the ones in backticks, and there is no way to reorder that -- the
|
||||||
|
order is the node's. `appendCodeChip` therefore takes the code span from
|
||||||
|
the renderer's builder, keeps its style and its space of padding either
|
||||||
|
side but drops the background, and marks the range; `LinkedText` draws
|
||||||
|
those ranges in a `drawBehind`, which is under both the selection and the
|
||||||
|
glyphs -- the same place a fenced block's box already was, which is why
|
||||||
|
one of those always looked right. Geometry is one box per line, from the
|
||||||
|
bounding boxes of the run's first and last characters, taken as far as the
|
||||||
|
line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to
|
||||||
|
the right edge of every line but the last, which left a full-width empty
|
||||||
|
chip behind whenever the code wrapped, and `visibleEnd` is what makes the
|
||||||
|
chip and the selection rectangle stop in the same place. Measured against
|
||||||
|
the same build without it, streaming 60 paragraphs of three chips each:
|
||||||
|
measure 755ms against 776ms, record 327ms against 321ms, transcript draw
|
||||||
|
0.22ms in both -- noise.
|
||||||
|
|
||||||
**Text draws on the platform directly.** A paragraph without an image
|
**Text draws on the platform directly.** A paragraph without an image
|
||||||
skips the renderer's `MarkdownText`, which charges every paragraph for the
|
skips the renderer's `MarkdownText`, which charges every paragraph for the
|
||||||
possibility of inline images (placement callback, derived inline-content
|
possibility of inline images (placement callback, derived inline-content
|
||||||
|
|||||||
@@ -208,10 +208,10 @@ fun AppRoot(
|
|||||||
// row key. Only reachable since a notification can move straight from one session to
|
// row key. Only reachable since a notification can move straight from one session to
|
||||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||||
key(here.summary.id) {
|
key(here.summary.id) {
|
||||||
// The gesture goes on a box around the screen rather than inside it, so it is the
|
// A Box so the explorer can be drawn *over* the session rather than instead of
|
||||||
// outermost thing in the tree and everything within has already had its chance at
|
// it; the session stays composed underneath. No imePadding here, for the reason
|
||||||
// the drag. See [swipeBack]. No imePadding here, for the reason above.
|
// above -- the explorer adds its own, since it has a text field.
|
||||||
Box(Modifier.swipeBack(goToMain)) {
|
Box {
|
||||||
SessionScreen(
|
SessionScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
summary = here.summary,
|
summary = here.summary,
|
||||||
@@ -220,9 +220,8 @@ fun AppRoot(
|
|||||||
share = share,
|
share = share,
|
||||||
onShareTaken = { share = null },
|
onShareTaken = { share = null },
|
||||||
)
|
)
|
||||||
// Over the session, in the same Box, with the session still composed beneath
|
// Its own back handler is registered after this screen's, so it is the one the
|
||||||
// it. Its own back handler is registered after this screen's, so it is the one
|
// platform asks first, and it steps back inside itself before closing.
|
||||||
// the platform asks first, and it steps back inside itself before closing.
|
|
||||||
here.files?.let { target ->
|
here.files?.let { target ->
|
||||||
FilesScreen(
|
FilesScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
@@ -233,7 +232,7 @@ fun AppRoot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
Box(Modifier.imePadding()) {
|
||||||
SpawnScreen(
|
SpawnScreen(
|
||||||
settings = current,
|
settings = current,
|
||||||
onSpawned = { spawned ->
|
onSpawned = { spawned ->
|
||||||
@@ -244,7 +243,7 @@ fun AppRoot(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is Screen.Settings ->
|
is Screen.Settings ->
|
||||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
Box(Modifier.imePadding()) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
existing = current,
|
existing = current,
|
||||||
onSaved = { saved ->
|
onSaved = { saved ->
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
var creating by remember { mutableStateOf(false) }
|
var creating by remember { mutableStateOf(false) }
|
||||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||||
// because they are what back has to know about -- and back arrives from three places (the
|
// because they are what back has to know about -- and back arrives from two places, the arrow
|
||||||
// button, the platform gesture, the swipe) which must all mean the same thing.
|
// and the platform's own gesture, which must mean the same thing.
|
||||||
var editing by remember { mutableStateOf(false) }
|
var editing by remember { mutableStateOf(false) }
|
||||||
var dirty by remember { mutableStateOf(false) }
|
var dirty by remember { mutableStateOf(false) }
|
||||||
var askUnsaved by remember { mutableStateOf(false) }
|
var askUnsaved by remember { mutableStateOf(false) }
|
||||||
@@ -125,9 +125,6 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
|||||||
// layout note), so the explorer adds its own -- otherwise the editor types under the
|
// layout note), so the explorer adds its own -- otherwise the editor types under the
|
||||||
// keyboard.
|
// keyboard.
|
||||||
.imePadding()
|
.imePadding()
|
||||||
// Innermost wins, so this takes the gesture before the session's own swipe-to-list
|
|
||||||
// does, and the reader steps back through the explorer rather than out of it.
|
|
||||||
.swipeBack(::back)
|
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
when (val spot = here) {
|
when (val spot = here) {
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ private fun MarkdownRoot(
|
|||||||
// exactly a card's own fill, so a fenced block inside a tool call had no
|
// exactly a card's own fill, so a fenced block inside a tool call had no
|
||||||
// background at all and one in a reply read as a step *up* out of the page.
|
// background at all and one in a reply read as a step *up* out of the page.
|
||||||
codeBackground = rawSurface,
|
codeBackground = rawSurface,
|
||||||
// The same colour, but let through. An inline span's background is part of the
|
// The same colour. Not drawn by the renderer as a span background but by
|
||||||
// *text's* own drawing and the selection rectangle is drawn underneath it, so an
|
// [LinkedText] behind the text, so a selection lands on top of it as it does on a
|
||||||
// opaque chip hides the selection completely: selecting a sentence highlighted
|
// fenced block -- see `appendCodeChip`.
|
||||||
// every word of it except the ones in backticks, which is a difference in
|
inlineCodeBackground = rawSurface,
|
||||||
// appearance the reader has no way to account for. Translucent, the selection
|
|
||||||
// shows through and the chip still reads as one step down from the page -- there
|
|
||||||
// is no way to draw it over the selection instead, since the order is the text
|
|
||||||
// node's.
|
|
||||||
inlineCodeBackground = rawSurface.copy(alpha = INLINE_CODE_ALPHA),
|
|
||||||
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
|
// The same tint a code block gets, rather than the renderer's 2%-alpha default:
|
||||||
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
|
// two adjacent tints that differ by a fiftieth read as one flat block on a phone,
|
||||||
// so the table would have had a border-less grid and nothing saying where it began.
|
// so the table would have had a border-less grid and nothing saying where it began.
|
||||||
@@ -719,12 +714,3 @@ class ParsedReplies {
|
|||||||
ready.clear()
|
ready.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the inline-code chip's fill is its own colour, the rest being whatever it sits on.
|
|
||||||
*
|
|
||||||
* High enough that the chip is still a clear step down from the page, low enough that a selection
|
|
||||||
* under it changes what the chip looks like. Both halves are the point: at 1.0 the chip was the
|
|
||||||
* only part of a selected sentence that did not look selected.
|
|
||||||
*/
|
|
||||||
private const val INLINE_CODE_ALPHA = 0.6f
|
|
||||||
@@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.node.Ref
|
import androidx.compose.ui.node.Ref
|
||||||
@@ -96,12 +99,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
val layout = remember { Ref<TextLayoutResult>() }
|
val layout = remember { Ref<TextLayoutResult>() }
|
||||||
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
// The renderer's own rule for a style that names no colour: the theme's text colour.
|
||||||
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
|
||||||
|
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
|
||||||
|
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
|
||||||
|
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
|
||||||
|
// finds it set -- no state needed, and a relayout redraws the node anyway.
|
||||||
|
val chipFills = remember { Ref<List<Rect>>() }
|
||||||
|
val chipFill =
|
||||||
|
if (chips.isEmpty()) Modifier
|
||||||
|
else
|
||||||
|
Modifier.drawBehind {
|
||||||
|
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
|
||||||
|
}
|
||||||
BasicText(
|
BasicText(
|
||||||
text = text,
|
text = text,
|
||||||
modifier =
|
modifier =
|
||||||
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
|
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
|
||||||
// second one has to be answered from inside the text rather than left to the card.
|
// second one has to be answered from inside the text rather than left to the card.
|
||||||
modifier.pointerInput(text, onPlainTap) {
|
modifier.then(chipFill).pointerInput(text, onPlainTap) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
// Unconsumed is not required: something outside may already be tracking this
|
// Unconsumed is not required: something outside may already be tracking this
|
||||||
// press, and it is still the press that may land on a link.
|
// press, and it is still the press that may land on a link.
|
||||||
@@ -130,7 +144,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
|
|||||||
},
|
},
|
||||||
style = style,
|
style = style,
|
||||||
color = { color },
|
color = { color },
|
||||||
onTextLayout = { layout.value = it },
|
onTextLayout = {
|
||||||
|
layout.value = it
|
||||||
|
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,15 +205,83 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
|
|||||||
private const val LINK_URL = "url"
|
private const val LINK_URL = "url"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs
|
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
|
||||||
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes
|
* but with no background of its own -- if it is a code span; false leaves anything else to the
|
||||||
* through a cell filled in once both exist.
|
* renderer.
|
||||||
|
*
|
||||||
|
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
|
||||||
|
* background is part of the text's own drawing, and the text node draws the selection first and the
|
||||||
|
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
|
||||||
|
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier
|
||||||
|
* on the text is under both, which is where a fenced block's box already is and why one of those
|
||||||
|
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
|
||||||
|
*/
|
||||||
|
private fun appendCodeChip(
|
||||||
|
builder: AnnotatedString.Builder,
|
||||||
|
content: String,
|
||||||
|
node: ASTNode,
|
||||||
|
settings: AnnotatorSettings,
|
||||||
|
): Boolean {
|
||||||
|
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
|
||||||
|
builder.pushStringAnnotation(CODE_CHIP, "")
|
||||||
|
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
|
||||||
|
builder.append(' ')
|
||||||
|
// The backticks are the first and last children.
|
||||||
|
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
|
||||||
|
builder.append(' ')
|
||||||
|
builder.pop()
|
||||||
|
builder.pop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CODE_CHIP = "code"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
|
||||||
|
*
|
||||||
|
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
|
||||||
|
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
|
||||||
|
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
|
||||||
|
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the
|
||||||
|
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
|
||||||
|
* when the only thing to reach a line is the space a chip is padded with.
|
||||||
|
*
|
||||||
|
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
|
||||||
|
* line reads in one direction; mixed directions inside a code span would draw one box across the
|
||||||
|
* whole run rather than one per direction, and code spans are code.
|
||||||
|
*/
|
||||||
|
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
|
||||||
|
val rects = mutableListOf<Rect>()
|
||||||
|
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
|
||||||
|
val from = maxOf(start, getLineStart(line))
|
||||||
|
val to = minOf(end, getLineEnd(line, visibleEnd = true))
|
||||||
|
if (from >= to) continue
|
||||||
|
val head = getBoundingBox(from)
|
||||||
|
val tail = getBoundingBox(to - 1)
|
||||||
|
rects +=
|
||||||
|
Rect(
|
||||||
|
left = minOf(head.left, tail.left),
|
||||||
|
top = minOf(head.top, tail.top),
|
||||||
|
right = maxOf(head.right, tail.right),
|
||||||
|
bottom = maxOf(head.bottom, tail.bottom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return rects
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
|
||||||
|
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
|
||||||
|
* the annotator, so the reference goes through a cell filled in once both exist.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun plainLinkSettings(): AnnotatorSettings {
|
private fun plainLinkSettings(): AnnotatorSettings {
|
||||||
val cell = remember { Ref<AnnotatorSettings>() }
|
val cell = remember { Ref<AnnotatorSettings>() }
|
||||||
val annotator = remember {
|
val annotator = remember {
|
||||||
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
|
markdownAnnotator { content, node ->
|
||||||
|
appendPlainLink(this, content, node, cell.value!!) ||
|
||||||
|
appendCodeChip(this, content, node, cell.value!!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return annotatorSettings(annotator = annotator).also { cell.value = it }
|
return annotatorSettings(annotator = annotator).also { cell.value = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.foundation.gestures.Orientation
|
|
||||||
import androidx.compose.foundation.gestures.draggable
|
|
||||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dragging the screen to the right to step back to the one behind it.
|
|
||||||
*
|
|
||||||
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
|
|
||||||
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
|
|
||||||
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
|
|
||||||
* screen.
|
|
||||||
*
|
|
||||||
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
|
|
||||||
* screen, so anything inside that wants horizontal drags has already taken them by the time this
|
|
||||||
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
|
|
||||||
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
|
|
||||||
* sideways or a text selection working -- they are the components the reader meant, and this is
|
|
||||||
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
|
|
||||||
* untouched.
|
|
||||||
*
|
|
||||||
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
|
|
||||||
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
|
|
||||||
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
|
|
||||||
* right, since there is nothing forward of these screens to go to.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
|
|
||||||
val offset = remember { Animatable(0f) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
|
|
||||||
return draggable(
|
|
||||||
state =
|
|
||||||
rememberDraggableState { delta ->
|
|
||||||
// Rightward only: a leftward drag stays at zero rather than lifting the
|
|
||||||
// screen off its left edge, which would look like a gesture that does
|
|
||||||
// something and does not.
|
|
||||||
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
|
|
||||||
},
|
|
||||||
orientation = Orientation.Horizontal,
|
|
||||||
onDragStopped = {
|
|
||||||
if (offset.value >= travel) {
|
|
||||||
onBack()
|
|
||||||
// Straight back rather than animated: the screen this was moving is being
|
|
||||||
// replaced, and animating it home first would show the old one sliding back
|
|
||||||
// into place after the new one had arrived.
|
|
||||||
offset.snapTo(0f)
|
|
||||||
} else {
|
|
||||||
offset.animateTo(0f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Read inside the block, so following the finger is a draw-phase change and costs no
|
|
||||||
// recomposition of the screen being dragged.
|
|
||||||
.graphicsLayer { translationX = offset.value }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
|
|
||||||
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
|
|
||||||
Reference in new issue
Block a user