From 76da84f90bb342d8498412df5db0b5727f315d3a Mon Sep 17 00:00:00 2001 From: Andrey Kulikov Date: Fri, 10 Jun 2022 17:11:45 +0100 Subject: [PATCH] [Pager] Allow scrolling to negative offsets and from LaunchedEffect --- pager/api/current.api | 4 +- .../google/accompanist/pager/PagerState.kt | 41 +++-- .../com/google/accompanist/pager/PagerTest.kt | 142 +++++++++++++++--- 3 files changed, 142 insertions(+), 45 deletions(-) diff --git a/pager/api/current.api b/pager/api/current.api index c00017400..734192d1d 100644 --- a/pager/api/current.api +++ b/pager/api/current.api @@ -31,7 +31,7 @@ package com.google.accompanist.pager { @androidx.compose.runtime.Stable @com.google.accompanist.pager.ExperimentalPagerApi public final class PagerState implements androidx.compose.foundation.gestures.ScrollableState { ctor public PagerState(optional @IntRange(from=0) int currentPage); method @Deprecated public suspend Object? animateScrollToPage(@IntRange(from=0) int page, optional @FloatRange(from=0.0, to=1.0) float pageOffset, optional androidx.compose.animation.core.AnimationSpec animationSpec, optional float initialVelocity, optional boolean skipPages, optional kotlin.coroutines.Continuation p); - method public suspend Object? animateScrollToPage(@IntRange(from=0) int page, optional @FloatRange(from=0.0, to=1.0) float pageOffset, optional kotlin.coroutines.Continuation p); + method public suspend Object? animateScrollToPage(@IntRange(from=0) int page, optional @FloatRange(from=-1.0, to=1.0) float pageOffset, optional kotlin.coroutines.Continuation p); method public float dispatchRawDelta(float delta); method @IntRange(from=0) public int getCurrentPage(); method public float getCurrentPageOffset(); @@ -40,7 +40,7 @@ package com.google.accompanist.pager { method public int getTargetPage(); method public boolean isScrollInProgress(); method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2,?> block, kotlin.coroutines.Continuation p); - method public suspend Object? scrollToPage(@IntRange(from=0) int page, optional @FloatRange(from=0.0, to=1.0) float pageOffset, optional kotlin.coroutines.Continuation p); + method public suspend Object? scrollToPage(@IntRange(from=0) int page, optional @FloatRange(from=-1.0, to=1.0) float pageOffset, optional kotlin.coroutines.Continuation p); property @IntRange(from=0) public final int currentPage; property public final float currentPageOffset; property public final androidx.compose.foundation.interaction.InteractionSource interactionSource; diff --git a/pager/src/main/java/com/google/accompanist/pager/PagerState.kt b/pager/src/main/java/com/google/accompanist/pager/PagerState.kt index 80327bf1e..75963163e 100644 --- a/pager/src/main/java/com/google/accompanist/pager/PagerState.kt +++ b/pager/src/main/java/com/google/accompanist/pager/PagerState.kt @@ -190,13 +190,13 @@ class PagerState( * Cancels the currently running scroll, if any, and suspends until the cancellation is * complete. * - * @param page the page to animate to. Must be between 0 and [pageCount] (inclusive). - * @param pageOffset the percentage of the page width to offset, from the start of [page]. - * Must be in the range 0f..1f. + * @param page the page to animate to. Must be >= 0. + * @param pageOffset the percentage of the page size to offset, from the start of [page]. + * Must be in the range -1f..1f. */ suspend fun animateScrollToPage( @IntRange(from = 0) page: Int, - @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, + @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f, ) { requireCurrentPage(page, "page") requireCurrentPageOffset(pageOffset, "pageOffset") @@ -210,11 +210,12 @@ class PagerState( lazyListState.scrollToItem(if (page > oldPage) page - 3 else page + 3) } - if (pageOffset <= 0.005f) { + if (pageOffset.absoluteValue <= 0.005f) { // If the offset is (close to) zero, just call animateScrollToItem and we're done lazyListState.animateScrollToItem(index = page) } else { // Else we need to figure out what the offset is in pixels... + lazyListState.scroll { } // this will await for the first layout. val layoutInfo = lazyListState.layoutInfo var target = layoutInfo.visibleItemsInfo .firstOrNull { it.index == page } @@ -235,9 +236,9 @@ class PagerState( ) // The target should be visible now - target = lazyListState.layoutInfo.visibleItemsInfo.first { it.index == page } + target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == page } - if (target.size != currentSize) { + if (target != null && target.size != currentSize) { // If the size we used for calculating the offset differs from the actual // target page size, we need to scroll again. This doesn't look great, // but there's not much else we can do. @@ -262,11 +263,13 @@ class PagerState( * Cancels the currently running scroll, if any, and suspends until the cancellation is * complete. * - * @param page the page to snap to. Must be between 0 and [pageCount] (inclusive). + * @param page the page to snap to. Must be >= 0. + * @param pageOffset the percentage of the page size to offset, from the start of [page]. + * Must be in the range -1f..1f. */ suspend fun scrollToPage( @IntRange(from = 0) page: Int, - @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f, + @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f, ) { requireCurrentPage(page, "page") requireCurrentPageOffset(pageOffset, "pageOffset") @@ -278,9 +281,9 @@ class PagerState( updateCurrentPageBasedOnLazyListState() // If we have a start spacing, we need to offset (scroll) by that too - if (pageOffset > 0.0001f) { - scroll { - currentPageLayoutInfo?.let { + if (pageOffset.absoluteValue > 0.0001f) { + currentPageLayoutInfo?.let { + scroll { scrollBy(it.size * pageOffset) } } @@ -324,21 +327,11 @@ class PagerState( ")" private fun requireCurrentPage(value: Int, name: String) { - if (pageCount == 0) { - require(value == 0) { "$name must be 0 when pageCount is 0" } - } else { - require(value in 0 until pageCount) { - "$name[$value] must be >= 0 and < pageCount" - } - } + require(value >= 0) { "$name[$value] must be >= 0" } } private fun requireCurrentPageOffset(value: Float, name: String) { - if (pageCount == 0) { - require(value == 0f) { "$name must be 0f when pageCount is 0" } - } else { - require(value in 0f..1f) { "$name must be >= 0 and <= 1" } - } + require(value in -1f..1f) { "$name must be >= 0 and <= 1" } } companion object { diff --git a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/PagerTest.kt b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/PagerTest.kt index 1d5f1854f..b9e8dbe0a 100644 --- a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/PagerTest.kt +++ b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/PagerTest.kt @@ -17,6 +17,7 @@ package com.google.accompanist.pager import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -286,54 +287,157 @@ abstract class PagerTest { fun scrollToPage() { val pagerState = setPagerContent(count = 10) - fun testScroll(targetPage: Int, offset: Float = 0f) { + fun testScroll( + page: Int, + offset: Float = 0f, + expectedPage: Int = page, + expectedOffset: Float = offset + ) { composeTestRule.runOnIdle { runBlocking { - pagerState.scrollToPage(targetPage, offset) + pagerState.scrollToPage(page, offset) } } composeTestRule.runOnIdle { - assertThat(pagerState.currentPage).isEqualTo(targetPage) - assertThat(pagerState.currentPageOffset).isWithin(0.001f).of(offset) + assertThat(pagerState.currentPage).isEqualTo(expectedPage) + assertThat(pagerState.currentPageOffset).isWithin(0.001f).of(expectedOffset) } - assertPagerLayout(targetPage, pagerState.pageCount, offset) + assertPagerLayout(expectedPage, pagerState.pageCount, expectedOffset) } // Scroll to page 3 and assert - testScroll(3) + testScroll(page = 3) // Now scroll to page 0 and assert - testScroll(0) + testScroll(page = 0) // Now scroll to page 1 with an offset of 0.5 and assert - testScroll(1, 0.5f) + testScroll(page = 1, offset = 0.5f) // Now scroll to page 8 with an offset of 0.25 and assert - testScroll(8, 0.25f) + testScroll(page = 8, offset = 0.25f) + // Now scroll to page 2 with a negative offset + testScroll(page = 2, offset = -0.4f) + // Now scroll to last page with the offset which can't be reached + testScroll(page = 9, offset = 0.5f, expectedOffset = 0f) + // Now scroll to page which doesn't exist + testScroll(page = 15, offset = 0.5f, expectedPage = 9, expectedOffset = 0f) } @Test fun animateScrollToPage() { val pagerState = setPagerContent(count = 10) - fun testScroll(targetPage: Int, offset: Float = 0f) { + fun testScroll( + page: Int, + offset: Float = 0f, + expectedPage: Int = page, + expectedOffset: Float = offset + ) { composeTestRule.runOnIdle { runBlocking(AutoTestFrameClock()) { - pagerState.animateScrollToPage(targetPage, offset) + pagerState.animateScrollToPage(page, offset) } } composeTestRule.runOnIdle { - assertThat(pagerState.currentPage).isEqualTo(targetPage) - assertThat(pagerState.currentPageOffset).isWithin(0.001f).of(offset) + assertThat(pagerState.currentPage + pagerState.currentPageOffset) + .isWithin(0.001f).of(expectedPage + expectedOffset) + assertThat(pagerState.currentPage).isEqualTo(expectedPage) } - assertPagerLayout(targetPage, pagerState.pageCount, offset) + assertPagerLayout(expectedPage, pagerState.pageCount, expectedOffset) } // Scroll to page 3 and assert - testScroll(3) + testScroll(page = 3) // Now scroll to page 0 and assert - testScroll(0) - // Now scroll to page 1 with an offset of 0.5 and assert - testScroll(1, 0.5f) + testScroll(page = 0) + // Now scroll to page 1 with an offset of 0.4 and assert + testScroll(page = 1, offset = 0.4f) // Now scroll to page 8 with an offset of 0.25 and assert - testScroll(8, 0.25f) + testScroll(page = 8, offset = 0.25f) + // Now scroll to page 2 with a negative offset + testScroll(page = 2, offset = -0.4f) + // Now scroll to last page with the offset which can't be reached + testScroll(page = 9, offset = 0.5f, expectedOffset = 0f) + // Now scroll to page which doesn't exist + testScroll(page = 15, offset = 0.5f, expectedPage = 9, expectedOffset = 0f) + } + + @Test + fun scrollToPage_LaunchedEffect() { + composeTestRule.setContent { + val state = rememberPagerState() + PagerContent( + count = { 10 }, + pagerState = state + ) + LaunchedEffect(state) { + state.scrollToPage(3) + } + } + + assertPagerLayout(3, 10) + } + + @Test + fun scrollToPage_withOffset_LaunchedEffect() { + composeTestRule.setContent { + val state = rememberPagerState() + PagerContent( + count = { 10 }, + pagerState = state + ) + LaunchedEffect(state) { + state.scrollToPage(3, 0.5f) + } + } + + assertPagerLayout(3, 10, 0.5f) + } + + @Test + fun animatedScrollToPage_LaunchedEffect() { + composeTestRule.setContent { + val state = rememberPagerState() + PagerContent( + count = { 10 }, + pagerState = state + ) + LaunchedEffect(state) { + state.animateScrollToPage(2) + } + } + + assertPagerLayout(2, 10) + } + + @Test + fun animatedScrollToPage_withOffset_LaunchedEffect() { + composeTestRule.setContent { + val state = rememberPagerState() + PagerContent( + count = { 10 }, + pagerState = state + ) + LaunchedEffect(state) { + state.animateScrollToPage(2, 0.5f) + } + } + + assertPagerLayout(2, 10, 0.5f) + } + + @Test + fun animatedScrollToPage_moreThan3Pages_LaunchedEffect() { + composeTestRule.setContent { + val state = rememberPagerState() + PagerContent( + count = { 10 }, + pagerState = state + ) + LaunchedEffect(state) { + state.animateScrollToPage(6) + } + } + + assertPagerLayout(6, 10) } @Test