v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign

This commit is contained in:
2026-08-20 23:28:54 +00:00
parent 779ddb497d
commit 51c2ca9d72
@@ -2,137 +2,160 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
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.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.CornerRadius
import com.hotdeal.alarm.ui.theme.Spacing import com.hotdeal.alarm.ui.theme.Spacing
/** /**
* 애니메이션이 있는 아이콘 버튼 * One UI 9 Elastic Spring Press Effect
* 클릭 시 부드럽게 눌리고 뗐을 때 탄성 있게 복원되는 고품질 인터랙션
*/ */
@Composable fun Modifier.elasticPressClickable(
fun AnimatedIconButton( interactionSource: MutableInteractionSource? = null,
onClick: () -> Unit, enabled: Boolean = true,
icon: @Composable () -> Unit, pressedScale: Float = 0.97f,
modifier: Modifier = Modifier, onClick: () -> Unit
isActivated: Boolean = false ): Modifier = composed {
) { val source = interactionSource ?: remember { MutableInteractionSource() }
val scale by animateFloatAsState( val isPressed by source.collectIsPressedAsState()
targetValue = if (isActivated) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "icon_scale"
)
IconButton(
onClick = onClick,
modifier = modifier.scale(scale)
) {
icon()
}
}
/**
* 페이드 인 애니메이션 박스
*/
@Composable
fun FadeInBox(
modifier: Modifier = Modifier,
delayMillis: Int = 0,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(delayMillis.toLong())
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(
animationSpec = tween(300)
) + slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 }
),
modifier = modifier
) {
content()
}
}
/**
* 스케일 애니메이션 카드
*/
@Composable
fun ScaleAnimationCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f, targetValue = if (isPressed && enabled) pressedScale else 1f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh stiffness = Spring.StiffnessMedium
), ),
label = "card_scale" label = "elastic_press_scale"
) )
Card( this
modifier = modifier .scale(scale)
.scale(scale), .clickable(
onClick = { interactionSource = source,
isPressed = true indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공
onClick() enabled = enabled,
} onClick = onClick
) { )
content()
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
}
}
} }
/** /**
* 로딩 오버레이 * One UI 9 Squircle Container Card with Ambient Border
*/ */
@Composable @Composable
fun LoadingOverlay( fun SquircleCard(
isLoading: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit shape: Shape = CornerRadius.shapeSquircle,
containerColor: Color = MaterialTheme.colorScheme.surface,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
borderWidth: Dp = 0.8.dp,
content: @Composable BoxScope.() -> Unit
) { ) {
Box(modifier = modifier) { Box(
content() modifier = modifier
.clip(shape)
.background(containerColor)
.border(borderWidth, borderColor, shape),
content = content
)
}
AnimatedVisibility( /**
visible = isLoading, * One UI 9 Pill Capsule Badge
enter = fadeIn(), */
exit = fadeOut(), @Composable
modifier = Modifier.matchParentSize() fun PillBadge(
text: String,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
icon: (@Composable () -> Unit)? = null
) {
Surface(
shape = CornerRadius.shapePill,
color = backgroundColor,
modifier = modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
) { ) {
Box( icon?.invoke()
modifier = Modifier Text(
.fillMaxSize() text = text,
.padding(Spacing.lg), style = MaterialTheme.typography.labelSmall,
contentAlignment = Alignment.Center color = textColor
) { )
CircularProgressIndicator() }
}
}
/**
* One UI 9 Now Status Pill (상단 브리핑 캡슐)
*/
@Composable
fun NowStatusPill(
text: String,
modifier: Modifier = Modifier,
isLive: Boolean = true,
accentColor: Color = MaterialTheme.colorScheme.primary
) {
val infiniteTransition = rememberInfiniteTransition(label = "now_pulse")
val dotAlpha by infiniteTransition.animateFloat(
initialValue = 0.4f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot_alpha"
)
Surface(
shape = CornerRadius.shapePill,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
border = androidx.compose.foundation.BorderStroke(
0.8.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
),
modifier = modifier.height(28.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
if (isLive) {
Box(
modifier = Modifier
.size(7.dp)
.background(accentColor.copy(alpha = dotAlpha), CircleShape)
)
} }
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }