Unlock Interactive Possibilities with SwipeBox: A Compose Multiplatform Library for Swipe-to-Action Widget

Kevin Zou
7 min readOct 20, 2023

--

Background

In this article, I will introduce a Compose Multiplatform UI component library compose-swipebox-multiplatform. This library offers a Composable component that supports left and right swiping and displaying operation buttons at the bottom. This feature is widely used in modern apps, where we often need to swipe a component to perform actions like deleting, bookmarking, or sharing. One common example is the shopping cart list page in a shopping application. Each product component can be bookmarked or deleted by displaying operation options through swiping.

Basic Usage

First, let’s take a look at its basic usage

One-way 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
)
}
}
}

The API is very easy to understand. The above code implements a simple component for single-directional swiping and displaying delete options.

Bidirectional swipe

This component also supports bidirectional swiping.

@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
)
}
}
}

Use in the List

The most common use for this component is to include it in a list. However, in order to ensure a smooth user experience, it is important to automatically restore any swipe-out component to its original state when the list scrolls or other items are swiped out. Otherwise, multiple list items may remain expanded, leading to a poor user experience. This component was specifically designed to address this situation and here is the implementation guidance.

  1. To begin, we must define a SwipeableState that will save the swipe state of the currently open list item. This will allow us to easily swipe it back later.
var currentSwipeState: SwipeableState<Int>? by remember {
mutableStateOf(null)
}

2. Then we need to define a nestedScrollConnection to intercept the scrolling of the list so that we can reset the expanded list items when scrolling.

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)
)

3. Finally, we need to define a callback function and pass it to the SwipeBox component. This callback function is responsible for updating the currently expanded list item and handling the mutual exclusion logic between them.

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 oneelse 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 completing these three steps, we have the ability to automatically reset the swipe component in the list. A complete example can be found at:

API

Finally, let’s take a look at the complete API of this component.

/**
* Designed a box layout that you can swipe to show action boxes from both directions
*
* @param modifier The modifier to be applied to the SwipeBox.
* @param state The state object to be used to control the SwipeBox.
* @param swipeDirection The direction to swipe to. If the direction is [SwipeDirection.EndToStart], then only the endContent will be shown.
* @param startContentWidth The width of the start content which will be shown when the swipe direction is StartToEnd or Both.
* @param startContent The content of the start content.
* Two parameters will be provided to that content:
* 1. swipeableState: [SwipeableState], which can be used to change the swipe state.
* 2. startSwipeProgress: [Float], which represent the progress of the swipe of the start content, 0f for null startContent.
*
* Note that the content will be layout in a [RowScope] with mutable width. Thus, for sub content inside it, you have to use the [weight] modifier to determine the width of the content
* instead of use the [width] modifier directly. Also, since the width of the container will change with swipe progress, the content inside the sub container have to use the [requiredWidth]
* modifier to avoid the abnormal recompose to that width change. Like size or visibility change.
* For content with just icon or text, I would recommend you to use the [SwipeIcon] and [SwipeText] which setup the size restriction for you and you only need to set the real content.
* @param endContentWidth The width of the end content which will be shown when the swipe direction is EndToStart or Both.
* @param endContent The content of the end content.
* Two parameters will be provided to that content:
* 1. swipeableState: [SwipeableState], which can be used to change the swipe state.
* 2. endSwipeProgress: [Float], which represent the progress of the swipe of the end content, 0f for null endContent.
*
* Note that the content will be layout in a [RowScope] with mutable width. Thus, for sub content inside it, you have to use the [weight] modifier to determine the width of the content
* instead of use the [width] modifier directly. Also, since the width of the container will change with swipe progress, the content inside the sub container have to use the [requiredWidth]
* modifier to avoid the abnormal recompose to that width change. Like size or visibility change.
* For content with just icon or text, I would recommend you to use the [SwipeIcon] and [SwipeText] which setup the size restriction for you and you only need to set the real content.
* @param thresholds Specifies where the thresholds between the states are. The thresholds will be used to determine which state to animate to when swiping stops. This is represented
* as a lambda that takes two states and returns the threshold between them in the form of a [ThresholdConfig].
* Note that the order of the states corresponds to the swipe direction.
*
* @param content The main content that will be shown at max width when there is no swipe action.
* It will be provided three parameters:
* 1. swipeableState: [SwipeableState], which can be used to change the swipe state.
* 2. startSwipeProgress: [Float], which represent the progress of the swipe of the start content, 0f for null startContent.
* 3. endSwipeProgress: [Float], which represent the progress of the swipe of the end content, 0f for null endContent.
*/
@OptIn(ExperimentalMaterialApi::class)
@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,
)

enum class SwipeDirection {
StartToEnd, EndToStart, Both
}

You can also refer to the API webpage for details:

Download

Multiplatform

repositories {
mavenCentral()
}

kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.kevinnzou:compose-swipebox-multiplatform:1.0.0")
}
}
}
}

Single Platform

If you just want to use it in Jetpack Compose, you can use this repo:

allprojects {
repositories {
mavenCentral()
}
}

dependencies {
implementation("io.github.kevinnzou:compose-swipebox:1.2.0")
}

Thanks for your reading!

Following Readings:

--

--

Kevin Zou

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