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 isPressed by source.collectIsPressedAsState()
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isActivated) 1.2f else 1f, targetValue = if (isPressed && enabled) pressedScale else 1f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessMedium
), ),
label = "icon_scale" label = "elastic_press_scale"
) )
IconButton( this
onClick = onClick, .scale(scale)
modifier = modifier.scale(scale) .clickable(
) { interactionSource = source,
icon() indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공
} enabled = enabled,
onClick = onClick
)
} }
/** /**
* 페이드 인 애니메이션 박스 * One UI 9 Squircle Container Card with Ambient Border
*/ */
@Composable @Composable
fun FadeInBox( fun SquircleCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
delayMillis: Int = 0, shape: Shape = CornerRadius.shapeSquircle,
content: @Composable AnimatedVisibilityScope.() -> Unit containerColor: Color = MaterialTheme.colorScheme.surface,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
borderWidth: Dp = 0.8.dp,
content: @Composable BoxScope.() -> Unit
) { ) {
var visible by remember { mutableStateOf(false) } Box(
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 modifier = modifier
) { .clip(shape)
content() .background(containerColor)
} .border(borderWidth, borderColor, shape),
} content = content
/**
* 스케일 애니메이션 카드
*/
@Composable
fun ScaleAnimationCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "card_scale"
) )
Card(
modifier = modifier
.scale(scale),
onClick = {
isPressed = true
onClick()
}
) {
content()
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
}
}
} }
/** /**
* 로딩 오버레이 * One UI 9 Pill Capsule Badge
*/ */
@Composable @Composable
fun LoadingOverlay( fun PillBadge(
isLoading: Boolean, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
icon: (@Composable () -> Unit)? = null
) { ) {
Box(modifier = modifier) { Surface(
content() shape = CornerRadius.shapePill,
color = backgroundColor,
AnimatedVisibility( modifier = modifier.height(24.dp)
visible = isLoading, ) {
enter = fadeIn(), Row(
exit = fadeOut(), verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.matchParentSize() 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
)
} }
} }
} }