Enhance Your App’s User Experience with SwipeBox — A Jetpack Compose Component for Intuitive Swiping Gestures!

Kevin Zou
6 min readAug 15, 2023

--

Background

I am developing a shopping App, which includes the shopping cart, order, address management, and other modules. When I was developing it, I found that many screens that contain a list will need a functionality, which is showing action buttons for the single item in the list. For example, the shopping cart page needs buttons to favorite or delete a single good. The address list page needs buttons to delete or modify a single address.

When researching other apps, I found that the general practice is to make the list item swipeable and show the action buttons when it is swiped out. I also think it is a good user experience and decide to use that plan in my app.

Then I tried to find a native Compose component to support this functionality. Unfortunately, I couldn’t find it. Therefore, I can only try to implement this component myself. Then I came up with this library:

This Library provides a composable widget SwipeBox that can be swiped left or right to show the action buttons. It supports custom designs for the action buttons. It also provides the composable widgets SwipeIcon and SwipeText for easy design of action buttons.

API

The core component of this library is the SwipeBox

@Composable
fun SwipeBox(
modifier: Modifier = Modifier,
state: SwipeableState<Int> = rememberSwipeableState(initialValue = 0),
swipeDirection: SwipeDirection = SwipeDirection.EndToStart,
startContentWidth: Dp = 0.dp,
startContent: @Composable (RowScope.(swipeableState: SwipeableState<Int>, startSwipeProgress: Float) -> Unit)? = null,
endContentWidth: Dp = 0.dp,
endContent: @Composable (RowScope.(swipeableState: SwipeableState<Int>, endSwipeProgress: Float) -> Unit)? = null,
thresholds: (from: Int, to: Int) -> ThresholdConfig = { _, _ -> FixedThreshold(12.dp) },
content: @Composable BoxScope.(swipeableState: SwipeableState<Int>, startSwipeProgress: Float, endSwipeProgress: Float) -> Unit,
)

The modifier parameter is used to apply a modifier to the SwipeBox. The swipeDirection parameter defines the direction of the swipe, and if the direction is SwipeDirection.EndToStart, only the endContent will be shown.

The startContentWidth parameter specifies the width of the start content, which will be displayed when the swipe direction is StartToEnd or Both. The startContent parameter is the content of the start content, and it takes two parameters: swipeableState, which can be used to change the swipe state, and startSwipeProgress, which represents the progress of the swipe of the start content (0f for null startContent). Same usage for parameter endContentWidth and endContent .

Note that the content will be layout in a RowScope with mutable width. Thus, for the sub-content inside it, you have to use the weight modifier to determine the width of the content instead of using the width modifier directly. Also, since the width of the container will change with swipe progress, the content inside the sub-container has to use the requiredWidth modifier to avoid abnormal recompose to that width change. For content with just icons or text, it is recommended to use the SwipeIcon and SwipeText which set up the size restriction for you, and you just need to set the real content.

Usage

The usage of this component is quite simple.

