feat(orb): implement orb animation
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -3,6 +3,7 @@ package io.visus.orbis.core.ui
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Black: Color = Color(0xFF000000)
|
||||
@@ -52,6 +53,9 @@ val Green200: Color = Color(0xFFC2F5DA)
|
||||
val Green100: Color = Color(0xFFD0FBE9)
|
||||
val Green50: Color = Color(0xFFE0FAEC)
|
||||
|
||||
val NavyBlue900: Color = Color(0xFF00002E)
|
||||
val NavyBlue800: Color = Color(0xFF1A1A47)
|
||||
|
||||
@Immutable
|
||||
data class Colors(
|
||||
val primary: Color,
|
||||
@@ -79,6 +83,7 @@ data class Colors(
|
||||
val textDisabled: Color,
|
||||
val scrim: Color,
|
||||
val elevation: Color,
|
||||
val defaultBackgroundGradient: Brush,
|
||||
)
|
||||
|
||||
internal val LightColors =
|
||||
@@ -108,6 +113,9 @@ internal val LightColors =
|
||||
textDisabled = Gray400,
|
||||
scrim = Color.Black.copy(alpha = 0.32f),
|
||||
elevation = Gray700,
|
||||
defaultBackgroundGradient = Brush.verticalGradient(
|
||||
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||
),
|
||||
)
|
||||
|
||||
internal val DarkColors =
|
||||
@@ -137,6 +145,9 @@ internal val DarkColors =
|
||||
textDisabled = Gray600,
|
||||
scrim = Color.Black.copy(alpha = 0.72f),
|
||||
elevation = Gray200,
|
||||
defaultBackgroundGradient = Brush.verticalGradient(
|
||||
colors = listOf(NavyBlue800, NavyBlue900, Black)
|
||||
),
|
||||
)
|
||||
|
||||
val LocalColors = staticCompositionLocalOf { LightColors }
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
package io.visus.orbis.core.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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.contentColorFor
|
||||
import io.visus.orbis.core.ui.foundation.ButtonElevation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
variant: IconButtonVariant = IconButtonVariant.Primary,
|
||||
shape: Shape = IconButtonDefaults.ButtonSquareShape,
|
||||
onClick: () -> Unit = {},
|
||||
contentPadding: PaddingValues = IconButtonDefaults.ButtonPadding,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val style = IconButtonDefaults.styleFor(variant, shape)
|
||||
|
||||
IconButtonComponent(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
loading = loading,
|
||||
style = style,
|
||||
onClick = onClick,
|
||||
contentPadding = contentPadding,
|
||||
interactionSource = interactionSource,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconButtonComponent(
|
||||
modifier: Modifier,
|
||||
enabled: Boolean,
|
||||
loading: Boolean,
|
||||
style: IconButtonStyle,
|
||||
onClick: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
interactionSource: MutableInteractionSource,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val containerColor = style.colors.containerColor(enabled).value
|
||||
val contentColor = style.colors.contentColor(enabled).value
|
||||
val borderColor = style.colors.borderColor(enabled).value
|
||||
val borderStroke = if (borderColor != null) BorderStroke(IconButtonDefaults.OutlineHeight, borderColor) else null
|
||||
|
||||
val shadowElevation = style.elevation?.shadowElevation(enabled, interactionSource)?.value ?: 0.dp
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier =
|
||||
modifier.defaultMinSize(
|
||||
minWidth = IconButtonDefaults.ButtonSize,
|
||||
minHeight = IconButtonDefaults.ButtonSize,
|
||||
).semantics { role = Role.Button },
|
||||
enabled = enabled,
|
||||
shape = style.shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
border = borderStroke,
|
||||
shadowElevation = shadowElevation,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Add a loading indicator if needed
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class IconButtonVariant {
|
||||
Primary,
|
||||
PrimaryOutlined,
|
||||
PrimaryElevated,
|
||||
PrimaryGhost,
|
||||
Secondary,
|
||||
SecondaryOutlined,
|
||||
SecondaryElevated,
|
||||
SecondaryGhost,
|
||||
Destructive,
|
||||
DestructiveOutlined,
|
||||
DestructiveElevated,
|
||||
DestructiveGhost,
|
||||
Ghost,
|
||||
}
|
||||
|
||||
internal object IconButtonDefaults {
|
||||
val ButtonSize = 44.dp
|
||||
val ButtonPadding = PaddingValues(4.dp)
|
||||
val ButtonSquareShape = RoundedCornerShape(12.dp)
|
||||
val ButtonCircleShape = RoundedCornerShape(percent = 50)
|
||||
val OutlineHeight = 1.dp
|
||||
|
||||
@Composable
|
||||
fun buttonElevation() =
|
||||
ButtonElevation(
|
||||
defaultElevation = 2.dp,
|
||||
pressedElevation = 2.dp,
|
||||
focusedElevation = 2.dp,
|
||||
hoveredElevation = 2.dp,
|
||||
disabledElevation = 0.dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun styleFor(variant: IconButtonVariant, shape: Shape): IconButtonStyle {
|
||||
return when (variant) {
|
||||
IconButtonVariant.Primary -> primaryFilled(shape)
|
||||
IconButtonVariant.PrimaryOutlined -> primaryOutlined(shape)
|
||||
IconButtonVariant.PrimaryElevated -> primaryElevated(shape)
|
||||
IconButtonVariant.PrimaryGhost -> primaryGhost(shape)
|
||||
IconButtonVariant.Secondary -> secondaryFilled(shape)
|
||||
IconButtonVariant.SecondaryOutlined -> secondaryOutlined(shape)
|
||||
IconButtonVariant.SecondaryElevated -> secondaryElevated(shape)
|
||||
IconButtonVariant.SecondaryGhost -> secondaryGhost(shape)
|
||||
IconButtonVariant.Destructive -> destructiveFilled(shape)
|
||||
IconButtonVariant.DestructiveOutlined -> destructiveOutlined(shape)
|
||||
IconButtonVariant.DestructiveElevated -> destructiveElevated(shape)
|
||||
IconButtonVariant.DestructiveGhost -> destructiveGhost(shape)
|
||||
IconButtonVariant.Ghost -> ghost(shape)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun primaryFilled(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.primary,
|
||||
contentColor = OrbisTheme.colors.onPrimary,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun primaryOutlined(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.primary,
|
||||
borderColor = OrbisTheme.colors.primary,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.disabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun primaryElevated(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.primary,
|
||||
contentColor = OrbisTheme.colors.onPrimary,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = buttonElevation(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun primaryGhost(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.primary,
|
||||
borderColor = OrbisTheme.colors.transparent,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.transparent,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun secondaryFilled(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.secondary,
|
||||
contentColor = OrbisTheme.colors.onSecondary,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun secondaryOutlined(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.secondary,
|
||||
borderColor = OrbisTheme.colors.secondary,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.disabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun secondaryElevated(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.secondary,
|
||||
contentColor = OrbisTheme.colors.onSecondary,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = buttonElevation(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun secondaryGhost(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.secondary,
|
||||
borderColor = OrbisTheme.colors.transparent,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.transparent,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun destructiveFilled(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.error,
|
||||
contentColor = OrbisTheme.colors.onError,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun destructiveOutlined(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.error,
|
||||
borderColor = OrbisTheme.colors.error,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.disabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun destructiveElevated(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.error,
|
||||
contentColor = OrbisTheme.colors.onError,
|
||||
disabledContainerColor = OrbisTheme.colors.disabled,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = buttonElevation(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun destructiveGhost(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = OrbisTheme.colors.error,
|
||||
borderColor = OrbisTheme.colors.transparent,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
disabledBorderColor = OrbisTheme.colors.transparent,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ghost(shape: Shape) =
|
||||
IconButtonStyle(
|
||||
colors =
|
||||
IconButtonColors(
|
||||
containerColor = OrbisTheme.colors.transparent,
|
||||
contentColor = LocalContentColor.current,
|
||||
disabledContainerColor = OrbisTheme.colors.transparent,
|
||||
disabledContentColor = OrbisTheme.colors.onDisabled,
|
||||
),
|
||||
shape = shape,
|
||||
elevation = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class IconButtonColors(
|
||||
val containerColor: Color,
|
||||
val contentColor: Color,
|
||||
val borderColor: Color? = null,
|
||||
val disabledContainerColor: Color,
|
||||
val disabledContentColor: Color,
|
||||
val disabledBorderColor: Color? = null,
|
||||
) {
|
||||
@Composable
|
||||
fun containerColor(enabled: Boolean) = rememberUpdatedState(if (enabled) containerColor else disabledContainerColor)
|
||||
|
||||
@Composable
|
||||
fun contentColor(enabled: Boolean) = rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
|
||||
|
||||
@Composable
|
||||
fun borderColor(enabled: Boolean) = rememberUpdatedState(if (enabled) borderColor else disabledBorderColor)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class IconButtonStyle(
|
||||
val colors: IconButtonColors,
|
||||
val shape: Shape,
|
||||
val elevation: ButtonElevation? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PrimaryIconButtonPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BasicText(text = "Primary Icon Buttons", style = OrbisTheme.typography.h2)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
IconButton(variant = IconButtonVariant.Primary) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.PrimaryOutlined) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.PrimaryElevated) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.PrimaryGhost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun SecondaryIconButtonPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BasicText(text = "Secondary Icon Buttons", style = OrbisTheme.typography.h2)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
IconButton(variant = IconButtonVariant.Secondary) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.SecondaryOutlined) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.SecondaryElevated) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.SecondaryGhost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DestructiveIconButtonPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BasicText(text = "Destructive Icon Buttons", style = OrbisTheme.typography.h2)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
IconButton(variant = IconButtonVariant.Destructive) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.DestructiveOutlined) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.DestructiveElevated) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(variant = IconButtonVariant.DestructiveGhost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@Preview
|
||||
fun GhostIconButtonPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BasicText(text = "Ghost Icon Buttons", style = OrbisTheme.typography.h2)
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.background)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.primary)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.secondary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.secondary)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.tertiary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.tertiary)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.surface)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8)).background(OrbisTheme.colors.error),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(color = OrbisTheme.colors.error)) {
|
||||
IconButton(variant = IconButtonVariant.Ghost) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun IconButtonShapesPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
BasicText(text = "Square Shape", style = OrbisTheme.typography.h2)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
IconButton(
|
||||
variant = IconButtonVariant.Primary,
|
||||
shape = IconButtonDefaults.ButtonSquareShape,
|
||||
) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(
|
||||
variant = IconButtonVariant.PrimaryOutlined,
|
||||
shape = IconButtonDefaults.ButtonSquareShape,
|
||||
) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(text = "Circle Shape", style = OrbisTheme.typography.h2)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
IconButton(
|
||||
variant = IconButtonVariant.Primary,
|
||||
shape = IconButtonDefaults.ButtonCircleShape,
|
||||
) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
IconButton(
|
||||
variant = IconButtonVariant.PrimaryOutlined,
|
||||
shape = IconButtonDefaults.ButtonCircleShape,
|
||||
) {
|
||||
DummyIconForIconButtonPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun DummyIconForIconButtonPreview() {
|
||||
Canvas(modifier = Modifier.size(16.dp)) {
|
||||
val center = size / 2f
|
||||
val radius = size.minDimension * 0.4f
|
||||
val strokeWidth = 4f
|
||||
val cap = StrokeCap.Round
|
||||
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start = Offset(center.width - radius, center.height),
|
||||
end = Offset(center.width + radius, center.height),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = cap,
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start = Offset(center.width, center.height - radius),
|
||||
end = Offset(center.width, center.height + radius),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = cap,
|
||||
)
|
||||
|
||||
val diagonalRadius = radius * 0.75f
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start =
|
||||
Offset(
|
||||
center.width - diagonalRadius,
|
||||
center.height - diagonalRadius,
|
||||
),
|
||||
end =
|
||||
Offset(
|
||||
center.width + diagonalRadius,
|
||||
center.height + diagonalRadius,
|
||||
),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = cap,
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start =
|
||||
Offset(
|
||||
center.width - diagonalRadius,
|
||||
center.height + diagonalRadius,
|
||||
),
|
||||
end =
|
||||
Offset(
|
||||
center.width + diagonalRadius,
|
||||
center.height - diagonalRadius,
|
||||
),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = cap,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.visus.orbis.core.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nomanr.composables.bottomsheet.BasicModalBottomSheet
|
||||
import com.nomanr.composables.bottomsheet.SheetState
|
||||
import com.nomanr.composables.bottomsheet.rememberModalBottomSheetState
|
||||
import io.visus.orbis.core.ui.OrbisTheme
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun ModalBottomSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
isVisible: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
sheetGesturesEnabled: Boolean = true,
|
||||
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
if (isVisible) {
|
||||
BasicModalBottomSheet(
|
||||
modifier = modifier,
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetGesturesEnabled = sheetGesturesEnabled,
|
||||
containerColor = OrbisTheme.colors.background,
|
||||
scrimColor = OrbisTheme.colors.scrim,
|
||||
shape = BottomSheetDefaults.ModalBottomSheetShape,
|
||||
dragHandle = dragHandle,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal object BottomSheetDefaults {
|
||||
private val DragHandleHeight = 6.dp
|
||||
private val DragHandleWidth = 36.dp
|
||||
private val DragHandleShape = RoundedCornerShape(50)
|
||||
private val DragHandlePadding = 12.dp
|
||||
val ModalBottomSheetShape =
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DragHandle() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(DragHandlePadding)
|
||||
.background(color = Color.Companion.Unspecified, shape = RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.size(width = DragHandleWidth, height = DragHandleHeight)
|
||||
.background(color = OrbisTheme.colors.secondary, shape = DragHandleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ModalBottomSheetPreview() {
|
||||
ModalBottomSheet(isVisible = true, onDismissRequest = { }) {
|
||||
Column {
|
||||
for (i in 0..10) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.height(40.dp)
|
||||
.fillMaxWidth()
|
||||
.background(color = if (i % 2 == 0) Color.Red else Color.Blue),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
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"
|
||||
@@ -0,0 +1,438 @@
|
||||
package io.visus.orbis.core.ui.components
|
||||
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nomanr.composables.slider.BasicRangeSlider
|
||||
import com.nomanr.composables.slider.BasicSlider
|
||||
import com.nomanr.composables.slider.RangeSliderState
|
||||
import com.nomanr.composables.slider.SliderColors
|
||||
import com.nomanr.composables.slider.SliderState
|
||||
import io.visus.orbis.core.ui.OrbisTheme
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun Slider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
@IntRange(from = 0) steps: Int = 0,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
) {
|
||||
val state =
|
||||
remember(steps, valueRange) {
|
||||
SliderState(
|
||||
value,
|
||||
steps,
|
||||
onValueChangeFinished,
|
||||
valueRange,
|
||||
)
|
||||
}
|
||||
|
||||
state.onValueChangeFinished = onValueChangeFinished
|
||||
state.onValueChange = onValueChange
|
||||
state.value = value
|
||||
|
||||
Slider(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Slider(
|
||||
state: SliderState,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
require(state.steps >= 0) { "steps should be >= 0" }
|
||||
|
||||
BasicSlider(modifier = modifier, state = state, colors = colors, enabled = enabled, interactionSource = interactionSource)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeSlider(
|
||||
value: ClosedFloatingPointRange<Float>,
|
||||
onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
@IntRange(from = 0) steps: Int = 0,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val state =
|
||||
remember(steps, valueRange) {
|
||||
RangeSliderState(
|
||||
value.start,
|
||||
value.endInclusive,
|
||||
steps,
|
||||
onValueChangeFinished,
|
||||
valueRange,
|
||||
)
|
||||
}
|
||||
|
||||
state.onValueChangeFinished = onValueChangeFinished
|
||||
state.onValueChange = { onValueChange(it.start..it.endInclusive) }
|
||||
state.activeRangeStart = value.start
|
||||
state.activeRangeEnd = value.endInclusive
|
||||
|
||||
RangeSlider(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
colors = colors,
|
||||
startInteractionSource = startInteractionSource,
|
||||
endInteractionSource = endInteractionSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeSlider(
|
||||
state: RangeSliderState,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colors: SliderColors = SliderDefaults.colors(),
|
||||
startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
require(state.steps >= 0) { "steps should be >= 0" }
|
||||
|
||||
BasicRangeSlider(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
enabled = enabled,
|
||||
startInteractionSource = startInteractionSource,
|
||||
endInteractionSource = endInteractionSource,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
object SliderDefaults {
|
||||
@Composable
|
||||
fun colors(
|
||||
thumbColor: Color = OrbisTheme.colors.primary,
|
||||
activeTrackColor: Color = OrbisTheme.colors.primary,
|
||||
activeTickColor: Color = OrbisTheme.colors.onPrimary,
|
||||
inactiveTrackColor: Color = OrbisTheme.colors.secondary,
|
||||
inactiveTickColor: Color = OrbisTheme.colors.primary,
|
||||
disabledThumbColor: Color = OrbisTheme.colors.disabled,
|
||||
disabledActiveTrackColor: Color = OrbisTheme.colors.disabled,
|
||||
disabledActiveTickColor: Color = OrbisTheme.colors.disabled,
|
||||
disabledInactiveTrackColor: Color = OrbisTheme.colors.disabled,
|
||||
disabledInactiveTickColor: Color = Color.Unspecified,
|
||||
) = SliderColors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
activeTickColor = activeTickColor,
|
||||
inactiveTrackColor = inactiveTrackColor,
|
||||
inactiveTickColor = inactiveTickColor,
|
||||
disabledThumbColor = disabledThumbColor,
|
||||
disabledActiveTrackColor = disabledActiveTrackColor,
|
||||
disabledActiveTickColor = disabledActiveTickColor,
|
||||
disabledInactiveTrackColor = disabledInactiveTrackColor,
|
||||
disabledInactiveTickColor = disabledInactiveTickColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SliderPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.White)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
BasicText(
|
||||
text = "Slider Components",
|
||||
style = OrbisTheme.typography.h3,
|
||||
)
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Basic Slider",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var value by remember { mutableFloatStateOf(0.5f) }
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Stepped Slider (5 steps)",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var value by remember { mutableFloatStateOf(0.4f) }
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
steps = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Custom Range (0-100)",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var value by remember { mutableFloatStateOf(30f) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
BasicText(
|
||||
text = "${value.toInt()}",
|
||||
style = OrbisTheme.typography.body1,
|
||||
modifier = Modifier.width(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Disabled States",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
Slider(
|
||||
value = 0.3f,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Slider(
|
||||
value = 0.7f,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Custom Colors",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var value by remember { mutableFloatStateOf(0.5f) }
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = OrbisTheme.colors.error,
|
||||
activeTrackColor = OrbisTheme.colors.error,
|
||||
inactiveTrackColor = OrbisTheme.colors.error.copy(alpha = 0.3f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Interactive Slider",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var value by remember { mutableFloatStateOf(50f) }
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
BasicText(
|
||||
text = if (isEditing) "Editing..." else "Value: ${value.toInt()}",
|
||||
style = OrbisTheme.typography.body1,
|
||||
)
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
isEditing = true
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = { isEditing = false },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RangeSliderPreview() {
|
||||
OrbisTheme {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.White)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
BasicText(
|
||||
text = "Range Slider Components",
|
||||
style = OrbisTheme.typography.h3,
|
||||
)
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Basic Range Slider",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var range by remember { mutableStateOf(0.2f..0.8f) }
|
||||
RangeSlider(
|
||||
value = range,
|
||||
onValueChange = { range = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Stepped Range Slider (5 steps)",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var range by remember { mutableStateOf(0.2f..0.6f) }
|
||||
RangeSlider(
|
||||
value = range,
|
||||
onValueChange = { range = it },
|
||||
steps = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Custom Range (0-100)",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var range by remember { mutableStateOf(20f..80f) }
|
||||
Column {
|
||||
RangeSlider(
|
||||
value = range,
|
||||
onValueChange = { range = it },
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
BasicText(
|
||||
text = "Start: ${range.start.toInt()}",
|
||||
style = OrbisTheme.typography.body1,
|
||||
)
|
||||
BasicText(
|
||||
text = "End: ${range.endInclusive.toInt()}",
|
||||
style = OrbisTheme.typography.body1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Disabled State",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
RangeSlider(
|
||||
value = 0.3f..0.7f,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Custom Colors",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var range by remember { mutableStateOf(0.3f..0.7f) }
|
||||
RangeSlider(
|
||||
value = range,
|
||||
onValueChange = { range = it },
|
||||
colors =
|
||||
SliderDefaults.colors(
|
||||
thumbColor = OrbisTheme.colors.error,
|
||||
activeTrackColor = OrbisTheme.colors.error,
|
||||
inactiveTrackColor = OrbisTheme.colors.error.copy(alpha = 0.3f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
BasicText(
|
||||
text = "Interactive Range Slider",
|
||||
style = OrbisTheme.typography.h4,
|
||||
)
|
||||
var range by remember { mutableStateOf(30f..70f) }
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
BasicText(
|
||||
text = if (isEditing) "Editing..." else "Range: ${range.start.toInt()} - ${range.endInclusive.toInt()}",
|
||||
style = OrbisTheme.typography.body1,
|
||||
)
|
||||
RangeSlider(
|
||||
value = range,
|
||||
onValueChange = {
|
||||
range = it
|
||||
isEditing = true
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = { isEditing = false },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package io.visus.orbis.core.ui.foundation
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
@Stable
|
||||
class ButtonElevation internal constructor(
|
||||
private val defaultElevation: Dp,
|
||||
private val pressedElevation: Dp,
|
||||
private val focusedElevation: Dp,
|
||||
private val hoveredElevation: Dp,
|
||||
private val disabledElevation: Dp,
|
||||
) {
|
||||
@Composable
|
||||
internal fun shadowElevation(
|
||||
enabled: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
): State<Dp> {
|
||||
return animateElevation(enabled = enabled, interactionSource = interactionSource)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun animateElevation(
|
||||
enabled: Boolean,
|
||||
interactionSource: InteractionSource,
|
||||
): State<Dp> {
|
||||
val interactions = remember { mutableStateListOf<Interaction>() }
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is HoverInteraction.Enter -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
|
||||
is HoverInteraction.Exit -> {
|
||||
interactions.remove(interaction.enter)
|
||||
}
|
||||
|
||||
is FocusInteraction.Focus -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
|
||||
is FocusInteraction.Unfocus -> {
|
||||
interactions.remove(interaction.focus)
|
||||
}
|
||||
|
||||
is PressInteraction.Press -> {
|
||||
interactions.add(interaction)
|
||||
}
|
||||
|
||||
is PressInteraction.Release -> {
|
||||
interactions.remove(interaction.press)
|
||||
}
|
||||
|
||||
is PressInteraction.Cancel -> {
|
||||
interactions.remove(interaction.press)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val interaction = interactions.lastOrNull()
|
||||
|
||||
val target =
|
||||
if (!enabled) {
|
||||
disabledElevation
|
||||
} else {
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> pressedElevation
|
||||
is HoverInteraction.Enter -> hoveredElevation
|
||||
is FocusInteraction.Focus -> focusedElevation
|
||||
else -> defaultElevation
|
||||
}
|
||||
}
|
||||
|
||||
val animatable = remember { Animatable(target, Dp.VectorConverter) }
|
||||
|
||||
if (!enabled) {
|
||||
// No transition when moving to a disabled state
|
||||
LaunchedEffect(target) { animatable.snapTo(target) }
|
||||
} else {
|
||||
LaunchedEffect(target) {
|
||||
val lastInteraction =
|
||||
when (animatable.targetValue) {
|
||||
pressedElevation -> PressInteraction.Press(Offset.Zero)
|
||||
hoveredElevation -> HoverInteraction.Enter()
|
||||
focusedElevation -> FocusInteraction.Focus()
|
||||
else -> null
|
||||
}
|
||||
animatable.animateElevation(
|
||||
from = lastInteraction,
|
||||
to = interaction,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return animatable.asState()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || other !is ButtonElevation) return false
|
||||
|
||||
if (defaultElevation != other.defaultElevation) return false
|
||||
if (pressedElevation != other.pressedElevation) return false
|
||||
if (focusedElevation != other.focusedElevation) return false
|
||||
if (hoveredElevation != other.hoveredElevation) return false
|
||||
return disabledElevation == other.disabledElevation
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = defaultElevation.hashCode()
|
||||
result = 31 * result + pressedElevation.hashCode()
|
||||
result = 31 * result + focusedElevation.hashCode()
|
||||
result = 31 * result + hoveredElevation.hashCode()
|
||||
result = 31 * result + disabledElevation.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user