From 6bd1f5c63cf301ead07c5b097e7f168c45335f1b Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Tue, 11 Jul 2023 22:52:14 +0200 Subject: [PATCH] Improve pager layout bidirectional scroll handling --- .../launcher2/ui/launcher/PagerScaffold.kt | 207 +++++++++++++++++- .../ui/launcher/search/SearchColumn.kt | 2 + .../mm20/launcher2/ui/modifier/Scrolling.kt | 22 +- 3 files changed, 222 insertions(+), 9 deletions(-) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt index 4a9b3ef4..6f1f6d77 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt @@ -8,7 +8,13 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.slideIn import androidx.compose.animation.slideOut 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.drag +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.pager.HorizontalPager 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.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -51,17 +59,24 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer 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.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass 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.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset 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.gestures.LocalGestureDetector 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.helper.WallpaperBlur 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 kotlinx.coroutines.launch import kotlin.math.absoluteValue +import kotlin.math.pow import kotlin.math.roundToInt @Composable @@ -271,10 +288,18 @@ fun PagerScaffold( object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 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) 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) } @@ -283,9 +308,9 @@ fun PagerScaffold( available: Offset, source: NestedScrollSource ): Offset { - if (source == NestedScrollSource.Drag && !isWidgetEditMode) gestureManager.dispatchDrag( - available - ) + if (source == NestedScrollSource.Drag && !isWidgetEditMode && available != Offset.Zero) { + gestureManager.dispatchDrag(available) + } val deltaSearchBarOffset = consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1 searchBarOffset.value = @@ -295,10 +320,18 @@ fun PagerScaffold( override suspend fun onPreFling(available: Velocity): Velocity { 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() 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() return super.onPreFling(available) } @@ -306,7 +339,7 @@ fun PagerScaffold( } val innerNestedScrollConnection = remember { - object: NestedScrollConnection {} + object : NestedScrollConnection {} } LaunchedEffect(pagerState.currentPage) { @@ -345,6 +378,8 @@ fun PagerScaffold( LocalOverscrollConfiguration provides null, ) { + val minFlingVelocity = 1000.dp.toPixels() + HorizontalPager( modifier = Modifier .fillMaxSize() @@ -352,12 +387,29 @@ fun PagerScaffold( beyondBoundsPageCount = 1, reverseLayout = reverse, state = pagerState, - userScrollEnabled = !isWidgetEditMode, + userScrollEnabled = false,//!isWidgetEditMode, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, lowVelocityAnimationSpec = spring( 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, ) { @@ -404,7 +456,12 @@ fun PagerScaffold( }, ) } - .verticalScroll(widgetsScrollState) + .pagerScaffoldScrollHandler( + pagerState, + widgetsScrollState, + reversePager = reverse, + ) + .verticalScroll(widgetsScrollState, enabled = false) .windowInsetsPadding(WindowInsets.safeDrawing) .graphicsLayer { val pagerProgress = @@ -470,6 +527,12 @@ fun PagerScaffold( alpha = pagerProgress } .nestedScroll(searchNestedScrollConnection) + .pagerScaffoldScrollHandler( + pagerState, + searchState, + reversePager = reverse, + reverseScroll = reverseSearchResults + ) .padding( start = windowInsets.calculateStartPadding( LocalLayoutDirection.current @@ -481,6 +544,7 @@ fun PagerScaffold( reverse = reverseSearchResults, state = searchState, paddingValues = paddingValues, + userScrollEnabled = false, ) } } @@ -574,3 +638,130 @@ fun PagerScaffold( 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 {} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index 67d68051..26535158 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -74,6 +74,7 @@ fun SearchColumn( paddingValues: PaddingValues = PaddingValues(0.dp), state: LazyListState = rememberLazyListState(), reverse: Boolean = false, + userScrollEnabled: Boolean = true, ) { val columns = LocalGridSettings.current.columnCount @@ -118,6 +119,7 @@ fun SearchColumn( LazyColumn( state = state, modifier = modifier, + userScrollEnabled = userScrollEnabled, contentPadding = paddingValues, reverseLayout = reverse, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/modifier/Scrolling.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/modifier/Scrolling.kt index 2e4acf80..71f16d52 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/modifier/Scrolling.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/modifier/Scrolling.kt @@ -1,18 +1,38 @@ 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.composed import androidx.compose.ui.geometry.Offset 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.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.dp +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.sign /** * Consumes all scrolling, so that the parent doesn't scroll. */ fun Modifier.consumeAllScrolling() = this.nestedScroll(ConsumeAllScrollConnection) -private object ConsumeAllScrollConnection: +private object ConsumeAllScrollConnection : NestedScrollConnection { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {