1
0

chore(core/ui): add components

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-01-17 08:52:34 -05:00
parent f35f4f7983
commit bf2b9cbb39
4 changed files with 624 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.paint
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toolingGraphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import io.visus.orbis.core.ui.LocalContentColor
@Composable
fun Icon(
imageVector: ImageVector,
modifier: Modifier = Modifier,
contentDescription: String? = null,
tint: Color = LocalContentColor.current,
) {
Icon(
painter = rememberVectorPainter(imageVector),
contentDescription = contentDescription,
modifier = modifier,
tint = tint,
)
}
@Composable
fun Icon(
bitmap: ImageBitmap,
modifier: Modifier = Modifier,
contentDescription: String? = null,
tint: Color = LocalContentColor.current,
) {
val painter = remember(bitmap) { BitmapPainter(bitmap) }
Icon(
painter = painter,
contentDescription = contentDescription,
modifier = modifier,
tint = tint,
)
}
@Composable
fun Icon(
painter: Painter,
modifier: Modifier = Modifier,
contentDescription: String? = null,
tint: Color = LocalContentColor.current,
) {
val colorFilter = if (tint == Color.Unspecified) null else ColorFilter.tint(tint)
val semantics =
if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
Box(
modifier
.toolingGraphicsLayer()
.defaultSizeFor(painter)
.paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit)
.then(semantics),
)
}
private fun Modifier.defaultSizeFor(painter: Painter) =
this.then(
if (painter.intrinsicSize == Size.Unspecified || painter.intrinsicSize.isInfinite()) {
DefaultIconSizeModifier
} else {
Modifier
},
)
private fun Size.isInfinite() = width.isInfinite() && height.isInfinite()
private val DefaultIconSizeModifier = Modifier.size(IconDefaults.iconSize)
internal object IconDefaults {
val iconSize = 24.dp
}

View File

