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
98568ba713
commit
5573344fb9
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
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue