Skip to content

Commit

Permalink
Use 3rd-party swipe library
Browse files Browse the repository at this point in the history
  • Loading branch information
jocmp committed Oct 20, 2024
1 parent 211df80 commit dc01f61
Show file tree
Hide file tree
Showing 10 changed files with 804 additions and 121 deletions.
Original file line number Diff line number Diff line change
@@ -1,77 +1,26 @@
package com.capyreader.app.ui.articles.list

import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.jocmp.capy.Article
import me.saket.swipe.SwipeableActionsBox

@Composable
fun ArticleRowSwipeBox(
article: Article,
content: @Composable () -> Unit
) {
val swipeState = rememberArticleRowSwipeState(article = article)
val dismissState = swipeState.state
val action = swipeState.action

SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = swipeState.enableStart,
enableDismissFromEndToStart = swipeState.enableEnd,
gesturesEnabled = swipeState.enabled,
backgroundContent = {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

val color by animateColorAsState(
when (swipeState.state.targetValue) {
SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surface
else -> MaterialTheme.colorScheme.surfaceContainerHighest
},
label = ""
)
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
) {
Icon(
painterResource(action.icon),
contentDescription = stringResource(id = action.translationKey),
modifier = Modifier
.padding(24.dp)
.align(
when (dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> if (isRtl) Alignment.CenterEnd else Alignment.CenterStart
else -> if (isRtl) Alignment.CenterStart else Alignment.CenterEnd
}
)
)
}
}
) {
if (swipeState.disabled) {
content()
}

if (dismissState.currentValue != SwipeToDismissBoxValue.Settled) {
LaunchedEffect(Unit) {
action.commit()
dismissState.reset()
}
SwipeableActionsBox(
startActions = swipeState.start,
endActions = swipeState.end,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surface
) {
content()
}
}
Original file line number Diff line number Diff line change
@@ -1,74 +1,86 @@
package com.capyreader.app.ui.articles.list

import android.content.Context
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.capyreader.app.R
import com.capyreader.app.common.AppPreferences
import com.capyreader.app.common.asState
import com.capyreader.app.common.openLinkExternally
import com.capyreader.app.ui.articles.LocalArticleActions
import com.capyreader.app.ui.components.ArticleAction
import com.capyreader.app.ui.components.readAction
import com.capyreader.app.ui.components.rememberNoFlingSwipeToDismissBoxState
import com.capyreader.app.ui.components.starAction
import com.capyreader.app.ui.settings.panels.RowSwipeOption
import com.jocmp.capy.Article
import me.saket.swipe.SwipeAction
import org.koin.compose.koinInject

internal data class ArticleRowSwipeState(
val state: SwipeToDismissBoxState,
val action: ArticleAction,
val enableStart: Boolean,
val enableEnd: Boolean,
val start: List<SwipeAction>,
val end: List<SwipeAction>,
) {
val enabled = enableStart || enableEnd
val disabled = start.isEmpty() && end.isEmpty()
}

@Composable
internal fun rememberArticleRowSwipeState(
article: Article,
appPreferences: AppPreferences = koinInject(),
): ArticleRowSwipeState {
val actions = LocalArticleActions.current
val state = rememberNoFlingSwipeToDismissBoxState()
val context = LocalContext.current
val swipeStart by appPreferences.articleListOptions.swipeStart.asState()
val swipeEnd by appPreferences.articleListOptions.swipeEnd.asState()

return remember(state.currentValue, state.dismissDirection, swipeStart, swipeEnd) {
val preference = swipePreference(state, swipeStart, swipeEnd)
val start = swipeActions(article, swipeStart)
val end = swipeActions(article, swipeEnd)

val swipeAction = when (preference) {
RowSwipeOption.TOGGLE_STARRED -> starAction(article, actions)
RowSwipeOption.OPEN_EXTERNALLY -> openExternally(context, article)
else -> readAction(article, actions)
}
return ArticleRowSwipeState(
start = start,
end = end,
)
}

ArticleRowSwipeState(
state,
swipeAction,
enableStart = swipeStart != RowSwipeOption.DISABLED,
enableEnd = swipeEnd != RowSwipeOption.DISABLED,
)
@Composable
private fun swipeActions(article: Article, option: RowSwipeOption): List<SwipeAction> {
if (option == RowSwipeOption.DISABLED) {
return emptyList()
}
}

fun swipePreference(
state: SwipeToDismissBoxState,
swipeStart: RowSwipeOption,
swipeEnd: RowSwipeOption,
): RowSwipeOption {
return when (state.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> swipeStart
else -> swipeEnd
val actions = LocalArticleActions.current
val context = LocalContext.current

val action = when (option) {
RowSwipeOption.TOGGLE_STARRED -> starAction(article, actions)
RowSwipeOption.OPEN_EXTERNALLY -> openExternally(context, article)
else -> readAction(article, actions)
}

return listOf(
SwipeAction(
onSwipe = action.commit,
background = MaterialTheme.colorScheme.surfaceContainerHighest,
icon = {
Box(Modifier.padding(16.dp)) {
Icon(
painterResource(action.icon),
contentDescription = stringResource(action.translationKey)
)
}
},
)
)
}


private fun openExternally(context: Context, article: Article) =
ArticleAction(
R.drawable.icon_open_in_new,
Expand Down

This file was deleted.

58 changes: 58 additions & 0 deletions app/src/main/java/me/saket/swipe/ActionFinder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package me.saket.swipe

import kotlin.math.abs

internal data class SwipeActionMeta(
val value: SwipeAction,
val isOnRightSide: Boolean,
)

internal data class ActionFinder(
val left: List<SwipeAction>,
val right: List<SwipeAction>
) {

fun actionAt(offset: Float, totalWidth: Int): SwipeActionMeta? {
if (offset == 0f) {
return null
}

val isOnRightSide = offset < 0f
val actions = if (isOnRightSide) right else left

val actionAtOffset = actions.actionAt(
offset = abs(offset).coerceAtMost(totalWidth.toFloat()),
totalWidth = totalWidth
)
return actionAtOffset?.let {
SwipeActionMeta(
value = actionAtOffset,
isOnRightSide = isOnRightSide
)
}
}

private fun List<SwipeAction>.actionAt(offset: Float, totalWidth: Int): SwipeAction? {
if (isEmpty()) {
return null
}

val totalWeights = this.sumOf { it.weight }
var offsetSoFar = 0.0

@Suppress("ReplaceManualRangeWithIndicesCalls") // Avoid allocating an Iterator for every pixel swiped.
for (i in 0 until size) {
val action = this[i]
val actionWidth = (action.weight / totalWeights) * totalWidth
val actionEndX = offsetSoFar + actionWidth

if (offset <= actionEndX) {
return action
}
offsetSoFar += actionEndX
}

// Precision error in the above loop maybe?
error("Couldn't find any swipe action. Width=$totalWidth, offset=$offset, actions=$this")
}
}
77 changes: 77 additions & 0 deletions app/src/main/java/me/saket/swipe/SwipeAction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package me.saket.swipe

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp

/**
* Represents an action that can be shown in [SwipeableActionsBox].
*
* @param background Color used as the background of [SwipeableActionsBox] while
* this action is visible. If this action is swiped, its background color is
* also used for drawing a ripple over the content for providing a visual
* feedback to the user.
*
* @param weight The proportional width to give to this element, as related
* to the total of all weighted siblings. [SwipeableActionsBox] will divide its
* horizontal space and distribute it to actions according to their weight.
*
* @param isUndo Determines the direction in which a ripple is drawn when this
* action is swiped. When false, the ripple grows from this action's position
* to consume the entire composable, and vice versa. This can be used for
* actions that can be toggled on and off.
*/
class SwipeAction(
val onSwipe: () -> Unit,
val icon: @Composable () -> Unit,
val background: Color,
val weight: Double = 1.0,
val isUndo: Boolean = false
) {
init {
require(weight > 0.0) { "invalid weight $weight; must be greater than zero" }
}

fun copy(
onSwipe: () -> Unit = this.onSwipe,
icon: @Composable () -> Unit = this.icon,
background: Color = this.background,
weight: Double = this.weight,
isUndo: Boolean = this.isUndo,
) = SwipeAction(
onSwipe = onSwipe,
icon = icon,
background = background,
weight = weight,
isUndo = isUndo
)
}

/**
* See [SwipeAction] for documentation.
*/
fun SwipeAction(
onSwipe: () -> Unit,
icon: Painter,
background: Color,
weight: Double = 1.0,
isUndo: Boolean = false
): SwipeAction {
return SwipeAction(
icon = {
Image(
modifier = Modifier.padding(16.dp),
painter = icon,
contentDescription = null
)
},
background = background,
weight = weight,
onSwipe = onSwipe,
isUndo = isUndo
)
}
Loading

0 comments on commit dc01f61

Please sign in to comment.