diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a08149ee5..d178945dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - + versionCode = 103 versionName = "0.14.6" @@ -239,6 +239,7 @@ dependencies { implementation(libs.bundles.voyager) implementation(libs.compose.materialmotion) implementation(libs.compose.simpleicons) + implementation(libs.swipe) // Logging implementation(libs.logcat) diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt index b168e5ee0..63fdfecca 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt @@ -1,20 +1,12 @@ package eu.kanade.presentation.manga.components -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable 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.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn -import androidx.compose.material.DismissDirection -import androidx.compose.material.DismissValue -import androidx.compose.material.SwipeToDismiss import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Circle @@ -25,26 +17,28 @@ import androidx.compose.material.icons.outlined.Done import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.FileDownloadOff import androidx.compose.material.icons.outlined.RemoveDone -import androidx.compose.material.rememberDismissState import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.res.stringResource @@ -53,10 +47,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download +import me.saket.swipe.SwipeableActionsBox +import me.saket.swipe.rememberSwipeableActionsState import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.presentation.core.components.material.ReadItemAlpha import tachiyomi.presentation.core.components.material.SecondaryItemAlpha import tachiyomi.presentation.core.util.selectedBackground +import kotlin.math.absoluteValue @Composable fun MangaChapterListItem( @@ -78,6 +75,12 @@ fun MangaChapterListItem( onDownloadClick: ((ChapterDownloadAction) -> Unit)?, onChapterSwipe: (LibraryPreferences.ChapterSwipeAction) -> Unit, ) { + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + + val textAlpha = if (read) ReadItemAlpha else 1f + val textSubtitleAlpha = if (read) ReadItemAlpha else SecondaryItemAlpha + // Increase touch slop of swipe action to reduce accidental trigger val configuration = LocalViewConfiguration.current CompositionLocalProvider( @@ -85,247 +88,188 @@ fun MangaChapterListItem( override val touchSlop: Float = configuration.touchSlop * 3f }, ) { - val textAlpha = if (read) ReadItemAlpha else 1f - val textSubtitleAlpha = if (read) ReadItemAlpha else SecondaryItemAlpha - - val chapterSwipeStartEnabled = chapterSwipeStartAction != LibraryPreferences.ChapterSwipeAction.Disabled - val chapterSwipeEndEnabled = chapterSwipeEndAction != LibraryPreferences.ChapterSwipeAction.Disabled - - val dismissState = rememberDismissState() - val dismissDirections = remember { mutableSetOf() } - var lastDismissDirection: DismissDirection? by remember { mutableStateOf(null) } - if (lastDismissDirection == null) { - if (chapterSwipeStartEnabled) { - dismissDirections.add(DismissDirection.EndToStart) - } - if (chapterSwipeEndEnabled) { - dismissDirections.add(DismissDirection.StartToEnd) - } - } - val animateDismissContentAlpha by animateFloatAsState( - label = "animateDismissContentAlpha", - targetValue = if (lastDismissDirection != null) 1f else 0f, - animationSpec = tween(durationMillis = if (lastDismissDirection != null) 500 else 0), - finishedListener = { - lastDismissDirection = null - }, + val start = getSwipeAction( + action = chapterSwipeStartAction, + read = read, + bookmark = bookmark, + downloadState = downloadStateProvider(), + background = MaterialTheme.colorScheme.primaryContainer, + onSwipe = { onChapterSwipe(chapterSwipeStartAction) }, ) - val dismissContentAlpha = if (lastDismissDirection != null) animateDismissContentAlpha else 1f - val backgroundColor = if (chapterSwipeEndEnabled && (dismissState.dismissDirection == DismissDirection.StartToEnd || lastDismissDirection == DismissDirection.StartToEnd)) { - MaterialTheme.colorScheme.primary - } else if (chapterSwipeStartEnabled && (dismissState.dismissDirection == DismissDirection.EndToStart || lastDismissDirection == DismissDirection.EndToStart)) { - MaterialTheme.colorScheme.primary - } else { - Color.Unspecified + val end = getSwipeAction( + action = chapterSwipeEndAction, + read = read, + bookmark = bookmark, + downloadState = downloadStateProvider(), + background = MaterialTheme.colorScheme.primaryContainer, + onSwipe = { onChapterSwipe(chapterSwipeEndAction) }, + ) + + val swipeableActionsState = rememberSwipeableActionsState() + LaunchedEffect(Unit) { + // Haptic effect when swipe over threshold + val swipeActionThresholdPx = with(density) { swipeActionThreshold.toPx() } + snapshotFlow { swipeableActionsState.offset.value.absoluteValue > swipeActionThresholdPx } + .collect { if (it) haptic.performHapticFeedback(HapticFeedbackType.LongPress) } } - LaunchedEffect(dismissState.currentValue) { - when (dismissState.currentValue) { - DismissValue.DismissedToEnd -> { - lastDismissDirection = DismissDirection.StartToEnd - val dismissDirectionsCopy = dismissDirections.toSet() - dismissDirections.clear() - onChapterSwipe(chapterSwipeEndAction) - dismissState.snapTo(DismissValue.Default) - dismissDirections.addAll(dismissDirectionsCopy) - } - DismissValue.DismissedToStart -> { - lastDismissDirection = DismissDirection.EndToStart - val dismissDirectionsCopy = dismissDirections.toSet() - dismissDirections.clear() - onChapterSwipe(chapterSwipeStartAction) - dismissState.snapTo(DismissValue.Default) - dismissDirections.addAll(dismissDirectionsCopy) - } - DismissValue.Default -> { } - } - } - - SwipeToDismiss( - state = dismissState, - directions = dismissDirections, - background = { - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor), + SwipeableActionsBox( + modifier = Modifier.clipToBounds(), + state = swipeableActionsState, + startActions = listOfNotNull(start), + endActions = listOfNotNull(end), + swipeThreshold = swipeActionThreshold, + backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Row( + modifier = modifier + .selectedBackground(selected) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - if (dismissState.dismissDirection in dismissDirections) { - val downloadState = downloadStateProvider() - SwipeBackgroundIcon( - modifier = Modifier - .padding(start = 16.dp) - .align(Alignment.CenterStart) - .alpha( - if (dismissState.dismissDirection == DismissDirection.StartToEnd) 1f else 0f, - ), - tint = contentColorFor(backgroundColor), - swipeAction = chapterSwipeEndAction, - read = read, - bookmark = bookmark, - downloadState = downloadState, - ) - SwipeBackgroundIcon( - modifier = Modifier - .padding(end = 16.dp) - .align(Alignment.CenterEnd) - .alpha( - if (dismissState.dismissDirection == DismissDirection.EndToStart) 1f else 0f, - ), - tint = contentColorFor(backgroundColor), - swipeAction = chapterSwipeStartAction, - read = read, - bookmark = bookmark, - downloadState = downloadState, - ) - } - } - }, - dismissContent = { - Row( - modifier = modifier - .background( - MaterialTheme.colorScheme.background.copy(dismissContentAlpha), - ) - .selectedBackground(selected) - .alpha(dismissContentAlpha) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ) - .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - var textHeight by remember { mutableIntStateOf(0) } - if (!read) { - Icon( - imageVector = Icons.Filled.Circle, - contentDescription = stringResource(R.string.unread), - modifier = Modifier - .height(8.dp) - .padding(end = 4.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - if (bookmark) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(R.string.action_filter_bookmarked), - modifier = Modifier - .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), - tint = MaterialTheme.colorScheme.primary, - ) - } - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = LocalContentColor.current.copy(alpha = textAlpha), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - onTextLayout = { textHeight = it.size.height }, + var textHeight by remember { mutableIntStateOf(0) } + if (!read) { + Icon( + imageVector = Icons.Filled.Circle, + contentDescription = stringResource(R.string.unread), + modifier = Modifier + .height(8.dp) + .padding(end = 4.dp), + tint = MaterialTheme.colorScheme.primary, ) } + if (bookmark) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = stringResource(R.string.action_filter_bookmarked), + modifier = Modifier + .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = textAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { textHeight = it.size.height }, + ) + } - Row { - ProvideTextStyle( - value = MaterialTheme.typography.bodyMedium.copy( - fontSize = 12.sp, - color = LocalContentColor.current.copy(alpha = textSubtitleAlpha), - ), - ) { - if (date != null) { - Text( - text = date, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (readProgress != null || scanlator != null) DotSeparatorText() - } - if (readProgress != null) { - Text( - text = readProgress, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.alpha(ReadItemAlpha), - ) - if (scanlator != null) DotSeparatorText() - } - if (scanlator != null) { - Text( - text = scanlator, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + Row { + ProvideTextStyle( + value = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.sp, + color = LocalContentColor.current.copy(alpha = textSubtitleAlpha), + ), + ) { + if (date != null) { + Text( + text = date, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (readProgress != null || scanlator != null) DotSeparatorText() + } + if (readProgress != null) { + Text( + text = readProgress, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(ReadItemAlpha), + ) + if (scanlator != null) DotSeparatorText() + } + if (scanlator != null) { + Text( + text = scanlator, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } - - if (onDownloadClick != null) { - ChapterDownloadIndicator( - enabled = downloadIndicatorEnabled, - modifier = Modifier.padding(start = 4.dp), - downloadStateProvider = downloadStateProvider, - downloadProgressProvider = downloadProgressProvider, - onClick = onDownloadClick, - ) - } } - }, - ) + + if (onDownloadClick != null) { + ChapterDownloadIndicator( + enabled = downloadIndicatorEnabled, + modifier = Modifier.padding(start = 4.dp), + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onClick = onDownloadClick, + ) + } + } + } } } -@Composable -private fun SwipeBackgroundIcon( - modifier: Modifier = Modifier, - tint: Color, - swipeAction: LibraryPreferences.ChapterSwipeAction, +private fun getSwipeAction( + action: LibraryPreferences.ChapterSwipeAction, read: Boolean, bookmark: Boolean, downloadState: Download.State, -) { - val imageVector = when (swipeAction) { - LibraryPreferences.ChapterSwipeAction.ToggleRead -> { - if (!read) { - Icons.Outlined.Done - } else { - Icons.Outlined.RemoveDone - } - } - LibraryPreferences.ChapterSwipeAction.ToggleBookmark -> { - if (!bookmark) { - Icons.Outlined.BookmarkAdd - } else { - Icons.Outlined.BookmarkRemove - } - } - LibraryPreferences.ChapterSwipeAction.Download -> { - when (downloadState) { - Download.State.NOT_DOWNLOADED, - Download.State.ERROR, - -> { Icons.Outlined.Download } - Download.State.QUEUE, - Download.State.DOWNLOADING, - -> { Icons.Outlined.FileDownloadOff } - Download.State.DOWNLOADED -> { Icons.Outlined.Delete } - } - } + background: Color, + onSwipe: () -> Unit, +): me.saket.swipe.SwipeAction? { + return when (action) { + LibraryPreferences.ChapterSwipeAction.ToggleRead -> SwipeAction( + icon = if (!read) Icons.Outlined.Done else Icons.Outlined.RemoveDone, + background = background, + isUndo = read, + onSwipe = onSwipe, + ) + LibraryPreferences.ChapterSwipeAction.ToggleBookmark -> SwipeAction( + icon = if (!bookmark) Icons.Outlined.BookmarkAdd else Icons.Outlined.BookmarkRemove, + background = background, + isUndo = bookmark, + onSwipe = onSwipe, + ) + LibraryPreferences.ChapterSwipeAction.Download -> SwipeAction( + icon = when (downloadState) { + Download.State.NOT_DOWNLOADED, Download.State.ERROR -> Icons.Outlined.Download + Download.State.QUEUE, Download.State.DOWNLOADING -> Icons.Outlined.FileDownloadOff + Download.State.DOWNLOADED -> Icons.Outlined.Delete + }, + background = background, + onSwipe = onSwipe, + ) LibraryPreferences.ChapterSwipeAction.Disabled -> null } - imageVector?.let { - Icon( - modifier = modifier, - imageVector = imageVector, - tint = tint, - contentDescription = null, - ) - } } + +private fun SwipeAction( + onSwipe: () -> Unit, + icon: ImageVector, + background: Color, + isUndo: Boolean = false, +): me.saket.swipe.SwipeAction { + return me.saket.swipe.SwipeAction( + icon = { + Icon( + modifier = Modifier.padding(16.dp), + imageVector = icon, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null, + ) + }, + background = background, + onSwipe = onSwipe, + isUndo = isUndo, + ) +} + +private val swipeActionThreshold = 56.dp diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b136821c4..c6675a008 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,8 @@ insetter = "dev.chrisbanes.insetter:insetter:0.6.1" compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.3" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0" +swipe = "me.saket.swipe:swipe:1.2.0" + logcat = "com.squareup.logcat:logcat:0.1" acra-http = "ch.acra:acra-http:5.10.1"