mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat(ui): shared chip glide and flight dot, and the hop between sections
This commit is contained in:
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user