One Direction Swipe

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeBoxAtEnd() {
val coroutineScope = rememberCoroutineScope()
SwipeBox(
modifier = Modifier.fillMaxWidth(),
swipeDirection = SwipeDirection.EndToStart,
endContentWidth = 60.dp,
endContent = { swipeableState, endSwipeProgress ->SwipeIcon(
imageVector = Icons.Outlined.Delete,
contentDescription = "Delete",
tint = Color.White,
background = Color(0xFFFA1E32),
weight = 1f,
iconSize = 20.dp
) {
coroutineScope.launch {
swipeableState.animateTo(0)
}
}
}
) { _, _, _ ->Box(
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.background(Color(148, 184, 216)),
contentAlignment = Alignment.Center
) {
Text(
text = "Swipe Left", color = Color.White, fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}

Bidirectional Swipe

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeBoxAtBoth() {
val coroutineScope = rememberCoroutineScope()
SwipeBox(
modifier = Modifier.fillMaxWidth(),
swipeDirection = SwipeDirection.Both,
startContentWidth = 60.dp,
startContent = { swipeableState, endSwipeProgress ->SwipeIcon(
imageVector = Icons.Outlined.Favorite,
contentDescription = "Favorite",
tint = Color.White,
background = Color(0xFFFFB133),
weight = 1f,
iconSize = 20.dp
) {
coroutineScope.launch {
swipeableState.animateTo(0)
}
}
},
endContentWidth = 60.dp,
endContent = { swipeableState, endSwipeProgress ->SwipeIcon(
imageVector = Icons.Outlined.Delete,
contentDescription = "Delete",
tint = Color.White,
background = Color(0xFFFA1E32),
weight = 1f,
iconSize = 20.dp
) {
coroutineScope.launch {
swipeableState.animateTo(0)
}
}
}
) { _, _, _ ->Box(
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.background(Color(148, 184, 216)),
contentAlignment = Alignment.Center
) {
Text(
text = "Swipe Both Directions", color = Color.White, fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}

Multiple Action Buttons

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeBoxWithText() {
val coroutineScope = rememberCoroutineScope()
SwipeBox(
modifier = Modifier.fillMaxWidth(),
swipeDirection = SwipeDirection.EndToStart,
endContentWidth = 120.dp,
endContent = { swipeableState, endSwipeProgress ->
SwipeText(background = Color(0xFFFFB133),
weight = 1f,
onClick = {
coroutineScope.launch {
swipeableState.animateTo(0)
}
}) {
Text(
text = "收藏", fontSize = 12.sp, maxLines = 1,
textAlign = TextAlign.Center, color = Color.White, fontWeight = FontWeight.Bold,
)
}
SwipeText(background = Color(0xFFFA1E32),
weight = 1f,
onClick = {
coroutineScope.launch {
swipeableState.animateTo(0)
}
}) {
Text(
text = "删除", fontSize = 12.sp, maxLines = 1,
textAlign = TextAlign.Center, color = Color.White, fontWeight = FontWeight.Bold,
)
}
}
) { _, _, _ ->
mainContent("Swipe Left Text")
}
}

Interaction With List

Normally, this widget will be used in a list that needs it to swipe back when the list starts to scroll or another box swipe out. This widget is designed to support that feature but needs extra implementations.

First, we need to define a MutableState which is the SwipeableState of the current opening swipe-box so that we can control it later.

var currentSwipeState: SwipeableState<Int>? by remember {
mutableStateOf(null)
}

Second, we need to define a nestedScrollConnection and set it to the Modifier.nestedScroll of the list so that we can intercept the scroll event and make the current opening box swipe backward.

val nestedScrollConnection = remember {
object : NestedScrollConnection {
/**
* we need to intercept the scroll event and check whether there is an open box
* if so ,then we need to swipe that box back and reset the state
*/
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (currentSwipeState != null && currentSwipeState!!.currentValue != 0) {
coroutineScope.launch {
currentSwipeState!!.animateTo(0)
currentSwipeState = null
}
}
return Offset.Zero
}
}
}

LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
)

Finally, we need to define a callback and pass it to the swipe-box so that we can get informed when the swipe-box starts to swipe and then update the currentSwipeState

val onSwipeStateChanged = { state : SwipeableState<Int> ->
/**
* if it is swiping back and it equals to the current state
* it means that the current open box is closed, then we set the state to null
*/
if (state.targetValue == 0 && currentSwipeState == state) {
currentSwipeState = null
}
// if there is no opening box, we set it to this opening one
else if (currentSwipeState == null) {
currentSwipeState = state
} else {
// there already had one box opening, we need to swipe it back and then update the state to new one
coroutineScope.launch {
currentSwipeState!!.animateTo(0)
currentSwipeState = state
}
}

}


SwipeBox(onSwipeStateChanged){ state, _, _ ->
// callback on parent when the state targetValue changes which means it is swiping to another state.
LaunchedEffect(state.targetValue) {
onSwipeStateChanged(state)
}
}

After that, your list will react to the list scroll and update the swipeboxs’ states. Please refer to SwipeBoxList for a full code example.

Download

The Current Release Version is 1.1.0. For future releases, please refer to the release session of the GitHub repository.

allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}

dependencies {
implementation("com.github.kevinnzou:compose-swipeBox:1.1.0")
}

--

--

Kevin Zou

Android Developer in NetEase. Focusing on Kotlin Multiplatform and Compose Multiplatform