SilentByte
SilentByte

Reputation: 1220

How to detect an attempt to swipe past the bounds of a HorizontalPager?

I'd like to detect when a user attempts to swipe past the last page of a HorizontalPager. At that point, I'd like it to scroll back to the first page. I don't want an infinite pager, I simply want it to scroll back to the beginning upon swiping past the last page.

I'd imagine this involves setting up some kind of pointer input gesture detector, but I'm not sure how to do this without breaking the built-in gesture detection within HorizonalPager.

Upvotes: 2

Views: 595

Answers (1)

Thracian
Thracian

Reputation: 67149

You can use Modifier.pointerInput and awaitPointerEvent(pass = PointerEventPass.Initial). PointerEventPass.Initial makes sure that Modifier.pointerInput is invoked before inner one of HorizontalPager via Modifier.scroll. Also since we don't consume any event we do not interfere with pager. You can refer answers below for more details about gestures and event propagation.

https://stackoverflow.com/a/70847531/5457853

Jetpack Compose Intercept pinch/zoom in child layout

However since Pager subcomposes items based on beyondPageSomething, they changed name of this param on 1.7 again, it will animate one or more pages via pager.animateScrollToPage

Result

enter image description here

Demo

@Preview
@Composable
fun PagerScrollSample() {

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp)
    ) {

        val pagerState = rememberPagerState {
            5
        }

        var shouldScrollToFirstPage by remember {
            mutableStateOf(false)
        }

        LaunchedEffect(shouldScrollToFirstPage) {
            if (shouldScrollToFirstPage) {
                delay(100)
                pagerState.animateScrollToPage(0)
                shouldScrollToFirstPage = false
            }
        }

        Text("shouldScrollToFirstPage: $shouldScrollToFirstPage")
        HorizontalPager(
            userScrollEnabled = shouldScrollToFirstPage.not(),
            modifier = Modifier
                .pointerInput(Unit) {
                    awaitEachGesture {
                        awaitFirstDown()
                        shouldScrollToFirstPage = false

                        do {
                            val event: PointerEvent = awaitPointerEvent(
                                pass = PointerEventPass.Initial
                            )

                            event.changes.forEach {

                                if (pagerState.currentPage == 4 &&                          
                                    pagerState.currentPage == pagerState.settledPage &&

                                    // current position of finger
                                    it.position.x < 200f &&
                                    shouldScrollToFirstPage.not()
                                ) {
                                    shouldScrollToFirstPage = true
                                }
                            }

                        } while (event.changes.any { it.pressed })


                        // User lifts pointer, you can animate here as well
//                        if (pagerState.currentPage == 4 &&
//                            pagerState.currentPageOffsetFraction == 0f
//                        ) {
//                            shouldScrollToFirstPage = true
//                        }


                    }
                },
            state = pagerState,
            pageSpacing = 16.dp,
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .background(Color.LightGray, RoundedCornerShape(16.dp)),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "Page $it",
                    fontSize = 28.sp
                )
            }
        }
    }
}

Another alternative with partially visible and clickable items

enter image description here

@Preview
@Composable
private fun PagerScrollSample2() {

    val pagerState = rememberPagerState {
        5
    }

    var shouldScrollToFirstPage by remember {
        mutableStateOf(false)
    }

    val coroutineScope = rememberCoroutineScope()

    BoxWithConstraints {

        val pageSpacing = 16.dp
        val pageWidth = maxWidth - pageSpacing - 32.dp

        HorizontalPager(
            userScrollEnabled = shouldScrollToFirstPage.not(),
            contentPadding = PaddingValues(horizontal = 16.dp),
            pageSize = PageSize.Fixed(pageWidth),
            modifier = Modifier
                .pointerInput(Unit) {
                    awaitEachGesture {
                        val down = awaitFirstDown(pass = PointerEventPass.Initial)
                        shouldScrollToFirstPage = false

                        val firstTouchX = down.position.x

                        do {
                            val event: PointerEvent = awaitPointerEvent(
                                pass = PointerEventPass.Initial
                            )

                            event.changes.forEach {

                                val diff = firstTouchX - it.position.x

                                if (pagerState.currentPage == 4 &&
                                    pagerState.currentPage == 
                                    pagerState.settledPage &&
                                    // Scroll if user scrolled 10% from first touch position
                                    // or pointer is at the left of 20% of page
                                    (diff > size.width * .10f ||
                                            it.position.x < size.width * .2f) &&
                                    shouldScrollToFirstPage.not()
                                ) {
                                    coroutineScope.launch {
                                        shouldScrollToFirstPage = true
                                        pagerState.animateScrollToPage(
                                            0,
                                            animationSpec = tween(500)
                                        )
                                        shouldScrollToFirstPage = false
                                    }
                                }
                            }

                        } while (event.changes.any { it.pressed })
                    }
                },
            state = pagerState,
            pageSpacing = pageSpacing,
        ) {

            val context = LocalContext.current

            Box(
                modifier = Modifier
                    .clickable {
                        Toast.makeText(context, "Clicked $it", Toast.LENGTH_SHORT).show()
                    }
                    .fillMaxWidth()
                    .height(200.dp)
                    .background(Color.LightGray, RoundedCornerShape(16.dp)),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "Page $it",
                    fontSize = 28.sp
                )
            }
        }
    }

    Button(
        modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
        onClick = {
            coroutineScope.launch {
                pagerState.animateScrollToPage(0)
            }
        }
    ) {
        Text("Scroll to first page")
    }
}

Upvotes: 5

Related Questions