1
0

feat(orb): implement orb animation

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-01-21 11:05:33 -05:00
parent 063ec3b7a0
commit 3f6bd6c128
39 changed files with 2929 additions and 653 deletions

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,48 +0,0 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "io.visus.orbis.data"
compileSdk {
version = release(36)
}
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
// Project modules
implementation(project(":core:domain"))
// AndroidX
implementation(libs.androidx.core.ktx)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.koin.core)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,24 +0,0 @@
package io.visus.orbis.data
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.visus.orbis.data.test", appContext.packageName)
}
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -1,17 +0,0 @@
package io.visus.orbis.data
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,45 +0,0 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "io.visus.orbis.domain"
compileSdk {
version = release(36)
}
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
// AndroidX
implementation(libs.androidx.core.ktx)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.koin.core)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,24 +0,0 @@
package io.visus.orbis.domain
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.visus.orbis.domain.test", appContext.packageName)
}
}

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -1,17 +0,0 @@
package io.visus.orbis.domain
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -45,11 +45,15 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.runtime)
// Composables (bottom sheet, slider)
api(libs.nomanr.composables)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -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 }

View File

@@ -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,
)
}
}

View File

@@ -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),
)
}
}
}
}

View File

@@ -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"

View File

@@ -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(),
)
}
}
}
}

View File

@@ -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
}
}