Improve pager layout bidirectional scroll handling

This commit is contained in:
MM20 2023-07-11 22:52:14 +02:00
parent 6b41e84b29
commit 6bd1f5c63c
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
3 changed files with 222 additions and 9 deletions

View File

@ -8,7 +8,13 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.slideIn import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,6 +36,8 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerSnapDistance
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -51,17 +59,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
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.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
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.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
@ -74,6 +89,7 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.SearchBarLevel import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.gestures.LocalGestureDetector import de.mm20.launcher2.ui.gestures.LocalGestureDetector
import de.mm20.launcher2.ui.ktx.animateTo import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
import de.mm20.launcher2.ui.launcher.search.SearchColumn import de.mm20.launcher2.ui.launcher.search.SearchColumn
@ -84,6 +100,7 @@ import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.pow
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -271,10 +288,18 @@ fun PagerScaffold(
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val drag = gestureManager.currentDrag val drag = gestureManager.currentDrag
if (drag != null && (drag.y > 0 || (reverse && drag.x < 0 || !reverse && drag.x > 0))) { if (drag != null && drag.y > 0 && (reverse && drag.x < 0 || !reverse && drag.x > 0)) {
gestureManager.dispatchDrag(available) gestureManager.dispatchDrag(available)
return available return available
} }
if (drag != null && drag.y > 0) {
gestureManager.dispatchDrag(available.copy(x = 0f))
return available.copy(x = 0f)
}
if (drag != null && (reverse && drag.x < 0 || !reverse && drag.x > 0)) {
gestureManager.dispatchDrag(available.copy(y = 0f))
return available.copy(y = 0f)
}
return super.onPreScroll(available, source) return super.onPreScroll(available, source)
} }
@ -283,9 +308,9 @@ fun PagerScaffold(
available: Offset, available: Offset,
source: NestedScrollSource source: NestedScrollSource
): Offset { ): Offset {
if (source == NestedScrollSource.Drag && !isWidgetEditMode) gestureManager.dispatchDrag( if (source == NestedScrollSource.Drag && !isWidgetEditMode && available != Offset.Zero) {
available gestureManager.dispatchDrag(available)
) }
val deltaSearchBarOffset = val deltaSearchBarOffset =
consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1 consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1
searchBarOffset.value = searchBarOffset.value =
@ -295,10 +320,18 @@ fun PagerScaffold(
override suspend fun onPreFling(available: Velocity): Velocity { override suspend fun onPreFling(available: Velocity): Velocity {
val drag = gestureManager.currentDrag val drag = gestureManager.currentDrag
if (drag != null && (drag.y > 0 || (reverse && drag.x < 0 || !reverse && drag.x > 0))) { if (drag != null && drag.y > 0 && (reverse && drag.x < 0 || !reverse && drag.x > 0)) {
gestureManager.dispatchDragEnd() gestureManager.dispatchDragEnd()
return available return available
} }
if (drag != null && drag.y > 0) {
gestureManager.dispatchDragEnd()
return available.copy(x = 0f)
}
if (drag != null && (reverse && drag.x < 0 || !reverse && drag.x > 0)) {
gestureManager.dispatchDragEnd()
return available.copy(y = 0f)
}
gestureManager.dispatchDragEnd() gestureManager.dispatchDragEnd()
return super.onPreFling(available) return super.onPreFling(available)
} }
@ -306,7 +339,7 @@ fun PagerScaffold(
} }
val innerNestedScrollConnection = remember { val innerNestedScrollConnection = remember {
object: NestedScrollConnection {} object : NestedScrollConnection {}
} }
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
@ -345,6 +378,8 @@ fun PagerScaffold(
LocalOverscrollConfiguration provides null, LocalOverscrollConfiguration provides null,
) { ) {
val minFlingVelocity = 1000.dp.toPixels()
HorizontalPager( HorizontalPager(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -352,12 +387,29 @@ fun PagerScaffold(
beyondBoundsPageCount = 1, beyondBoundsPageCount = 1,
reverseLayout = reverse, reverseLayout = reverse,
state = pagerState, state = pagerState,
userScrollEnabled = !isWidgetEditMode, userScrollEnabled = false,//!isWidgetEditMode,
flingBehavior = PagerDefaults.flingBehavior( flingBehavior = PagerDefaults.flingBehavior(
state = pagerState, state = pagerState,
lowVelocityAnimationSpec = spring( lowVelocityAnimationSpec = spring(
stiffness = Spring.StiffnessMediumLow, stiffness = Spring.StiffnessMediumLow,
), ),
snapVelocityThreshold = 1000.dp,
pagerSnapDistance = remember {
object : PagerSnapDistance {
override fun calculateTargetPage(
startPage: Int,
suggestedTargetPage: Int,
velocity: Float,
pageSize: Int,
pageSpacing: Int
): Int {
if (velocity.absoluteValue < minFlingVelocity) {
return startPage
}
return suggestedTargetPage
}
}
}
), ),
pageNestedScrollConnection = innerNestedScrollConnection, pageNestedScrollConnection = innerNestedScrollConnection,
) { ) {
@ -404,7 +456,12 @@ fun PagerScaffold(
}, },
) )
} }
.verticalScroll(widgetsScrollState) .pagerScaffoldScrollHandler(
pagerState,
widgetsScrollState,
reversePager = reverse,
)
.verticalScroll(widgetsScrollState, enabled = false)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.graphicsLayer { .graphicsLayer {
val pagerProgress = val pagerProgress =
@ -470,6 +527,12 @@ fun PagerScaffold(
alpha = pagerProgress alpha = pagerProgress
} }
.nestedScroll(searchNestedScrollConnection) .nestedScroll(searchNestedScrollConnection)
.pagerScaffoldScrollHandler(
pagerState,
searchState,
reversePager = reverse,
reverseScroll = reverseSearchResults
)
.padding( .padding(
start = windowInsets.calculateStartPadding( start = windowInsets.calculateStartPadding(
LocalLayoutDirection.current LocalLayoutDirection.current
@ -481,6 +544,7 @@ fun PagerScaffold(
reverse = reverseSearchResults, reverse = reverseSearchResults,
state = searchState, state = searchState,
paddingValues = paddingValues, paddingValues = paddingValues,
userScrollEnabled = false,
) )
} }
} }
@ -574,3 +638,130 @@ fun PagerScaffold(
onHomeButtonPress = handleBackOrHomeEvent, onHomeButtonPress = handleBackOrHomeEvent,
) )
} }
fun Modifier.pagerScaffoldScrollHandler(
pagerState: PagerState,
scrollableState: ScrollableState,
reversePager: Boolean = false,
reverseScroll: Boolean = false,
) = composed {
val scope = rememberCoroutineScope()
val flingBehavior = ScrollableDefaults.flingBehavior()
val nestedScrollDispatcher = remember { NestedScrollDispatcher() }
val touchSlopSq = LocalViewConfiguration.current.touchSlop.pow(2)
this
.nestedScroll(DefaultNestedScrollConnection, nestedScrollDispatcher)
.pointerInput(scrollableState, pagerState, reversePager, reverseScroll) {
val velocityTracker = VelocityTracker()
val lockScrollThreshold = 200.dp.toPx()
val pagerMultiplier = if (reversePager) 1f else -1f
val scrollMultiplier = if (reverseScroll) 1f else -1f
awaitEachGesture {
var overSlop = false
var lockedInScroll = false
val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val down = if (scrollableState.isScrollInProgress || pagerState.isScrollInProgress) {
overSlop = true
scope.launch {
scrollableState.scrollBy(0f)
pagerState.scrollBy(0f)
}
initialDown
} else {
awaitFirstDown(requireUnconsumed = false)
}
velocityTracker.resetTracking()
velocityTracker.addPointerInputChange(down)
val notCanceled = drag(down.id) {
if (it.isConsumed) return@drag
val totalDrag = down.position - it.position
if (!lockedInScroll && totalDrag.y.absoluteValue > lockScrollThreshold) {
lockedInScroll = true
scope.launch {
pagerState.animateScrollToPage(pagerState.settledPage)
}
}
if (!lockedInScroll && !overSlop && totalDrag.getDistanceSquared() > touchSlopSq) {
overSlop = true
}
if (!overSlop) return@drag
val dragAmount = it
.positionChange()
.let {
if (!overSlop || lockedInScroll) it.copy(x = 0f) else it
}
it.consume()
velocityTracker.addPointerInputChange(it)
scope.launch {
val preConsumed = nestedScrollDispatcher.dispatchPreScroll(
dragAmount,
NestedScrollSource.Drag
)
val available = dragAmount - preConsumed
val consumedY = scrollableState.scrollBy(available.y * scrollMultiplier) * scrollMultiplier
val consumedX = if (!lockedInScroll) {
pagerState.scrollBy(available.x * pagerMultiplier) * pagerMultiplier
} else available.x
val totalConsumed = Offset(preConsumed.x + consumedX, preConsumed.y + consumedY)
nestedScrollDispatcher.dispatchPostScroll(
totalConsumed,
dragAmount - totalConsumed,
NestedScrollSource.Drag
)
}
}
if (notCanceled) {
val velocity = velocityTracker
.calculateVelocity()
if (velocity.x.absoluteValue > velocity.y.absoluteValue && !lockedInScroll) {
scope.launch {
val preConsumed = nestedScrollDispatcher.dispatchPreFling(velocity)
val flingVelocity = (velocity - preConsumed).x
if (flingVelocity.absoluteValue > 400.dp.toPx()) {
if (flingVelocity * pagerMultiplier < 0) {
pagerState.animateScrollToPage(pagerState.settledPage - 1)
} else {
pagerState.animateScrollToPage(pagerState.settledPage + 1)
}
} else {
pagerState.animateScrollToPage(pagerState.settledPage)
}
nestedScrollDispatcher.dispatchPostFling(
velocity,
Velocity.Zero,
)
}
} else {
scope.launch {
val preConsumed = nestedScrollDispatcher.dispatchPreFling(velocity)
val flingVelocity = (velocity - preConsumed).y
var consumed = 0f
launch {
with(flingBehavior) {
scrollableState.scroll {
consumed = performFling(flingVelocity * scrollMultiplier) * scrollMultiplier
}
}
val totalConsumed =
Velocity(preConsumed.x, preConsumed.y + consumed)
nestedScrollDispatcher.dispatchPostFling(
totalConsumed,
velocity - totalConsumed
)
}
launch {
pagerState.animateScrollToPage(pagerState.settledPage)
}
}
}
}
}
}
}
internal object DefaultNestedScrollConnection : NestedScrollConnection {}

