feat(ui): shared chip glide and flight dot, and the hop between sections

This commit is contained in:
xXJsonDeruloXx committed 2026-10-10 18:21:36 -04:00
1 parent adc3369975
commit 4c2ed55e9f
11 files changed
+513 -133

No files matched your search

@@ -690,6 +690,7 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
@@ -1185,7 +1186,8 @@ class MainActivity : ComponentActivity() {
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { showComponents = false },
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
@@ -132,8 +132,14 @@ internal fun Pane(
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
if (page != null && s.pageKey != null) {
pages[s.pageKey] = page
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]).ownedBy(railContent) }
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 }
}
}
// A page leaving through a hop's link is not coming back to its control.
val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary
LaunchedEffect(target) { Hops.linking = false }
AnimatedContent(
targetState = target,
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
@@ -144,7 +150,7 @@ internal fun Pane(
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
.apply { targetContentZIndex = 1f }
// Back out of it: the flood draws back into the control as the pane comes forward again.
initialState in origins && pageExit(origins[initialState]?.owner, targetState, PageLeave.Ordinary) == PageExit.Drain ->
initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain ->
(fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
.togetherWith(ExitTransition.None)
.apply { targetContentZIndex = -1f }
@@ -167,7 +173,7 @@ internal fun Pane(
else {
val leaving = transition.targetState == EnterExitState.PostExit
// Settled as the page starts to leave, from what is coming in, not from where it came in.
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, PageLeave.Ordinary) == PageExit.Drain }
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain }
// Keeps a leaving page on screen while its flood draws back into the control.
transition.animateFloat(
transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() },
@@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list and the resume entry under it.
@@ -106,24 +109,49 @@ internal fun SideRail(
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
// A hop in flight moves the selection ahead of the page it is bringing.
val shown = Hops.railPreview ?: selected
val keys = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop")
if (s.storeEnabled) add("store")
add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
// Where each item sits in the column, for the selection that glides between them.
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
// One selection for the whole rail, under the items: it glides from item to item as the
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown], tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
modifier = Modifier.fillMaxWidth(),
) {
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
val place: (String) -> Modifier = { key ->
Modifier.onGloballyPositioned {
bounds[key] = it.boundsInParent()
Hops.railBounds[key] = it.boundsInRoot()
}
}
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") }
// The game stores, right under Games: what they install lands there. Its badge counts
// the downloads in flight, so a download started here is never out of sight.
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", shown == "store", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
}
}
}
var lastRunning by remember { mutableStateOf("") }
@@ -142,6 +170,8 @@ private fun RailItem(
badge: Boolean = false,
/** A number on the item (downloads in flight); 0 shows nothing. */
count: Int = 0,
/** Reports where the item sits, for the rail's gliding selection and a hop's dot. */
place: (String) -> Modifier = { Modifier },
onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
@@ -162,8 +192,9 @@ private fun RailItem(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
// The selected look is the rail's glide under the item, so the item draws only hover here.
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
@@ -172,6 +203,9 @@ private fun RailItem(
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.testTag("rail-$key")
.then(place(key))
// Pops as a hop's dot lands on it.
.landingPop(Hops.railLanded[key] ?: 0)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
@@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -189,6 +191,8 @@ class FrontEndActions(
val onProtons: () -> Unit,
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
val onComponents: (focusContent: Boolean) -> Unit,
/** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */
val onComponentsTab: (String) -> Unit = {},
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
@@ -423,7 +427,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
}
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
// A page reached by a hop goes back the way it came; any other page just closes.
val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() }
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() }
// Left any other way, the hop's page has no way back to keep.
LaunchedEffect(s.pageKey) { Hops.forget(s.pageKey) }
val density = androidx.compose.ui.platform.LocalDensity.current.density
remember(density) { Hops.density = density; true }
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
BackHandler(
@@ -443,6 +453,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
remember { Motion.refresh(ctx); true }
val railSelection = when {
// A hop's page belongs to the rail item it flew to.
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
s.pageKey?.startsWith("settings:steam") == true -> "steam"
s.pageKey?.startsWith("settings:") == true -> "desktop"
@@ -458,6 +470,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
if (key == "components") a.onComponents(focusContent)
else {
if (s.pageKey != null) a.onPageBack()
Hops.origin = null
selected = key
}
}
@@ -519,7 +532,20 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
val railFocus = Modifier
.focusProperties { enter = { frontFocus.railFor(railSelection) } }
.focusGroup()
Row(modifier = Modifier.fillMaxSize()) {
// Back from a hop onto a surface that is not a sheet (a game's Components card): focus
// goes back to the control that asked, once the page has gone.
LaunchedEffect(Hops.restore) {
val r = Hops.restore ?: return@LaunchedEffect
if (r.surface != HOP_SURFACE_GAMES) return@LaunchedEffect
Hops.restore = null
if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
frontFocus.last = r.control
focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() }
}
var screenAt by remember { mutableStateOf(Offset.Zero) }
Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) {
SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
@@ -535,6 +561,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
}
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
FlightsLayer(screenAt)
appToChooseDisplay?.let { app ->
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
ChooseAppDisplayDialog(
@@ -479,7 +479,15 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
add { m ->
val back = stringResource(R.string.hop_from_games)
// Components is its own section: the card hops there, and B on it comes back here.
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
}
}
add { m ->
Box(m) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
@@ -0,0 +1,164 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
// A link from a control in one section to a page in another (the Play sheet's Graphics drivers,
// a game's Components card): the control squashes into a dot that flies to the destination's rail
// item, the rail's selection glides there as it passes the top of its arc, and the page floods
// out of the rail item once it lands. B on that page flies the dot back and puts the source
// surface back as it was, focus on the control that asked.
/**
* Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the
* Games page) and its tab, the control's key and its root bounds, and the words for the way back.
* [page] is the page key the hop opened, so leaving it any other way forgets the way back.
*/
internal class HopOrigin(
val rail: String,
val surface: String,
val tab: String?,
val control: String,
val bounds: Rect,
val back: String,
val dest: String,
val page: String,
)
/** The surfaces a hop can start from and come back to. */
internal const val HOP_SURFACE_GAMES = "games"
internal const val HOP_SURFACE_SHEET = "sheet"
internal object Hops {
/** The hop the page on screen was reached by, while B can still undo it. */
var origin by mutableStateOf<HopOrigin?>(null)
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
var railPreview by mutableStateOf<String?>(null)
/**
* Set as a page leaves through a hop (either way), so the pane sinks it rather than draining
* it; the pane clears it once the change has started.
*/
var linking by mutableStateOf(false)
/** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */
var restore by mutableStateOf<HopOrigin?>(null)
/** Per rail item, bumped when a dot lands on it: the item pops. */
val railLanded = mutableStateMapOf<String, Int>()
/** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */
var arrived by mutableIntStateOf(0)
/** Where each rail item sits, root px. */
val railBounds = HashMap<String, Rect>()
/** Where each control that can start a hop sits, root px. */
val sources = HashMap<String, Rect>()
/**
* Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands.
* With animations off there is no dot and no flood, only the page.
*/
fun go(origin: HopOrigin, go: () -> Unit) {
val railAt = railBounds[origin.dest]
val finish: () -> Unit = {
railPreview = null
this.origin = origin
if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest))
linking = true
go()
railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1
arrived++
}
if (railAt == null || Motion.scale == 0f) { finish(); return }
Flights.fly(Flight(
origin.bounds, to = { railAt.center },
onHalf = { railPreview = origin.dest },
onLand = finish,
))
}
/**
* B on a page reached by a hop: the page sinks, the source surface comes back as it was, and
* the dot flies home from the rail item to the control. False when there is no hop to undo.
*/
fun back(close: () -> Unit): Boolean {
val o = origin ?: return false
origin = null
val railAt = railBounds[o.dest]
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
restore = o
linking = true
close()
if (railAt == null || Motion.scale == 0f) { railPreview = null; return true }
Flights.fly(Flight(
railAt, to = { (sources[o.control] ?: o.bounds).center },
onHalf = { railPreview = null },
))
return true
}
/** 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
}
/** Screen density, for the rail item's corner; set by the front end. */
var density = 1f
}
/** Remembers where this control sits, so a hop from it can fly from it and back to it. */
internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() }
/**
* "B · back to <source>" at the right of a page's header while it was reached by a hop; a tap is B.
* Nothing when it was not.
*/
@Composable
internal fun HopBackChip(onBack: () -> Unit) {
val o = Hops.origin ?: return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(99.dp)
Text(
stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
.background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f))
.glideBorder(hot, shape, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack)
.controllerConfirm(onClick = onBack)
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
@@ -0,0 +1,208 @@
package com.droiddeck.launcher.ui
import android.os.SystemClock
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.sin
// The moves the stores made first, for any section: one selection shape that glides along a row
// (or down a column) of choices, and a pressed control that squashes into a dot and flies home.
/** Which way a [ChipGlide]'s choices run. */
internal enum class GlideAxis { Horizontal, Vertical }
/**
* The selection under a row of chips: [at] (in the row) of chip [index], in [tint]. A move to the
* next chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a
* beat later on its trail spring - and a hop past a chip pinches it to a drop, carries it and
* opens it. [axis] runs it down a column instead (the rail, a stack of layers). [corner] is the
* chips' own corner; null draws a capsule. [fill] and [outline] are the resting alphas.
*/
@Composable
internal fun BoxScope.ChipGlide(
index: Int,
at: Rect?,
tint: State<Color>,
axis: GlideAxis = GlideAxis.Horizontal,
corner: Dp? = null,
fill: Float = 0.16f,
outline: Float = 0.7f,
) {
val density = androidx.compose.ui.platform.LocalDensity.current
// The two edges along the axis (left and right, or top and bottom), and the two across it.
val start = remember { Animatable(Float.NaN) }
val end = remember { Animatable(0f) }
val pinch = remember { Animatable(0f) }
val crossStart = remember { mutableStateOf(0f) }
val crossEnd = remember { mutableStateOf(0f) }
val was = remember { intArrayOf(-1) }
val vertical = axis == GlideAxis.Vertical
LaunchedEffect(index, at) {
val b = at ?: return@LaunchedEffect
val s = if (vertical) b.top else b.left
val e = if (vertical) b.bottom else b.right
crossStart.value = if (vertical) b.left else b.top
crossEnd.value = if (vertical) b.right else b.bottom
val from = was[0]
was[0] = index
if (start.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
start.snapTo(s); end.snapTo(e); pinch.snapTo(0f)
return@LaunchedEffect
}
coroutineScope {
if (abs(index - from) > 1) {
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
pinch.animateTo(1f, Motion.tw(90))
val l = launch { start.animateTo(s, Motion.sp(0.7f, 380f)) }
val r = launch { end.animateTo(e, Motion.sp(0.7f, 380f)) }
l.join(); r.join()
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
} else {
val forward = index > from
val (lead, trail) = if (forward) end to start else start to end
val leadTo = if (forward) e else s
val trailTo = if (forward) s else e
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
}
}
}
Canvas(Modifier.matchParentSize()) {
if (start.value.isNaN() || crossEnd.value <= crossStart.value) return@Canvas
val p = pinch.value
val shape = glideShape(start.value, end.value, crossStart.value, crossEnd.value, p, with(density) { 6.dp.toPx() }, vertical)
val at0 = shape.topLeft
val size = shape.size
val round = minOf(size.width, size.height) / 2f
val r = CornerRadius(corner?.let { lerp(minOf(with(density) { it.toPx() }, round), round, p) } ?: round)
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
drawRoundRect(tint.value.copy(alpha = lerp(fill, 0.9f, p)), at0, size, r)
if (outline > 0f) drawRoundRect(tint.value.copy(alpha = outline * (1f - p)), at0, size, r, style = Stroke(with(density) { 1.dp.toPx() }))
}
}
/**
* Where a glide's shape is: its edges along the axis at [start] and [end] (either order), across
* it from [crossStart] to [crossEnd], pinched [pinch] of the way (0 to 1) into a drop [drop] each
* side of its centre. [vertical] runs the axis down instead of across.
*/
internal fun glideShape(start: Float, end: Float, crossStart: Float, crossEnd: Float, pinch: Float, drop: Float, vertical: Boolean): Rect {
val a0 = minOf(start, end)
val a1 = maxOf(start, end)
val ca = (a0 + a1) / 2f
val cc = (crossStart + crossEnd) / 2f
val s0 = lerp(a0, ca - drop, pinch)
val s1 = lerp(a1, ca + drop, pinch)
val c0 = lerp(crossStart, cc - drop, pinch)
val c1 = lerp(crossEnd, cc + drop, pinch)
return if (vertical) Rect(c0, s0, c1, s1) else Rect(s0, c0, s1, c1)
}
/**
* A dot on its way somewhere: from the control at [from] (root px) to the point [to] gives (root
* px, asked each frame, so a target that moves is followed). [color] null is the palette's signal
* blue. [onHalf] runs as it passes the top of its arc, [onLand] once it lands.
*/
internal class Flight(
val from: Rect,
val to: Density.() -> Offset?,
val color: Color? = null,
val onHalf: () -> Unit = {},
val onLand: () -> Unit = {},
) {
val id = SystemClock.uptimeMillis() * 1000 + (next++ % 1000)
private companion object { var next = 0L }
}
/** Dots flying anywhere outside the Stores page (which keeps its own): a hop, a swap, a toggle. */
internal object Flights {
val all = mutableStateListOf<Flight>()
/** Sends [f], or with animations off just runs its ends. */
fun fly(f: Flight) {
if (Motion.scale == 0f) { f.onHalf(); f.onLand(); return }
all += f
}
}
/** [Flights] drawn over everything, in the space of an element at [origin] (root px). */
@Composable
internal fun FlightsLayer(origin: Offset) {
for (f in Flights.all.toList()) key(f.id) { FlightDot(f, origin) { Flights.all.remove(f) } }
}
/**
* One dot: the control squashes into a 14dp dot (width first, a little bloop), which then flies
* on an arc to its target, stretched along its path like the focus ring's drop.
*/
@Composable
internal fun FlightDot(f: Flight, origin: Offset, onDone: () -> Unit) {
val pal = LocalPalette.current
val color = f.color ?: pal.signal
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) {
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
coroutineScope {
launch { delay(Motion.ms(150).toLong()); f.onHalf() }
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
}
f.onLand()
onDone()
}
Canvas(Modifier.fillMaxSize()) {
val r = 7.dp.toPx()
val from = f.from.translate(-origin)
val a = from.center
val b = (f.to(this) ?: return@Canvas) - origin
val v = t.value
if (v < 1f) {
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
val k = 1f - 0.15f * sin(PI * v).toFloat()
drawRoundRect(color, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
} else {
val p = v - 1f
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
val q = 1f - p
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
val stretch = sin(PI * p).toFloat()
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
rotate(angle, pos) {
val w = 2 * r * (1f + 0.5f * stretch)
val h = 2 * r * (1f - 0.25f * stretch)
drawOval(color, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
}
}
}
}
@@ -83,7 +83,8 @@ internal object StoresMotion {
fun fly(from: Rect) {
if (Motion.scale == 0f || downloadsChip == null) { landed++; return }
flights += Flight(from, SystemClock.uptimeMillis())
// The count sits at the chip's right end.
flights += Flight(from, to = { downloadsChip?.let { Offset(it.right - 22.dp.toPx(), it.center.y) } }, onLand = { landed++ })
}
/** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */
@@ -140,8 +141,6 @@ internal object StoresMotion {
/** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */
internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?)
internal class Flight(val from: Rect, val id: Long)
/**
* A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor]
* (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however.
@@ -167,63 +166,21 @@ internal class SignInFlood(val store: Store, val from: Rect, val start: Color, v
/** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */
@Composable
internal fun FlightLayer(origin: Offset) {
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) }
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) { StoresMotion.flights.remove(f) } }
}
/**
* One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then
* flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop.
* Pops (1.3 to 1, with one ring) each time [trigger] changes: by default each time a download's
* dot lands, for the count on the chip and the rail; any other landing passes its own count.
*/
@Composable
private fun FlightDot(f: Flight, origin: Offset) {
val pal = LocalPalette.current
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) {
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
StoresMotion.landed++
StoresMotion.flights.remove(f)
}
Canvas(Modifier.fillMaxSize()) {
val r = 7.dp.toPx()
val from = f.from.translate(-origin)
val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin)
val a = from.center
// The count sits at the chip's right end.
val b = Offset(chip.right - 22.dp.toPx(), chip.center.y)
val v = t.value
if (v < 1f) {
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
val k = 1f - 0.15f * sin(PI * v).toFloat()
drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
} else {
val p = v - 1f
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
val q = 1f - p
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
val stretch = sin(PI * p).toFloat()
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
rotate(angle, pos) {
val w = 2 * r * (1f + 0.5f * stretch)
val h = 2 * r * (1f - 0.25f * stretch)
drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
}
}
}
}
/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */
internal fun Modifier.landingPop(): Modifier = composed {
internal fun Modifier.landingPop(trigger: Int = StoresMotion.landed): Modifier = composed {
val pal = LocalPalette.current
val scale = remember { Animatable(1f) }
val ring = remember { Animatable(1f) }
val seen = remember { intArrayOf(StoresMotion.landed) }
LaunchedEffect(StoresMotion.landed) {
if (StoresMotion.landed == seen[0]) return@LaunchedEffect
seen[0] = StoresMotion.landed
val seen = remember { intArrayOf(trigger) }
LaunchedEffect(trigger) {
if (trigger == seen[0]) return@LaunchedEffect
seen[0] = trigger
coroutineScope {
launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) }
launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) }
@@ -627,68 +627,6 @@ private fun StoreChips(
}
}
/**
* The selection under the chips: [at] (in the row) of chip [index], in [tint]. A move to the next
* chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a beat
* later on its trail spring - and a hop past a chip pinches it to a drop, carries it and opens it.
*/
@Composable
private fun BoxScope.ChipGlide(index: Int, at: Rect?, tint: State<Color>) {
val density = androidx.compose.ui.platform.LocalDensity.current
val left = remember { Animatable(Float.NaN) }
val right = remember { Animatable(0f) }
val pinch = remember { Animatable(0f) }
val top = remember { mutableStateOf(0f) }
val bottom = remember { mutableStateOf(0f) }
val was = remember { intArrayOf(-1) }
LaunchedEffect(index, at) {
val b = at ?: return@LaunchedEffect
top.value = b.top; bottom.value = b.bottom
val from = was[0]
was[0] = index
if (left.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
left.snapTo(b.left); right.snapTo(b.right); pinch.snapTo(0f)
return@LaunchedEffect
}
coroutineScope {
if (kotlin.math.abs(index - from) > 1) {
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
pinch.animateTo(1f, Motion.tw(90))
val l = launch { left.animateTo(b.left, Motion.sp(0.7f, 380f)) }
val r = launch { right.animateTo(b.right, Motion.sp(0.7f, 380f)) }
l.join(); r.join()
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
} else {
val forward = index > from
val (lead, trail) = if (forward) right to left else left to right
val leadTo = if (forward) b.right else b.left
val trailTo = if (forward) b.left else b.right
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
}
}
}
androidx.compose.foundation.Canvas(Modifier.matchParentSize()) {
if (left.value.isNaN() || bottom.value <= top.value) return@Canvas
val l = minOf(left.value, right.value)
val r = maxOf(left.value, right.value)
val cx = (l + r) / 2f
val cy = (top.value + bottom.value) / 2f
val drop = with(density) { 6.dp.toPx() }
val p = pinch.value
val x0 = androidx.compose.ui.util.lerp(l, cx - drop, p)
val x1 = androidx.compose.ui.util.lerp(r, cx + drop, p)
val y0 = androidx.compose.ui.util.lerp(top.value, cy - drop, p)
val y1 = androidx.compose.ui.util.lerp(bottom.value, cy + drop, p)
val size = androidx.compose.ui.geometry.Size(x1 - x0, y1 - y0)
val corner = androidx.compose.ui.geometry.CornerRadius(minOf(size.width, size.height) / 2f)
val at0 = Offset(x0, y0)
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
drawRoundRect(tint.value.copy(alpha = androidx.compose.ui.util.lerp(0.16f, 0.9f, p)), at0, size, corner)
drawRoundRect(tint.value.copy(alpha = 0.7f * (1f - p)), at0, size, corner, style = androidx.compose.ui.graphics.drawscope.Stroke(with(density) { 1.dp.toPx() }))
}
}
@Composable
private fun StoreChip(
label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit,
+5
View File
@@ -2171,4 +2171,9 @@
<item quantity="one">%1$d already in Games</item>
<item quantity="other">%1$d already in Games</item>
</plurals>
<!-- A page reached through a link from another section: the way back, named for where it came from. -->
<string name="hop_back">B · Back to %1$s</string>
<string name="hop_from_games">the game</string>
<string name="hop_from_play">Play settings</string>
<string name="hop_from_desktop">Desktop settings</string>
</resources>
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class GlideShapeTest {
@Test fun restingAcrossCoversTheChip() {
val r = glideShape(10f, 90f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
assertEquals(0f, r.top, 0.01f); assertEquals(40f, r.bottom, 0.01f)
}
@Test fun edgesInEitherOrder() {
val r = glideShape(90f, 10f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
}
@Test fun pinchedIsATwelveDpDropAtTheCentre() {
val r = glideShape(10f, 90f, 0f, 40f, pinch = 1f, drop = 6f, vertical = false)
assertEquals(12f, r.width, 0.01f); assertEquals(12f, r.height, 0.01f)
assertEquals(50f, r.center.x, 0.01f); assertEquals(20f, r.center.y, 0.01f)
}
@Test fun verticalRunsTheAxisDown() {
val r = glideShape(100f, 160f, 4f, 84f, pinch = 0f, drop = 6f, vertical = true)
assertEquals(4f, r.left, 0.01f); assertEquals(84f, r.right, 0.01f)
assertEquals(100f, r.top, 0.01f); assertEquals(160f, r.bottom, 0.01f)
}
}