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
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-12 03:16:07 +00:00
1 parent 52c67df4c7
commit 22f02fb6bf
6 files changed
+121 -42

No files matched your search

@@ -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)
@@ -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<Float> = 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<Float> = 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<Float> = 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<Job>(7) }
// An edge each, the corner, a droplet carrying all of them, its solid fill, and the chase.
val jobs = remember { arrayOfNulls<Job>(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<AnimationSpec<Float>> = List(4) { follow() }
var dirSpecs: List<Edge> = List(4) { follow() }
var delays = LongArray(4)
var lastMove = 0L
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, 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() }
@@ -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),
@@ -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
@@ -400,6 +400,8 @@ private fun FlathubChips(mode: String, modes: List<String>, 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<String>, 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),
@@ -585,10 +585,9 @@ private fun StoreChips(
val rootBounds = remember { HashMap<String, Rect>() }
// 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)