View File

@ -74,6 +74,7 @@ fun SearchColumn(
paddingValues: PaddingValues = PaddingValues(0.dp), paddingValues: PaddingValues = PaddingValues(0.dp),
state: LazyListState = rememberLazyListState(), state: LazyListState = rememberLazyListState(),
reverse: Boolean = false, reverse: Boolean = false,
userScrollEnabled: Boolean = true,
) { ) {
val columns = LocalGridSettings.current.columnCount val columns = LocalGridSettings.current.columnCount
@ -118,6 +119,7 @@ fun SearchColumn(
LazyColumn( LazyColumn(
state = state, state = state,
modifier = modifier, modifier = modifier,
userScrollEnabled = userScrollEnabled,
contentPadding = paddingValues, contentPadding = paddingValues,
reverseLayout = reverse, reverseLayout = reverse,
) { ) {

View File

@ -1,18 +1,38 @@
package de.mm20.launcher2.ui.modifier package de.mm20.launcher2.ui.modifier
import android.util.Log
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.sign
/** /**
* Consumes all scrolling, so that the parent doesn't scroll. * Consumes all scrolling, so that the parent doesn't scroll.
*/ */
fun Modifier.consumeAllScrolling() = this.nestedScroll(ConsumeAllScrollConnection) fun Modifier.consumeAllScrolling() = this.nestedScroll(ConsumeAllScrollConnection)
private object ConsumeAllScrollConnection: private object ConsumeAllScrollConnection :
NestedScrollConnection { NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {