Skip to content

Commit

Permalink
Merge pull request #1195 from andkulikov/main
Browse files Browse the repository at this point in the history
[Pager] Allow scrolling to negative offsets and from LaunchedEffect
  • Loading branch information
andkulikov committed Jun 13, 2022
2 parents 568be72 + 76da84f commit 5ab77e3
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 45 deletions.
4 changes: 2 additions & 2 deletions pager/api/current.api
Expand Up @@ -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<java.lang.Float> animationSpec, optional float initialVelocity, optional boolean skipPages, optional kotlin.coroutines.Continuation<? super kotlin.Unit> 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<? super kotlin.Unit> 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<? super kotlin.Unit> p);
method public float dispatchRawDelta(float delta);
method @IntRange(from=0) public int getCurrentPage();
method public float getCurrentPageOffset();
Expand All @@ -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<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit> 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<? super kotlin.Unit> 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<? super kotlin.Unit> p);
property @IntRange(from=0) public final int currentPage;
property public final float currentPageOffset;
property public final androidx.compose.foundation.interaction.InteractionSource interactionSource;
Expand Down
41 changes: 17 additions & 24 deletions pager/src/main/java/com/google/accompanist/pager/PagerState.kt
Expand Up @@ -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")
Expand All @@ -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 }
Expand All @@ -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.
Expand All @@ -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")
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
142 changes: 123 additions & 19 deletions pager/src/sharedTest/kotlin/com/google/accompanist/pager/PagerTest.kt
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 5ab77e3

Please sign in to comment.