Skip to content

Commit 8d2ae03

Browse files
authored
Use new swipe library to avoid incorrect taps (#443)
* Use 3rd-party swipe library * Fix RTL with open PR - saket/swipe#33
1 parent b5788ec commit 8d2ae03

File tree

9 files changed

+805
-96
lines changed

9 files changed

+805
-96
lines changed
Lines changed: 9 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,26 @@
11
package com.capyreader.app.ui.articles.list
22

3-
import androidx.compose.animation.animateColorAsState
4-
import androidx.compose.foundation.background
5-
import androidx.compose.foundation.layout.Box
6-
import androidx.compose.foundation.layout.fillMaxSize
7-
import androidx.compose.foundation.layout.padding
8-
import androidx.compose.material3.Icon
93
import androidx.compose.material3.MaterialTheme
10-
import androidx.compose.material3.SwipeToDismissBox
11-
import androidx.compose.material3.SwipeToDismissBoxValue
124
import androidx.compose.runtime.Composable
13-
import androidx.compose.runtime.LaunchedEffect
14-
import androidx.compose.runtime.getValue
15-
import androidx.compose.ui.Alignment
16-
import androidx.compose.ui.Modifier
17-
import androidx.compose.ui.platform.LocalLayoutDirection
18-
import androidx.compose.ui.res.painterResource
19-
import androidx.compose.ui.res.stringResource
20-
import androidx.compose.ui.unit.LayoutDirection
21-
import androidx.compose.ui.unit.dp
225
import com.jocmp.capy.Article
6+
import me.saket.swipe.SwipeableActionsBox
237

248
@Composable
259
fun ArticleRowSwipeBox(
2610
article: Article,
2711
content: @Composable () -> Unit
2812
) {
2913
val swipeState = rememberArticleRowSwipeState(article = article)
30-
val dismissState = swipeState.state
31-
val action = swipeState.action
3214

33-
SwipeToDismissBox(
34-
state = dismissState,
35-
enableDismissFromStartToEnd = swipeState.enableStart,
36-
enableDismissFromEndToStart = swipeState.enableEnd,
37-
gesturesEnabled = swipeState.enabled,
38-
backgroundContent = {
39-
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
40-
41-
val color by animateColorAsState(
42-
when (swipeState.state.targetValue) {
43-
SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surface
44-
else -> MaterialTheme.colorScheme.surfaceContainerHighest
45-
},
46-
label = ""
47-
)
48-
Box(
49-
modifier = Modifier
50-
.fillMaxSize()
51-
.background(color)
52-
) {
53-
Icon(
54-
painterResource(action.icon),
55-
contentDescription = stringResource(id = action.translationKey),
56-
modifier = Modifier
57-
.padding(24.dp)
58-
.align(
59-
when (dismissState.dismissDirection) {
60-
SwipeToDismissBoxValue.StartToEnd -> if (isRtl) Alignment.CenterEnd else Alignment.CenterStart
61-
else -> if (isRtl) Alignment.CenterStart else Alignment.CenterEnd
62-
}
63-
)
64-
)
65-
}
66-
}
67-
) {
15+
if (swipeState.disabled) {
6816
content()
69-
}
70-
71-
if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
72-
LaunchedEffect(Unit) {
73-
action.commit()
74-
dismissState.reset()
17+
} else {
18+
SwipeableActionsBox(
19+
startActions = swipeState.start,
20+
endActions = swipeState.end,
21+
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surface
22+
) {
23+
content()
7524
}
7625
}
7726
}

app/src/main/java/com/capyreader/app/ui/articles/list/ArticleRowSwipeState.kt

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
package com.capyreader.app.ui.articles.list
22

33
import android.content.Context
4-
import androidx.compose.material.icons.Icons
5-
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
6-
import androidx.compose.material3.SwipeToDismissBoxState
7-
import androidx.compose.material3.SwipeToDismissBoxValue
8-
import androidx.compose.material3.rememberSwipeToDismissBoxState
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.material3.Icon
7+
import androidx.compose.material3.MaterialTheme
98
import androidx.compose.runtime.Composable
109
import androidx.compose.runtime.getValue
11-
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.Modifier
1211
import androidx.compose.ui.platform.LocalContext
12+
import androidx.compose.ui.res.painterResource
13+
import androidx.compose.ui.res.stringResource
14+
import androidx.compose.ui.unit.dp
1315
import com.capyreader.app.R
1416
import com.capyreader.app.common.AppPreferences
1517
import com.capyreader.app.common.asState
@@ -20,57 +22,65 @@ import com.capyreader.app.ui.components.readAction
2022
import com.capyreader.app.ui.components.starAction
2123
import com.capyreader.app.ui.settings.panels.RowSwipeOption
2224
import com.jocmp.capy.Article
25+
import me.saket.swipe.SwipeAction
2326
import org.koin.compose.koinInject
2427

2528
internal data class ArticleRowSwipeState(
26-
val state: SwipeToDismissBoxState,
27-
val action: ArticleAction,
28-
val enableStart: Boolean,
29-
val enableEnd: Boolean,
29+
val start: List<SwipeAction>,
30+
val end: List<SwipeAction>,
3031
) {
31-
val enabled = enableStart || enableEnd
32+
val disabled = start.isEmpty() && end.isEmpty()
3233
}
3334

3435
@Composable
3536
internal fun rememberArticleRowSwipeState(
3637
article: Article,
3738
appPreferences: AppPreferences = koinInject(),
3839
): ArticleRowSwipeState {
39-
val actions = LocalArticleActions.current
40-
val state = rememberSwipeToDismissBoxState()
41-
val context = LocalContext.current
4240
val swipeStart by appPreferences.articleListOptions.swipeStart.asState()
4341
val swipeEnd by appPreferences.articleListOptions.swipeEnd.asState()
4442

45-
return remember(state.currentValue, state.dismissDirection, swipeStart, swipeEnd) {
46-
val preference = swipePreference(state, swipeStart, swipeEnd)
43+
val start = swipeActions(article, swipeStart)
44+
val end = swipeActions(article, swipeEnd)
4745

48-
val swipeAction = when (preference) {
49-
RowSwipeOption.TOGGLE_STARRED -> starAction(article, actions)
50-
RowSwipeOption.OPEN_EXTERNALLY -> openExternally(context, article)
51-
else -> readAction(article, actions)
52-
}
46+
return ArticleRowSwipeState(
47+
start = start,
48+
end = end,
49+
)
50+
}
5351

54-
ArticleRowSwipeState(
55-
state,
56-
swipeAction,
57-
enableStart = swipeStart != RowSwipeOption.DISABLED,
58-
enableEnd = swipeEnd != RowSwipeOption.DISABLED,
59-
)
52+
@Composable
53+
private fun swipeActions(article: Article, option: RowSwipeOption): List<SwipeAction> {
54+
if (option == RowSwipeOption.DISABLED) {
55+
return emptyList()
6056
}
61-
}
6257

63-
fun swipePreference(
64-
state: SwipeToDismissBoxState,
65-
swipeStart: RowSwipeOption,
66-
swipeEnd: RowSwipeOption,
67-
): RowSwipeOption {
68-
return when (state.dismissDirection) {
69-
SwipeToDismissBoxValue.StartToEnd -> swipeStart
70-
else -> swipeEnd
58+
val actions = LocalArticleActions.current
59+
val context = LocalContext.current
60+
61+
val action = when (option) {
62+
RowSwipeOption.TOGGLE_STARRED -> starAction(article, actions)
63+
RowSwipeOption.OPEN_EXTERNALLY -> openExternally(context, article)
64+
else -> readAction(article, actions)
7165
}
66+
67+
return listOf(
68+
SwipeAction(
69+
onSwipe = action.commit,
70+
background = MaterialTheme.colorScheme.surfaceContainerHighest,
71+
icon = {
72+
Box(Modifier.padding(16.dp)) {
73+
Icon(
74+
painterResource(action.icon),
75+
contentDescription = stringResource(action.translationKey)
76+
)
77+
}
78+
},
79+
)
80+
)
7281
}
7382

83+
7484
private fun openExternally(context: Context, article: Article) =
7585
ArticleAction(
7686
R.drawable.icon_open_in_new,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package me.saket.swipe
2+
3+
import kotlin.math.abs
4+
5+
internal data class SwipeActionMeta(
6+
val value: SwipeAction,
7+
val isOnRightSide: Boolean,
8+
)
9+
10+
internal data class ActionFinder(
11+
val left: List<SwipeAction>,
12+
val right: List<SwipeAction>
13+
) {
14+
15+
fun actionAt(offset: Float, totalWidth: Int): SwipeActionMeta? {
16+
if (offset == 0f) {
17+
return null
18+
}
19+
20+
val isOnRightSide = offset < 0f
21+
val actions = if (isOnRightSide) right else left
22+
23+
val actionAtOffset = actions.actionAt(
24+
offset = abs(offset).coerceAtMost(totalWidth.toFloat()),
25+
totalWidth = totalWidth
26+
)
27+
return actionAtOffset?.let {
28+
SwipeActionMeta(
29+
value = actionAtOffset,
30+
isOnRightSide = isOnRightSide
31+
)
32+
}
33+
}
34+
35+
private fun List<SwipeAction>.actionAt(offset: Float, totalWidth: Int): SwipeAction? {
36+
if (isEmpty()) {
37+
return null
38+
}
39+
40+
val totalWeights = this.sumOf { it.weight }
41+
var offsetSoFar = 0.0
42+
43+
@Suppress("ReplaceManualRangeWithIndicesCalls") // Avoid allocating an Iterator for every pixel swiped.
44+
for (i in 0 until size) {
45+
val action = this[i]
46+
val actionWidth = (action.weight / totalWeights) * totalWidth
47+
val actionEndX = offsetSoFar + actionWidth
48+
49+
if (offset <= actionEndX) {
50+
return action
51+
}
52+
offsetSoFar += actionEndX
53+
}
54+
55+
// Precision error in the above loop maybe?
56+
error("Couldn't find any swipe action. Width=$totalWidth, offset=$offset, actions=$this")
57+
}
58+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package me.saket.swipe
2+
3+
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.layout.padding
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.graphics.Color
8+
import androidx.compose.ui.graphics.painter.Painter
9+
import androidx.compose.ui.unit.dp
10+
11+
/**
12+
* Represents an action that can be shown in [SwipeableActionsBox].
13+
*
14+
* @param background Color used as the background of [SwipeableActionsBox] while
15+
* this action is visible. If this action is swiped, its background color is
16+
* also used for drawing a ripple over the content for providing a visual
17+
* feedback to the user.
18+
*
19+
* @param weight The proportional width to give to this element, as related
20+
* to the total of all weighted siblings. [SwipeableActionsBox] will divide its
21+
* horizontal space and distribute it to actions according to their weight.
22+
*
23+
* @param isUndo Determines the direction in which a ripple is drawn when this
24+
* action is swiped. When false, the ripple grows from this action's position
25+
* to consume the entire composable, and vice versa. This can be used for
26+
* actions that can be toggled on and off.
27+
*/
28+
class SwipeAction(
29+
val onSwipe: () -> Unit,
30+
val icon: @Composable () -> Unit,
31+
val background: Color,
32+
val weight: Double = 1.0,
33+
val isUndo: Boolean = false
34+
) {
35+
init {
36+
require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }
37+
}
38+
39+
fun copy(
40+
onSwipe: () -> Unit = this.onSwipe,
41+
icon: @Composable () -> Unit = this.icon,
42+
background: Color = this.background,
43+
weight: Double = this.weight,
44+
isUndo: Boolean = this.isUndo,
45+
) = SwipeAction(
46+
onSwipe = onSwipe,
47+
icon = icon,
48+
background = background,
49+
weight = weight,
50+
isUndo = isUndo
51+
)
52+
}
53+
54+
/**
55+
* See [SwipeAction] for documentation.
56+
*/
57+
fun SwipeAction(
58+
onSwipe: () -> Unit,
59+
icon: Painter,
60+
background: Color,
61+
weight: Double = 1.0,
62+
isUndo: Boolean = false
63+
): SwipeAction {
64+
return SwipeAction(
65+
icon = {
66+
Image(
67+
modifier = Modifier.padding(16.dp),
68+
painter = icon,
69+
contentDescription = null
70+
)
71+
},
72+
background = background,
73+
weight = weight,
74+
onSwipe = onSwipe,
75+
isUndo = isUndo
76+
)
77+
}

0 commit comments

Comments
 (0)