Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Pager] Allow scrolling to negative offsets and from LaunchedEffect #1195

Merged
merged 1 commit into from Jun 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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