From 730035aff77dc29b71a4ccc66297b95e33223a15 Mon Sep 17 00:00:00 2001 From: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:02:58 +0800 Subject: [PATCH] Refine Fablayout (#403) * Fix read page * Refine FabLayout * Let Fablayout have consistent animation state, walk consistently * Let Fablayout collapse when secondaryFab updated * Move animation delay out of FabLayout, should not handle that within FabLayout * Refine predictive back * Refine predictive back * Introduce waitCollapse * Refine * Simplify * Simplify --------- Co-authored-by: GG Bond --- .../com/hippo/ehviewer/ui/main/FabLayout.kt | 163 +++++++++++++----- .../ehviewer/ui/screen/DownloadsScreen.kt | 2 +- .../ehviewer/ui/screen/FavoritesScreen.kt | 16 +- .../ehviewer/ui/screen/GalleryDetailScreen.kt | 23 +-- .../ehviewer/ui/screen/GalleryListScreen.kt | 16 +- 5 files changed, 166 insertions(+), 54 deletions(-) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt index 41dbeb095..a5122b1dd 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt @@ -1,7 +1,12 @@ package com.hippo.ehviewer.ui.main -import androidx.compose.animation.core.animateFloatAsState +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -14,11 +19,14 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer @@ -27,10 +35,66 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp -import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState import eu.kanade.tachiyomi.util.lang.launchIO +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import moe.tarsin.coroutines.runSuspendCatching +enum class FabLayoutValue { + Hidden, + Primary, + Expand, +} + +@Composable +fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary): FabLayoutState { + return remember { FabLayoutState(initialValue) } +} + +@Stable +class FabLayoutState( + initialValue: FabLayoutValue, + private val animationSpec: AnimationSpec = tween(FAB_ANIMATE_TIME), +) { + val mutatorMutex = MutatorMutex() + + val appearProgress = Animatable(if (initialValue != FabLayoutValue.Hidden) 1f else 0f) + .apply { updateBounds(0f, 1f) } + + val expandProgress = Animatable(if (initialValue == FabLayoutValue.Expand) 1f else 0f) + .apply { updateBounds(0f, 1f) } + + private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) { + mutatorMutex.mutate(priority) { + if (expandProgress.value != 0f) expandProgress.animateTo(0f, animationSpec) + if (value == FabLayoutValue.Hidden) { + appearProgress.animateTo(0f, animationSpec) + } + } + } + + private suspend fun upTo(value: FabLayoutValue, priority: MutatePriority) { + mutatorMutex.mutate(priority) { + if (appearProgress.value != 1f) appearProgress.animateTo(1f, animationSpec) + if (value == FabLayoutValue.Expand) { + expandProgress.animateTo(1f, animationSpec) + } + } + } + + suspend fun waitCollapse() { + if (expandProgress.value != 0f) { + snapshotFlow { expandProgress.value }.first { it == 0f } + } + } + suspend fun show(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Primary, priority) + suspend fun hide(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Hidden, priority) + suspend fun collapse(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Primary, priority) + suspend fun expand(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Expand, priority) +} + fun interface FabBuilder { fun onClick(icon: ImageVector, that: suspend () -> Unit) } @@ -41,36 +105,50 @@ fun FabLayout( expanded: Boolean, onExpandChanged: (Boolean) -> Unit, autoCancel: Boolean, - showAddButton: Boolean = true, - delayOnShow: Boolean = false, + rotateWhenExpand: Boolean = true, fabBuilder: FabBuilder.() -> Unit, ) { - var delayOnExpand by remember { mutableStateOf(hidden) } - var delayOnHide by remember { mutableStateOf(expanded) } - val secondaryFab = remember(fabBuilder) { - buildList { - fabBuilder { icon, action -> - add(icon to action) + val updatedHidden by rememberUpdatedState(hidden) + val updatedExpanded by rememberUpdatedState(expanded) + val newState = when { + updatedHidden -> FabLayoutValue.Hidden + updatedExpanded -> FabLayoutValue.Expand + else -> FabLayoutValue.Primary + } + val state = rememberFabLayoutState(newState) + LaunchedEffect(newState) { + when (newState) { + FabLayoutValue.Expand -> state.expand() + FabLayoutValue.Hidden -> state.hide() + FabLayoutValue.Primary -> { + state.collapse() + state.show() } } } - if (expanded) { - if (autoCancel) { - Spacer( - modifier = Modifier.fillMaxSize().pointerInput(expanded) { - detectTapGestures { - onExpandChanged(false) - } - }, - ) + val builder by rememberUpdatedState(fabBuilder) + + fun buildFab(builder: FabBuilder.() -> Unit) = buildList { + builder { icon, action -> + add(icon to action) } } + + val secondaryFab by remember { + snapshotFlow { builder }.map { one -> + state.waitCollapse() + buildFab(one) + } + }.collectAsState(remember { buildFab(fabBuilder) }) val coroutineScope = rememberCoroutineScope() - val appearState by animateFloatAsState( - targetValue = if (hidden) 0f else 1f, - animationSpec = tween(FAB_ANIMATE_TIME, if (delayOnHide || !hidden && delayOnShow) FAB_ANIMATE_TIME else 0), - label = "hiddenState", - ) { delayOnExpand = hidden } + if (updatedExpanded && autoCancel) { + Spacer( + modifier = Modifier.fillMaxSize().pointerInput(Unit) { + detectTapGestures { onExpandChanged(false) } + }, + ) + } + val appearState by state.appearProgress.asState() Box( modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp), contentAlignment = Alignment.BottomEnd, @@ -83,22 +161,29 @@ fun FabLayout( }, contentAlignment = Alignment.Center, ) { - val animatedProgress by animateFloatMergePredictiveBackAsState( - expanded, - tween(FAB_ANIMATE_TIME, if (delayOnExpand) FAB_ANIMATE_TIME else 0), - finishedListener = { delayOnHide = expanded }, - ) { - onExpandChanged(false) + val animatedProgress by state.expandProgress.asState() + PredictiveBackHandler(updatedExpanded) { flow -> + try { + state.mutatorMutex.mutate(MutatePriority.UserInput) { + flow.collect { + val eased = EaseOut.transform(it.progress) + state.expandProgress.snapTo(1 - eased) + } + } + onExpandChanged(false) + } catch (e: CancellationException) { + coroutineScope.launch { + state.expand() + } + } } - FloatingActionButton( - onClick = { onExpandChanged(!expanded) }, - ) { + FloatingActionButton(onClick = { onExpandChanged(!updatedExpanded) }) { Icon( imageVector = Icons.Default.Close, contentDescription = null, - modifier = if (showAddButton) { + modifier = if (rotateWhenExpand) { Modifier.graphicsLayer { - rotationZ = lerp(0f, -135f, animatedProgress) + rotationZ = lerp(-135f, 0f, animatedProgress) } } else { Modifier @@ -119,11 +204,11 @@ fun FabLayout( modifier = Modifier.layout { measurable, constraints -> val placeable = measurable.measure(constraints) layout(placeable.width, placeable.height) { - val distance = lerp(150 * (size - index) + 50, 0, animatedProgress) + val distance = lerp(0, 150 * (size - index) + 50, animatedProgress) placeable.placeRelative(0, -distance, -(size - index).toFloat()) } }.graphicsLayer { - alpha = 1 - animatedProgress + alpha = animatedProgress }, ) { Icon(imageVector = imageVector, contentDescription = null) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt index 7af84e668..867ee8f47 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt @@ -488,7 +488,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) { expanded = selectMode, onExpandChanged = { checkedInfoMap.clear() }, autoCancel = false, - showAddButton = false, + rotateWhenExpand = false, ) { onClick(Icons.Default.DoneAll) { val info = list.associateBy { it.gid } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt index 1bf8c6a32..1154f4e87 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -68,6 +69,7 @@ import com.hippo.ehviewer.ui.LocalSideSheetState import com.hippo.ehviewer.ui.LockDrawer import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination +import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME import com.hippo.ehviewer.ui.main.FabLayout import com.hippo.ehviewer.ui.main.GalleryInfoGridItem import com.hippo.ehviewer.ui.main.GalleryInfoListItem @@ -83,6 +85,9 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import eu.kanade.tachiyomi.util.lang.withIOContext import kotlin.math.roundToInt import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import moe.tarsin.coroutines.runSuspendCatching @@ -307,17 +312,24 @@ fun FavouritesScreen(navigator: DestinationsNavigator) { ) } + val hideFab by remember { + snapshotFlow { + hidden + }.onEach { + if (!it) delay(FAB_ANIMATE_TIME.toLong()) + }.mapLatest { it } + }.collectAsState(hidden) + // Explicitly reallocate fabBuilder lambda to recompose secondary fab val select = selectMode FabLayout( - hidden = hidden, + hidden = hideFab, expanded = expanded || selectMode, onExpandChanged = { expanded = it checkedInfoMap.clear() }, autoCancel = !selectMode, - delayOnShow = true, ) { if (!select) { onClick(EhIcons.Default.GoTo) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt index 889af7545..263042221 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryDetailScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.text.parseAsHtml +import androidx.lifecycle.compose.LifecycleResumeEffect import arrow.core.partially1 import coil.imageLoader import com.google.android.material.snackbar.Snackbar @@ -897,19 +898,21 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa val windowSizeClass = calculateWindowSizeClass(activity) val thumbColumns by Settings.thumbColumns.collectAsState() val readText = stringResource(R.string.read) - val downloadText = stringResource(R.string.download) var readButtonText by rememberSaveable { mutableStateOf(readText) } - LaunchedEffect(Unit) { - runSuspendCatching { - val queen = SpiderQueen.obtainSpiderQueen(galleryInfo, MODE_READ) - val startPage = queen.awaitStartPage() - SpiderQueen.releaseSpiderQueen(queen, MODE_READ) - readButtonText = if (startPage == 0) { - readText - } else { - context.getString(R.string.read_from, startPage + 1) + LifecycleResumeEffect(Unit) { + coroutineScope.launchIO { + runSuspendCatching { + val queen = SpiderQueen.obtainSpiderQueen(galleryInfo, MODE_READ) + val startPage = queen.awaitStartPage() + SpiderQueen.releaseSpiderQueen(queen, MODE_READ) + readButtonText = if (startPage == 0) { + readText + } else { + context.getString(R.string.read_from, startPage + 1) + } } } + onPauseOrDispose { } } val downloadState by EhDownloadManager.collectDownloadState(gid) val downloadButtonText = when (downloadState) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index dca2feb03..fa27c8104 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -66,6 +67,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -154,6 +156,8 @@ import eu.kanade.tachiyomi.util.lang.withUIContext import java.io.File import kotlin.math.roundToInt import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import moe.tarsin.coroutines.runSuspendCatching import sh.calvin.reorderable.ReorderableItem @@ -806,12 +810,20 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) { val gotoTitle = stringResource(R.string.go_to) val invalidNum = stringResource(R.string.error_invalid_number) val outOfRange = stringResource(R.string.error_out_of_range) + + val hideFab by remember { + snapshotFlow { + hidden || showSearchLayout + }.onEach { + if (!it) delay(FAB_ANIMATE_TIME.toLong()) + }.mapLatest { it } + }.collectAsState(hidden || showSearchLayout) + FabLayout( - hidden = hidden || showSearchLayout, + hidden = hideFab, expanded = expanded, onExpandChanged = { expanded = it }, autoCancel = true, - delayOnShow = true, ) { onClick(Icons.Default.Refresh) { urlBuilder.setIndex(null)