Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Density hack to improve swipe dismiss #443

Merged
merged 3 commits into from
Oct 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
} else {
SwipeableActionsBox(
startActions = swipeState.start,
endActions = swipeState.end,
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surface
) {
content()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package com.capyreader.app.ui.articles.list

import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
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
Expand All @@ -20,57 +22,65 @@ import com.capyreader.app.ui.components.readAction
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 = rememberSwipeToDismissBoxState()
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
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