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:
parent
20ee8023c1
commit
9aafc003cb
2 changed files with 66 additions and 30 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue