From 9aafc003cb36e4f6e8e502e4cf0e7856423f6a28 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:19:12 -0700 Subject: [PATCH] vc=31: smoother swipe-to-minimize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../straw/feature/detail/VideoDetailScreen.kt | 92 +++++++++++++------ 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 021a8e0ff..ce37f0b7c 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 502314b25..59f48f148 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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