[新增] 悬浮底栏和液态玻璃底栏

This commit is contained in:
HapeLee
2026-04-08 02:02:50 +08:00
parent a37e1ac62e
commit e38d96a6e5
16 changed files with 990 additions and 106 deletions
@@ -207,6 +207,8 @@ object PreferKey {
const val themePref = "themePref"
const val bookshelfSortOrder = "bookshelfSortOrder"
const val showBottomView = "showBottomView"
const val useFloatingBottomBar = "useFloatingBottomBar"
const val useFloatingBottomBarLiquidGlass = "useFloatingBottomBarLiquidGlass"
const val mangaScrollMode = "mangaScrollMode"
const val webtoonSidePaddingDp = "webtoonSidePaddingDp"
const val mangaVolumeKeyPage = "mangaVolumeKeyPage"
@@ -267,6 +269,7 @@ object PreferKey {
const val defaultSourceChangeAll = "defaultSourceChangeAll"
const val showTip = "showTip"
const val showBookCount = "showBookCount"
const val sliderVibrator = "sliderVibrator"
const val selectVibrator = "selectVibrator"
const val audioCacheCleanTime = "audioCacheCleanTime"
@@ -0,0 +1,142 @@
package io.legado.app.ui.animation
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.MutatorMutex
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.IntSize
import io.legado.app.ui.util.inspectDragGestures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs
class DampedDragAnimation(
private val animationScope: CoroutineScope,
val initialValue: Float,
val valueRange: ClosedRange<Float>,
val visibilityThreshold: Float,
val initialScale: Float,
val pressedScale: Float,
val canDrag: (Offset) -> Boolean = { true },
val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit,
val onDragStopped: DampedDragAnimation.() -> Unit,
val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit,
) {
private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold)
private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f)
private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f)
private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f)
private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f)
private val valueAnimation = Animatable(initialValue, visibilityThreshold)
private val velocityAnimation = Animatable(0f, 5f)
private val pressProgressAnimation = Animatable(0f, 0.001f)
private val scaleXAnimation = Animatable(initialScale, 0.001f)
private val scaleYAnimation = Animatable(initialScale, 0.001f)
private val mutatorMutex = MutatorMutex()
private val velocityTracker = VelocityTracker()
val value: Float get() = valueAnimation.value
val targetValue: Float get() = valueAnimation.targetValue
val pressProgress: Float get() = pressProgressAnimation.value
val scaleX: Float get() = scaleXAnimation.value
val scaleY: Float get() = scaleYAnimation.value
val velocity: Float get() = velocityAnimation.value
val modifier: Modifier = Modifier.pointerInput(Unit) {
inspectDragGestures(
onDragStart = { down ->
onDragStarted(down.position)
press()
},
onDragEnd = {
onDragStopped()
release()
},
onDragCancel = {
onDragStopped()
release()
}
) { change, dragAmount ->
val position = change.position
val previousPosition = change.previousPosition
val isInside = canDrag(position)
val wasInside = canDrag(previousPosition)
if (isInside && wasInside) {
onDrag(size, dragAmount)
}
}
}
fun press() {
velocityTracker.resetTracking()
animationScope.launch {
launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) }
}
}
fun release() {
animationScope.launch {
awaitFrame()
if (value != targetValue) {
val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f
snapshotFlow { valueAnimation.value }
.filter { abs(it - valueAnimation.targetValue) < threshold }
.first()
}
launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) }
}
}
fun updateValue(value: Float) {
val coercedTargetValue = value.coerceIn(valueRange)
animationScope.launch {
launch {
valueAnimation.animateTo(coercedTargetValue, valueAnimationSpec) {
updateVelocity()
}
}
}
}
fun animateToValue(value: Float) {
animationScope.launch {
mutatorMutex.mutate {
press()
val coercedTargetValue = value.coerceIn(valueRange)
launch { valueAnimation.animateTo(coercedTargetValue, valueAnimationSpec) }
if (velocity != 0f) {
launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
}
release()
}
}
}
private fun updateVelocity() {
velocityTracker.addPosition(
System.currentTimeMillis(),
Offset(value, 0f)
)
val targetVelocity =
velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start)
animationScope.launch {
velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec)
}
}
}
@@ -0,0 +1,109 @@
package io.legado.app.ui.animation
import android.annotation.SuppressLint
import android.graphics.RuntimeShader
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.util.fastCoerceIn
import io.legado.app.ui.util.inspectDragGestures
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.intellij.lang.annotations.Language
@SuppressLint("NewApi")
class InteractiveHighlight(
val animationScope: CoroutineScope,
val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset }
) {
private val pressProgressAnimationSpec = spring(0.5f, 300f, 0.001f)
private val positionAnimationSpec = spring(0.5f, 300f, Offset.VisibilityThreshold)
private val pressProgressAnimation = Animatable(0f, 0.001f)
private val positionAnimation =
Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold)
private var startPosition = Offset.Zero
@Language("AGSL")
private val shader = RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 position;
half4 main(float2 coord) {
float dist = distance(coord, position);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}
"""
)
val modifier: Modifier =
Modifier.drawWithContent {
val progress = pressProgressAnimation.value
if (progress > 0f) {
drawRect(
Color.White.copy(0.06f * progress),
blendMode = BlendMode.Plus
)
shader.apply {
val currentPosition = position(size, positionAnimation.value)
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.12f * progress).toArgb())
setFloatUniform("radius", size.minDimension * 1.2f)
setFloatUniform(
"position",
currentPosition.x.fastCoerceIn(0f, size.width),
currentPosition.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(shader),
blendMode = BlendMode.Plus
)
}
drawContent()
}
val gestureModifier: Modifier =
Modifier.pointerInput(animationScope) {
inspectDragGestures(
onDragStart = { down ->
startPosition = down.position
animationScope.launch {
launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
launch { positionAnimation.snapTo(startPosition) }
}
},
onDragEnd = {
animationScope.launch {
launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
}
},
onDragCancel = {
animationScope.launch {
launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
}
}
) { change, _ ->
animationScope.launch { positionAnimation.snapTo(change.position) }
}
}
}
@@ -7,6 +7,11 @@ object MainConfig {
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRSS by prefDelegate(PreferKey.showRss, true)
var showBottomView by prefDelegate(PreferKey.showBottomView, true)
var useFloatingBottomBar by prefDelegate(PreferKey.useFloatingBottomBar, false)
var useFloatingBottomBarLiquidGlass by prefDelegate(
PreferKey.useFloatingBottomBarLiquidGlass,
false
)
var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
@@ -72,6 +72,13 @@ object ThemeConfig {
var showBottomView by prefDelegate(PreferKey.showBottomView, true)
var useFloatingBottomBar by prefDelegate(PreferKey.useFloatingBottomBar, false)
var useFloatingBottomBarLiquidGlass by prefDelegate(
PreferKey.useFloatingBottomBarLiquidGlass,
false
)
var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")
@@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.BrightnessMedium
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -43,10 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.runtime.Composable
@@ -375,6 +371,20 @@ fun ThemeConfigScreen(
checked = ThemeConfig.showBottomView,
onCheckedChange = { ThemeConfig.showBottomView = it }
)
SwitchSettingItem(
title = stringResource(R.string.floating_bottom_bar),
description = stringResource(R.string.floating_bottom_bar_summary),
checked = ThemeConfig.useFloatingBottomBar,
onCheckedChange = { ThemeConfig.useFloatingBottomBar = it }
)
AnimatedVisibility(visible = ThemeConfig.useFloatingBottomBar) {
SwitchSettingItem(
title = stringResource(R.string.floating_bottom_bar_liquid_glass),
description = stringResource(R.string.floating_bottom_bar_liquid_glass_summary),
checked = ThemeConfig.useFloatingBottomBarLiquidGlass,
onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it }
)
}
DropdownListSettingItem(
title = stringResource(R.string.tabletInterface),
selectedValue = ThemeConfig.tabletInterface,
@@ -1,68 +1,33 @@
package io.legado.app.ui.main
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.R
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Album
import top.yukonga.miuix.kmp.icon.extended.ContactsBook
import top.yukonga.miuix.kmp.icon.extended.Favorites
import top.yukonga.miuix.kmp.icon.extended.Settings
sealed class MainDestination(
val route: String,
@StringRes val labelId: Int,
val m3Icon: ImageVector,
val m3SelectedIcon: ImageVector,
val miuixIcon: ImageVector,
val miuixSelectedIcon: ImageVector
@StringRes val labelId: Int
) {
object Bookshelf : MainDestination(
route = "bookshelf",
labelId = R.string.bookshelf,
m3Icon = Icons.AutoMirrored.Outlined.LibraryBooks,
m3SelectedIcon = Icons.AutoMirrored.Filled.LibraryBooks,
miuixIcon = MiuixIcons.Regular.ContactsBook, // 替换为实际的 MIUIX 线框图标
miuixSelectedIcon = MiuixIcons.Heavy.ContactsBook // 替换为实际的 MIUIX 填充图标
labelId = R.string.bookshelf
)
object Explore : MainDestination(
route = "explore",
labelId = R.string.discovery,
m3Icon = Icons.Outlined.Explore,
m3SelectedIcon = Icons.Default.Explore,
miuixIcon = MiuixIcons.Regular.Album,
miuixSelectedIcon = MiuixIcons.Heavy.Album
labelId = R.string.discovery
)
object Rss : MainDestination(
route = "rss",
labelId = R.string.rss,
m3Icon = Icons.Outlined.RssFeed,
m3SelectedIcon = Icons.Default.RssFeed,
miuixIcon = MiuixIcons.Regular.Favorites,
miuixSelectedIcon = MiuixIcons.Heavy.Favorites
labelId = R.string.rss
)
object My : MainDestination(
route = "my",
labelId = R.string.my,
m3Icon = Icons.Outlined.Person,
m3SelectedIcon = Icons.Default.Person,
miuixIcon = MiuixIcons.Regular.Settings,
miuixSelectedIcon = MiuixIcons.Heavy.Settings
labelId = R.string.my
)
companion object {
val mainDestinations = listOf(Bookshelf, Explore, Rss, My)
}
}
}
@@ -1,14 +1,21 @@
package io.legado.app.ui.main
import android.os.Build
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
@@ -39,15 +46,18 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.search.SearchActivity
@@ -57,11 +67,17 @@ import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreScreen
import io.legado.app.ui.main.my.MyScreen
import io.legado.app.ui.main.rss.RssScreen
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.regularHazeEffect
import io.legado.app.ui.theme.responsiveHazeSource
import io.legado.app.ui.widget.components.AppNavigationBar
import io.legado.app.ui.widget.components.AppNavigationBarItem
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.FloatingBottomBar
import io.legado.app.ui.widget.components.FloatingBottomBarItem
import io.legado.app.ui.widget.components.GlassDefaults
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
@@ -88,6 +104,11 @@ fun MainScreen(
val bookshelfUiState by bookshelfViewModel.uiState.collectAsState()
val hazeState = remember { HazeState() }
val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
val floatingBarBackdrop = rememberLayerBackdrop {
drawRect(floatingBarSurfaceColor)
drawContent()
}
val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) {
MainDestination.mainDestinations.filter {
when (it) {
@@ -103,6 +124,15 @@ fun MainScreen(
if (index != -1) index else 0
}
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
val labelVisibilityMode = MainConfig.labelVisibilityMode
val isUnlabeled = labelVisibilityMode == "unlabeled"
val useFloatingBottomBar =
!useRail && MainConfig.showBottomView && MainConfig.useFloatingBottomBar
val useLiquidGlass = useFloatingBottomBar &&
MainConfig.useFloatingBottomBarLiquidGlass &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val alwaysShowLabel = labelVisibilityMode == "labeled"
val showLabel = !isUnlabeled
val navState = rememberWideNavigationRailState(
initialValue = if (MainConfig.navExtended)
@@ -230,51 +260,116 @@ fun MainScreen(
}
AppScaffold(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.responsiveHazeSource(hazeState),
bottomBar = {
if (!useRail && MainConfig.showBottomView) {
val labelVisibilityMode = MainConfig.labelVisibilityMode
val isUnlabeled = labelVisibilityMode == "unlabeled"
AppNavigationBar(
modifier = Modifier
.regularHazeEffect(state = hazeState)
.height(if (isUnlabeled) 64.dp else 80.dp)
) {
val alwaysShowLabel = when (labelVisibilityMode) {
"labeled" -> true
else -> false
}
destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index
AppNavigationBarItem(
selected = selected,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
labelString = stringResource(destination.labelId),
miuixIcon = if (selected) destination.miuixSelectedIcon else destination.miuixIcon,
m3Icon = {
NavigationIcon(
destination = destination,
selected = selected,
upBooksCount = uiState.upBooksCount
if (useFloatingBottomBar) {
Box(modifier = Modifier.fillMaxWidth()) {
FloatingBottomBar(
modifier = Modifier
.align(Alignment.BottomCenter)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}
)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 12.dp + WindowInsets.navigationBars
.asPaddingValues()
.calculateBottomPadding()
),
selectedIndex = { pagerState.currentPage },
onSelected = { index ->
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
m3IndicatorColor = GlassDefaults.glassColor(
noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
blurAlpha = GlassDefaults.ThickBlurAlpha
),
m3ShowLabel = !isUnlabeled,
m3AlwaysShowLabel = alwaysShowLabel
)
backdrop = floatingBarBackdrop,
tabsCount = destinations.size,
isBlurEnabled = useLiquidGlass
) {
destinations.forEachIndexed { index, destination ->
val selected = pagerState.currentPage == index
FloatingBottomBarItem(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
modifier = Modifier.defaultMinSize(minWidth = 76.dp)
) {
NavigationIcon(
destination = destination,
selected = selected,
upBooksCount = uiState.upBooksCount
)
if (showLabel && (alwaysShowLabel || selected)) {
Text(
text = stringResource(destination.labelId),
style = MaterialTheme.typography.labelSmall,
color = if (selected) {
LegadoTheme.colorScheme.primary
} else {
LegadoTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
} else {
AppNavigationBar(
modifier = Modifier
.regularHazeEffect(state = hazeState)
.height(if (isUnlabeled) 64.dp else 80.dp)
) {
destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index
AppNavigationBarItem(
selected = selected,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
labelString = stringResource(destination.labelId),
iconVector = AppIcons.mainDestination(destination, selected),
m3Icon = {
NavigationIcon(
destination = destination,
selected = selected,
upBooksCount = uiState.upBooksCount
)
},
m3IndicatorColor = GlassDefaults.glassColor(
noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
blurAlpha = GlassDefaults.ThickBlurAlpha
),
m3ShowLabel = showLabel,
m3AlwaysShowLabel = alwaysShowLabel
)
}
}
}
}
},
contentWindowInsets = WindowInsets(0)
) { _ ->
Box(modifier = Modifier.hazeSource(hazeState)) {
Box(
modifier = Modifier
.then(
if (useFloatingBottomBar) {
Modifier.layerBackdrop(floatingBarBackdrop)
} else {
Modifier
}
)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
@@ -319,14 +414,14 @@ private fun NavigationIcon(
upBooksCount: Int,
modifier: Modifier = Modifier
) {
val icon = if (selected) destination.m3SelectedIcon else destination.m3Icon
val icon = AppIcons.mainDestination(destination, selected)
Box(modifier = modifier) {
if (destination == MainDestination.Bookshelf && upBooksCount > 0) {
BadgedBox(badge = { Badge { Text(upBooksCount.toString()) } }) {
Icon(icon, contentDescription = null)
AppIcon(icon, contentDescription = null)
}
} else {
Icon(icon, contentDescription = null)
AppIcon(icon, contentDescription = null)
}
}
}
@@ -0,0 +1,82 @@
package io.legado.app.ui.util
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.util.fastFirstOrNull
suspend fun PointerInputScope.inspectDragGestures(
onDragStart: (down: PointerInputChange) -> Unit = {},
onDragEnd: (change: PointerInputChange) -> Unit = {},
onDragCancel: () -> Unit = {},
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
awaitEachGesture {
val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
val down = awaitFirstDown(false)
onDragStart(down)
onDrag(initialDown, Offset.Zero)
val upEvent = drag(
pointerId = initialDown.id,
onDrag = { onDrag(it, it.positionChange()) }
)
if (upEvent == null) {
onDragCancel()
} else {
onDragEnd(upEvent)
}
}
}
private suspend inline fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit
): PointerInputChange? {
val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
if (isPointerUp) {
return null
}
var pointer = pointerId
while (true) {
val change = awaitDragOrUp(pointer) ?: return null
if (change.isConsumed) {
return null
}
if (change.changedToUpIgnoreConsumed()) {
return change
}
onDrag(change)
pointer = change.id
}
}
private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
pointerId: PointerId
): PointerInputChange? {
var pointer = pointerId
while (true) {
val event = awaitPointerEvent()
val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
if (dragEvent.changedToUpIgnoreConsumed()) {
val otherDown = event.changes.fastFirstOrNull { it.pressed }
if (otherDown == null) {
return dragEvent
} else {
pointer = otherDown.id
}
} else {
val hasDragged = dragEvent.previousPosition != dragEvent.position
if (hasDragged) {
return dragEvent
}
}
}
}
@@ -61,7 +61,7 @@ fun RowScope.AppNavigationBarItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
labelString: String,
miuixIcon: ImageVector,
iconVector: ImageVector,
m3Icon: @Composable () -> Unit,
m3IndicatorColor: Color,
m3ShowLabel: Boolean,
@@ -73,7 +73,7 @@ fun RowScope.AppNavigationBarItem(
MiuixNavigationBarItem(
selected = selected,
onClick = onClick,
icon = miuixIcon,
icon = iconVector,
label = labelString,
modifier = modifier
)
@@ -90,4 +90,4 @@ fun RowScope.AppNavigationBarItem(
alwaysShowLabel = m3AlwaysShowLabel
)
}
}
}
@@ -0,0 +1,419 @@
// Portions of this file are derived from weishu/KernelSU
// (https://github.com/tiann/KernelSU)
// Copyright (C) KernelSU contributors
// Licensed under GPL-3.0
package io.legado.app.ui.widget.components
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import com.kyant.capsule.ContinuousCapsule
import io.legado.app.ui.animation.DampedDragAnimation
import io.legado.app.ui.animation.InteractiveHighlight
import io.legado.app.ui.theme.LegadoTheme
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign
val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } }
@Composable
fun RowScope.FloatingBottomBarItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scale = LocalFloatingBottomBarTabScale.current
Column(
modifier
.clip(ContinuousCapsule)
.clickable(
interactionSource = null,
indication = null,
role = Role.Tab,
onClick = onClick
)
.fillMaxHeight()
.weight(1f)
.graphicsLayer {
val currentScale = scale()
scaleX = currentScale
scaleY = currentScale
},
verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
content = content
)
}
@Composable
fun FloatingBottomBar(
modifier: Modifier = Modifier,
selectedIndex: () -> Int,
onSelected: (index: Int) -> Unit,
backdrop: Backdrop,
tabsCount: Int,
isBlurEnabled: Boolean = true,
content: @Composable RowScope.() -> Unit
) {
val isInLightTheme = !LegadoTheme.isDark
val accentColor = LegadoTheme.colorScheme.primary
val containerColor = if (isBlurEnabled) {
LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.4f)
} else {
LegadoTheme.colorScheme.surfaceContainer
}
val tabsBackdrop = rememberLayerBackdrop()
val density = LocalDensity.current
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val animationScope = rememberCoroutineScope()
var tabWidthPx by remember { mutableFloatStateOf(0f) }
var totalWidthPx by remember { mutableFloatStateOf(0f) }
val offsetAnimation = remember { Animatable(0f) }
val panelOffset by remember(density) {
derivedStateOf {
if (totalWidthPx == 0f) {
0f
} else {
val fraction = (offsetAnimation.value / totalWidthPx).fastCoerceIn(-1f, 1f)
with(density) {
4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction))
}
}
}
}
var currentIndex by remember(selectedIndex) { mutableIntStateOf(selectedIndex()) }
class DampedDragAnimationHolder {
var instance: DampedDragAnimation? = null
}
val holder = remember { DampedDragAnimationHolder() }
val dampedDragAnimation = remember(animationScope, tabsCount, density, isLtr) {
DampedDragAnimation(
animationScope = animationScope,
initialValue = selectedIndex().toFloat(),
valueRange = 0f..(tabsCount - 1).toFloat(),
visibilityThreshold = 0.001f,
initialScale = 1f,
pressedScale = 78f / 56f,
canDrag = { offset ->
val anim = holder.instance ?: return@DampedDragAnimation true
if (tabWidthPx == 0f) return@DampedDragAnimation false
val currentValue = anim.value
val indicatorX = currentValue * tabWidthPx
val padding = with(density) { 4.dp.toPx() }
val globalTouchX = if (isLtr) {
val touchX = indicatorX + offset.x
padding + touchX
} else {
totalWidthPx - padding - tabWidthPx - indicatorX + offset.x
}
globalTouchX in 0f..totalWidthPx
},
onDragStarted = {},
onDragStopped = {
val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
currentIndex = targetIndex
animateToValue(targetIndex.toFloat())
animationScope.launch {
offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
}
},
onDrag = { _, dragAmount ->
if (tabWidthPx > 0f) {
updateValue(
(targetValue + dragAmount.x / tabWidthPx * if (isLtr) 1f else -1f)
.fastCoerceIn(0f, (tabsCount - 1).toFloat())
)
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
}
}
}
).also { holder.instance = it }
}
LaunchedEffect(selectedIndex) {
snapshotFlow { selectedIndex() }.collectLatest { currentIndex = it }
}
LaunchedEffect(dampedDragAnimation) {
snapshotFlow { currentIndex }.drop(1).collectLatest { index ->
dampedDragAnimation.animateToValue(index.toFloat())
onSelected(index)
}
}
val interactiveHighlight =
if (isBlurEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
remember(animationScope, tabWidthPx) {
InteractiveHighlight(
animationScope = animationScope,
position = { size, _ ->
Offset(
if (isLtr) {
(dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset
} else {
size.width - (dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset
},
size.height / 2f
)
}
)
}
} else {
null
}
Box(
modifier = modifier.width(IntrinsicSize.Min),
contentAlignment = Alignment.CenterStart
) {
Row(
Modifier
.onGloballyPositioned { coords ->
totalWidthPx = coords.size.width.toFloat()
val contentWidthPx = totalWidthPx - with(density) { 8.dp.toPx() }
tabWidthPx = contentWidthPx / tabsCount
}
.graphicsLayer { translationX = panelOffset }
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}
)
.drawBackdrop(
backdrop = backdrop,
shape = { ContinuousCapsule },
effects = {
if (isBlurEnabled) {
vibrancy()
blur(8f.dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx())
}
},
highlight = {
Highlight.Default.copy(alpha = if (isBlurEnabled) 1f else 0f)
},
shadow = {
Shadow.Default.copy(
color = Color.Black.copy(if (isInLightTheme) 0.1f else 0.2f)
)
},
layerBlock = {
if (isBlurEnabled) {
val progress = dampedDragAnimation.pressProgress
val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
scaleX = scale
scaleY = scale
}
},
onDrawSurface = { drawRect(containerColor) }
)
.then(
if (isBlurEnabled && interactiveHighlight != null) {
interactiveHighlight.modifier
} else {
Modifier
}
)
.height(64.dp)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content
)
CompositionLocalProvider(
LocalFloatingBottomBarTabScale provides {
if (isBlurEnabled) {
lerp(1f, 1.2f, dampedDragAnimation.pressProgress)
} else {
1f
}
}
) {
Row(
Modifier
.clearAndSetSemantics {}
.alpha(0f)
.layerBackdrop(tabsBackdrop)
.graphicsLayer { translationX = panelOffset }
.drawBackdrop(
backdrop = backdrop,
shape = { ContinuousCapsule },
effects = {
if (isBlurEnabled) {
val progress = dampedDragAnimation.pressProgress
vibrancy()
blur(8f.dp.toPx())
lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
}
},
highlight = {
Highlight.Default.copy(
alpha = if (isBlurEnabled) {
dampedDragAnimation.pressProgress
} else {
0f
}
)
},
onDrawSurface = { drawRect(containerColor) }
)
.then(
if (isBlurEnabled && interactiveHighlight != null) {
interactiveHighlight.modifier
} else {
Modifier
}
)
.height(56.dp)
.padding(horizontal = 4.dp)
.graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
if (tabWidthPx > 0f) {
Box(
Modifier
.padding(horizontal = 4.dp)
.graphicsLayer {
val contentWidth = totalWidthPx - with(density) { 8.dp.toPx() }
val singleTabWidth = contentWidth / tabsCount
val progressOffset = dampedDragAnimation.value * singleTabWidth
translationX = if (isLtr) {
progressOffset + panelOffset
} else {
-progressOffset + panelOffset
}
}
.then(
if (isBlurEnabled && interactiveHighlight != null) {
interactiveHighlight.gestureModifier
} else {
Modifier
}
)
.then(dampedDragAnimation.modifier)
.drawBackdrop(
backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
shape = { ContinuousCapsule },
effects = {
if (isBlurEnabled) {
val progress = dampedDragAnimation.pressProgress
lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, true)
}
},
highlight = {
Highlight.Default.copy(
alpha = if (isBlurEnabled) {
dampedDragAnimation.pressProgress
} else {
0f
}
)
},
shadow = {
Shadow(alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f)
},
innerShadow = {
InnerShadow(
radius = 8f.dp * dampedDragAnimation.pressProgress,
alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
)
},
layerBlock = {
if (isBlurEnabled) {
scaleX = dampedDragAnimation.scaleX
scaleY = dampedDragAnimation.scaleY
val velocity = dampedDragAnimation.velocity / 10f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
}
},
onDrawSurface = {
val progress =
if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
drawRect(
color = if (isInLightTheme) {
Color.Black.copy(0.1f)
} else {
Color.White.copy(0.1f)
},
alpha = 1f - progress
)
drawRect(Color.Black.copy(alpha = 0.03f * progress))
}
)
.height(56.dp)
.width(with(density) { ((totalWidthPx - 8.dp.toPx()) / tabsCount).toDp() })
)
}
}
}
@@ -2,22 +2,32 @@ package io.legado.app.ui.widget.components.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.RssFeed
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.main.MainDestination
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.extended.Album
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.Close
import top.yukonga.miuix.kmp.icon.extended.ContactsBook
import top.yukonga.miuix.kmp.icon.extended.Favorites
import top.yukonga.miuix.kmp.icon.extended.Refresh
import top.yukonga.miuix.kmp.icon.extended.Settings
/**
* 应用全局图标映射层
*/
object AppIcons {
private val isMiuix: Boolean
@@ -26,29 +36,46 @@ object AppIcons {
val Search: ImageVector
@Composable
get() = if (isMiuix) {
MiuixIcons.Basic.Search
} else {
Icons.Default.Search
}
get() = if (isMiuix) MiuixIcons.Basic.Search else Icons.Default.Search
val Close: ImageVector
@Composable
get() = if (isMiuix) {
MiuixIcons.Close
} else {
Icons.Default.Clear
}
get() = if (isMiuix) MiuixIcons.Close else Icons.Default.Clear
val Back: ImageVector
@Composable
get() = if (isMiuix) {
MiuixIcons.Back
} else {
Icons.AutoMirrored.Filled.ArrowBack
}
get() = if (isMiuix) MiuixIcons.Back else Icons.AutoMirrored.Filled.ArrowBack
val Replay: ImageVector
@Composable
get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay
}
@Composable
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
return when (destination) {
MainDestination.Bookshelf -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.ContactsBook else MiuixIcons.Regular.ContactsBook
} else {
if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks
}
MainDestination.Explore -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Album else MiuixIcons.Regular.Album
} else {
if (selected) Icons.Default.Explore else Icons.Outlined.Explore
}
MainDestination.Rss -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Favorites else MiuixIcons.Regular.Favorites
} else {
if (selected) Icons.Default.RssFeed else Icons.Outlined.RssFeed
}
MainDestination.My -> if (isMiuix) {
if (selected) MiuixIcons.Heavy.Settings else MiuixIcons.Regular.Settings
} else {
if (selected) Icons.Default.Person else Icons.Outlined.Person
}
}
}
}