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:
junkfood 2024-02-11 19:35:19 +08:00 committed by GitHub
parent c21e22d91b
commit 8c11757be4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 998 additions and 106 deletions

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(

View File

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

View File

@ -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 = {

View File

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