|
@@ -1,8 +1,23 @@
|
|
|
package io.legado.app.ui.main
|
|
package io.legado.app.ui.main
|
|
|
|
|
|
|
|
|
|
+import androidx.compose.animation.core.Animatable
|
|
|
|
|
+import androidx.compose.animation.core.FastOutSlowInEasing
|
|
|
|
|
+import androidx.compose.animation.core.LinearOutSlowInEasing
|
|
|
|
|
+import androidx.compose.animation.core.tween
|
|
|
|
|
+import androidx.compose.foundation.background
|
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Box
|
|
|
|
|
+import androidx.compose.foundation.layout.fillMaxSize
|
|
|
|
|
+import androidx.compose.material3.MaterialTheme
|
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.Composable
|
|
|
|
|
+import androidx.compose.runtime.CompositionLocalProvider
|
|
|
|
|
+import androidx.compose.runtime.LaunchedEffect
|
|
|
|
|
+import androidx.compose.runtime.getValue
|
|
|
|
|
+import androidx.compose.runtime.mutableStateMapOf
|
|
|
|
|
+import androidx.compose.runtime.mutableStateOf
|
|
|
|
|
+import androidx.compose.runtime.setValue
|
|
|
|
|
+import androidx.compose.runtime.staticCompositionLocalOf
|
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.Modifier
|
|
|
|
|
+import androidx.compose.ui.graphics.graphicsLayer
|
|
|
import androidx.compose.ui.zIndex
|
|
import androidx.compose.ui.zIndex
|
|
|
import androidx.navigation3.runtime.NavEntry
|
|
import androidx.navigation3.runtime.NavEntry
|
|
|
import androidx.navigation3.runtime.NavKey
|
|
import androidx.navigation3.runtime.NavKey
|
|
@@ -13,9 +28,15 @@ import androidx.navigation3.scene.OverlayScene
|
|
|
import androidx.navigation3.scene.Scene
|
|
import androidx.navigation3.scene.Scene
|
|
|
import androidx.navigation3.scene.SceneStrategy
|
|
import androidx.navigation3.scene.SceneStrategy
|
|
|
import androidx.navigation3.scene.SceneStrategyScope
|
|
import androidx.navigation3.scene.SceneStrategyScope
|
|
|
|
|
+import kotlinx.coroutines.coroutineScope
|
|
|
|
|
+import kotlinx.coroutines.launch
|
|
|
|
|
|
|
|
-/** Keeps the previous destination composed while an entry renders its own modal window. */
|
|
|
|
|
|
|
+internal const val NAV_SLIDE_DURATION_MILLIS = 480
|
|
|
|
|
+internal const val NAV_FADE_DURATION_MILLIS = 360
|
|
|
|
|
+
|
|
|
|
|
+/** Keeps parent destinations composed beneath a Nav3 overlay scene. */
|
|
|
class ModalOverlaySceneStrategy : SceneStrategy<NavKey> {
|
|
class ModalOverlaySceneStrategy : SceneStrategy<NavKey> {
|
|
|
|
|
+ private val searchAnimations = SearchOverlayAnimations()
|
|
|
|
|
|
|
|
override fun SceneStrategyScope<NavKey>.calculateScene(
|
|
override fun SceneStrategyScope<NavKey>.calculateScene(
|
|
|
entries: List<NavEntry<NavKey>>,
|
|
entries: List<NavEntry<NavKey>>,
|
|
@@ -24,25 +45,37 @@ class ModalOverlaySceneStrategy : SceneStrategy<NavKey> {
|
|
|
entry.metadata[MetadataKey] ?: return null
|
|
entry.metadata[MetadataKey] ?: return null
|
|
|
val previousEntries = entries.dropLast(1)
|
|
val previousEntries = entries.dropLast(1)
|
|
|
if (previousEntries.isEmpty()) return null
|
|
if (previousEntries.isEmpty()) return null
|
|
|
|
|
+ if (entry.metadata[SearchSlideKey] != null) {
|
|
|
|
|
+ return SearchOverlayScene(
|
|
|
|
|
+ entry = entry,
|
|
|
|
|
+ previousEntries = previousEntries,
|
|
|
|
|
+ animations = searchAnimations,
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
return ModalOverlayScene(
|
|
return ModalOverlayScene(
|
|
|
entry = entry,
|
|
entry = entry,
|
|
|
previousEntries = previousEntries,
|
|
previousEntries = previousEntries,
|
|
|
|
|
+ animations = searchAnimations,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
companion object {
|
|
companion object {
|
|
|
private object MetadataKey : NavMetadataKey<Unit>
|
|
private object MetadataKey : NavMetadataKey<Unit>
|
|
|
|
|
+ private object SearchSlideKey : NavMetadataKey<Unit>
|
|
|
|
|
|
|
|
fun modalOverlay(): Map<String, Any> = metadata { put(MetadataKey, Unit) }
|
|
fun modalOverlay(): Map<String, Any> = metadata { put(MetadataKey, Unit) }
|
|
|
|
|
+ fun searchSlide(): Map<String, Any> = metadata { put(SearchSlideKey, Unit) }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private data class ModalOverlayScene(
|
|
private data class ModalOverlayScene(
|
|
|
private val entry: NavEntry<NavKey>,
|
|
private val entry: NavEntry<NavKey>,
|
|
|
override val previousEntries: List<NavEntry<NavKey>>,
|
|
override val previousEntries: List<NavEntry<NavKey>>,
|
|
|
|
|
+ private val animations: SearchOverlayAnimations,
|
|
|
) : OverlayScene<NavKey> {
|
|
) : OverlayScene<NavKey> {
|
|
|
override val key: Any = entry.contentKey
|
|
override val key: Any = entry.contentKey
|
|
|
override val entries: List<NavEntry<NavKey>> = listOf(entry)
|
|
override val entries: List<NavEntry<NavKey>> = listOf(entry)
|
|
|
|
|
+ override val metadata: Map<String, Any> = entry.metadata
|
|
|
|
|
|
|
|
// NavDisplay recursively resolves the scene underneath an overlay. Passing only the
|
|
// NavDisplay recursively resolves the scene underneath an overlay. Passing only the
|
|
|
// last entry turns a nested overlay (book info under a reader) into a SinglePane root,
|
|
// last entry turns a nested overlay (book info under a reader) into a SinglePane root,
|
|
@@ -52,8 +85,157 @@ private data class ModalOverlayScene(
|
|
|
// NavDisplay retains existing overlays and appends newly opened ones. Their
|
|
// NavDisplay retains existing overlays and appends newly opened ones. Their
|
|
|
// composition order can therefore differ from stack order for nested overlays.
|
|
// composition order can therefore differ from stack order for nested overlays.
|
|
|
// Draw and hit-test by stack depth so a reader always sits above book info.
|
|
// Draw and hit-test by stack depth so a reader always sits above book info.
|
|
|
- Box(Modifier.zIndex(previousEntries.size.toFloat())) {
|
|
|
|
|
- entry.Content()
|
|
|
|
|
|
|
+ val search = animations.covering(key)
|
|
|
|
|
+ Box(
|
|
|
|
|
+ Modifier
|
|
|
|
|
+ .zIndex(previousEntries.size.toFloat())
|
|
|
|
|
+ // 搜索覆盖阅读页时,阅读页淡出到主题背景,而不是露出更底层的主页。
|
|
|
|
|
+ .then(
|
|
|
|
|
+ if (search != null) {
|
|
|
|
|
+ Modifier
|
|
|
|
|
+ .fillMaxSize()
|
|
|
|
|
+ .background(MaterialTheme.colorScheme.background)
|
|
|
|
|
+ } else Modifier
|
|
|
|
|
+ )
|
|
|
|
|
+ ) {
|
|
|
|
|
+ Box(Modifier.graphicsLayer {
|
|
|
|
|
+ translationX = -size.width / 4f * (search?.progress?.value ?: 0f)
|
|
|
|
|
+ alpha = search?.underlayOpacity?.value ?: 1f
|
|
|
|
|
+ }) {
|
|
|
|
|
+ entry.Content()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Keeps the reader composed while Nav3 owns the search panel's entry and removal lifetime. */
|
|
|
|
|
+private data class SearchOverlayScene(
|
|
|
|
|
+ private val entry: NavEntry<NavKey>,
|
|
|
|
|
+ override val previousEntries: List<NavEntry<NavKey>>,
|
|
|
|
|
+ private val animations: SearchOverlayAnimations,
|
|
|
|
|
+) : OverlayScene<NavKey> {
|
|
|
|
|
+ override val key: Any = entry.contentKey
|
|
|
|
|
+ override val entries: List<NavEntry<NavKey>> = listOf(entry)
|
|
|
|
|
+ override val overlaidEntries: List<NavEntry<NavKey>> = previousEntries
|
|
|
|
|
+ override val metadata: Map<String, Any> = entry.metadata
|
|
|
|
|
+ private val animation = animations.state(key, previousEntries.last().contentKey)
|
|
|
|
|
+
|
|
|
|
|
+ override val content: @Composable () -> Unit = {
|
|
|
|
|
+ LaunchedEffect(key) {
|
|
|
|
|
+ coroutineScope {
|
|
|
|
|
+ launch {
|
|
|
|
|
+ animation.progress.animateTo(
|
|
|
|
|
+ 1f,
|
|
|
|
|
+ tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ launch {
|
|
|
|
|
+ animation.opacity.animateTo(
|
|
|
|
|
+ 1f,
|
|
|
|
|
+ tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ launch {
|
|
|
|
|
+ animation.underlayOpacity.animateTo(
|
|
|
|
|
+ 0f,
|
|
|
|
|
+ tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ CompositionLocalProvider(LocalSearchOverlayAnimation provides animation) {
|
|
|
|
|
+ Box(
|
|
|
|
|
+ Modifier
|
|
|
|
|
+ .fillMaxSize()
|
|
|
|
|
+ .zIndex(previousEntries.size.toFloat())
|
|
|
|
|
+ .graphicsLayer {
|
|
|
|
|
+ translationX =
|
|
|
|
|
+ if (animation.removing) 0f else size.width * (1f - animation.progress.value)
|
|
|
|
|
+ scaleX =
|
|
|
|
|
+ if (animation.removing) 0.8f + 0.2f * animation.progress.value else 1f
|
|
|
|
|
+ scaleY = scaleX
|
|
|
|
|
+ alpha = animation.opacity.value
|
|
|
|
|
+ }
|
|
|
|
|
+ ) {
|
|
|
|
|
+ entry.Content()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override suspend fun onRemove() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ animation.removing = true
|
|
|
|
|
+ coroutineScope {
|
|
|
|
|
+ launch {
|
|
|
|
|
+ animation.progress.animateTo(
|
|
|
|
|
+ 0f,
|
|
|
|
|
+ tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ launch { animation.opacity.animateTo(0f, tween(NAV_FADE_DURATION_MILLIS)) }
|
|
|
|
|
+ launch {
|
|
|
|
|
+ animation.underlayOpacity.animateTo(
|
|
|
|
|
+ 1f,
|
|
|
|
|
+ tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ animations.release(key, animation)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+internal class SearchOverlayAnimation(val underlayKey: Any) {
|
|
|
|
|
+ val progress = Animatable(0f)
|
|
|
|
|
+ val opacity = Animatable(0f)
|
|
|
|
|
+ val underlayOpacity = Animatable(1f)
|
|
|
|
|
+ var removing by mutableStateOf(false)
|
|
|
|
|
+
|
|
|
|
|
+ suspend fun previewBack(progress: Float) {
|
|
|
|
|
+ removing = true
|
|
|
|
|
+ // 与 NavDisplay 的 predictivePopTransitionSpec 使用同一 easing。
|
|
|
|
|
+ val fraction = progress.coerceIn(0f, 1f)
|
|
|
|
|
+ val remaining = 1f - FastOutSlowInEasing.transform(fraction)
|
|
|
|
|
+ this.progress.snapTo(remaining)
|
|
|
|
|
+ opacity.snapTo(remaining)
|
|
|
|
|
+ underlayOpacity.snapTo(LinearOutSlowInEasing.transform(fraction))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ suspend fun cancelPreview() {
|
|
|
|
|
+ coroutineScope {
|
|
|
|
|
+ launch {
|
|
|
|
|
+ progress.animateTo(
|
|
|
|
|
+ 1f,
|
|
|
|
|
+ tween(NAV_SLIDE_DURATION_MILLIS, easing = FastOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ launch { opacity.animateTo(1f, tween(NAV_FADE_DURATION_MILLIS)) }
|
|
|
|
|
+ launch {
|
|
|
|
|
+ underlayOpacity.animateTo(
|
|
|
|
|
+ 0f,
|
|
|
|
|
+ tween(NAV_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ removing = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+internal val LocalSearchOverlayAnimation =
|
|
|
|
|
+ staticCompositionLocalOf<SearchOverlayAnimation?> { null }
|
|
|
|
|
+
|
|
|
|
|
+private class SearchOverlayAnimations {
|
|
|
|
|
+ // 旧阅读场景可能被 NavDisplay 复用;搜索入栈时必须使它重新读取覆盖动画。
|
|
|
|
|
+ private val byKey = mutableStateMapOf<Any, SearchOverlayAnimation>()
|
|
|
|
|
+
|
|
|
|
|
+ fun state(key: Any, underlayKey: Any): SearchOverlayAnimation =
|
|
|
|
|
+ byKey.getOrPut(key) { SearchOverlayAnimation(underlayKey) }
|
|
|
|
|
+
|
|
|
|
|
+ fun covering(key: Any): SearchOverlayAnimation? =
|
|
|
|
|
+ byKey.values.lastOrNull { it.underlayKey == key }
|
|
|
|
|
+
|
|
|
|
|
+ fun release(key: Any, animation: SearchOverlayAnimation) {
|
|
|
|
|
+ if (byKey[key] === animation) byKey.remove(key)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|