mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
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:
1 parent
4bfc7154cd
commit
9836dade79
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)
|
||||
|
||||
Reference in new issue
Block a user