From 9836dade798ac454f71eab03e57807183dd1cd44 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:16:07 -0400 Subject: [PATCH] feat: the focus ring is the only fill and keeps up with focus (#605) * feat: with a pad the focus ring fills what it holds and is the only fill; chip rows mark their pick with a bar * fix: the focus ring chases focus on a continuous spring per edge instead of restarting on every move --- .../droiddeck/launcher/ui/ComponentsPage.kt | 7 +- .../com/droiddeck/launcher/ui/FocusGlide.kt | 95 ++++++++++++++----- .../com/droiddeck/launcher/ui/LiftSheet.kt | 8 +- .../com/droiddeck/launcher/ui/SharedMotion.kt | 27 ++++++ .../com/droiddeck/launcher/ui/StorePage.kt | 7 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 19 +--- 6 files changed, 121 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 383b03e1f..f33c1e365 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -1105,14 +1105,17 @@ private fun LayerCard( val pal = LocalPalette.current val src = remember { MutableInteractionSource() } val hot = rememberHot(src) + // The stack's glide draws the picked card's look by touch only; with a pad, its rest look and the bar. + val under = selected && androidx.compose.ui.platform.LocalInputModeManager.current.inputMode != androidx.compose.ui.input.InputMode.Keyboard Box(Modifier.fillMaxWidth().heightIn(min = 44.dp), contentAlignment = Alignment.Center) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().height(40.dp) .landingPop(landed).pulseRing(ring, pal.signal) .clip(LAYER_SHAPE) - .background(if (selected) Color.Transparent else colors.surface) - .glideBorder(hot, LAYER_SHAPE, pal.signal, if (selected) Color.Transparent else pal.line) + .background(if (under) Color.Transparent else colors.surface) + .shownBar(selected, pal.signal, vertical = true) + .glideBorder(hot, LAYER_SHAPE, pal.signal, if (under) Color.Transparent else pal.line) .onFocusChanged { if (it.isFocused) onFocus() } .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt index f68d85fb1..9f8f0b0fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -3,7 +3,6 @@ package com.droiddeck.launcher.ui import android.view.View import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -142,11 +141,14 @@ internal fun Modifier.glideBorder( } /** Leading edge on a focus move: quick and a little loose. */ -private fun lead(): AnimationSpec = Motion.sp(0.62f, 700f) +/** A spring an edge chases its target on: damping ratio and stiffness, as Compose's. */ +private class Edge(val damping: Float, val stiffness: Float) + +private fun lead() = Edge(0.62f, 700f) /** Trailing edge: slower, settles without a bounce to speak of. */ -private fun trail(): AnimationSpec = Motion.sp(0.78f, 360f) +private fun trail() = Edge(0.78f, 360f) /** Following a control that moves under the ring (a scroll): tight, no wobble. */ -private fun follow(): AnimationSpec = Motion.sp(1f, 2400f) +private fun follow() = Edge(1f, 2400f) /** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */ private const val SETTLE_MS = 450L @@ -155,6 +157,9 @@ private const val STILL_MS = 120L /** The longest the ring keeps checking after a move, for a control that never stands still. */ private const val TRACK_MAX_MS = 2_000L +/** How strongly the ring tints what it holds. */ +private const val RING_FILL = 0.14f + /** Moves closer together than this are a held direction: the ring runs along as one, no stretch. */ private const val REPEAT_MS = 180L @@ -177,8 +182,16 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable val solid = remember { Animatable(0f) } val alpha = remember { Animatable(0f) } val scope = rememberCoroutineScope() - // An edge each, the corner, and a droplet carrying all of them. - val jobs = remember { arrayOfNulls(7) } + // An edge each, the corner, a droplet carrying all of them, its solid fill, and the chase. + val jobs = remember { arrayOfNulls(8) } + // The chase: each edge on a spring of its own toward where focus is now. A new target only + // moves where it is heading - restarting an animation on every focus move started it from + // its first frame (which does not move), so a held direction over a busy page (each rail item + // swaps the page) left the ring standing while focus ran on ahead. + val target = remember { FloatArray(4) } + val vel = remember { FloatArray(4) } + val springs = remember { Array(4) { follow() } } + val startAt = remember { LongArray(4) } val cur = glide.current var lastColor by remember { mutableStateOf(Color.Transparent) } if (cur != null) lastColor = cur.color @@ -189,23 +202,53 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable LaunchedEffect(glide, Motion.scale) { var shown: GlideSource? = null var movedAt = 0L - var dirSpecs: List> = List(4) { follow() } + var dirSpecs: List = List(4) { follow() } var delays = LongArray(4) var lastMove = 0L - fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { + fun go(i: Int, v: Float, spec: Edge, wait: Long) { // A drop cut short by another move turns back into an outline on the way. if (jobs[5]?.isActive == true || solid.targetValue > 0f) { jobs[5]?.cancel() jobs[6]?.cancel() jobs[6] = scope.launch { solid.animateTo(0f, Motion.tw(120)) } } - // Picks up the speed it is going at: restarting from rest on every step of a held - // direction is what made it stutter. - val speed = edges[i].velocity - jobs[i]?.cancel() - jobs[i] = scope.launch { - if (wait > 0) delay(Motion.ms(wait.toInt()).toLong()) - edges[i].animateTo(v, spec, initialVelocity = if (wait > 0) 0f else speed) + val chasing = jobs[7]?.isActive == true + // An edge some other animation was moving (the appear, a drop's opening) is taken over + // at the speed it was going. + jobs[i]?.let { if (it.isActive) { it.cancel(); vel[i] = edges[i].velocity } } + if (!chasing) for (j in 0..3) if (jobs[j]?.isActive != true) vel[j] = edges[j].velocity + target[i] = v + springs[i] = spec + startAt[i] = System.nanoTime() + Motion.ms(wait.toInt()) * 1_000_000L + if (chasing) return + jobs[7] = scope.launch { + var last = withFrameNanos { it } + while (true) { + val frame = withFrameNanos { it } + // Real time, however long the frame took, in small steps so a stiff spring holds. + val dt = ((frame - last) / 1e9f).coerceAtMost(0.1f) / Motion.scale.coerceAtLeast(0.01f) + last = frame + val now = System.nanoTime() + var settled = true + for (j in 0..3) { + if (now < startAt[j]) { settled = false; continue } + val k = springs[j].stiffness + val c = 2f * springs[j].damping * kotlin.math.sqrt(k) + var x = edges[j].value + var u = vel[j] + var left = dt + while (left > 0f) { + val h = minOf(left, 0.004f) + u += (-k * (x - target[j]) - c * u) * h + x += u * h + left -= h + } + if (abs(x - target[j]) < 0.3f && abs(u) < 3f) { x = target[j]; u = 0f } else settled = false + vel[j] = u + edges[j].snapTo(x) + } + if (settled) break + } } } /** Pinch into a solid dot where it is, carry it over, and open it into [b] as it arrives. */ @@ -304,7 +347,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out) jobs.forEach { it?.cancel() } scope.launch { solid.snapTo(0f); edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join() - dirSpecs = List(4) { Motion.sp(0.6f, 700f) } + dirSpecs = List(4) { Edge(0.6f, 700f) } delays = LongArray(4) cornerTo(src, b, snap = true) scope.launch { alpha.animateTo(1f, Motion.tw(140)) } @@ -323,8 +366,8 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable if (dropped) droplet(src, b) else { // A held direction runs along as one piece; a single press stretches. - dirSpecs = if (held) List(4) { Motion.sp(0.9f, 900f) } - else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } } + dirSpecs = if (held) List(4) { Edge(0.9f, 900f) } + else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Edge(0.7f, 520f) } } delays = LongArray(4) { i -> if (i == trailIdx && !held) 30L else 0L } cornerTo(src, b, snap = false) } @@ -367,10 +410,18 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable color, topLeft = Offset(l, t), size = Size(r - l, b - t), cornerRadius = CornerRadius(corner.value), alpha = a * f, ) - if (f < 0.99f) drawRoundRect( - color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), - cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, - ) + if (f < 0.99f) { + // The ring's own infill: the only fill a pad sees, so it is always exactly what + // the ring holds and moves with it (a row's own sliding fill trailed it). + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), alpha = a * (1f - f) * RING_FILL, + ) + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, + ) + } }, ) { CompositionLocalProvider(LocalFocusGlide provides glide) { content() } 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 cfbd3680b..7833c42f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -801,11 +801,15 @@ private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: ( val src = remember { MutableInteractionSource() } val hot = rememberHot(src) val shape = RoundedCornerShape(99.dp) + // The row's glide draws the picked chip's look by touch only; with a pad it draws its own rest + // look, and the bar says which is picked. + val under = on && LocalInputModeManager.current.inputMode != InputMode.Keyboard Box( contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(shape) - .background(if (on) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f)) - .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .background(if (under) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f)) + .shownBar(on, pal.signal) + .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt index de0d9dfcd..feec78ccc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size @@ -66,6 +67,10 @@ internal fun BoxScope.ChipGlide( val start = remember { Animatable(Float.NaN) } val end = remember { Animatable(0f) } val pinch = remember { Animatable(0f) } + // With a pad the focus ring fills what it holds, and is the only fill: a selection of the + // row's own, sliding on its own beat, trailed the ring and sat on items the pad had left. + val pad = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode == androidx.compose.ui.input.InputMode.Keyboard + @Suppress("NAME_SHADOWING") val visible = visible && !pad val shown = remember { Animatable(if (visible) 1f else 0f) } val visibleNow = rememberUpdatedState(visible) LaunchedEffect(visible) { shown.animateTo(if (visible) 1f else 0f, Motion.tw(if (visible) 160 else 120)) } @@ -133,6 +138,28 @@ internal fun BoxScope.ChipGlide( } } +/** + * The chosen one of a row of chips (or a column, [vertical]): a short bar of [color] along its foot + * (or its start edge). Not a fill or an outline, so it never reads as focus; with a pad the ring's + * own infill is the only fill. Goes inside the chip's clip. + */ +@Composable +internal fun Modifier.shownBar(on: Boolean, color: Color, vertical: Boolean = false): Modifier { + val shown = androidx.compose.animation.core.animateFloatAsState(if (on) 1f else 0f, Motion.tw(if (on) 140 else 100), label = "shownBar").value + return drawWithContent { + drawContent() + if (shown <= 0.01f) return@drawWithContent + val t = 3.dp.toPx() + if (vertical) { + val l = size.height * 0.5f + drawRect(color, Offset(0f, (size.height - l) / 2f), Size(t, l), alpha = shown) + } else { + val w = size.width * 0.36f + drawRect(color, Offset((size.width - w) / 2f, size.height - t), Size(w, t), alpha = shown) + } + } +} + /** * 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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index 87eaaa92c..0143693ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -400,6 +400,8 @@ private fun FlathubChips(mode: String, modes: List, installed: Int, upda val hot = rememberHot(src) val shape = RoundedCornerShape(99.dp) val on = m == mode + // Drawn by the row's glide by touch only; with a pad its own rest look and the bar. + val under = on && androidx.compose.ui.platform.LocalInputModeManager.current.inputMode != androidx.compose.ui.input.InputMode.Keyboard Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), modifier = Modifier.weight(1f).paneItem("flathub:$m") @@ -408,8 +410,9 @@ private fun FlathubChips(mode: String, modes: List, installed: Int, upda if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot() } .heightIn(min = 44.dp).clip(shape) - .background(if (on) Color.Transparent else colors.surface) - .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .background(if (under) Color.Transparent else colors.surface) + .shownBar(on, pal.signal) + .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) } .controllerConfirm { onPick(m) } .padding(horizontal = 12.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index b4c224012..dfb5796d0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -585,10 +585,9 @@ private fun StoreChips( val rootBounds = remember { HashMap() } // The fill travels with focus; only the expensive pane change waits for the debounce. val highlighted = focused ?: selected - // With a pad the fill is only where focus is: on a chip that does not have it (the row first - // drawn with focus on the rail, or focus on the cog or the page) it read as focus. + // With a pad the row has no fill of its own: the focus ring's infill is the only one. val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode - val fillShown = focused != null || inputMode != androidx.compose.ui.input.InputMode.Keyboard + val fillShown = inputMode != androidx.compose.ui.input.InputMode.Keyboard val tint = androidx.compose.animation.animateColorAsState( Store.byId(highlighted)?.let { sourceColours(it.id).dot } ?: pal.signal, Motion.tw(160), label = "chipTint", ) @@ -647,9 +646,8 @@ private fun StoreChip( val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "chipScale") val shape = RoundedCornerShape(99.dp) val ink = if (on) colors.onBackground else if (hot) colors.onBackground else colors.onSurfaceVariant - // The chip whose page shows: a bar of its colour along the pill's foot, not an outline or a - // fill, so it never reads as focus. Picking it pulses its lead (a store's dot, Downloads' icon). - val shown by animateFloatAsState(if (on) 1f else 0f, Motion.tw(if (on) 140 else 100), label = "chipShown") + // The chip whose page shows has a bar of its colour along its foot. Picking it pulses its + // lead (a store's dot, Downloads' icon). var picked by remember { mutableStateOf(0) } val wasOn = remember { booleanArrayOf(on) } LaunchedEffect(on) { @@ -666,14 +664,7 @@ private fun StoreChip( } .heightIn(min = 44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(shape) .background(if (under) Color.Transparent else colors.surface) - .drawWithContent { - drawContent() - if (shown > 0.01f) { - val h = 3.dp.toPx() - val w = size.width * 0.36f - drawRect(mark, Offset((size.width - w) / 2f, size.height - h), androidx.compose.ui.geometry.Size(w, h), alpha = shown) - } - } + .shownBar(on, mark) .glideBorder(hot, shape, pal.signal, if (under) Color.Transparent else pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .controllerConfirm(onClick = onClick)