392 lines
13 KiB
Kotlin
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
|
|
}
|
|
} |