feat(ui): swipe to star/unstar, swipe to unread (#594)
* feat(ui): swipe to star & unstar * feat(ui): swipe to unread * feat(ui): add haptic feedback to swipe gesture * fix(ui): disable swipe gestures when scroll in progress * feat(ui): configure swipe gestures * fix(ui): workaround for swipe animation & remove text label * fix(ui): app initialize with toggle starred
This commit is contained in:
parent
c21e22d91b
commit
8c11757be4
@ -76,6 +76,8 @@ fun Preferences.toSettings(): Settings {
|
||||
// Interaction
|
||||
initialPage = InitialPagePreference.fromPreferences(this),
|
||||
initialFilter = InitialFilterPreference.fromPreferences(this),
|
||||
swipeStartAction = SwipeStartActionPreference.fromPreferences(this),
|
||||
swipeEndAction = SwipeEndActionPreference.fromPreferences(this),
|
||||
openLink = OpenLinkPreference.fromPreferences(this),
|
||||
openLinkSpecificBrowser = OpenLinkSpecificBrowserPreference.fromPreferences(this),
|
||||
|
||||
|
@ -75,6 +75,8 @@ data class Settings(
|
||||
// Interaction
|
||||
val initialPage: InitialPagePreference = InitialPagePreference.default,
|
||||
val initialFilter: InitialFilterPreference = InitialFilterPreference.default,
|
||||
val swipeStartAction: SwipeStartActionPreference = SwipeStartActionPreference.default,
|
||||
val swipeEndAction: SwipeEndActionPreference = SwipeEndActionPreference.default,
|
||||
val openLink: OpenLinkPreference = OpenLinkPreference.default,
|
||||
val openLinkSpecificBrowser: OpenLinkSpecificBrowserPreference = OpenLinkSpecificBrowserPreference.default,
|
||||
|
||||
@ -177,6 +179,8 @@ val LocalReadingImageMaximize =
|
||||
val LocalInitialPage = compositionLocalOf<InitialPagePreference> { InitialPagePreference.default }
|
||||
val LocalInitialFilter =
|
||||
compositionLocalOf<InitialFilterPreference> { InitialFilterPreference.default }
|
||||
val LocalArticleListSwipeEndAction = compositionLocalOf { SwipeEndActionPreference.default }
|
||||
val LocalArticleListSwipeStartAction = compositionLocalOf { SwipeStartActionPreference.default }
|
||||
val LocalOpenLink =
|
||||
compositionLocalOf<OpenLinkPreference> { OpenLinkPreference.default }
|
||||
val LocalOpenLinkSpecificBrowser =
|
||||
@ -263,6 +267,8 @@ fun SettingsProvider(
|
||||
// Interaction
|
||||
LocalInitialPage provides settings.initialPage,
|
||||
LocalInitialFilter provides settings.initialFilter,
|
||||
LocalArticleListSwipeStartAction provides settings.swipeStartAction,
|
||||
LocalArticleListSwipeEndAction provides settings.swipeEndAction,
|
||||
LocalOpenLink provides settings.openLink,
|
||||
LocalOpenLinkSpecificBrowser provides settings.openLinkSpecificBrowser,
|
||||
|
||||
|
@ -0,0 +1,98 @@
|
||||
package me.ash.reader.infrastructure.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.ui.ext.DataStoreKeys
|
||||
import me.ash.reader.ui.ext.dataStore
|
||||
import me.ash.reader.ui.ext.put
|
||||
|
||||
data object SwipeGestureActions {
|
||||
const val None = 0
|
||||
const val ToggleRead = 1
|
||||
const val ToggleStarred = 2
|
||||
}
|
||||
|
||||
sealed class SwipeEndActionPreference(val action: Int) : Preference() {
|
||||
override fun put(context: Context, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
context.dataStore.put(
|
||||
DataStoreKeys.SwipeEndAction, action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data object None : SwipeEndActionPreference(SwipeGestureActions.None)
|
||||
data object ToggleRead : SwipeEndActionPreference(SwipeGestureActions.ToggleRead)
|
||||
data object ToggleStarred :
|
||||
SwipeEndActionPreference(SwipeGestureActions.ToggleStarred)
|
||||
|
||||
val desc: String
|
||||
@Composable get() = when (this) {
|
||||
None -> stringResource(id = R.string.none)
|
||||
ToggleRead -> stringResource(id = R.string.toggle_read)
|
||||
ToggleStarred -> stringResource(id = R.string.toggle_starred)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default: SwipeEndActionPreference = ToggleRead
|
||||
val values = listOf(
|
||||
None,
|
||||
ToggleRead,
|
||||
ToggleStarred
|
||||
)
|
||||
|
||||
fun fromPreferences(preferences: Preferences): SwipeEndActionPreference {
|
||||
return when (preferences[DataStoreKeys.SwipeEndAction.key]) {
|
||||
SwipeGestureActions.None -> None
|
||||
SwipeGestureActions.ToggleRead -> ToggleRead
|
||||
SwipeGestureActions.ToggleStarred -> ToggleStarred
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SwipeStartActionPreference(val action: Int) : Preference() {
|
||||
override fun put(context: Context, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
context.dataStore.put(
|
||||
DataStoreKeys.SwipeStartAction, action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data object None : SwipeStartActionPreference(SwipeGestureActions.None)
|
||||
data object ToggleRead : SwipeStartActionPreference(SwipeGestureActions.ToggleRead)
|
||||
data object ToggleStarred :
|
||||
SwipeStartActionPreference(SwipeGestureActions.ToggleStarred)
|
||||
|
||||
val desc: String
|
||||
@Composable get() = when (this) {
|
||||
None -> stringResource(id = R.string.none)
|
||||
ToggleRead -> stringResource(id = R.string.toggle_read)
|
||||
ToggleStarred -> stringResource(id = R.string.toggle_starred)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default: SwipeStartActionPreference = ToggleStarred
|
||||
val values = listOf(
|
||||
None,
|
||||
ToggleRead,
|
||||
ToggleStarred
|
||||
)
|
||||
|
||||
fun fromPreferences(preferences: Preferences): SwipeStartActionPreference {
|
||||
return when (preferences[DataStoreKeys.SwipeStartAction.key]) {
|
||||
SwipeGestureActions.None -> None
|
||||
SwipeGestureActions.ToggleRead -> ToggleRead
|
||||
SwipeGestureActions.ToggleStarred -> ToggleStarred
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -404,6 +404,18 @@ sealed class DataStoreKeys<T> {
|
||||
get() = intPreferencesKey("initialFilter")
|
||||
}
|
||||
|
||||
data object SwipeStartAction : DataStoreKeys<Int>() {
|
||||
|
||||
override val key: Preferences.Key<Int>
|
||||
get() = intPreferencesKey("swipeStartAction")
|
||||
}
|
||||
|
||||
data object SwipeEndAction : DataStoreKeys<Int>() {
|
||||
|
||||
override val key: Preferences.Key<Int>
|
||||
get() = intPreferencesKey("swipeEndAction")
|
||||
}
|
||||
|
||||
object OpenLink : DataStoreKeys<Int>() {
|
||||
|
||||
override val key: Preferences.Key<Int>
|
||||
|
@ -1,21 +1,51 @@
|
||||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import android.util.Log
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.Animatable
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.StarOutline
|
||||
import androidx.compose.material.icons.rounded.CheckCircleOutline
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -24,7 +54,18 @@ import coil.size.Precision
|
||||
import coil.size.Scale
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||
import me.ash.reader.infrastructure.preference.*
|
||||
import me.ash.reader.domain.model.constant.ElevationTokens
|
||||
import me.ash.reader.infrastructure.preference.FlowArticleReadIndicatorPreference
|
||||
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeEndAction
|
||||
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeStartAction
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListDesc
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedIcon
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListFeedName
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListImage
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListReadIndicator
|
||||
import me.ash.reader.infrastructure.preference.LocalFlowArticleListTime
|
||||
import me.ash.reader.infrastructure.preference.SwipeEndActionPreference
|
||||
import me.ash.reader.infrastructure.preference.SwipeStartActionPreference
|
||||
import me.ash.reader.ui.component.FeedIcon
|
||||
import me.ash.reader.ui.component.base.RYAsyncImage
|
||||
import me.ash.reader.ui.component.base.SIZE_1000
|
||||
@ -50,19 +91,17 @@ fun ArticleItem(
|
||||
.clip(Shape20)
|
||||
.clickable { onClick(articleWithFeed) }
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.alpha(
|
||||
articleWithFeed.article.run {
|
||||
when (articleListReadIndicator) {
|
||||
FlowArticleReadIndicatorPreference.AllRead -> {
|
||||
if (isUnread) 1f else 0.5f
|
||||
}
|
||||
.alpha(articleWithFeed.article.run {
|
||||
when (articleListReadIndicator) {
|
||||
FlowArticleReadIndicatorPreference.AllRead -> {
|
||||
if (isUnread) 1f else 0.5f
|
||||
}
|
||||
|
||||
FlowArticleReadIndicatorPreference.ExcludingStarred -> {
|
||||
if (isUnread || isStarred) 1f else 0.5f
|
||||
}
|
||||
FlowArticleReadIndicatorPreference.ExcludingStarred -> {
|
||||
if (isUnread || isStarred) 1f else 0.5f
|
||||
}
|
||||
}
|
||||
),
|
||||
}),
|
||||
) {
|
||||
// Top
|
||||
Row(
|
||||
@ -170,70 +209,212 @@ fun ArticleItem(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
private const val PositionalThresholdFraction = 0.15f
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SwipeableArticleItem(
|
||||
articleWithFeed: ArticleWithFeed,
|
||||
isFilterUnread: Boolean,
|
||||
articleListTonalElevation: Int,
|
||||
onClick: (ArticleWithFeed) -> Unit = {},
|
||||
onSwipeOut: (ArticleWithFeed) -> Unit = {},
|
||||
isScrollInProgress: () -> Boolean = { false },
|
||||
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
|
||||
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
|
||||
) {
|
||||
var isArticleVisible by remember { mutableStateOf(true) }
|
||||
val dismissState =
|
||||
rememberDismissState(initialValue = DismissValue.Default, confirmStateChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
isArticleVisible = !isFilterUnread
|
||||
onSwipeOut(articleWithFeed)
|
||||
}
|
||||
isFilterUnread
|
||||
})
|
||||
if (isArticleVisible) {
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
/*** create dismiss alert background box */
|
||||
background = {
|
||||
if (dismissState.dismissDirection == DismissDirection.StartToEnd) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// .background(MaterialTheme.colorScheme.surface)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CheckCircleOutline,
|
||||
contentDescription = stringResource(R.string.mark_as_read),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mark_as_read),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
/**** Dismiss Content */
|
||||
dismissContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
articleListTonalElevation.dp
|
||||
) onDark MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
ArticleItem(articleWithFeed, onClick)
|
||||
}
|
||||
},
|
||||
/*** Set Direction to dismiss */
|
||||
directions = setOf(DismissDirection.StartToEnd),
|
||||
val density = LocalDensity.current
|
||||
val confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = {
|
||||
when (it) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> {
|
||||
onSwipeStartToEnd?.invoke(articleWithFeed)
|
||||
swipeToEndAction == SwipeEndActionPreference.ToggleRead && isFilterUnread
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
onSwipeEndToStart?.invoke(articleWithFeed)
|
||||
swipeToStartAction == SwipeStartActionPreference.ToggleRead && isFilterUnread
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.Settled -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
val positionalThreshold: (totalDistance: Float) -> Float = {
|
||||
it * PositionalThresholdFraction
|
||||
}
|
||||
val velocityThreshold: () -> Float = { Float.POSITIVE_INFINITY }
|
||||
val animationSpec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow)
|
||||
val swipeState = rememberSaveable(
|
||||
articleWithFeed.article, saver = SwipeToDismissBoxState.Saver(
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
animationSpec = animationSpec,
|
||||
velocityThreshold = velocityThreshold,
|
||||
positionalThreshold = positionalThreshold
|
||||
)
|
||||
) {
|
||||
SwipeToDismissBoxState(
|
||||
initialValue = SwipeToDismissBoxValue.Settled,
|
||||
density = density,
|
||||
animationSpec = animationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = velocityThreshold
|
||||
)
|
||||
}
|
||||
val view = LocalView.current
|
||||
var isActive by remember(articleWithFeed) { mutableStateOf(false) }
|
||||
LaunchedEffect(swipeState.progress > PositionalThresholdFraction) {
|
||||
if (swipeState.progress > PositionalThresholdFraction && swipeState.targetValue != SwipeToDismissBoxValue.Settled) {
|
||||
isActive = true
|
||||
view.performHapticFeedback(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE)
|
||||
|
||||
} else {
|
||||
isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = swipeState,
|
||||
enabled = !isScrollInProgress(),
|
||||
/*** create dismiss alert background box */
|
||||
backgroundContent = {
|
||||
SwipeToDismissBoxBackgroundContent(
|
||||
direction = swipeState.dismissDirection,
|
||||
isActive = isActive,
|
||||
isStarred = articleWithFeed.article.isStarred,
|
||||
isRead = !articleWithFeed.article.isUnread
|
||||
)
|
||||
},
|
||||
/**** Dismiss Content */
|
||||
content = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
articleListTonalElevation.dp
|
||||
) onDark MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
ArticleItem(articleWithFeed, onClick)
|
||||
}
|
||||
},
|
||||
/*** Set Direction to dismiss */
|
||||
enableDismissFromEndToStart = onSwipeEndToStart != null,
|
||||
enableDismissFromStartToEnd = onSwipeStartToEnd != null
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RowScope.SwipeToDismissBoxBackgroundContent(
|
||||
direction: SwipeToDismissBoxValue,
|
||||
isActive: Boolean,
|
||||
isStarred: Boolean,
|
||||
isRead: Boolean,
|
||||
) {
|
||||
val containerColor = MaterialTheme.colorScheme.surface
|
||||
val containerColorElevated = MaterialTheme.colorScheme.tertiaryContainer
|
||||
val backgroundColor = remember { Animatable(containerColor) }
|
||||
|
||||
LaunchedEffect(isActive) {
|
||||
backgroundColor.animateTo(
|
||||
if (isActive) {
|
||||
containerColorElevated
|
||||
} else {
|
||||
containerColor
|
||||
}
|
||||
)
|
||||
}
|
||||
val alignment = when (direction) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart
|
||||
SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd
|
||||
SwipeToDismissBoxValue.Settled -> Alignment.Center
|
||||
}
|
||||
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||
|
||||
val starImageVector =
|
||||
remember(isStarred) { if (isStarred) Icons.Outlined.StarOutline else Icons.Rounded.Star }
|
||||
|
||||
val readImageVector =
|
||||
remember(isRead) { if (isRead) Icons.Outlined.Circle else Icons.Rounded.CheckCircleOutline }
|
||||
|
||||
val starText =
|
||||
stringResource(if (isStarred) R.string.mark_as_unstar else R.string.mark_as_starred)
|
||||
|
||||
val readText =
|
||||
stringResource(if (isRead) R.string.mark_as_unread else R.string.mark_as_read)
|
||||
|
||||
val imageVector = remember(direction) {
|
||||
when (direction) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> {
|
||||
|
||||
when (swipeToEndAction) {
|
||||
SwipeEndActionPreference.None -> null
|
||||
SwipeEndActionPreference.ToggleRead -> readImageVector
|
||||
SwipeEndActionPreference.ToggleStarred -> starImageVector
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
when (swipeToStartAction) {
|
||||
SwipeStartActionPreference.None -> null
|
||||
SwipeStartActionPreference.ToggleRead -> readImageVector
|
||||
SwipeStartActionPreference.ToggleStarred -> starImageVector
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.Settled -> null
|
||||
}
|
||||
}
|
||||
|
||||
val text = remember(direction) {
|
||||
when (direction) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> {
|
||||
when (swipeToEndAction) {
|
||||
SwipeEndActionPreference.None -> null
|
||||
SwipeEndActionPreference.ToggleRead -> readText
|
||||
SwipeEndActionPreference.ToggleStarred -> starText
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
when (swipeToStartAction) {
|
||||
SwipeStartActionPreference.None -> null
|
||||
SwipeStartActionPreference.ToggleRead -> readText
|
||||
SwipeStartActionPreference.ToggleStarred -> starText
|
||||
}
|
||||
}
|
||||
|
||||
SwipeToDismissBoxValue.Settled -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind { drawRect(backgroundColor.value) },
|
||||
) {
|
||||
Column(modifier = Modifier.align(alignment = alignment)) {
|
||||
imageVector?.let {
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -4,7 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
@ -12,37 +11,41 @@ import me.ash.reader.domain.model.article.ArticleFlowItem
|
||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun LazyListScope.ArticleList(
|
||||
pagingItems: LazyPagingItems<ArticleFlowItem>,
|
||||
isFilterUnread: Boolean,
|
||||
isShowFeedIcon: Boolean,
|
||||
isShowStickyHeader: Boolean,
|
||||
articleListTonalElevation: Int,
|
||||
isScrollInProgress: () -> Boolean = { false },
|
||||
onClick: (ArticleWithFeed) -> Unit = {},
|
||||
onSwipeOut: (ArticleWithFeed) -> Unit = {}
|
||||
onSwipeStartToEnd: ((ArticleWithFeed) -> Unit)? = null,
|
||||
onSwipeEndToStart: ((ArticleWithFeed) -> Unit)? = null,
|
||||
) {
|
||||
for (index in 0 until pagingItems.itemCount) {
|
||||
when (val item = pagingItems.peek(index)) {
|
||||
is ArticleFlowItem.Article -> {
|
||||
item(key = item.articleWithFeed.article.id) {
|
||||
if (item.articleWithFeed.article.isUnread) {
|
||||
SwipeableArticleItem(
|
||||
articleWithFeed = item.articleWithFeed,
|
||||
isFilterUnread = isFilterUnread,
|
||||
articleListTonalElevation = articleListTonalElevation,
|
||||
onClick = { onClick(it) },
|
||||
onSwipeOut = { onSwipeOut(it) }
|
||||
)
|
||||
} else {
|
||||
// Currently we don't have swipe left to mark as unread,
|
||||
// so [SwipeableArticleItem] is not necessary for read articles.
|
||||
ArticleItem(
|
||||
articleWithFeed = (pagingItems[index] as ArticleFlowItem.Article).articleWithFeed,
|
||||
) {
|
||||
onClick(it)
|
||||
}
|
||||
}
|
||||
// if (item.articleWithFeed.article.isUnread) {
|
||||
SwipeableArticleItem(
|
||||
articleWithFeed = item.articleWithFeed,
|
||||
isFilterUnread = isFilterUnread,
|
||||
articleListTonalElevation = articleListTonalElevation,
|
||||
onClick = { onClick(it) },
|
||||
isScrollInProgress = isScrollInProgress,
|
||||
onSwipeStartToEnd = onSwipeStartToEnd,
|
||||
onSwipeEndToStart = onSwipeEndToStart
|
||||
)
|
||||
/* } else {
|
||||
// Currently we don't have swipe left to mark as unread,
|
||||
// so [SwipeableArticleItem] is not necessary for read articles.
|
||||
ArticleItem(
|
||||
articleWithFeed = (pagingItems[index] as ArticleFlowItem.Article).articleWithFeed,
|
||||
) {
|
||||
onClick(it)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ import androidx.work.WorkInfo
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.domain.model.article.ArticleFlowItem
|
||||
import me.ash.reader.domain.model.article.ArticleWithFeed
|
||||
import me.ash.reader.domain.model.general.Filter
|
||||
import me.ash.reader.domain.model.general.MarkAsReadConditions
|
||||
import me.ash.reader.infrastructure.preference.*
|
||||
@ -52,6 +52,8 @@ fun FlowPage(
|
||||
val filterBarFilled = LocalFlowFilterBarFilled.current
|
||||
val filterBarPadding = LocalFlowFilterBarPadding.current
|
||||
val filterBarTonalElevation = LocalFlowFilterBarTonalElevation.current
|
||||
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||
|
||||
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
|
||||
val flowUiState = flowViewModel.flowUiState.collectAsStateValue()
|
||||
@ -71,6 +73,37 @@ fun FlowPage(
|
||||
it?.let { isSyncing = it.any { it.state == WorkInfo.State.RUNNING } }
|
||||
}
|
||||
|
||||
val onToggleStarred: State<(ArticleWithFeed) -> Unit> = rememberUpdatedState {
|
||||
flowViewModel.updateStarredStatus(
|
||||
articleId = it.article.id,
|
||||
isStarred = !it.article.isStarred,
|
||||
withDelay = 300
|
||||
)
|
||||
}
|
||||
|
||||
val onToggleRead: State<(ArticleWithFeed) -> Unit> = rememberUpdatedState {
|
||||
flowViewModel.updateReadStatus(
|
||||
groupId = null,
|
||||
feedId = null,
|
||||
articleId = it.article.id,
|
||||
conditions = MarkAsReadConditions.All,
|
||||
isUnread = !it.article.isUnread,
|
||||
withDelay = 300
|
||||
)
|
||||
}
|
||||
|
||||
val onSwipeEndToStart = when (swipeToStartAction) {
|
||||
SwipeStartActionPreference.None -> null
|
||||
SwipeStartActionPreference.ToggleRead -> onToggleRead.value
|
||||
SwipeStartActionPreference.ToggleStarred -> onToggleStarred.value
|
||||
}
|
||||
|
||||
val onSwipeStartToEnd = when (swipeToEndAction) {
|
||||
SwipeEndActionPreference.None -> null
|
||||
SwipeEndActionPreference.ToggleRead -> onToggleRead.value
|
||||
SwipeEndActionPreference.ToggleStarred -> onToggleStarred.value
|
||||
}
|
||||
|
||||
LaunchedEffect(onSearch) {
|
||||
snapshotFlow { onSearch }.collect {
|
||||
if (it) {
|
||||
@ -192,11 +225,12 @@ fun FlowPage(
|
||||
},
|
||||
) {
|
||||
markAsRead = false
|
||||
flowViewModel.markAsRead(
|
||||
flowViewModel.updateReadStatus(
|
||||
groupId = filterUiState.group?.id,
|
||||
feedId = filterUiState.feed?.id,
|
||||
articleId = null,
|
||||
conditions = it,
|
||||
isUnread = false
|
||||
)
|
||||
}
|
||||
RYExtensibleVisibility(visible = onSearch) {
|
||||
@ -238,20 +272,16 @@ fun FlowPage(
|
||||
isShowFeedIcon = articleListFeedIcon.value,
|
||||
isShowStickyHeader = articleListDateStickyHeader.value,
|
||||
articleListTonalElevation = articleListTonalElevation.value,
|
||||
onClick = {
|
||||
isScrollInProgress = { listState.isScrollInProgress },
|
||||
onClick = {
|
||||
onSearch = false
|
||||
navController.navigate("${RouteName.READING}/${it.article.id}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
flowViewModel.markAsRead(
|
||||
groupId = null,
|
||||
feedId = null,
|
||||
articleId = it.article.id,
|
||||
MarkAsReadConditions.All
|
||||
)
|
||||
}
|
||||
},
|
||||
onSwipeStartToEnd = onSwipeStartToEnd,
|
||||
onSwipeEndToStart = onSwipeEndToStart
|
||||
)
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(128.dp))
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
|
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@ -33,22 +34,42 @@ class FlowViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(
|
||||
fun updateReadStatus(
|
||||
groupId: String?,
|
||||
feedId: String?,
|
||||
articleId: String?,
|
||||
conditions: MarkAsReadConditions,
|
||||
isUnread: Boolean,
|
||||
withDelay: Long = 0,
|
||||
) {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
delay(withDelay)
|
||||
rssService.get().markAsRead(
|
||||
groupId = groupId,
|
||||
feedId = feedId,
|
||||
articleId = articleId,
|
||||
before = conditions.toDate(),
|
||||
isUnread = false,
|
||||
isUnread = isUnread,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStarredStatus(
|
||||
articleId: String?,
|
||||
isStarred: Boolean,
|
||||
withDelay: Long = 0,
|
||||
) {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
// FIXME: a dirty hack to ensure the swipe animation doesn't get interrupted when recomposed, remove this after implementing a lazy tag!
|
||||
delay(withDelay)
|
||||
if (articleId != null) {
|
||||
rssService.get().markAsStarred(
|
||||
articleId = articleId,
|
||||
isStarred = isStarred,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FlowUiState(
|
||||
|
@ -0,0 +1,471 @@
|
||||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SwipeToDismissBoxState.Companion.Saver
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.node.LayoutModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The directions in which a [SwipeToDismissBox] can be dismissed.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
enum class SwipeToDismissBoxValue {
|
||||
/**
|
||||
* Can be dismissed by swiping in the reading direction.
|
||||
*/
|
||||
StartToEnd,
|
||||
|
||||
/**
|
||||
* Can be dismissed by swiping in the reverse of the reading direction.
|
||||
*/
|
||||
EndToStart,
|
||||
|
||||
/**
|
||||
* Cannot currently be dismissed.
|
||||
*/
|
||||
Settled
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the [SwipeToDismissBox] composable.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ExperimentalMaterial3Api
|
||||
class SwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue,
|
||||
internal val density: Density,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
velocityThreshold: () -> Float = { with(density) { DismissThreshold.toPx() } },
|
||||
positionalThreshold: (totalDistance: Float) -> Float
|
||||
) {
|
||||
internal val anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = animationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = velocityThreshold
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
/**
|
||||
* Require the current offset.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* The current state value of the [SwipeToDismissBoxState].
|
||||
*/
|
||||
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target state. This is the closest state to the current offset (taking into account
|
||||
* positional thresholds). If no interactions like animations or drags are in progress, this
|
||||
* will be the current state.
|
||||
*/
|
||||
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
|
||||
*/
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
val progress: Float get() = anchoredDraggableState.progress
|
||||
|
||||
/**
|
||||
* The direction (if any) in which the composable has been or is being dismissed.
|
||||
*
|
||||
* Use this to change the background of the [SwipeToDismissBox] if you want different actions on each
|
||||
* side.
|
||||
*/
|
||||
val dismissDirection: SwipeToDismissBoxValue
|
||||
get() = if (offset == 0f || offset.isNaN())
|
||||
SwipeToDismissBoxValue.Settled
|
||||
else if (offset > 0f)
|
||||
SwipeToDismissBoxValue.StartToEnd else SwipeToDismissBoxValue.EndToStart
|
||||
|
||||
/**
|
||||
* Whether the component has been dismissed in the given [direction].
|
||||
*
|
||||
* @param direction The dismiss direction.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "DismissDirection is no longer used by SwipeToDismissBoxState. Please compare " +
|
||||
"currentValue against SwipeToDismissValue instead.",
|
||||
level = DeprecationLevel.HIDDEN
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
fun isDismissed(direction: DismissDirection): Boolean {
|
||||
return currentValue == (
|
||||
if (direction == DismissDirection.StartToEnd) {
|
||||
SwipeToDismissBoxValue.StartToEnd
|
||||
} else {
|
||||
SwipeToDismissBoxValue.EndToStart
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position with animation and suspend until it if fully
|
||||
* reset or animation has been cancelled. This method will throw [CancellationException] if
|
||||
* the animation is interrupted
|
||||
*
|
||||
* @return the reason the reset animation ended
|
||||
*/
|
||||
suspend fun reset() = anchoredDraggableState.animateTo(
|
||||
targetValue = SwipeToDismissBoxValue.Settled
|
||||
)
|
||||
|
||||
/**
|
||||
* Dismiss the component in the given [direction], with an animation and suspend. This method
|
||||
* will throw [CancellationException] if the animation is interrupted
|
||||
*
|
||||
* @param direction The dismiss direction.
|
||||
*/
|
||||
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.animateTo(targetValue = direction)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SwipeToDismissBoxState].
|
||||
*/
|
||||
fun Saver(
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
animationSpec: AnimationSpec<Float>,
|
||||
density: Density
|
||||
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
|
||||
save = { it.currentValue },
|
||||
restore = {
|
||||
SwipeToDismissBoxState(
|
||||
it,
|
||||
density,
|
||||
animationSpec,
|
||||
confirmValueChange,
|
||||
velocityThreshold,
|
||||
positionalThreshold
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that can be dismissed by swiping left or right.
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.SwipeToDismissListItems
|
||||
*
|
||||
* @param state The state of this component.
|
||||
* @param background A composable that is stacked behind the content and is exposed when the
|
||||
* content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||
* @param dismissContent The content that can be dismissed.
|
||||
* @param modifier Optional [Modifier] for this component.
|
||||
* @param directions The set of directions in which the component can be dismissed.
|
||||
*/
|
||||
@Composable
|
||||
@Deprecated(
|
||||
level = DeprecationLevel.WARNING,
|
||||
message = "Use SwipeToDismissBox instead",
|
||||
replaceWith =
|
||||
ReplaceWith(
|
||||
"SwipeToDismissBox(state, background, modifier, " +
|
||||
"enableDismissFromStartToEnd, enableDismissFromEndToStart, dismissContent)"
|
||||
)
|
||||
)
|
||||
@ExperimentalMaterial3Api
|
||||
fun SwipeToDismiss(
|
||||
state: SwipeToDismissBoxState,
|
||||
background: @Composable RowScope.() -> Unit,
|
||||
dismissContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
directions: Set<SwipeToDismissBoxValue> = setOf(
|
||||
SwipeToDismissBoxValue.EndToStart,
|
||||
SwipeToDismissBoxValue.StartToEnd
|
||||
),
|
||||
) = SwipeToDismissBox(
|
||||
state = state,
|
||||
backgroundContent = background,
|
||||
modifier = modifier,
|
||||
enableDismissFromStartToEnd = SwipeToDismissBoxValue.StartToEnd in directions,
|
||||
enableDismissFromEndToStart = SwipeToDismissBoxValue.EndToStart in directions,
|
||||
content = dismissContent
|
||||
)
|
||||
|
||||
/**
|
||||
* A composable that can be dismissed by swiping left or right.
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.SwipeToDismissListItems
|
||||
*
|
||||
* @param state The state of this component.
|
||||
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when the
|
||||
* content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||
* @param modifier Optional [Modifier] for this component.
|
||||
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
|
||||
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
|
||||
* @param content The content that can be dismissed.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun SwipeToDismissBox(
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
enableDismissFromStartToEnd: Boolean = true,
|
||||
enableDismissFromEndToStart: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.anchoredDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
orientation = Orientation.Horizontal,
|
||||
enabled = enabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||
reverseDirection = isRtl,
|
||||
),
|
||||
propagateMinConstraints = true
|
||||
) {
|
||||
Row(
|
||||
content = backgroundContent,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
Row(
|
||||
content = content,
|
||||
modifier = Modifier.swipeToDismissBoxAnchors(
|
||||
state,
|
||||
enableDismissFromStartToEnd,
|
||||
enableDismissFromEndToStart
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Contains default values for [SwipeToDismissBox] and [SwipeToDismissBoxState]. */
|
||||
@ExperimentalMaterial3Api
|
||||
object SwipeToDismissBoxDefaults {
|
||||
/** Default positional threshold of 56.dp for [SwipeToDismissBoxState]. */
|
||||
val positionalThreshold: (totalDistance: Float) -> Float
|
||||
@Composable get() = with(LocalDensity.current) {
|
||||
{ 56.dp.toPx() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The directions in which a [SwipeToDismissBox] can be dismissed.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Deprecated(
|
||||
message = "Dismiss direction is no longer used by SwipeToDismissBoxState. Please use " +
|
||||
"SwipeToDismissBoxValue instead.",
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
enum class DismissDirection {
|
||||
/**
|
||||
* Can be dismissed by swiping in the reading direction.
|
||||
*/
|
||||
StartToEnd,
|
||||
|
||||
/**
|
||||
* Can be dismissed by swiping in the reverse of the reading direction.
|
||||
*/
|
||||
EndToStart,
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible values of [SwipeToDismissBoxState].
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Deprecated(
|
||||
message = "DismissValue is no longer used by SwipeToDismissBoxState. Please use " +
|
||||
"SwipeToDismissBoxValue instead.",
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
enum class DismissValue {
|
||||
/**
|
||||
* Indicates the component has not been dismissed yet.
|
||||
*/
|
||||
Default,
|
||||
|
||||
/**
|
||||
* Indicates the component has been dismissed in the reading direction.
|
||||
*/
|
||||
DismissedToEnd,
|
||||
|
||||
/**
|
||||
* Indicates the component has been dismissed in the reverse of the reading direction.
|
||||
*/
|
||||
DismissedToStart
|
||||
}
|
||||
|
||||
private val DismissThreshold = 125.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun Modifier.swipeToDismissBoxAnchors(
|
||||
state: SwipeToDismissBoxState,
|
||||
enableDismissFromStartToEnd: Boolean,
|
||||
enableDismissFromEndToStart: Boolean
|
||||
) = this then SwipeToDismissAnchorsElement(
|
||||
state,
|
||||
enableDismissFromStartToEnd,
|
||||
enableDismissFromEndToStart
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private class SwipeToDismissAnchorsElement(
|
||||
private val state: SwipeToDismissBoxState,
|
||||
private val enableDismissFromStartToEnd: Boolean,
|
||||
private val enableDismissFromEndToStart: Boolean,
|
||||
) : ModifierNodeElement<SwipeToDismissAnchorsNode>() {
|
||||
|
||||
override fun create() = SwipeToDismissAnchorsNode(
|
||||
state,
|
||||
enableDismissFromStartToEnd,
|
||||
enableDismissFromEndToStart,
|
||||
)
|
||||
|
||||
override fun update(node: SwipeToDismissAnchorsNode) {
|
||||
node.state = state
|
||||
node.enableDismissFromStartToEnd = enableDismissFromStartToEnd
|
||||
node.enableDismissFromEndToStart = enableDismissFromEndToStart
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
other as SwipeToDismissAnchorsElement
|
||||
if (state != other.state) return false
|
||||
if (enableDismissFromStartToEnd != other.enableDismissFromStartToEnd) return false
|
||||
if (enableDismissFromEndToStart != other.enableDismissFromEndToStart) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + enableDismissFromStartToEnd.hashCode()
|
||||
result = 31 * result + enableDismissFromEndToStart.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
debugInspectorInfo {
|
||||
properties["state"] = state
|
||||
properties["enableDismissFromStartToEnd"] = enableDismissFromStartToEnd
|
||||
properties["enableDismissFromEndToStart"] = enableDismissFromEndToStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
private class SwipeToDismissAnchorsNode(
|
||||
var state: SwipeToDismissBoxState,
|
||||
var enableDismissFromStartToEnd: Boolean,
|
||||
var enableDismissFromEndToStart: Boolean,
|
||||
) : Modifier.Node(), LayoutModifierNode {
|
||||
private var didLookahead: Boolean = false
|
||||
|
||||
override fun onDetach() {
|
||||
didLookahead = false
|
||||
}
|
||||
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||
// update the anchors in the main pass.
|
||||
if (isLookingAhead || !didLookahead) {
|
||||
val width = placeable.width.toFloat()
|
||||
val newAnchors = DraggableAnchors {
|
||||
SwipeToDismissBoxValue.Settled at 0f
|
||||
if (enableDismissFromStartToEnd) {
|
||||
SwipeToDismissBoxValue.StartToEnd at width
|
||||
}
|
||||
if (enableDismissFromEndToStart) {
|
||||
SwipeToDismissBoxValue.EndToStart at -width
|
||||
}
|
||||
}
|
||||
state.anchoredDraggableState.updateAnchors(newAnchors)
|
||||
}
|
||||
didLookahead = isLookingAhead || didLookahead
|
||||
return layout(placeable.width, placeable.height) {
|
||||
// In a lookahead pass, we use the position of the current target as this is where any
|
||||
// ongoing animations would move. If SwipeToDismissBox is in a settled state, lookahead
|
||||
// and post-lookahead will converge.
|
||||
val xOffset = if (isLookingAhead) {
|
||||
state.anchoredDraggableState.anchors.positionOf(state.targetValue)
|
||||
} else state.requireOffset()
|
||||
placeable.place(xOffset.roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.SwipeLeft
|
||||
import androidx.compose.material.icons.rounded.SwipeRight
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -24,11 +26,15 @@ import androidx.navigation.NavHostController
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.infrastructure.preference.InitialFilterPreference
|
||||
import me.ash.reader.infrastructure.preference.InitialPagePreference
|
||||
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeEndAction
|
||||
import me.ash.reader.infrastructure.preference.LocalArticleListSwipeStartAction
|
||||
import me.ash.reader.infrastructure.preference.LocalInitialFilter
|
||||
import me.ash.reader.infrastructure.preference.LocalInitialPage
|
||||
import me.ash.reader.infrastructure.preference.LocalOpenLink
|
||||
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
|
||||
import me.ash.reader.infrastructure.preference.OpenLinkPreference
|
||||
import me.ash.reader.infrastructure.preference.SwipeEndActionPreference
|
||||
import me.ash.reader.infrastructure.preference.SwipeStartActionPreference
|
||||
import me.ash.reader.ui.component.base.DisplayText
|
||||
import me.ash.reader.ui.component.base.FeedbackIconButton
|
||||
import me.ash.reader.ui.component.base.RYScaffold
|
||||
@ -46,6 +52,8 @@ fun InteractionPage(
|
||||
val context = LocalContext.current
|
||||
val initialPage = LocalInitialPage.current
|
||||
val initialFilter = LocalInitialFilter.current
|
||||
val swipeToStartAction = LocalArticleListSwipeStartAction.current
|
||||
val swipeToEndAction = LocalArticleListSwipeEndAction.current
|
||||
val openLink = LocalOpenLink.current
|
||||
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@ -54,6 +62,8 @@ fun InteractionPage(
|
||||
}
|
||||
var initialPageDialogVisible by remember { mutableStateOf(false) }
|
||||
var initialFilterDialogVisible by remember { mutableStateOf(false) }
|
||||
var swipeStartDialogVisible by remember { mutableStateOf(false) }
|
||||
var swipeEndDialogVisible by remember { mutableStateOf(false) }
|
||||
var openLinkDialogVisible by remember { mutableStateOf(false) }
|
||||
var openLinkSpecificBrowserDialogVisible by remember { mutableStateOf(false) }
|
||||
|
||||
@ -93,6 +103,27 @@ fun InteractionPage(
|
||||
initialFilterDialogVisible = true
|
||||
},
|
||||
) {}
|
||||
Subtitle(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
text = stringResource(R.string.article_list),
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
title = stringResource(R.string.swipe_to_start),
|
||||
desc = swipeToStartAction.desc,
|
||||
onClick = {
|
||||
swipeStartDialogVisible = true
|
||||
},
|
||||
) {}
|
||||
|
||||
SettingItem(
|
||||
title = stringResource(R.string.swipe_to_end),
|
||||
desc = swipeToEndAction.desc,
|
||||
onClick = {
|
||||
swipeEndDialogVisible = true
|
||||
},
|
||||
) {}
|
||||
|
||||
Subtitle(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
text = stringResource(R.string.external_links),
|
||||
@ -154,6 +185,37 @@ fun InteractionPage(
|
||||
initialFilterDialogVisible = false
|
||||
}
|
||||
|
||||
RadioDialog(
|
||||
visible = swipeStartDialogVisible,
|
||||
title = stringResource(R.string.swipe_to_start),
|
||||
options = SwipeStartActionPreference.values.map {
|
||||
RadioDialogOption(
|
||||
text = it.desc,
|
||||
selected = it == swipeToStartAction,
|
||||
) {
|
||||
it.put(context, scope)
|
||||
}
|
||||
},
|
||||
) {
|
||||
swipeStartDialogVisible = false
|
||||
}
|
||||
|
||||
RadioDialog(
|
||||
visible = swipeEndDialogVisible,
|
||||
title = stringResource(R.string.swipe_to_end),
|
||||
options = SwipeEndActionPreference.values.map {
|
||||
RadioDialogOption(
|
||||
text = it.desc,
|
||||
selected = it == swipeToEndAction,
|
||||
) {
|
||||
it.put(context, scope)
|
||||
}
|
||||
},
|
||||
) {
|
||||
swipeEndDialogVisible = false
|
||||
}
|
||||
|
||||
|
||||
RadioDialog(
|
||||
visible = openLinkDialogVisible,
|
||||
title = stringResource(R.string.initial_open_app),
|
||||
@ -174,14 +236,15 @@ fun InteractionPage(
|
||||
}
|
||||
|
||||
RadioDialog(
|
||||
visible = openLinkSpecificBrowserDialogVisible ,
|
||||
visible = openLinkSpecificBrowserDialogVisible,
|
||||
title = stringResource(R.string.open_link_specific_browser),
|
||||
options = browserList.map {
|
||||
RadioDialogOption(
|
||||
text = it.loadLabel(context.packageManager).toString(),
|
||||
selected = it.activityInfo.packageName == openLinkSpecificBrowser.packageName,
|
||||
) {
|
||||
openLinkSpecificBrowser.copy(packageName = it.activityInfo.packageName).put(context, scope)
|
||||
openLinkSpecificBrowser.copy(packageName = it.activityInfo.packageName)
|
||||
.put(context, scope)
|
||||
}
|
||||
},
|
||||
onDismissRequest = {
|
||||
|
@ -414,4 +414,9 @@
|
||||
<string name="submit_bug_report">submit a bug report on GitHub</string>
|
||||
<string name="unexpected_error_msg">The app encountered an unexpected error and had to close.\n\nTo help us identify and fix this issue quickly, you can %1$s with the error stack trace below.</string>
|
||||
<string name="next_article">Next article</string>
|
||||
<string name="swipe_to_start">Swipe left</string>
|
||||
<string name="swipe_to_end">Swipe right</string>
|
||||
<string name="none">None</string>
|
||||
<string name="toggle_read">Toggle read</string>
|
||||
<string name="toggle_starred">Toggle starred</string>
|
||||
</resources>
|
||||
|
Loading…
x
Reference in New Issue
Block a user