Shamrock/app/src/main/java/moe/fuqiuluo/shamrock/ui/tools/tabs.kt

392 lines
13 KiB
Kotlin

package moe.fuqiuluo.shamrock.ui.tools
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Indication
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.max
private enum class ColorSchemeKeyTokens {
Background,
Error,
ErrorContainer,
InverseOnSurface,
InversePrimary,
InverseSurface,
OnBackground,
OnError,
OnErrorContainer,
OnPrimary,
OnPrimaryContainer,
OnSecondary,
OnSecondaryContainer,
OnSurface,
OnSurfaceVariant,
OnTertiary,
OnTertiaryContainer,
Outline,
OutlineVariant,
Primary,
PrimaryContainer,
Scrim,
Secondary,
SecondaryContainer,
Surface,
SurfaceTint,
SurfaceVariant,
Tertiary,
TertiaryContainer,
}
private enum class ShapeKeyTokens {
CornerExtraLarge,
CornerExtraLargeTop,
CornerExtraSmall,
CornerExtraSmallTop,
CornerFull,
CornerLarge,
CornerLargeEnd,
CornerLargeTop,
CornerMedium,
CornerNone,
CornerSmall,
}
private enum class TypographyKeyTokens {
BodyLarge,
BodyMedium,
BodySmall,
DisplayLarge,
DisplayMedium,
DisplaySmall,
HeadlineLarge,
HeadlineMedium,
HeadlineSmall,
LabelLarge,
LabelMedium,
LabelSmall,
TitleLarge,
TitleMedium,
TitleSmall,
}
private object ElevationTokens {
val Level0 = 0.0.dp
val Level1 = 1.0.dp
val Level2 = 3.0.dp
val Level3 = 6.0.dp
val Level4 = 8.0.dp
val Level5 = 12.0.dp
}
private object PrimaryNavigationTabTokens {
val ActiveIndicatorColor = ColorSchemeKeyTokens.Primary
val ActiveIndicatorHeight = 3.0.dp
val ActiveIndicatorShape = RoundedCornerShape(3.0.dp)
val ContainerColor = ColorSchemeKeyTokens.Surface
val ContainerElevation = ElevationTokens.Level0
val ContainerHeight = 48.0.dp
val ContainerShape = ShapeKeyTokens.CornerNone
val DividerColor = ColorSchemeKeyTokens.SurfaceVariant
val DividerHeight = 1.0.dp
val ActiveFocusIconColor = ColorSchemeKeyTokens.Primary
val ActiveHoverIconColor = ColorSchemeKeyTokens.Primary
val ActiveIconColor = ColorSchemeKeyTokens.Primary
val ActivePressedIconColor = ColorSchemeKeyTokens.Primary
val IconAndLabelTextContainerHeight = 64.0.dp
val IconSize = 24.0.dp
val InactiveFocusIconColor = ColorSchemeKeyTokens.OnSurface
val InactiveHoverIconColor = ColorSchemeKeyTokens.OnSurface
val InactiveIconColor = ColorSchemeKeyTokens.OnSurfaceVariant
val InactivePressedIconColor = ColorSchemeKeyTokens.OnSurface
val ActiveFocusLabelTextColor = ColorSchemeKeyTokens.Primary
val ActiveHoverLabelTextColor = ColorSchemeKeyTokens.Primary
val ActiveLabelTextColor = ColorSchemeKeyTokens.Primary
val ActivePressedLabelTextColor = ColorSchemeKeyTokens.Primary
val InactiveFocusLabelTextColor = ColorSchemeKeyTokens.OnSurface
val InactiveHoverLabelTextColor = ColorSchemeKeyTokens.OnSurface
val InactiveLabelTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
val InactivePressedLabelTextColor = ColorSchemeKeyTokens.OnSurface
val LabelTextFont = TypographyKeyTokens.TitleSmall
}
private val HorizontalTextPadding = 16.dp
private val LargeTabHeight = 72.dp
private val SmallTabHeight = PrimaryNavigationTabTokens.ContainerHeight
private val IconDistanceFromBaseline = 20.sp
// Distance from the top of the indicator to the text baseline when there is one line of text and an
// icon
private val SingleLineTextBaselineWithIcon = 14.dp
// Distance from the top of the indicator to the last text baseline when there are two lines of text
// and an icon
private val DoubleLineTextBaselineWithIcon = 6.dp
@Composable
private fun TabBaselineLayout(
text: @Composable (() -> Unit)?,
icon: @Composable (() -> Unit)?
) {
Layout(
{
if (text != null) {
Box(
Modifier
.layoutId("text")
.padding(horizontal = HorizontalTextPadding)
) { text() }
}
if (icon != null) {
Box(Modifier.layoutId("icon")) { icon() }
}
}
) { measurables, constraints ->
val textPlaceable = text?.let {
measurables.first { it.layoutId == "text" }.measure(
// Measure with loose constraints for height as we don't want the text to take up more
// space than it needs
constraints.copy(minHeight = 0)
)
}
val iconPlaceable = icon?.let {
measurables.first { it.layoutId == "icon" }.measure(constraints)
}
val tabWidth = max(textPlaceable?.width ?: 0, iconPlaceable?.width ?: 0)
val specHeight = if (textPlaceable != null && iconPlaceable != null) {
LargeTabHeight
} else {
SmallTabHeight
}.roundToPx()
val tabHeight = max(
specHeight,
(iconPlaceable?.height ?: 0) + (textPlaceable?.height ?: 0) +
IconDistanceFromBaseline.roundToPx()
)
val firstBaseline = textPlaceable?.get(FirstBaseline)
val lastBaseline = textPlaceable?.get(LastBaseline)
layout(tabWidth, tabHeight) {
when {
textPlaceable != null && iconPlaceable != null -> placeTextAndIcon(
density = this@Layout,
textPlaceable = textPlaceable,
iconPlaceable = iconPlaceable,
tabWidth = tabWidth,
tabHeight = tabHeight,
firstBaseline = firstBaseline!!,
lastBaseline = lastBaseline!!
)
textPlaceable != null -> placeTextOrIcon(textPlaceable, tabHeight)
iconPlaceable != null -> placeTextOrIcon(iconPlaceable, tabHeight)
else -> {
}
}
}
}
}
private fun Placeable.PlacementScope.placeTextOrIcon(
textOrIconPlaceable: Placeable,
tabHeight: Int
) {
val contentY = (tabHeight - textOrIconPlaceable.height) / 2
textOrIconPlaceable.placeRelative(0, contentY)
}
private fun Placeable.PlacementScope.placeTextAndIcon(
density: Density,
textPlaceable: Placeable,
iconPlaceable: Placeable,
tabWidth: Int,
tabHeight: Int,
firstBaseline: Int,
lastBaseline: Int
) {
val baselineOffset = if (firstBaseline == lastBaseline) {
SingleLineTextBaselineWithIcon
} else {
DoubleLineTextBaselineWithIcon
}
// Total offset between the last text baseline and the bottom of the Tab layout
val textOffset = with(density) {
baselineOffset.roundToPx() + PrimaryNavigationTabTokens.ActiveIndicatorHeight.roundToPx()
}
// How much space there is between the top of the icon (essentially the top of this layout)
// and the top of the text layout's bounding box (not baseline)
val iconOffset = with(density) {
iconPlaceable.height + IconDistanceFromBaseline.roundToPx() - firstBaseline
}
val textPlaceableX = (tabWidth - textPlaceable.width) / 2
val textPlaceableY = tabHeight - lastBaseline - textOffset
textPlaceable.placeRelative(textPlaceableX, textPlaceableY)
val iconPlaceableX = (tabWidth - iconPlaceable.width) / 2
val iconPlaceableY = textPlaceableY - iconOffset
iconPlaceable.placeRelative(iconPlaceableX, iconPlaceableY)
}
@Composable
fun ShamrockTab(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
selectedContentColor: Color = LocalContentColor.current,
unselectedContentColor: Color = selectedContentColor,
indication: Indication? = rememberRipple(bounded = true, color = selectedContentColor),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val styledText: @Composable (() -> Unit)? = text?.let {
@Composable {
val style =
MaterialTheme.typography.fromToken(PrimaryNavigationTabTokens.LabelTextFont)
.copy(textAlign = TextAlign.Center)
ProvideTextStyle(style, content = text)
}
}
ShamrockTab(
selected,
onClick,
modifier,
enabled,
selectedContentColor,
unselectedContentColor,
interactionSource,
indication
) {
TabBaselineLayout(icon = icon, text = styledText)
}
}
@Composable
fun ShamrockTab(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
selectedContentColor: Color = LocalContentColor.current,
unselectedContentColor: Color = selectedContentColor,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = rememberRipple(bounded = true, color = selectedContentColor),
content: @Composable ColumnScope.() -> Unit
) {
// The color of the Ripple should always the selected color, as we want to show the color
// before the item is considered selected, and hence before the new contentColor is
// provided by TabTransition.
TabTransition(selectedContentColor, unselectedContentColor, selected) {
Column(
modifier = modifier
.selectable(
selected = selected,
onClick = onClick,
enabled = enabled,
role = Role.Tab,
interactionSource = interactionSource,
indication = indication
)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
content = content
)
}
}
private const val TabFadeInAnimationDuration = 150
private const val TabFadeInAnimationDelay = 100
private const val TabFadeOutAnimationDuration = 100
@Composable
private fun TabTransition(
activeColor: Color,
inactiveColor: Color,
selected: Boolean,
content: @Composable () -> Unit
) {
val transition = updateTransition(selected, label = "")
val color by transition.animateColor(
transitionSpec = {
if (false isTransitioningTo true) {
tween(
durationMillis = TabFadeInAnimationDuration,
delayMillis = TabFadeInAnimationDelay,
easing = LinearEasing
)
} else {
tween(
durationMillis = TabFadeOutAnimationDuration,
easing = LinearEasing
)
}
}, label = ""
) {
if (it) activeColor else inactiveColor
}
CompositionLocalProvider(
LocalContentColor provides color,
content = content
)
}
private fun Typography.fromToken(value: TypographyKeyTokens): TextStyle {
return when (value) {
TypographyKeyTokens.DisplayLarge -> displayLarge
TypographyKeyTokens.DisplayMedium -> displayMedium
TypographyKeyTokens.DisplaySmall -> displaySmall
TypographyKeyTokens.HeadlineLarge -> headlineLarge
TypographyKeyTokens.HeadlineMedium -> headlineMedium
TypographyKeyTokens.HeadlineSmall -> headlineSmall
TypographyKeyTokens.TitleLarge -> titleLarge
TypographyKeyTokens.TitleMedium -> titleMedium
TypographyKeyTokens.TitleSmall -> titleSmall
TypographyKeyTokens.BodyLarge -> bodyLarge
TypographyKeyTokens.BodyMedium -> bodyMedium
TypographyKeyTokens.BodySmall -> bodySmall
TypographyKeyTokens.LabelLarge -> labelLarge
TypographyKeyTokens.LabelMedium -> labelMedium
TypographyKeyTokens.LabelSmall -> labelSmall
}
}