implement glassmorphism design across UI components

This commit is contained in:
jonasgaudian
2026-02-16 11:13:08 +01:00
parent 2b8b9a84a3
commit 2d0bf4cb1c
7 changed files with 71 additions and 90 deletions

View File

@@ -130,6 +130,7 @@ dependencies {
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.androidx.compose.foundation.layout)
ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation
// Networking // Networking

View File

@@ -35,7 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -159,14 +159,14 @@ private fun MenuItem(
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.shadow(elevation = 8.dp, shape = ComponentDefaults.DefaultShape) .glassmorphic(shape = RoundedCornerShape(16.dp), alpha = 0.4f)
.clickable( .clickable(
onClick = onClick, onClick = onClick,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
), ),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer color = Color.Transparent // Allow glassmorphic modifier to handle color
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -197,15 +197,3 @@ private fun MenuItem(
} }
} }
} }
@Preview
@Composable
fun MenuItemPreview() {
@Suppress("HardCodedStringLiteral")
MenuItem(
text = "Menu Item",
imageVector = AppIcons.Add,
painter = null,
onClick = {}
)
}

View File

@@ -69,10 +69,8 @@ fun <T : TabItem> AppTabLayout(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp) .padding(vertical = 8.dp, horizontal = 8.dp)
.height(56.dp) .height(56.dp)
.background( // Replace background with glassmorphic extension
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), .glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.3f)
shape = ComponentDefaults.CardShape
)
) { ) {
val tabWidth = maxWidth / tabs.size val tabWidth = maxWidth / tabs.size
@@ -89,7 +87,7 @@ fun <T : TabItem> AppTabLayout(
.fillMaxHeight() .fillMaxHeight()
.padding(4.dp) .padding(4.dp)
.background( .background(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), // Glassy indicator
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
) )

View File

@@ -25,6 +25,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -41,14 +43,21 @@ fun AppTopAppBar(
onNavigateBack: (() -> Unit)? = null, onNavigateBack: (() -> Unit)? = null,
navigationIcon: @Composable (() -> Unit)? = null, navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
hintContent: Hint? = null hintContent: Hint? = null
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
Surface(
modifier = modifier.glassmorphic(shape = RectangleShape, alpha = 0.2f),
color = Color.Transparent
) {
TopAppBar( TopAppBar(
modifier = modifier.height(56.dp), modifier = Modifier.height(56.dp),
windowInsets = WindowInsets(0.dp), windowInsets = WindowInsets(0.dp),
colors = colors, colors = colors,
title = { title = {
@@ -104,6 +113,7 @@ fun AppTopAppBar(
}, },
actions = actions actions = actions
) )
}
if (showBottomSheet) { if (showBottomSheet) {
HintBottomSheet( HintBottomSheet(

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@@ -28,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -100,24 +102,25 @@ fun BottomNavigationBar(
targetOffsetY = { it } targetOffsetY = { it }
) )
) { ) {
val baseHeight = if (showLabels) 80.dp else 56.dp val baseHeight = if (showLabels) 80.dp else 56.dp
val density = LocalDensity.current val density = LocalDensity.current
val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val height = baseHeight + navBarDp val height = baseHeight + navBarDp
NavigationBar( NavigationBar(
modifier = modifier.height(height), modifier = modifier
containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant .height(height)
tonalElevation = 8.dp, // Slight elevation for depth // Apply glassmorphism on the top corners
.glassmorphic(shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), alpha = 0.35f),
containerColor = Color.Transparent, // Let the glass shine through
tonalElevation = 0.dp,
) { ) {
screens.forEach { screen -> screens.forEach { screen ->
val isSelected = screen == selectedItem val isSelected = screen == selectedItem
val title = stringResource(id = screen.title) val title = stringResource(id = screen.title)
// 1. Spring Animation for the Icon Scale
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isSelected) 1.2f else 1.0f, // Subtle pop effect targetValue = if (isSelected) 1.2f else 1.0f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@@ -129,7 +132,7 @@ fun BottomNavigationBar(
selected = isSelected, selected = isSelected,
onClick = { onClick = {
if (!isSelected) { if (!isSelected) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 2. Tactile feedback haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onItemSelected(screen) onItemSelected(screen)
} }
}, },
@@ -145,17 +148,16 @@ fun BottomNavigationBar(
} }
} else null, } else null,
icon = { icon = {
// 3. Crossfade between Outlined and Filled icons
Crossfade(targetState = isSelected, label = "iconFade") { selected -> Crossfade(targetState = isSelected, label = "iconFade") { selected ->
Icon( Icon(
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon, imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
contentDescription = title, contentDescription = title,
modifier = Modifier.scale(scale) // Apply the spring scale modifier = Modifier.scale(scale)
) )
} }
}, },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer, indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), // Glassy indicator
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedTextColor = MaterialTheme.colorScheme.primary, selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,

View File

@@ -5,6 +5,8 @@ package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -43,6 +45,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
@@ -55,49 +58,51 @@ import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
object ComponentDefaults { object ComponentDefaults {
// Sizing
val DefaultButtonHeight = 48.dp val DefaultButtonHeight = 48.dp
val CardPadding = 8.dp val CardPadding = 8.dp
// Elevation
val DefaultElevation = 0.dp val DefaultElevation = 0.dp
val NoElevation = 0.dp val NoElevation = 0.dp
// Borders
val DefaultBorderWidth = 1.dp val DefaultBorderWidth = 1.dp
// Shapes
val DefaultCornerRadius = 16.dp val DefaultCornerRadius = 16.dp
val CardClipRadius = 8.dp val CardClipRadius = 16.dp // Increased slightly for softer glass look
val NoRounding = 0.dp val NoRounding = 0.dp
val DefaultShape = RoundedCornerShape(DefaultCornerRadius) val DefaultShape = RoundedCornerShape(DefaultCornerRadius)
val CardClipShape = RoundedCornerShape(CardClipRadius) val CardClipShape = RoundedCornerShape(CardClipRadius)
val CardShape = RoundedCornerShape(DefaultCornerRadius) val CardShape = RoundedCornerShape(DefaultCornerRadius)
val NoShape = RoundedCornerShape(NoRounding) val NoShape = RoundedCornerShape(NoRounding)
// Opacity Levels
const val ALPHA_HIGH = 0.6f const val ALPHA_HIGH = 0.6f
const val ALPHA_MEDIUM = 0.5f const val ALPHA_MEDIUM = 0.4f
const val ALPHA_LOW = 0.3f const val ALPHA_LOW = 0.2f // Adjusted for glass
} }
/** /**
* A styled card container for displaying content with a consistent floating look. * Standard Glassmorphism Modifier
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/ */
fun Modifier.glassmorphic(
shape: Shape = ComponentDefaults.DefaultShape,
alpha: Float = ComponentDefaults.ALPHA_LOW,
borderAlpha: Float = 0.15f
): Modifier = composed {
this
.shadow(elevation = 8.dp, shape = shape, spotColor = Color.Black.copy(alpha = 0.05f))
.clip(shape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = alpha))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = borderAlpha),
shape = shape
)
}
@Composable @Composable
fun AppCard( fun AppCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null, title: String? = null,
icon: ImageVector? = null, // New optional icon parameter icon: ImageVector? = null,
text: String? = null, text: String? = null,
expandable: Boolean = false, expandable: Boolean = false,
initiallyExpanded: Boolean = false, initiallyExpanded: Boolean = false,
@@ -110,25 +115,17 @@ fun AppCard(
label = "Chevron Rotation" label = "Chevron Rotation"
) )
// Check if we need to render the header row
// Updated to include icon in the check
val hasHeader = title != null || text != null || expandable || icon != null val hasHeader = title != null || text != null || expandable || icon != null
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.shadow( .glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.25f)
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(), .animateContentSize(),
shape = ComponentDefaults.CardShape, shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer color = Color.Transparent // Let glassmorphic handle the background
) { ) {
Column { Column {
// --- Header Row ---
if (hasHeader) { if (hasHeader) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -137,7 +134,6 @@ fun AppCard(
.padding(ComponentDefaults.CardPadding), .padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 1. Optional Icon on the left
if (icon != null) { if (icon != null) {
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -148,7 +144,6 @@ fun AppCard(
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
} }
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) { if (!title.isNullOrBlank()) {
Text( Text(
@@ -157,12 +152,9 @@ fun AppCard(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) { if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp)) Spacer(Modifier.size(4.dp))
} }
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
Text( Text(
text = text, text = text,
@@ -172,7 +164,6 @@ fun AppCard(
} }
} }
// 3. Expand Chevron (Far right)
if (expandable) { if (expandable) {
Icon( Icon(
imageVector = AppIcons.ArrowDropDown, imageVector = AppIcons.ArrowDropDown,
@@ -184,15 +175,12 @@ fun AppCard(
} }
} }
// --- Content Area ---
if (!expandable || isExpanded) { if (!expandable || isExpanded) {
Column( Column(
modifier = Modifier.padding( modifier = Modifier.padding(
start = ComponentDefaults.CardPadding, start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding, end = ComponentDefaults.CardPadding,
bottom = ComponentDefaults.CardPadding, bottom = ComponentDefaults.CardPadding,
// If we have a header, remove the top padding so content sits closer to the title.
// If no header (legacy behavior), keep the top padding.
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
), ),
content = content content = content
@@ -304,31 +292,27 @@ fun AppButton(
modifier: Modifier? = Modifier, modifier: Modifier? = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: Shape? = null, shape: Shape? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(), colors: ButtonColors = ButtonDefaults.buttonColors(
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) // Glassy primary
),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(defaultElevation = 0.dp),
border: BorderStroke? = null, border: BorderStroke? = null,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding, contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight) val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight)
val s = shape ?: ComponentDefaults.DefaultShape val s = shape ?: ComponentDefaults.DefaultShape
Button( Button(
onClick = onClick, onClick = onClick,
modifier = m, modifier = m.border(1.dp, MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), s),
enabled = enabled, enabled = enabled,
shape = s, shape = s,
colors = colors, colors = colors,
elevation = elevation, elevation = elevation,
border = border, border = border,
contentPadding = PaddingValues( contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
start = 8.dp, // More horizontal padding
end = 8.dp,
top = 8.dp, // Default vertical padding
bottom = 8.dp
),
interactionSource = interactionSource interactionSource = interactionSource
) { ) {
content() content()
@@ -368,11 +352,7 @@ fun AppOutlinedButton(
) )
} }
@Preview
@Composable
fun PrimaryButtonWithIconPreview() {
PrimaryButton(onClick = { }, text = stringResource(R.string.primary_with_icon), icon = AppIcons.Add)
}
/** /**
* The secondary button for less prominent actions. * The secondary button for less prominent actions.

View File

@@ -43,6 +43,7 @@ truth = "1.4.5"
zstdJni = "1.5.7-7" zstdJni = "1.5.7-7"
composeMarkdown = "0.5.8" composeMarkdown = "0.5.8"
jitpack = "1.0.10" jitpack = "1.0.10"
foundationLayoutVersion = "1.10.3"
[libraries] [libraries]
@@ -103,6 +104,7 @@ hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", ve
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.3.0" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.3.0" }
mockk = { module = "io.mockk:mockk", version = "1.14.9" } mockk = { module = "io.mockk:mockk", version = "1.14.9" }
compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "composeMarkdown" } compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "composeMarkdown" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayoutVersion" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }