refactor: 优化阅读界面菜单切换page时的高度过渡动画

This commit is contained in:
fansan
2026-06-13 16:48:47 +08:00
parent 318bef3649
commit 5f83446a15
4 changed files with 237 additions and 62 deletions
@@ -613,6 +613,7 @@ private fun ReadBookMenuSurface(
title = stringResource(R.string.read_config),
maxHeight = maxHeight,
bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
animateSize = false,
onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
) {
ReadStyleContent(
@@ -762,6 +763,7 @@ private fun ReadBookMenuRoutePage(
maxHeight: Dp,
scrollContent: Boolean = false,
bottomPadding: Dp = 0.dp,
animateSize: Boolean = true,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
@@ -769,7 +771,7 @@ private fun ReadBookMenuRoutePage(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxHeight)
.animateContentSize()
.let { if (animateSize) it.animateContentSize() else it }
.padding(top = 16.dp, bottom = 16.dp + bottomPadding),
) {
Row(
@@ -1,5 +1,8 @@
package io.legado.app.ui.book.read.sheet
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,20 +18,26 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
@@ -39,7 +48,6 @@ import io.legado.app.ui.book.read.ConfigUpdate
import io.legado.app.ui.book.read.ReadBookIntent
import io.legado.app.ui.widget.components.FontSelectSheet
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import org.koin.compose.koinInject
import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
@@ -48,6 +56,7 @@ import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
import io.legado.app.ui.widget.components.tabRow.CardTabRow
import io.legado.app.utils.getCompatColor
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
// Color picker IDs
private const val COLOR_HEADER = 7
@@ -70,6 +79,34 @@ internal fun HeaderFooterPage(
val pagerState = rememberPagerState(pageCount = { 3 })
var selectedTab by remember { mutableIntStateOf(0) }
val pageHeights = remember { mutableStateMapOf<Int, Int>() }
val density = LocalDensity.current
val animatedHeight by remember(pagerState, density) {
derivedStateOf {
val pageCount = pagerState.pageCount
val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(0f, (pageCount - 1).coerceAtLeast(0).toFloat())
val floorPage = position.toInt().coerceIn(0, pageCount - 1)
val ceilPage = (floorPage + 1).coerceIn(0, pageCount - 1)
val floorHeight = pageHeights[floorPage] ?: 0
val ceilHeight = pageHeights[ceilPage] ?: floorHeight
val fraction = position - floorPage
val startHeight = if (floorHeight > 0) floorHeight else (pageHeights.values.firstOrNull() ?: 0)
val endHeight = if (ceilHeight > 0) ceilHeight else startHeight
val interpolated = startHeight + (endHeight - startHeight) * fraction
if (interpolated > 0) {
with(density) { interpolated.toDp() }
} else {
Dp.Unspecified
}
}
}
// Header state
var headerMode by remember { mutableIntStateOf(ReadBookConfig.headerMode) }
var headerLeft by remember { mutableIntStateOf(ReadBookConfig.tipHeaderLeft) }
@@ -138,15 +175,31 @@ internal fun HeaderFooterPage(
selectedTabIndex = selectedTab,
onTabSelected = { index ->
selectedTab = index
scope.launch { pagerState.animateScrollToPage(index) }
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
)
}
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
)
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.clipToBounds()
.pagerHeight(animatedHeight),
) { page ->
when (page) {
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
pageHeights[page] = size.height
}
) {
when (page) {
0 -> {
// Header tab
Column(
@@ -372,6 +425,7 @@ internal fun HeaderFooterPage(
}
}
}
}
// Color picker
ColorPickerSheet(
@@ -1,6 +1,8 @@
package io.legado.app.ui.book.read.sheet
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -8,14 +10,22 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.clipToBounds
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.book.read.ConfigUpdate
@@ -44,6 +54,34 @@ fun ReadStyleContent(
val pagerState = rememberPagerState(pageCount = { 3 })
var currentPage by remember { mutableIntStateOf(0) }
val pageHeights = remember { mutableStateMapOf<Int, Int>() }
val density = LocalDensity.current
val animatedHeight by remember(pagerState, density) {
derivedStateOf {
val pageCount = pagerState.pageCount
val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(0f, (pageCount - 1).coerceAtLeast(0).toFloat())
val floorPage = position.toInt().coerceIn(0, pageCount - 1)
val ceilPage = (floorPage + 1).coerceIn(0, pageCount - 1)
val floorHeight = pageHeights[floorPage] ?: 0
val ceilHeight = pageHeights[ceilPage] ?: floorHeight
val fraction = position - floorPage
val startHeight = if (floorHeight > 0) floorHeight else (pageHeights.values.firstOrNull() ?: 0)
val endHeight = if (ceilHeight > 0) ceilHeight else startHeight
val interpolated = startHeight + (endHeight - startHeight) * fraction
if (interpolated > 0) {
with(density) { interpolated.toDp() }
} else {
Dp.Unspecified
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
currentPage = page
@@ -57,35 +95,46 @@ fun ReadStyleContent(
) {
HorizontalPager(
state = pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.weight(1f, fill = false)
.animateContentSize(),
.clipToBounds()
.pagerHeight(animatedHeight),
) { page ->
when (page) {
0 -> GlobalThemePage(
onToggleDayNight = onToggleDayNight,
onOpenBgTextConfig = onOpenBgTextConfig,
onOpenTextTitle = onOpenTextTitle,
onOpenPaddingConfig = onOpenPaddingConfig,
onShareLayoutChange = { shareLayout ->
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(shareLayout)))
},
onStyleSelect = { index ->
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StyleSelect(index)))
},
modifier = Modifier.padding(horizontal = 16.dp),
onIntent = onIntent,
styleConfig = styleConfig,
)
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
pageHeights[page] = size.height
}
) {
when (page) {
0 -> GlobalThemePage(
onToggleDayNight = onToggleDayNight,
onOpenBgTextConfig = onOpenBgTextConfig,
onOpenTextTitle = onOpenTextTitle,
onOpenPaddingConfig = onOpenPaddingConfig,
onShareLayoutChange = { shareLayout ->
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(shareLayout)))
},
onStyleSelect = { index ->
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StyleSelect(index)))
},
modifier = Modifier.padding(horizontal = 16.dp),
onIntent = onIntent,
styleConfig = styleConfig,
)
1 -> SystemMenuPage(
customIcons = readMenuCustomIcons,
bottomBarButtons = bottomBarButtons,
onIntent = onIntent,
)
2 -> HeaderFooterPage(
onIntent = onIntent,
)
1 -> SystemMenuPage(
customIcons = readMenuCustomIcons,
bottomBarButtons = bottomBarButtons,
onIntent = onIntent,
)
2 -> HeaderFooterPage(
onIntent = onIntent,
)
}
}
}
@@ -100,7 +149,12 @@ fun ReadStyleContent(
selectedTabIndex = currentPage,
onTabSelected = { index ->
if (index < 3) {
scope.launch { pagerState.animateScrollToPage(index) }
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
)
}
} else {
onOpenMoreConfig()
}
@@ -109,3 +163,11 @@ fun ReadStyleContent(
)
}
}
internal fun Modifier.pagerHeight(height: Dp) = this.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val layoutHeight = if (height != Dp.Unspecified) height.roundToPx() else placeable.height
layout(placeable.width, layoutHeight) {
placeable.placeRelative(0, 0)
}
}
@@ -2,6 +2,8 @@ package io.legado.app.ui.book.read.sheet
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -24,20 +26,28 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Translate
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.clipToBounds
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
@@ -89,6 +99,34 @@ internal fun SystemMenuPage(
val pagerState = rememberPagerState(pageCount = { 3 })
var selectedTab by remember { mutableIntStateOf(0) }
val pageHeights = remember { mutableStateMapOf<Int, Int>() }
val density = LocalDensity.current
val animatedHeight by remember(pagerState, density) {
derivedStateOf {
val pageCount = pagerState.pageCount
val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(0f, (pageCount - 1).coerceAtLeast(0).toFloat())
val floorPage = position.toInt().coerceIn(0, pageCount - 1)
val ceilPage = (floorPage + 1).coerceIn(0, pageCount - 1)
val floorHeight = pageHeights[floorPage] ?: 0
val ceilHeight = pageHeights[ceilPage] ?: floorHeight
val fraction = position - floorPage
val startHeight = if (floorHeight > 0) floorHeight else (pageHeights.values.firstOrNull() ?: 0)
val endHeight = if (ceilHeight > 0) ceilHeight else startHeight
val interpolated = startHeight + (endHeight - startHeight) * fraction
if (interpolated > 0) {
with(density) { interpolated.toDp() }
} else {
Dp.Unspecified
}
}
}
// Shared state for sheets
var showColorPicker by remember { mutableStateOf(false) }
var colorPickerId by remember { mutableIntStateOf(0) }
@@ -112,39 +150,58 @@ internal fun SystemMenuPage(
selectedTabIndex = selectedTab,
onTabSelected = { index ->
selectedTab = index
scope.launch { pagerState.animateScrollToPage(index) }
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
)
}
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
)
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f, fill = false),
verticalAlignment = Alignment.Top,
modifier = Modifier
.weight(1f, fill = false)
.clipToBounds()
.pagerHeight(animatedHeight),
) { page ->
when (page) {
0 -> GlobalMenuTab(
preferences = preferences,
readBarStyle = preferences.readBarStyle,
readMenuColorMode = preferences.readMenuColorMode,
onIntent = onIntent,
onShowColorPicker = { id, initial ->
colorPickerId = id
colorPickerInitial = initial
showColorPicker = true
},
)
1 -> BottomBarTab(
preferences = preferences,
customIcons = customIcons,
onIntent = onIntent,
onShowIconSheet = { showIconSheet = true },
onShowColorPicker = { id, initial ->
colorPickerId = id
colorPickerInitial = initial
showColorPicker = true
},
)
2 -> TopBarTab(preferences = preferences, onIntent = onIntent)
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
pageHeights[page] = size.height
}
) {
when (page) {
0 -> GlobalMenuTab(
preferences = preferences,
readBarStyle = preferences.readBarStyle,
readMenuColorMode = preferences.readMenuColorMode,
onIntent = onIntent,
onShowColorPicker = { id, initial ->
colorPickerId = id
colorPickerInitial = initial
showColorPicker = true
},
)
1 -> BottomBarTab(
preferences = preferences,
customIcons = customIcons,
onIntent = onIntent,
onShowIconSheet = { showIconSheet = true },
onShowColorPicker = { id, initial ->
colorPickerId = id
colorPickerInitial = initial
showColorPicker = true
},
)
2 -> TopBarTab(preferences = preferences, onIntent = onIntent)
}
}
}
}