@@ -0,0 +1,318 @@
package io.visus.orbis.core.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.visus.orbis.core.ui.OrbisTheme
import io.visus.orbis.core.ui.LocalContentColor
import io.visus.orbis.core.ui.components.NavigationBarDefaults.NavigationBarHeight
import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.ItemAnimationDurationMillis
import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.NavigationBarItemHorizontalPadding
import io.visus.orbis.core.ui.components.NavigationBarItemDefaults.NavigationBarItemVerticalPadding
import io.visus.orbis.core.ui.contentColorFor
import io.visus.orbis.core.ui.foundation.ProvideTextStyle
import io.visus.orbis.core.ui.foundation.systemBarsForVisualComponents
import kotlin.math.roundToInt
@Composable
fun NavigationBar(
modifier: Modifier = Modifier,
containerColor: Color = NavigationBarDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor),
windowInsets: WindowInsets = NavigationBarDefaults.windowInsets,
content: @Composable RowScope.() -> Unit,
) {
Surface(
color = containerColor,
contentColor = contentColor,
modifier = modifier,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(windowInsets)
.height(NavigationBarHeight)
.selectableGroup(),
content = content,
)
}
}
@Composable
fun RowScope.NavigationBarItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true,
colors: NavigationBarItemColors = NavigationBarItemDefaults.colors(),
textStyle: TextStyle = NavigationBarItemDefaults.textStyle(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val styledIcon = @Composable {
val iconColor by colors.iconColor(selected = selected, enabled = enabled)
val clearSemantics = label != null && (alwaysShowLabel || selected)
Box(modifier = if (clearSemantics) Modifier.clearAndSetSemantics {} else Modifier) {
CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
}
}
val styledLabel: @Composable (() -> Unit)? =
label?.let {
@Composable {
val textColor by colors.textColor(selected = selected, enabled = enabled)
CompositionLocalProvider(LocalContentColor provides textColor) {
ProvideTextStyle(textStyle, content = label)
}
}
}
var itemWidth by remember { mutableIntStateOf(0) }
Box(
modifier
.selectable(
selected = selected,
onClick = onClick,
enabled = enabled,
role = Role.Tab,
interactionSource = interactionSource,
indication = null,
)
.semantics {
role = Role.Tab
}
.weight(1f)
.onSizeChanged {
itemWidth = it.width
},
contentAlignment = Alignment.Center,
) {
val animationProgress: Float by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(ItemAnimationDurationMillis),
)
NavigationBarItemBaselineLayout(
icon = styledIcon,
label = styledLabel,
alwaysShowLabel = alwaysShowLabel,
animationProgress = animationProgress,
)
}
}
@Composable
private fun NavigationBarItemBaselineLayout(
icon: @Composable () -> Unit,
label: @Composable (() -> Unit)?,
alwaysShowLabel: Boolean,
animationProgress: Float,
) {
Layout({
Box(Modifier.layoutId(IconLayoutIdTag)) { icon() }
if (label != null) {
Box(
Modifier
.layoutId(LabelLayoutIdTag)
.alpha(if (alwaysShowLabel) 1f else animationProgress)
.padding(horizontal = NavigationBarItemHorizontalPadding / 2),
) { label() }
}
}) { measurables, constraints ->
val iconPlaceable =
measurables.first { it.layoutId == IconLayoutIdTag }.measure(constraints)
val labelPlaceable =
label?.let {
measurables.first { it.layoutId == LabelLayoutIdTag }.measure(
constraints.copy(minHeight = 0),
)
}
if (label == null) {
placeIcon(iconPlaceable, constraints)
} else {
placeLabelAndIcon(
labelPlaceable!!,
iconPlaceable,
constraints,
alwaysShowLabel,
animationProgress,
)
}
}
}
private fun MeasureScope.placeIcon(
iconPlaceable: Placeable,
constraints: Constraints,
): MeasureResult {
val width = constraints.maxWidth
val height = constraints.maxHeight
val iconX = (width - iconPlaceable.width) / 2
val iconY = (height - iconPlaceable.height) / 2
return layout(width, height) {
iconPlaceable.placeRelative(iconX, iconY)
}
}
private fun MeasureScope.placeLabelAndIcon(
labelPlaceable: Placeable,
iconPlaceable: Placeable,
constraints: Constraints,
alwaysShowLabel: Boolean,
animationProgress: Float,
): MeasureResult {
val height = constraints.maxHeight
val labelY =
height - labelPlaceable.height - NavigationBarItemVerticalPadding.roundToPx()
val selectedIconY = NavigationBarItemVerticalPadding.roundToPx()
val unselectedIconY =
if (alwaysShowLabel) selectedIconY else (height - iconPlaceable.height) / 2
val iconDistance = unselectedIconY - selectedIconY
val offset = (iconDistance * (1 - animationProgress)).roundToInt()
val containerWidth = constraints.maxWidth
val labelX = (containerWidth - labelPlaceable.width) / 2
val iconX = (containerWidth - iconPlaceable.width) / 2
return layout(containerWidth, height) {
if (alwaysShowLabel || animationProgress != 0f) {
labelPlaceable.placeRelative(labelX, labelY + offset)
}
iconPlaceable.placeRelative(iconX, selectedIconY + offset)
}
}
internal object NavigationBarDefaults {
internal val NavigationBarHeight: Dp = 80.0.dp
val containerColor: Color @Composable get() = OrbisTheme.colors.background
val windowInsets: WindowInsets
@Composable get() =
WindowInsets.systemBarsForVisualComponents.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
)
}
object NavigationBarItemDefaults {
internal val NavigationBarItemHorizontalPadding: Dp = 8.dp
internal val NavigationBarItemVerticalPadding: Dp = 18.dp
internal const val ItemAnimationDurationMillis: Int = 100
@Composable
fun colors(
selectedIconColor: Color = OrbisTheme.colors.onBackground,
selectedTextColor: Color = OrbisTheme.colors.onBackground,
unselectedIconColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.65f),
unselectedTextColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.65f),
disabledIconColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.3f),
disabledTextColor: Color = OrbisTheme.colors.onBackground.copy(alpha = 0.3f),
): NavigationBarItemColors =
NavigationBarItemColors(
selectedIconColor = selectedIconColor,
selectedTextColor = selectedTextColor,
unselectedIconColor = unselectedIconColor,
unselectedTextColor = unselectedTextColor,
disabledIconColor = disabledIconColor,
disabledTextColor = disabledTextColor,
)
@Composable
fun textStyle(): TextStyle = OrbisTheme.typography.label2
}
@ConsistentCopyVisibility
@Stable
data class NavigationBarItemColors internal constructor(
private val selectedIconColor: Color,
private val selectedTextColor: Color,
private val unselectedIconColor: Color,
private val unselectedTextColor: Color,
private val disabledIconColor: Color,
private val disabledTextColor: Color,
) {
@Composable
internal fun iconColor(selected: Boolean, enabled: Boolean): State<Color> {
val targetValue =
when {
!enabled -> disabledIconColor
selected -> selectedIconColor
else -> unselectedIconColor
}
return animateColorAsState(
targetValue = targetValue,
animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis),
label = "icon-color",
)
}
@Composable
internal fun textColor(selected: Boolean, enabled: Boolean): State<Color> {
val targetValue =
when {
!enabled -> disabledTextColor
selected -> selectedTextColor
else -> unselectedTextColor
}
return animateColorAsState(
targetValue = targetValue,
animationSpec = tween(NavigationBarItemDefaults.ItemAnimationDurationMillis),
label = "text-color",
)
}
}
private const val IconLayoutIdTag: String = "icon"
private const val LabelLayoutIdTag: String = "label"

