androidandroid-jetpack-composehorizontal-pager

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.


Solution

  • 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")
        }
    }