1

So I am rewriting an app's UI using Jetpack Compose. I have implemented a Navigation Drawer using the regular Scaffold function. Out of the box this provides two ways of opening the drawer: either press the navigationIcon or drag towards End of screen. The screen in question is a LazyColumn of list items.

I have at a later date implemented the SwipeToDismiss pattern on these list items. The swipe to dismiss works fine but it is no longer possible to drag anywhere to open the navigation drawer.

In the old View-based system, the navigation drawer would reserve a small width inside which you could always drag to open the drawer - regardless of child items having drag support. I am unsure how to achieve the same using Compose. It seems like it should be the job of the navigation drawer to handle this - and not a screen inside it.

The screen with navigation drawer:

    val coroutineScope = rememberCoroutineScope()
    val scaffoldState = rememberScaffoldState(
        rememberDrawerState(initialValue = DrawerValue.Closed)
    )

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = {
            TopAppBar(
                title = { Text(screenTitle) },
                navigationIcon = {
                    IconButton(
                        onClick = {
                            coroutineScope.launch {
                                scaffoldState.drawerState.open()
                            }
                        }
                    ) {
                        Icon(
                            Icons.Default.Menu,
                            contentDescription = "Drawer toggle button"
                        )
                    }
                },
                actions = {
                    ...
                }
            )
        },
        drawerContent = {
            // List of stuff
            ...
        },
        floatingActionButton = {
            ...
        }
    ) { padding ->
        /// Layout with a LazyColumn with elements having SwipeToDismiss
        ...
    }

and swipe to dismiss item (displayed inside LazyColumn)

@OptIn(
    ExperimentalFoundationApi::class,
    ExperimentalMaterialApi::class,
    ExperimentalAnimationApi::class
)
@Composable
fun SwipeableFeedItemPreview(
    onSwipe: suspend () -> Unit,
    onlyUnread: Boolean,
    item: FeedListItem,
    showThumbnail: Boolean,
    imagePainter: @Composable (String) -> Unit,
    onMarkAboveAsRead: () -> Unit,
    onMarkBelowAsRead: () -> Unit,
    onItemClick: () -> Unit
) {
    val animatedVisibilityState = remember { MutableTransitionState(true) }
    val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
    // Needs to be set once layout is complete
    var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
    val anchors = mapOf(
        0f to FeedItemSwipeState.NONE,
        -itemSize.width to FeedItemSwipeState.LEFT,
        itemSize.width to FeedItemSwipeState.RIGHT
    )

    AnimatedVisibility(
        visibleState = animatedVisibilityState,
        enter = fadeIn(1f),
        exit = shrinkVertically(Alignment.CenterVertically) + fadeOut()
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .onGloballyPositioned { layoutCoordinates ->
                    itemSize = layoutCoordinates.size.toSize()
                }
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    orientation = Orientation.Horizontal,
                    thresholds = { _, _ ->
                        FractionalThreshold(0.25f)
                    }
                )
        ) {
            Box(
                contentAlignment = swipeIconAlignment,
                modifier = Modifier
                    .matchParentSize()
                    .background(color)
                    .padding(horizontal = 24.dp)
            ) {
                AnimatedVisibility(
                    visible = swipeableState.targetValue != FeedItemSwipeState.NONE,
                    enter = fadeIn(),
                    exit = fadeOut()
                ) {
                    Icon(
                        when (item.unread) {
                            true -> Icons.Default.VisibilityOff
                            false -> Icons.Default.Visibility
                        },
                        contentDescription = stringResource(id = R.string.toggle_read_status)
                    )
                }
            }

            FeedItemPreview(
                item = item,
                showThumbnail = showThumbnail,
                imagePainter = imagePainter,
                onMarkAboveAsRead = onMarkAboveAsRead,
                onMarkBelowAsRead = onMarkBelowAsRead,
                onItemClick = onItemClick,
                modifier = Modifier
                    .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
            )
        }
    }
}
Jonas Kalderstam
  • 1,136
  • 12
  • 27

2 Answers2

2

You can easily decrease swipeable range using padding, like this:


enum class FeedItemSwipeState {
    NONE, LEFT, RIGHT,
}