View File

@@ -0,0 +1,180 @@
package io.visus.orbis.core.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import io.visus.orbis.core.ui.LocalContentColor
import io.visus.orbis.core.ui.LocalTextStyle
import io.visus.orbis.core.ui.LocalTypography
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign = TextAlign.Start,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
Text(
text = AnnotatedString(text = text),
modifier = modifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
onTextLayout = onTextLayout,
style = style,
)
}
@Composable
internal fun Text(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign = TextAlign.Start,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
val mergedStyle =
style.merge(
TextStyle(
color = color,
fontSize = fontSize,
fontWeight = fontWeight,
textAlign = textAlign,
lineHeight = lineHeight,
fontFamily = fontFamily,
textDecoration = textDecoration,
fontStyle = fontStyle,
letterSpacing = letterSpacing,
),
)
BasicText(
text,
modifier,
mergedStyle,
onTextLayout,
overflow,
softWrap,
maxLines,
minLines,
inlineContent,
)
}
@Preview
@Composable
fun TypographySample() {
val typography = LocalTypography.current
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "H1 Heading",
style = typography.h1,
)
Text(
text = "H2 Heading",
style = typography.h2,
)
Text(
text = "H3 Heading",
style = typography.h3,
)
Text(
text = "H4 Heading",
style = typography.h4,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "This is body1 text.",
style = typography.body1,
)
Text(
text = "This is body2 text.",
style = typography.body2,
)
Text(
text = "Body3 text for fine print.",
style = typography.body3,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Label1: Form Label",
style = typography.label1,
)
Text(
text = "Label2: Secondary Info",
style = typography.label2,
)
Text(
text = "Label3: Tiny Details",
style = typography.label3,
)
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@@ -0,0 +1,28 @@
package io.visus.orbis.core.ui.foundation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import io.visus.orbis.core.ui.LocalContentColor
import io.visus.orbis.core.ui.LocalTextStyle
@Composable
fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) {
val mergedStyle = LocalTextStyle.current.merge(value)
CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content)
}
@Composable
internal fun ProvideContentColorTextStyle(
contentColor: Color,
textStyle: TextStyle,
content: @Composable () -> Unit,
) {
val mergedStyle = LocalTextStyle.current.merge(textStyle)
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalTextStyle provides mergedStyle,
content = content,
)
}