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:
Sulkta 2026-05-25 12:19:12 -07:00
parent 98568ba713
commit 5573344fb9
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 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 30 const val STRAW_VERSION_CODE = 31
const val STRAW_VERSION_NAME = "0.1.0-AP" const val STRAW_VERSION_NAME = "0.1.0-AQ"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -13,11 +13,15 @@ import android.util.Rational
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.core.Animatable 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.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -63,14 +67,13 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight 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.formatCount
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class, UnstableApi::class) @OptIn(ExperimentalLayoutApi::class, UnstableApi::class)
@Composable @Composable
@ -132,39 +134,73 @@ fun VideoDetailScreen(
} }
// Swipe-down to minimize. The drag handle is the inline player surface // 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 // motion reads as "the video is being tucked away" rather than "this
// one widget slid." The graphicsLayer + alpha/scale fade keeps it // one widget slid."
// smooth — no per-pixel coroutine churn from offset { }. //
// 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 density = LocalDensity.current
val configuration = LocalConfiguration.current
val dismissThresholdPx = with(density) { 140.dp.toPx() } val dismissThresholdPx = with(density) { 140.dp.toPx() }
val dragY = remember { Animatable(0f) } val flingVelocityThreshold = with(density) { 600.dp.toPx() }
val scope = rememberCoroutineScope() val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
val playerDragModifier = Modifier.pointerInput(Unit) { var liveDrag by remember { mutableStateOf(0f) }
detectVerticalDragGestures( var dragging by remember { mutableStateOf(false) }
onDragEnd = { val releaseAnim = remember { Animatable(0f) }
if (dragY.value > dismissThresholdPx) { val draggableState = rememberDraggableState { delta ->
onMinimize() liveDrag = (liveDrag + delta).coerceAtLeast(0f)
} 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 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
val y = dragY.value val y = if (dragging) liveDrag else releaseAnim.value
translationY = y translationY = y
val p = (y / dismissThresholdPx).coerceIn(0f, 1f) val p = (y / dismissThresholdPx).coerceIn(0f, 1f)
alpha = 1f - p * 0.4f alpha = 1f - p * 0.4f