@Composable
fun TestView(
) {
    val scaffoldState = rememberScaffoldState(
        rememberDrawerState(initialValue = DrawerValue.Closed)
    )

    Scaffold(
        scaffoldState = scaffoldState,
        drawerContent = {

        },
    ) {
        val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
        // Needs to be set once layout is complete
        var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
        val anchors = mapOf(
            0f to FeedItemSwipeState.NONE,
            -itemSize.width to FeedItemSwipeState.LEFT,
            itemSize.width to FeedItemSwipeState.RIGHT
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
        ) {
            Box(Modifier.fillMaxWidth()) {
                Box(
                    modifier = Modifier
                        .matchParentSize()
                        .clickable { // clickable on whole view
                        }
                        .padding(start = 30.dp) // left distance for drawer
                        .onGloballyPositioned { layoutCoordinates ->
                            itemSize = layoutCoordinates.size.toSize()
                        }
                        .swipeable( // swipeable after padding to allow drawerContent work
                            state = swipeableState,
                            anchors = anchors,
                            orientation = Orientation.Horizontal,
                            thresholds = { _, _ ->
                                FractionalThreshold(0.25f)
                            }
                        )
                )
                Text(
                    "item",
                    modifier = Modifier
                        .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                )
            }
        }
    }
}

I'm not sure if that's something Scaffold should be responsible for, if you think it should - create an issue on compose issue tracker

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • While this does indeed fix the dragging issue it also introduces a major problem. The swipeable modifier will intercept clicks and because the FeedItem is not a child of the swipeable box - it is no longer possible to click the item except for the small padding area – Jonas Kalderstam Aug 14 '21 at 21:08
  • @JonasKalderstam Then I guess you need to pass padding+swipeable modifier as an other modifier variable and apply it to the same item which handles touches – Phil Dukhov Aug 15 '21 at 04:14
  • Sure but I don't want padding on the swipeable item. I really want only the swipeable area padded - not the actual item. Currently these concepts can't be separated it seems – Jonas Kalderstam Aug 15 '21 at 19:26
  • @JonasKalderstam but you can add both swipable and clickable, and any other gestures on an empty box too! In this case padding only defines the scope of touches. Check out my updated answer – Phil Dukhov Aug 16 '21 at 05:30
  • 1
    Thank you! You gave me the answer. I ended up simplifying it slightly (IMO at least) see my answer below for reference. I suspected the answer would be as simple as this, but I still haven't gotten round to thinking as simple as Compose is. I end up thinking "how did this work in the old view system". Thanks again – Jonas Kalderstam Aug 16 '21 at 21:19
0

This is the approach I ended up using after Philip gave his answer. It's slightly "less boxy". In summary - the key was simply to let the parent box handle the clicking - allowing a separate box to focus solely on the swiping - and the feeditem itself doesn't handle clicking


enum class FeedItemSwipeState {
    NONE, LEFT, RIGHT,
}

@Composable
fun TestView(
) {
    val scaffoldState = rememberScaffoldState(
        rememberDrawerState(initialValue = DrawerValue.Closed)
    )

    Scaffold(
        scaffoldState = scaffoldState,
        drawerContent = {

        },
    ) {
        val swipeableState = rememberSwipeableState(initialValue = FeedItemSwipeState.NONE)
        // Needs to be set once layout is complete
        var itemSize by remember { mutableStateOf(Size(1f, 1f)) }
        val anchors = mapOf(
            0f to FeedItemSwipeState.NONE,
            -itemSize.width to FeedItemSwipeState.LEFT,
            itemSize.width to FeedItemSwipeState.RIGHT
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .onGloballyPositioned { layoutCoordinates ->
                    itemSize = layoutCoordinates.size.toSize()
                }
                .combinedClickable(
                    onLongClick = { ... },
                    onClick = { ... },
                )
        ) {
            Box(
                modifier = Modifier
                    .padding(start = 48.dp)
                    .matchParentSize()
                    .swipeable(
                        state = swipeableState,
                        anchors = anchors,
                        orientation = Orientation.Horizontal,
                        thresholds = { _, _ ->
                            FractionalThreshold(0.25f)
                        }
                    )
            )

            FeedItemPreview(
                item = "item",
                swipeableModifier = Modifier
                    .padding(start = 30.dp) // left distance for drawer
                    .onGloballyPositioned { layoutCoordinates ->
                        itemSize = layoutCoordinates.size.toSize()
                    }
                    .swipeable(
                        state = swipeableState,
                        anchors = anchors,
                        orientation = Orientation.Horizontal,
                        thresholds = { _, _ ->
                            FractionalThreshold(0.25f)
                        }
                    )
                ,
                modifier = Modifier
                    .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
            )
        }
    }
}

@Composable
fun FeedItemPreview(
    item: String,
    modifier: Modifier,
) {
    Text(
        item,
        modifier = modifier
    )
}

With example in app where swipeable area is highlighted by a border:

Illustration of padded swipeable area

Jonas Kalderstam
  • 1,136
  • 12
  • 27