feat(ui): refine the sheet's beats, the lip's return and the hop's sink and swell

This commit is contained in:
xXJsonDeruloXx committed 2026-10-11 14:11:04 -04:00
1 parent 1b75436bc9
commit b9d4ad2b0a
5 files changed
+58 -7

No files matched your search

@@ -138,12 +138,12 @@ internal fun Pane(
pages[s.pageKey] = page
if (s.pageKey !in origins) PageOrigin.take()?.let { o ->
// A hop's page came out of its own rail item, and is that item's content.
origins[s.pageKey] = o.translate(-paneAt[0]).let { if (it.owner == null) it.ownedBy(railContent) else it }
origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it }
}
} else if (target != before[0] && target !in origins) {
// A section's own page opened from a control in it (an emulator's card, a ROM's) floods
// out of that control and belongs to what was showing; a hop's comes out of its rail item.
PageOrigin.take()?.let { o -> origins[target] = o.translate(-paneAt[0]).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
}
before[0] = target
val targetNow by rememberUpdatedState(target)
@@ -175,8 +175,16 @@ internal fun Pane(
},
label = "pane",
) { key ->
val shown = (if (page != null && key == s.pageKey) page else pages[key])
val drawn = (if (page != null && key == s.pageKey) page else pages[key])
?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) }
// A hop leaving this content: it sinks back as the dot leaves it (and comes forward
// again, freshly drawn, when the hop is undone).
val hopSink by androidx.compose.animation.core.animateFloatAsState(
if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink",
)
val shown: @Composable () -> Unit = {
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() }
}
run {
DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } }
val from = origins[key]
@@ -204,6 +212,14 @@ internal fun Pane(
}
}
/**
* An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's
* height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a
* bar that raced across from the far edge.
*/
internal fun inPane(o: Origin): Origin =
if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f))
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
@@ -483,6 +483,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
// Another section: no way back to keep, and no sheet to put back.
Hops.origin = null
Hops.restore = null
Hops.sinking = null
// Picked from the rail, Desktop starts at its hero; back from one of its pages it is where it was.
if (key == "desktop" && selected != "desktop") DesktopMemory.scroll = 0
selected = key
@@ -60,6 +60,9 @@ internal object Hops {
/** The hop the page on screen was reached by, while B can still undo it. */
var origin by mutableStateOf<HopOrigin?>(null)
/** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */
var sinking by mutableStateOf<String?>(null)
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
var railPreview by mutableStateOf<String?>(null)
@@ -100,6 +103,8 @@ internal object Hops {
arrived++
}
if (railAt == null || Motion.scale == 0f) { finish(); return }
// The surface the control is on sinks as its dot leaves, and stays down as it goes.
sinking = origin.rail
Flights.fly(Flight(
origin.bounds, to = { railAt.center },
onHalf = { railPreview = origin.dest },
@@ -114,6 +119,7 @@ internal object Hops {
fun back(close: () -> Unit): Boolean {
val o = origin ?: return false
origin = null
sinking = null
val railAt = railBounds[o.dest]
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
restore = o
@@ -130,7 +136,7 @@ internal object Hops {
/** The page the hop opened has gone some other way: there is no way back to keep. */
fun forget(pageKey: String?) {
val o = origin ?: return
if (pageKey != o.page) origin = null
if (pageKey != o.page) { origin = null; sinking = null }
}
/** Screen density, for the rail item's corner; set by the front end. */
@@ -221,6 +221,8 @@ internal fun LiftHero(
var travel by remember { mutableStateOf(1) }
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
var focusOnLift by remember { mutableStateOf<String?>(null) }
// Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone.
var lipBack by remember { mutableStateOf(false) }
val pull = remember { Animatable(0f) }
val sink = remember { Animatable(0f) }
val lift = remember { Animatable(0f) }
@@ -268,6 +270,7 @@ internal fun LiftHero(
}
LaunchedEffect(open, launching) {
if (open && !launching) {
lipBack = false
shown = true
onOpen()
fold.snapTo(0f); foldTop.snapTo(0f)
@@ -284,7 +287,8 @@ internal fun LiftHero(
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
}
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
// Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts.
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
if (inputMode.inputMode == InputMode.Keyboard) launch {
val control = focusOnLift
focusOnLift = null
@@ -313,8 +317,11 @@ internal fun LiftHero(
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
launch { sink.animateTo(0f, Motion.tw(300)) }
// The lip tucks back under Play as it lands, not once every spring has settled.
launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true }
}
shown = false
lipBack = false
lipGone.snapTo(0f)
hot.snapTo(1f)
} else {
@@ -332,11 +339,12 @@ internal fun LiftHero(
val touchOnly = inputMode.inputMode == InputMode.Touch
val lipFocus = remember { FocusRequester() }
var lipFocused by remember { mutableStateOf(false) }
val lipWanted = !open && !shown && (playFocused || playHovered || touchOnly || lipFocused)
val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused)
val lipNow = rememberUpdatedState(lip)
LaunchedEffect(lipWanted) {
if (lipWanted) {
delay(Motion.ms(160).toLong())
// Back from the sheet it comes straight away; otherwise a beat after Play has focus.
if (!lipBack) delay(Motion.ms(160).toLong())
val now = lipNow.value()
lit = lipChanged(LipMemory.seen[key], now)
LipMemory.seen[key] = now.toMap()
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.geometry.Rect
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class InPaneTest {
@Test fun aRailItemLeftOfThePaneStartsHalfInside() {
val o = inPane(Origin(Rect(-200f, 300f, -20f, 420f), 14f))
assertEquals(-90f, o.bounds.left, 0.01f)
assertEquals(90f, o.bounds.right, 0.01f)
assertEquals(300f, o.bounds.top, 0.01f)
}
@Test fun aControlInThePaneStaysPut() {
val origin = Origin(Rect(40f, 300f, 200f, 360f), 12f)
assertSame(origin, inPane(origin))
}
}