From b9d4ad2b0afbe53b2772742e4c33efedb2d53bc6 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 14:11:04 -0400 Subject: [PATCH] feat(ui): refine the sheet's beats, the lip's return and the hop's sink and swell --- .../droiddeck/launcher/ui/FrontEndContent.kt | 22 ++++++++++++++++--- .../droiddeck/launcher/ui/FrontEndScreen.kt | 1 + .../java/com/droiddeck/launcher/ui/Hop.kt | 8 ++++++- .../com/droiddeck/launcher/ui/LiftSheet.kt | 14 +++++++++--- .../com/droiddeck/launcher/ui/InPaneTest.kt | 20 +++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 0335c97cd..be4f6fc38 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 215f8d2ba..5b4345c03 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt index 359d2cb83..a5ee4c8f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt @@ -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(null) + /** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */ + var sinking by mutableStateOf(null) + /** Set while a hop's dot is flying: the rail item its selection has already glided to. */ var railPreview by mutableStateOf(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. */ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index 6a056d1a3..a35909ffc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -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(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() diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt new file mode 100644 index 000000000..b25eea49e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt @@ -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)) + } +}