vc=31: smoother swipe-to-minimize

Rewrote the drag-to-dismiss state machine. Old version launched a
coroutine per pointer event to call Animatable.snapTo (which is
suspend) — multiple launches racing per frame caused the stutter.

Two-state pattern now:
  liveDrag (mutableFloatStateOf) — updated synchronously inside
    rememberDraggableState's callback. One state write per pointer
    event, no coroutine spawn during the drag itself.
  releaseAnim (Animatable) — driven by a single coroutine in
    Modifier.draggable's onDragStopped. Either spring-back to 0 or
    slide off-screen + onMinimize.

graphicsLayer reads liveDrag when actively dragging, releaseAnim
otherwise — a single Boolean gate.

Bonus: dismiss now SLIDES the page off-screen before popping nav,
instead of cutting. tween(220ms, FastOutLinearInEasing). Spring-back
on a short drag uses MediumBouncy/MediumLow for a real spring feel
instead of a hard snap. Fling-velocity threshold (600dp/s) also
counts — flick-down past 600dp/s dismisses even if the drag distance
was short.
This commit is contained in:
Kayos 2026-05-25 12:19:12 -07:00
parent 20ee8023c1
commit 9aafc003cb
2 changed files with 66 additions and 30 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 30
const val STRAW_VERSION_NAME = "0.1.0-AP"
const val STRAW_VERSION_CODE = 31
const val STRAW_VERSION_NAME = "0.1.0-AQ"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -13,11 +13,15 @@ import android.util.Rational
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -63,14 +67,13 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@ -98,7 +101,6 @@ import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
@Composable
@ -132,39 +134,73 @@ fun VideoDetailScreen(
}
// Swipe-down to minimize. The drag handle is the inline player surface
// (the 16:9 box at the top); we translate the WHOLE page with it so the
// at the top of the page; we translate the WHOLE page with it so the
// motion reads as "the video is being tucked away" rather than "this
// one widget slid." The graphicsLayer + alpha/scale fade keeps it
// smooth — no per-pixel coroutine churn from offset { }.
// one widget slid."
//
// Two-state pattern so the drag stays smooth at 120fps:
// liveDrag — mutableFloatStateOf updated SYNCHRONOUSLY in
// rememberDraggableState's callback. One state write
// per pointer event, no coroutine spawn.
// releaseAnim — Animatable driven by a single coroutine that
// runs only when the finger leaves (spring back
// if short, slide off-screen + onMinimize if past
// threshold or flung).
// graphicsLayer reads whichever is active via the `dragging` flag.
// The old single-Animatable / scope.launch-per-pixel pattern
// raced coroutines for every drag delta and stuttered on fast
// gestures; this doesn't.
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val dismissThresholdPx = with(density) { 140.dp.toPx() }
val dragY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val playerDragModifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragY.value > dismissThresholdPx) {
onMinimize()
} else {
scope.launch { dragY.animateTo(0f, spring()) }
}
},
onDragCancel = {
scope.launch { dragY.animateTo(0f, spring()) }
},
onVerticalDrag = { _, dy ->
scope.launch {
dragY.snapTo((dragY.value + dy).coerceAtLeast(0f))
}
},
)
val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
var liveDrag by remember { mutableStateOf(0f) }
var dragging by remember { mutableStateOf(false) }
val releaseAnim = remember { Animatable(0f) }
val draggableState = rememberDraggableState { delta ->
liveDrag = (liveDrag + delta).coerceAtLeast(0f)
}
val playerDragModifier = Modifier.draggable(
orientation = Orientation.Vertical,
state = draggableState,
onDragStarted = {
releaseAnim.stop()
liveDrag = releaseAnim.value
dragging = true
},
onDragStopped = { velocity ->
val shouldDismiss =
liveDrag > dismissThresholdPx || velocity > flingVelocityThreshold
releaseAnim.snapTo(liveDrag)
dragging = false
if (shouldDismiss) {
// Slide the rest of the way off-screen, then pop. The
// pop happens AFTER the animation so the user sees the
// page leave under their finger instead of a hard cut.
releaseAnim.animateTo(
screenHeightPx,
tween(durationMillis = 220, easing = FastOutLinearInEasing),
)
onMinimize()
} else {
releaseAnim.animateTo(
0f,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}
liveDrag = 0f
},
)
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val y = dragY.value
val y = if (dragging) liveDrag else releaseAnim.value
translationY = y
val p = (y / dismissThresholdPx).coerceIn(0f, 1f)
alpha = 1f - p * 0.4f