androidandroid-jetpack-composenavigation-drawerandroid-jetpack

How to enable swipe gesture to open ModalNavigationDrawer when using a full-screen HorizontalPager in Jetpack Compose?


I'm using Jetpack Compose and I am trying to enable opening a ModalNavigationDrawer via a swipe gesture (from left to right) similar to how Twitter does it. I have a HorizontalPager that occupies the entire screen, and while I can open the drawer by tapping an icon in the Top Bar and swiping from it, swiping from inside the HorizontalPgaer doesn't trigger the drawer to open.

I expect the ModalNavigationDrawer to open when swiping from left to right on the first page (index = 0) of the HorizontalPager, while still allowing horizontal swipes between pages on the other pages.

My code is the following one:


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TestNavigationDrawer() {
    val drawerState = rememberDrawerState(DrawerValue.Closed)
    val pagerState = rememberPagerState(initialPage = 0) { 2 }
    val scope = rememberCoroutineScope()

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            Column(
                Modifier.fillMaxSize().padding(end = 64.dp)
                    .background(MaterialTheme.colorScheme.surface)
                    .systemBarsPadding().systemBarsPadding()
            ) {
                Text("Navigation Drawer")
            }
        }
    ) {

        Scaffold(topBar = {
            CenterAlignedTopAppBar(
                title = { Text("Top bar") },
                navigationIcon = {
                    IconButton(onClick = {
                        scope.launch {
                            drawerState.open()
                        }
                    }
                    ) {
                        Icon(imageVector = Icons.Default.Info, contentDescription = null)
                    }
                })
        }) { innerPadding ->
            Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
                HorizontalPager(modifier = Modifier.fillMaxSize(), state = pagerState) { page ->
                    when (page) {
                        0 -> Text("Page 0")
                        1 -> Text("Page 1")
                    }
                }
            }
        }
    }
}

This is a gif with the current behavior of my code. I can open the drawer by tapping the icon and swiping from the Top Bar.

Video with the current behavior


Solution

  • You can do it by using a gesture that does not consume which does not prevent neither scroll of ModalDrawer nor HorizontalPager such as

    fun Modifier.customTouch(
        pass: PointerEventPass = PointerEventPass.Main,
        onDown: (pointer: PointerInputChange) -> Unit,
        onMove: (changes: List<PointerInputChange>) -> Unit,
        onUp: () -> Unit,
    ) = this.then(
        Modifier.pointerInput(pass) {
            awaitEachGesture {
                val down = awaitFirstDown(pass = pass, requireUnconsumed = false)
                onDown(down)
                do {
                    val event: PointerEvent = awaitPointerEvent(
                        pass = pass
                    )
    
                    onMove(event.changes)
    
                } while (event.changes.any { it.pressed })
                onUp()
            }
        }
    )
    

    Since we don't consume this gesture will never prevent HorizontalPager from scrolling.

    Inside onMove check if page is zero and swiped to right to start ModalNavigationDrawer scrolling by disabling gesture of HorizontalPager as

    .customTouch(
        onDown = {},
        onMove = { changeList ->
            changeList.firstOrNull()?.let {
                if (scrollEnabled && (it.position.x - it.previousPosition.x) > 0f
                    && pagerState.currentPage == 0
                ) {
                    scrollEnabled = false
                }
            }
        },
    
        onUp = {
            scrollEnabled = true
        }
    )
    

    Full code

    @OptIn(ExperimentalMaterial3Api::class)
    @Preview
    @Composable
    fun TestNavigationDrawer() {
        val drawerState = rememberDrawerState(DrawerValue.Closed)
        val pagerState = rememberPagerState(initialPage = 0) { 2 }
        val scope = rememberCoroutineScope()
    
        ModalNavigationDrawer(
            drawerState = drawerState,
            drawerContent = {
                Column(
                    Modifier.fillMaxSize().padding(end = 64.dp)
                        .background(MaterialTheme.colorScheme.surface)
                        .systemBarsPadding().systemBarsPadding()
                ) {
                    Text("Navigation Drawer")
                }
            }
        ) {
    
            var scrollEnabled by remember {
                mutableStateOf(true)
            }
    
            Scaffold(topBar = {
                CenterAlignedTopAppBar(
                    title = { Text("Top bar") },
                    navigationIcon = {
                        IconButton(onClick = {
                            scope.launch {
                                drawerState.open()
                            }
                        }
                        ) {
                            Icon(imageVector = Icons.Default.Info, contentDescription = null)
                        }
                    })
            }) { innerPadding ->
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(innerPadding)
                        .customTouch(
                            onDown = {},
                            onMove = { changeList ->
                                changeList.firstOrNull()?.let {
                                    if (scrollEnabled && (it.position.x - it.previousPosition.x) > 0f
                                        && pagerState.currentPage == 0
                                    ) {
                                        scrollEnabled = false
                                    }
                                }
                            },
    
                            onUp = {
                                scrollEnabled = true
                            }
                        )
                ) {
    
    //                Box(
    //                    modifier = Modifier.fillMaxSize(),
    //                    contentAlignment = Alignment.Center
    //                ) {
    //                    Text("Page 0", fontSize = 40.sp, fontWeight = FontWeight.Bold)
    //                }
    
                    HorizontalPager(
                        modifier = Modifier
                            .fillMaxSize(),
                        state = pagerState,
                        userScrollEnabled = scrollEnabled
                    ) { page ->
                        Box(
                            modifier = Modifier.fillMaxSize(),
                            contentAlignment = Alignment.Center
                        ) {
                            when (page) {
                                0 -> Text("Page 0", fontSize = 40.sp, fontWeight = FontWeight.Bold)
                                1 -> Text("Page 1", fontSize = 32.sp, fontWeight = FontWeight.Bold)
                            }
                        }
                    }
                }
            }
        }
    }
    

    However, ModalNavigationDrawer you won't get the same experience as in Twitter because of two things.

    1- twitter component animates to pointer position after slope threshold is passed which is not possible with ModalNavigationDrawer.

    2- Also it either requires fast swipe or swipe to start from very close to start of screen to move properly when you release your finger. This is independent of HorizontalPager or gesture i posted, you can remove gesture and only try with Box to see how it behaves.

    To have exact behavior you need to write a custom Composable or gesture instead of ModalNavigationDrawer while using the gesture and disable HorizontalPager scroll logic.