From d61cce7eaffe4e1a4c72ad7355eeb04e8e5af10c Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 12:35:17 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20PrezelVoiceChrome=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `VoiceChromeStatus` (IDLE, LISTENING, WAITING)에 따른 UI 상태 정의 - 음성 인식 강도 표현을 위한 `VoiceChromeGradient` 애니메이션 로직 추가 - 상태별 하단 라인 표시 및 텍스트 브러시 효과 적용 - "듣고 있어요", "일시정지됨" 등 관련 시스템 문자열 추가 --- .../component/voice/PrezelVoiceChrome.kt | 290 ++++++++++++++++++ .../src/main/res/values/strings.xml | 2 + 2 files changed, 292 insertions(+) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt new file mode 100644 index 00000000..d417a115 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -0,0 +1,290 @@ +package com.team.prezel.core.designsystem.component.voice + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.LargeDevicePreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Immutable +enum class VoiceChromeStatus { + IDLE, + LISTENING, + WAITING, +} + +@Immutable +enum class VoiceChromeGradient { + NONE, + MIN, + MAX, +} + +@Composable +fun PrezelVoiceChrome( + titleText: String, + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + gradient: VoiceChromeGradient = VoiceChromeGradient.NONE, +) { + PrezelVoiceChromeContent( + titleText = titleText, + modifier = modifier, + status = status, + gradientStop = gradient.stop, + ) +} + +@Composable +private fun PrezelVoiceChromeContent( + titleText: String, + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + gradientStop: Float = VoiceChromeGradient.NONE.stop, +) { + val lineColor = when (status) { + VoiceChromeStatus.IDLE, + VoiceChromeStatus.LISTENING, + -> PrezelTheme.colors.interactiveRegular + + VoiceChromeStatus.WAITING -> PrezelTheme.colors.borderLarge + } + + Box( + modifier = modifier + .size(width = 360.dp, height = 160.dp) + .voiceChromeBackground( + status = status, + gradientStop = gradientStop, + color = PrezelTheme.colors.interactiveRegular, + ), + contentAlignment = Alignment.Center, + ) { + VoiceChromeTitle( + titleText = titleText, + status = status, + ) + + VoiceChromeLine( + status = status, + color = lineColor, + ) + } +} + +@Composable +private fun VoiceChromeTitle( + titleText: String, + status: VoiceChromeStatus, +) { + val baseStyle = PrezelTheme.typography.title1Bold.copy(textAlign = TextAlign.Center) + + when (status) { + VoiceChromeStatus.IDLE -> Text( + text = titleText, + style = baseStyle.withIdleTitleBrush(), + ) + + VoiceChromeStatus.LISTENING -> Text( + text = stringResource(R.string.core_designsystem_voice_chrome_listening), + style = baseStyle, + color = PrezelTheme.colors.interactiveRegular, + ) + + VoiceChromeStatus.WAITING -> Text( + text = stringResource(R.string.core_designsystem_voice_chrome_waiting), + style = baseStyle, + color = PrezelTheme.colors.textMedium, + ) + } +} + +@Composable +private fun TextStyle.withIdleTitleBrush(): TextStyle { + val colors = PrezelTheme.colors + + return copy( + brush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to colors.interactiveSmall, + 0.5f to colors.interactiveRegular, + 1f to colors.interactiveSmall, + ), + ), + ) +} + +@Composable +private fun BoxScope.VoiceChromeLine( + status: VoiceChromeStatus, + color: Color, +) { + val visible = status != VoiceChromeStatus.IDLE + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .size(width = 360.dp, height = 4.dp) + .drawWithCache { + val lineBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to color.copy(alpha = 0f), + 0.5f to color, + 1f to color.copy(alpha = 0f), + ), + ) + + onDrawBehind { + if (visible) { + drawRect(brush = lineBrush) + } + } + }, + ) +} + +private fun Modifier.voiceChromeBackground( + status: VoiceChromeStatus, + gradientStop: Float, + color: Color, +): Modifier = + drawWithCache { + val shouldDrawGradient = status == VoiceChromeStatus.LISTENING && gradientStop > 0f + val gradientBrush = if (shouldDrawGradient) { + Brush.radialGradient( + colorStops = arrayOf( + 0f to color, + gradientStop to color.copy(alpha = 0f), + ), + center = Offset(size.width / 2f, size.height), + radius = size.width, + ) + } else { + null + } + + onDrawBehind { + if (gradientBrush != null) { + clipRect { + withTransform( + { + scale( + scaleX = 1f, + scaleY = 0.45f, + pivot = Offset(size.width / 2f, size.height), + ) + }, + ) { + drawCircle( + brush = gradientBrush, + radius = size.width, + center = Offset(size.width / 2f, size.height), + ) + } + } + } + } + } + +private val VoiceChromeGradient.stop: Float + get() = when (this) { + VoiceChromeGradient.NONE -> 0f + VoiceChromeGradient.MIN -> 0.24f + VoiceChromeGradient.MAX -> 0.44f + } + +@LargeDevicePreview +@Composable +private fun PrezelVoiceChromeComponentPreview() { + PrezelTheme { + Column { + Row { + PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") + + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.WAITING, + ) + } + + Row { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.NONE, + ) + Spacer(modifier = Modifier.width(20.dp)) + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MIN, + ) + Spacer(modifier = Modifier.width(20.dp)) + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MAX, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeAnimatedPreview() { + val transition = rememberInfiniteTransition(label = "VoiceChromePreviewTransition") + val progress by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1_200, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "VoiceChromePreviewGradient", + ) + val gradientStop = VoiceChromeGradient.MIN.stop + + (VoiceChromeGradient.MAX.stop - VoiceChromeGradient.MIN.stop) * progress + + PrezelTheme { + PrezelVoiceChromeContent( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradientStop = gradientStop, + ) + } +} diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index 70523987..a51ac86d 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -13,6 +13,8 @@ 스크립트 일치 트랙 발화 트랙 툴팁 닫기 + 듣고 있어요 + 일시정지됨 From 8177ace1de704454060d414834a0601b134c2cb9 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 13:17:20 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20PrezelVoiceChromeWave=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 음성 입력 상태(IDLE, LISTENING, WAITING)에 따른 웨이브 애니메이션 UI 추가 - 입력 볼륨 크기에 따라 막대 높이가 동적으로 변하는 시각화 로직 구현 - 기준선 표시 여부 및 커스텀 스타일(브러시, 색상, 간격) 설정 기능 제공 - 컴포넌트 동작 확인을 위한 프리뷰 및 애니메이션 샘플 추가 --- .../component/voice/PrezelVoiceChromeWave.kt | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt new file mode 100644 index 00000000..95b78e90 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -0,0 +1,389 @@ +package com.team.prezel.core.designsystem.component.voice + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.LargeDevicePreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSurface +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.math.PI +import kotlin.math.roundToInt +import kotlin.math.sin + +@Composable +fun PrezelVoiceChromeWave( + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + volumes: ImmutableList = persistentListOf(), + showBaseline: Boolean = true, +) { + val adjustedVolumes = when (status) { + VoiceChromeStatus.IDLE, + VoiceChromeStatus.WAITING, + -> persistentListOf() + + VoiceChromeStatus.LISTENING -> { + val clippedVolumes = volumes.map { volume -> + volume.coerceIn( + minimumValue = 0.1f, + maximumValue = 1f, + ) + } + + clippedVolumes.toImmutableList() + } + } + + Spacer( + modifier = modifier.drawVoiceChromeWave( + status = status, + volumes = adjustedVolumes, + showBaseline = showBaseline, + ), + ) +} + +@Composable +private fun Modifier.drawVoiceChromeWave( + status: VoiceChromeStatus, + volumes: ImmutableList, + showBaseline: Boolean, +): Modifier { + val colors = PrezelTheme.colors + + return size(width = 360.dp, height = 60.dp).drawWithCache { + val barWidth = 2.dp.toPx() + val barSpacing = 6.dp.toPx() + val minBarHeight = 4.dp.toPx() + val maxBarHeight = 40.dp.toPx() + val baselineStrokeWidth = 1.dp.toPx() + val barRadius = CornerRadius(barWidth / 2f, barWidth / 2f) + val activeBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to colors.interactiveSmall, + 0.5f to colors.interactiveRegular, + 1f to colors.interactiveSmall, + ), + startX = 0f, + endX = size.width, + ) + val drawConfig = VoiceChromeWaveDrawConfig( + barWidth = barWidth, + barSpacing = barSpacing, + minBarHeight = minBarHeight, + maxBarHeight = maxBarHeight, + barRadius = barRadius, + activeBrush = activeBrush, + waitingColor = colors.interactiveXSmall, + idleColor = colors.bgDisabled, + ) + + onDrawBehind { + drawVoiceChromeWaveBars( + status = status, + volumes = volumes, + config = drawConfig, + ) + + drawVoiceChromeWaveBaseline( + visible = showBaseline, + color = colors.borderRegular, + strokeWidth = baselineStrokeWidth, + ) + } + } +} + +private data class VoiceChromeWaveDrawConfig( + val barWidth: Float, + val barSpacing: Float, + val minBarHeight: Float, + val maxBarHeight: Float, + val barRadius: CornerRadius, + val activeBrush: Brush, + val waitingColor: Color, + val idleColor: Color, +) + +private fun DrawScope.drawVoiceChromeWaveBars( + status: VoiceChromeStatus, + volumes: ImmutableList, + config: VoiceChromeWaveDrawConfig, +) { + var barX = -config.barWidth + var barIndex = 0 + val barCount = (size.width / config.barSpacing).roundToInt() + 1 + + while (barX < size.width + config.barSpacing) { + val volume = volumes.sampleVolume( + index = barIndex, + sampleCount = barCount, + ) + val barHeight = config.volumeToBarHeight(volume) + val barTop = (size.height - barHeight) / 2f + val topLeft = Offset(x = barX, y = barTop) + val barSize = Size(width = config.barWidth, height = barHeight) + + when (status) { + VoiceChromeStatus.IDLE -> drawRoundRect( + color = config.idleColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.LISTENING -> drawRoundRect( + brush = config.activeBrush, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.WAITING -> drawRoundRect( + color = config.waitingColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + } + + barX += config.barSpacing + barIndex += 1 + } +} + +private fun DrawScope.drawVoiceChromeWaveBaseline( + visible: Boolean, + color: Color, + strokeWidth: Float, +) { + if (!visible) return + + drawLine( + color = color, + start = Offset(x = size.width / 2f, y = 0f), + end = Offset(x = size.width / 2f, y = size.height), + strokeWidth = strokeWidth, + ) +} + +private fun VoiceChromeWaveDrawConfig.volumeToBarHeight(volume: Float): Float { + val progress = (volume - 0.1f) / (1f - 0.1f) + + return minBarHeight + progress * (maxBarHeight - minBarHeight) +} + +private fun ImmutableList.sampleVolume( + index: Int, + sampleCount: Int, +): Float { + if (isEmpty()) return 0.1f + if (size == 1 || sampleCount <= 1) return first() + + val sampleIndex = (index * (lastIndex.toFloat() / (sampleCount - 1))).roundToInt() + return get(sampleIndex.coerceIn(indices)) +} + +@LargeDevicePreview +@Composable +private fun PrezelVoiceChromeWaveComponentPreview() { + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewSection( + title = "Show Baseline - True", + showBaseline = true, + ) + VoiceChromeWavePreviewSection( + title = "Show Baseline - False", + showBaseline = false, + ) + VoiceChromeWaveVolumePreviewSection() + } + } +} + +@Composable +private fun VoiceChromeWavePreviewSection( + title: String, + showBaseline: Boolean, +) { + Column { + Text( + text = title, + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + VoiceChromeWaveStatusPreviewRow(showBaseline = showBaseline) + } +} + +@Composable +private fun VoiceChromeWaveStatusPreviewRow(showBaseline: Boolean) { + Row { + VoiceChromeWavePreviewItem(label = "Status - Idle") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.IDLE, + showBaseline = showBaseline, + ) + } + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWavePreviewItem(label = "Status - Listening") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.LISTENING, + volumes = previewVolumes( + offset = 0f, + peakVolume = 0.75f, + ), + showBaseline = showBaseline, + ) + } + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWavePreviewItem(label = "Status - Waiting") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.WAITING, + showBaseline = showBaseline, + ) + } + } +} + +@Composable +private fun VoiceChromeWaveVolumePreviewSection() { + Column { + Text( + text = "Volume", + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row { + VoiceChromeWaveVolumePreviewItem( + label = "Volume - Min", + peakVolume = 0.1f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - Max", + peakVolume = 1f, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row { + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 25%", + peakVolume = 0.25f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 50%", + peakVolume = 0.5f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 75%", + peakVolume = 0.75f, + ) + } + } +} + +@Composable +private fun VoiceChromeWaveVolumePreviewItem( + label: String, + @FloatRange(from = 0.0, to = 1.0) peakVolume: Float, +) { + VoiceChromeWavePreviewItem(label = label) { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.LISTENING, + volumes = previewVolumes( + offset = 0f, + peakVolume = peakVolume, + ), + ) + } +} + +@Composable +private fun VoiceChromeWavePreviewItem( + label: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = label, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeWaveAnimatedPreview() { + val transition = rememberInfiniteTransition(label = "VoiceChromeWavePreviewTransition") + val offset by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1_200, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "VoiceChromeWavePreviewVolume", + ) + + PrezelTheme { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.LISTENING, + volumes = previewVolumes( + offset = offset, + peakVolume = 1f, + ), + ) + } +} + +private fun previewVolumes( + @FloatRange(from = 0.0, to = 1.0) offset: Float, + @FloatRange(from = 0.0, to = 1.0) peakVolume: Float, +): ImmutableList = + List(61) { index -> + val progress = index / (61 - 1f) + val lowWave = (sin((progress * 2f + offset) * PI).toFloat() + 1f) / 2f + val highWave = (sin((progress * 7f + offset * 2f) * PI).toFloat() + 1f) / 2f + 0.1f + + (lowWave * 0.7f + highWave * 0.3f) * + (peakVolume - 0.1f) + }.toImmutableList() From 1c7365292eba99306764b64274117d4ee8d6521b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 13:22:14 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20PrezelVoiceChrome=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PrezelVoiceChromeComponentPreview`를 상태별(Status, Gradient, Title) 세션으로 분리하여 가독성 향상 - `PreviewSurface`, `PreviewColumn` 등 공통 미리보기 컴포넌트 적용 - 미리보기용 보조 컴포넌트(`VoiceChromePreviewSection`, `VoiceChromePreviewItem`) 추가 - 애니메이션 미리보기(`PrezelVoiceChromeAnimatedPreview`)에 레이블 및 배경 스타일 적용 --- .../component/voice/PrezelVoiceChrome.kt | 108 +++++++++++++++--- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt index d417a115..2762aa50 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.R import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.preview.LargeDevicePreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSurface import com.team.prezel.core.designsystem.theme.PrezelTheme @Immutable @@ -225,42 +227,116 @@ private val VoiceChromeGradient.stop: Float @LargeDevicePreview @Composable private fun PrezelVoiceChromeComponentPreview() { - PrezelTheme { - Column { - Row { - PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") + PreviewSurface { + PreviewColumn { + VoiceChromeStatusPreviewSection() + Spacer(modifier = Modifier.height(4.dp)) + VoiceChromeGradientPreviewSection() + Spacer(modifier = Modifier.height(4.dp)) + VoiceChromeTitlePreviewSection() + } + } +} +@Composable +private fun VoiceChromeStatusPreviewSection() { + VoiceChromePreviewSection(title = "Status") { + Row { + VoiceChromePreviewItem(label = "Status - Idle") { + PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") + } + VoiceChromePreviewItem(label = "Status - Listening") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MIN, + ) + } + VoiceChromePreviewItem(label = "Status - Waiting") { PrezelVoiceChrome( titleText = "지금부터 발표해볼까요?", status = VoiceChromeStatus.WAITING, ) } + } + } +} - Row { +@Composable +private fun VoiceChromeGradientPreviewSection() { + VoiceChromePreviewSection(title = "Gradient") { + Row { + VoiceChromePreviewItem(label = "Gradient - None") { PrezelVoiceChrome( titleText = "지금부터 발표해볼까요?", status = VoiceChromeStatus.LISTENING, gradient = VoiceChromeGradient.NONE, ) - Spacer(modifier = Modifier.width(20.dp)) + } + VoiceChromePreviewItem(label = "Gradient - Min") { PrezelVoiceChrome( titleText = "지금부터 발표해볼까요?", status = VoiceChromeStatus.LISTENING, gradient = VoiceChromeGradient.MIN, ) - Spacer(modifier = Modifier.width(20.dp)) + } + VoiceChromePreviewItem(label = "Gradient - Max") { PrezelVoiceChrome( titleText = "지금부터 발표해볼까요?", status = VoiceChromeStatus.LISTENING, gradient = VoiceChromeGradient.MAX, ) } + } + } +} - Spacer(modifier = Modifier.height(20.dp)) +@Composable +private fun VoiceChromeTitlePreviewSection() { + VoiceChromePreviewSection(title = "Title") { + Row { + VoiceChromePreviewItem(label = "Title - Default") { + PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") + } + VoiceChromePreviewItem(label = "Title - Custom") { + PrezelVoiceChrome(titleText = "수고하셨어요") + } } } } +@Composable +private fun VoiceChromePreviewSection( + title: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = title, + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@Composable +private fun VoiceChromePreviewItem( + label: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = label, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + @BasicPreview @Composable private fun PrezelVoiceChromeAnimatedPreview() { @@ -280,11 +356,15 @@ private fun PrezelVoiceChromeAnimatedPreview() { val gradientStop = VoiceChromeGradient.MIN.stop + (VoiceChromeGradient.MAX.stop - VoiceChromeGradient.MIN.stop) * progress - PrezelTheme { - PrezelVoiceChromeContent( - titleText = "지금부터 발표해볼까요?", - status = VoiceChromeStatus.LISTENING, - gradientStop = gradientStop, - ) + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Animated - Listening") { + PrezelVoiceChromeContent( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradientStop = gradientStop, + ) + } + } } } From 2b525184845e112f4e1018b9463e67e695608d79 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 22:28:06 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20PrezelVoiceChrome=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `VoiceChromeStatus.LISTENING` 상태일 때 그라데이션이 자동으로 움직이는 무한 애니메이션 추가 - 상태 변경 시 라인 컬러 및 타이틀 텍스트 컬러가 부드럽게 전환되도록 `animateColorAsState` 적용 - `VoiceChromeStatus.IDLE`에서 다른 상태로 전환될 때 라인이 중앙에서 양옆으로 확장되는 애니메이션 구현 - `VoiceChromeGradient`에 따른 애니메이션 시작/종료 지점 계산 로직 추가 - 컴포넌트 동작 확인을 위한 클릭 토글 프리뷰 추가 및 기존 프리뷰 구조 정리 --- .../component/voice/PrezelVoiceChrome.kt | 178 +++++++++++++----- 1 file changed, 135 insertions(+), 43 deletions(-) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt index 2762aa50..daba242d 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -1,11 +1,15 @@ package com.team.prezel.core.designsystem.component.voice +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -13,11 +17,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +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.draw.drawWithCache @@ -58,11 +64,31 @@ fun PrezelVoiceChrome( status: VoiceChromeStatus = VoiceChromeStatus.IDLE, gradient: VoiceChromeGradient = VoiceChromeGradient.NONE, ) { + val transition = rememberInfiniteTransition(label = "VoiceChromeGradientTransition") + val animatedGradientStop by transition.animateFloat( + initialValue = gradient.initialAnimatedStop, + targetValue = gradient.targetAnimatedStop, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2400, + delayMillis = 160, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "VoiceChromeGradientStop", + ) + val gradientStop = when { + status != VoiceChromeStatus.LISTENING -> VoiceChromeGradient.NONE.stop + gradient == VoiceChromeGradient.NONE -> VoiceChromeGradient.NONE.stop + else -> animatedGradientStop + } + PrezelVoiceChromeContent( titleText = titleText, modifier = modifier, status = status, - gradientStop = gradient.stop, + gradientStop = gradientStop, ) } @@ -73,13 +99,30 @@ private fun PrezelVoiceChromeContent( status: VoiceChromeStatus = VoiceChromeStatus.IDLE, gradientStop: Float = VoiceChromeGradient.NONE.stop, ) { - val lineColor = when (status) { + val targetLineColor = when (status) { VoiceChromeStatus.IDLE, VoiceChromeStatus.LISTENING, -> PrezelTheme.colors.interactiveRegular VoiceChromeStatus.WAITING -> PrezelTheme.colors.borderLarge } + val lineColor by animateColorAsState( + targetValue = targetLineColor, + animationSpec = tween(durationMillis = 280), + label = "VoiceChromeLineColor", + ) + val targetTitleColor = when (status) { + VoiceChromeStatus.IDLE, + VoiceChromeStatus.LISTENING, + -> PrezelTheme.colors.interactiveRegular + + VoiceChromeStatus.WAITING -> PrezelTheme.colors.textMedium + } + val titleColor by animateColorAsState( + targetValue = targetTitleColor, + animationSpec = tween(durationMillis = 280), + label = "VoiceChromeTitleColor", + ) Box( modifier = modifier @@ -94,6 +137,7 @@ private fun PrezelVoiceChromeContent( VoiceChromeTitle( titleText = titleText, status = status, + color = titleColor, ) VoiceChromeLine( @@ -107,6 +151,7 @@ private fun PrezelVoiceChromeContent( private fun VoiceChromeTitle( titleText: String, status: VoiceChromeStatus, + color: Color, ) { val baseStyle = PrezelTheme.typography.title1Bold.copy(textAlign = TextAlign.Center) @@ -119,13 +164,13 @@ private fun VoiceChromeTitle( VoiceChromeStatus.LISTENING -> Text( text = stringResource(R.string.core_designsystem_voice_chrome_listening), style = baseStyle, - color = PrezelTheme.colors.interactiveRegular, + color = color, ) VoiceChromeStatus.WAITING -> Text( text = stringResource(R.string.core_designsystem_voice_chrome_waiting), style = baseStyle, - color = PrezelTheme.colors.textMedium, + color = color, ) } } @@ -150,7 +195,14 @@ private fun BoxScope.VoiceChromeLine( status: VoiceChromeStatus, color: Color, ) { - val visible = status != VoiceChromeStatus.IDLE + val lineProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.IDLE) 0f else 1f, + animationSpec = tween( + durationMillis = 560, + easing = CubicBezierEasing(0f, 0f, 0.58f, 1f), + ), + label = "VoiceChromeLineProgress", + ) Box( modifier = Modifier @@ -166,7 +218,11 @@ private fun BoxScope.VoiceChromeLine( ) onDrawBehind { - if (visible) { + val halfLineWidth = size.width * lineProgress / 2f + clipRect( + left = size.width / 2f - halfLineWidth, + right = size.width / 2f + halfLineWidth, + ) { drawRect(brush = lineBrush) } } @@ -220,10 +276,28 @@ private fun Modifier.voiceChromeBackground( private val VoiceChromeGradient.stop: Float get() = when (this) { VoiceChromeGradient.NONE -> 0f - VoiceChromeGradient.MIN -> 0.24f + VoiceChromeGradient.MIN -> 0.28f VoiceChromeGradient.MAX -> 0.44f } +private val VoiceChromeGradient.initialAnimatedStop: Float + get() = when (this) { + VoiceChromeGradient.NONE, + VoiceChromeGradient.MIN, + -> VoiceChromeGradient.MIN.stop + + VoiceChromeGradient.MAX -> VoiceChromeGradient.MAX.stop + } + +private val VoiceChromeGradient.targetAnimatedStop: Float + get() = when (this) { + VoiceChromeGradient.NONE, + VoiceChromeGradient.MIN, + -> VoiceChromeGradient.MAX.stop + + VoiceChromeGradient.MAX -> VoiceChromeGradient.MIN.stop + } + @LargeDevicePreview @Composable private fun PrezelVoiceChromeComponentPreview() { @@ -232,8 +306,6 @@ private fun PrezelVoiceChromeComponentPreview() { VoiceChromeStatusPreviewSection() Spacer(modifier = Modifier.height(4.dp)) VoiceChromeGradientPreviewSection() - Spacer(modifier = Modifier.height(4.dp)) - VoiceChromeTitlePreviewSection() } } } @@ -291,20 +363,6 @@ private fun VoiceChromeGradientPreviewSection() { } } -@Composable -private fun VoiceChromeTitlePreviewSection() { - VoiceChromePreviewSection(title = "Title") { - Row { - VoiceChromePreviewItem(label = "Title - Default") { - PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") - } - VoiceChromePreviewItem(label = "Title - Custom") { - PrezelVoiceChrome(titleText = "수고하셨어요") - } - } - } -} - @Composable private fun VoiceChromePreviewSection( title: String, @@ -339,30 +397,64 @@ private fun VoiceChromePreviewItem( @BasicPreview @Composable -private fun PrezelVoiceChromeAnimatedPreview() { - val transition = rememberInfiniteTransition(label = "VoiceChromePreviewTransition") - val progress by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1_200, - easing = LinearEasing, - ), - repeatMode = RepeatMode.Reverse, - ), - label = "VoiceChromePreviewGradient", - ) - val gradientStop = VoiceChromeGradient.MIN.stop + - (VoiceChromeGradient.MAX.stop - VoiceChromeGradient.MIN.stop) * progress +private fun PrezelVoiceChromeIdleMinTogglePreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.IDLE) } PreviewSurface { PreviewColumn { - VoiceChromePreviewItem(label = "Animated - Listening") { - PrezelVoiceChromeContent( + VoiceChromePreviewItem(label = "Idle <-> Min") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + modifier = Modifier.clickable { + status = if (status == VoiceChromeStatus.IDLE) { + VoiceChromeStatus.LISTENING + } else { + VoiceChromeStatus.IDLE + } + }, + status = status, + gradient = VoiceChromeGradient.MIN, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeMaxWaitingTogglePreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.LISTENING) } + + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Max <-> Waiting") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + modifier = Modifier.clickable { + status = if (status == VoiceChromeStatus.WAITING) { + VoiceChromeStatus.LISTENING + } else { + VoiceChromeStatus.WAITING + } + }, + status = status, + gradient = VoiceChromeGradient.MAX, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeMaxMinMaxPreview() { + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Max <-> Min") { + PrezelVoiceChrome( titleText = "지금부터 발표해볼까요?", status = VoiceChromeStatus.LISTENING, - gradientStop = gradientStop, + gradient = VoiceChromeGradient.MAX, ) } } From 221f12ab49ede39164156284c3ff8e9e98b76810 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 25 May 2026 22:45:38 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20PrezelVoiceChromeWave=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `activationProgress`와 `volumeProgress`를 도입하여 상태 전환 시 부드러운 애니메이션 효과 추가 - `drawVoiceChromeWaveContent`를 통해 IDLE에서 LISTENING 상태로 전환 시 가로 스크롤 연출 구현 - `volumeToBarHeight` 계산 로직에 `progress` 파라미터를 추가하여 가변적인 높이 조절 지원 - `PreviewVolumePattern` 상수를 정의하고 애니메이션 프리뷰 로직을 상태 기반으로 리팩터링 - 불필요한 `rememberInfiniteTransition` 및 `sin` 함수 기반의 볼륨 샘플링 제거 --- .../component/voice/PrezelVoiceChromeWave.kt | 200 +++++++++++++----- 1 file changed, 145 insertions(+), 55 deletions(-) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt index 95b78e90..a63c9450 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -1,12 +1,9 @@ package com.team.prezel.core.designsystem.component.voice import androidx.annotation.FloatRange -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -16,6 +13,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius @@ -33,9 +33,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlin.math.PI import kotlin.math.roundToInt -import kotlin.math.sin @Composable fun PrezelVoiceChromeWave( @@ -45,11 +43,11 @@ fun PrezelVoiceChromeWave( showBaseline: Boolean = true, ) { val adjustedVolumes = when (status) { - VoiceChromeStatus.IDLE, - VoiceChromeStatus.WAITING, - -> persistentListOf() + VoiceChromeStatus.IDLE -> persistentListOf() - VoiceChromeStatus.LISTENING -> { + VoiceChromeStatus.LISTENING, + VoiceChromeStatus.WAITING, + -> { val clippedVolumes = volumes.map { volume -> volume.coerceIn( minimumValue = 0.1f, @@ -60,11 +58,23 @@ fun PrezelVoiceChromeWave( clippedVolumes.toImmutableList() } } + val activationProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.IDLE) 0f else 1f, + animationSpec = tween(durationMillis = 6400), + label = "VoiceChromeWaveActivationProgress", + ) + val volumeProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.LISTENING) 1f else 0f, + animationSpec = tween(durationMillis = 440), + label = "VoiceChromeWaveVolumeProgress", + ) Spacer( modifier = modifier.drawVoiceChromeWave( status = status, volumes = adjustedVolumes, + activationProgress = activationProgress, + volumeProgress = volumeProgress, showBaseline = showBaseline, ), ) @@ -74,6 +84,8 @@ fun PrezelVoiceChromeWave( private fun Modifier.drawVoiceChromeWave( status: VoiceChromeStatus, volumes: ImmutableList, + activationProgress: Float, + volumeProgress: Float, showBaseline: Boolean, ): Modifier { val colors = PrezelTheme.colors @@ -83,7 +95,6 @@ private fun Modifier.drawVoiceChromeWave( val barSpacing = 6.dp.toPx() val minBarHeight = 4.dp.toPx() val maxBarHeight = 40.dp.toPx() - val baselineStrokeWidth = 1.dp.toPx() val barRadius = CornerRadius(barWidth / 2f, barWidth / 2f) val activeBrush = Brush.horizontalGradient( colorStops = arrayOf( @@ -103,19 +114,18 @@ private fun Modifier.drawVoiceChromeWave( activeBrush = activeBrush, waitingColor = colors.interactiveXSmall, idleColor = colors.bgDisabled, + baselineStrokeWidth = 1.dp.toPx(), ) onDrawBehind { - drawVoiceChromeWaveBars( + drawVoiceChromeWaveContent( status = status, volumes = volumes, config = drawConfig, - ) - - drawVoiceChromeWaveBaseline( - visible = showBaseline, - color = colors.borderRegular, - strokeWidth = baselineStrokeWidth, + activationProgress = activationProgress, + volumeProgress = volumeProgress, + showBaseline = showBaseline, + baselineColor = colors.borderRegular, ) } } @@ -130,14 +140,58 @@ private data class VoiceChromeWaveDrawConfig( val activeBrush: Brush, val waitingColor: Color, val idleColor: Color, + val baselineStrokeWidth: Float, ) +private fun DrawScope.drawVoiceChromeWaveContent( + status: VoiceChromeStatus, + volumes: ImmutableList, + config: VoiceChromeWaveDrawConfig, + activationProgress: Float, + volumeProgress: Float, + showBaseline: Boolean, + baselineColor: Color, +) { + if (status == VoiceChromeStatus.LISTENING && activationProgress < 1f) { + drawVoiceChromeWaveBars( + status = VoiceChromeStatus.IDLE, + volumes = persistentListOf(), + config = config, + xOffset = -size.width * activationProgress, + volumeProgress = 0f, + ) + drawVoiceChromeWaveBars( + status = VoiceChromeStatus.LISTENING, + volumes = volumes, + config = config, + xOffset = size.width * (1f - activationProgress), + volumeProgress = volumeProgress, + ) + } else { + drawVoiceChromeWaveBars( + status = status, + volumes = volumes, + config = config, + xOffset = 0f, + volumeProgress = volumeProgress, + ) + } + + drawVoiceChromeWaveBaseline( + visible = showBaseline, + color = baselineColor, + strokeWidth = config.baselineStrokeWidth, + ) +} + private fun DrawScope.drawVoiceChromeWaveBars( status: VoiceChromeStatus, volumes: ImmutableList, config: VoiceChromeWaveDrawConfig, + xOffset: Float, + volumeProgress: Float, ) { - var barX = -config.barWidth + var barX = -config.barWidth + xOffset var barIndex = 0 val barCount = (size.width / config.barSpacing).roundToInt() + 1 @@ -146,7 +200,10 @@ private fun DrawScope.drawVoiceChromeWaveBars( index = barIndex, sampleCount = barCount, ) - val barHeight = config.volumeToBarHeight(volume) + val barHeight = config.volumeToBarHeight( + volume = volume, + progress = volumeProgress, + ) val barTop = (size.height - barHeight) / 2f val topLeft = Offset(x = barX, y = barTop) val barSize = Size(width = config.barWidth, height = barHeight) @@ -194,10 +251,14 @@ private fun DrawScope.drawVoiceChromeWaveBaseline( ) } -private fun VoiceChromeWaveDrawConfig.volumeToBarHeight(volume: Float): Float { - val progress = (volume - 0.1f) / (1f - 0.1f) +private fun VoiceChromeWaveDrawConfig.volumeToBarHeight( + volume: Float, + progress: Float, +): Float { + val volumeProgress = (volume - 0.1f) / (1f - 0.1f) + val targetHeight = minBarHeight + volumeProgress * (maxBarHeight - minBarHeight) - return minBarHeight + progress * (maxBarHeight - minBarHeight) + return minBarHeight + (targetHeight - minBarHeight) * progress } private fun ImmutableList.sampleVolume( @@ -259,7 +320,6 @@ private fun VoiceChromeWaveStatusPreviewRow(showBaseline: Boolean) { PrezelVoiceChromeWave( status = VoiceChromeStatus.LISTENING, volumes = previewVolumes( - offset = 0f, peakVolume = 0.75f, ), showBaseline = showBaseline, @@ -324,7 +384,6 @@ private fun VoiceChromeWaveVolumePreviewItem( PrezelVoiceChromeWave( status = VoiceChromeStatus.LISTENING, volumes = previewVolumes( - offset = 0f, peakVolume = peakVolume, ), ) @@ -349,41 +408,72 @@ private fun VoiceChromeWavePreviewItem( @BasicPreview @Composable -private fun PrezelVoiceChromeWaveAnimatedPreview() { - val transition = rememberInfiniteTransition(label = "VoiceChromeWavePreviewTransition") - val offset by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1_200, - easing = LinearEasing, - ), - repeatMode = RepeatMode.Reverse, - ), - label = "VoiceChromeWavePreviewVolume", - ) +private fun PrezelVoiceChromeWaveIdleToListeningPreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.IDLE) } - PrezelTheme { - PrezelVoiceChromeWave( - status = VoiceChromeStatus.LISTENING, - volumes = previewVolumes( - offset = offset, - peakVolume = 1f, - ), - ) + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewItem(label = "Idle > Listening") { + PrezelVoiceChromeWave( + modifier = Modifier.clickable { + status = VoiceChromeStatus.LISTENING + }, + status = status, + volumes = previewVolumes( + peakVolume = 1f, + ), + showBaseline = false, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeWaveListeningToWaitingPreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.LISTENING) } + + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewItem(label = "Listening > Waiting") { + PrezelVoiceChromeWave( + modifier = Modifier.clickable { + status = VoiceChromeStatus.WAITING + }, + status = status, + volumes = previewVolumes( + peakVolume = 1f, + ), + showBaseline = false, + ) + } + } } } private fun previewVolumes( - @FloatRange(from = 0.0, to = 1.0) offset: Float, @FloatRange(from = 0.0, to = 1.0) peakVolume: Float, ): ImmutableList = List(61) { index -> - val progress = index / (61 - 1f) - val lowWave = (sin((progress * 2f + offset) * PI).toFloat() + 1f) / 2f - val highWave = (sin((progress * 7f + offset * 2f) * PI).toFloat() + 1f) / 2f - 0.1f + - (lowWave * 0.7f + highWave * 0.3f) * - (peakVolume - 0.1f) + val volume = PreviewVolumePattern[index % PreviewVolumePattern.size] + 0.1f + (volume - 0.1f) * ((peakVolume - 0.1f) / 0.9f) }.toImmutableList() + +private val PreviewVolumePattern = listOf( + 0.24f, + 0.42f, + 0.30f, + 0.56f, + 0.38f, + 0.70f, + 0.48f, + 0.86f, + 1.00f, + 0.78f, + 0.62f, + 0.44f, + 0.58f, + 0.36f, + 0.28f, +) From e8e5de8b046590968c37ca5eb8d90c45fd816c9b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 26 May 2026 12:15:40 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20PrezelVoiceChrome=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=A1=9C=EC=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PrezelVoiceChrome`의 그라데이션 애니메이션 로직을 상태(`status`)에 따라 조건부로 실행되도록 최적화 - 하단 그라데이션 영역의 너비를 고정값(360.dp)에서 `fillMaxWidth()`로 변경 - 보이스 크롬의 `radialGradient` 컬러 스톱을 세분화하여 더욱 부드러운 효과 구현 - `PrezelVoiceChromeWave`에서 라인이 그려지는 방향을 수직에서 수평으로 수정 (그려지는 좌표 계산 로직 변경) --- .../component/voice/PrezelVoiceChrome.kt | 52 +++++++++++-------- .../component/voice/PrezelVoiceChromeWave.kt | 4 +- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt index daba242d..254f6958 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Text @@ -64,24 +65,26 @@ fun PrezelVoiceChrome( status: VoiceChromeStatus = VoiceChromeStatus.IDLE, gradient: VoiceChromeGradient = VoiceChromeGradient.NONE, ) { - val transition = rememberInfiniteTransition(label = "VoiceChromeGradientTransition") - val animatedGradientStop by transition.animateFloat( - initialValue = gradient.initialAnimatedStop, - targetValue = gradient.targetAnimatedStop, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2400, - delayMillis = 160, - easing = LinearEasing, + val shouldAnimateGradient = status == VoiceChromeStatus.LISTENING && + gradient != VoiceChromeGradient.NONE + val gradientStop = if (shouldAnimateGradient) { + val transition = rememberInfiniteTransition(label = "VoiceChromeGradientTransition") + val animatedGradientStop by transition.animateFloat( + initialValue = gradient.initialAnimatedStop, + targetValue = gradient.targetAnimatedStop, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2400, + delayMillis = 160, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, ), - repeatMode = RepeatMode.Reverse, - ), - label = "VoiceChromeGradientStop", - ) - val gradientStop = when { - status != VoiceChromeStatus.LISTENING -> VoiceChromeGradient.NONE.stop - gradient == VoiceChromeGradient.NONE -> VoiceChromeGradient.NONE.stop - else -> animatedGradientStop + label = "VoiceChromeGradientStop", + ) + animatedGradientStop + } else { + VoiceChromeGradient.NONE.stop } PrezelVoiceChromeContent( @@ -207,7 +210,8 @@ private fun BoxScope.VoiceChromeLine( Box( modifier = Modifier .align(Alignment.BottomCenter) - .size(width = 360.dp, height = 4.dp) + .fillMaxWidth() + .height(4.dp) .drawWithCache { val lineBrush = Brush.horizontalGradient( colorStops = arrayOf( @@ -239,10 +243,14 @@ private fun Modifier.voiceChromeBackground( val shouldDrawGradient = status == VoiceChromeStatus.LISTENING && gradientStop > 0f val gradientBrush = if (shouldDrawGradient) { Brush.radialGradient( - colorStops = arrayOf( - 0f to color, - gradientStop to color.copy(alpha = 0f), - ), + 0f to color.copy(alpha = 1f), + gradientStop * 0.12f to color.copy(alpha = 0.86f), + gradientStop * 0.24f to color.copy(alpha = 0.68f), + gradientStop * 0.38f to color.copy(alpha = 0.48f), + gradientStop * 0.54f to color.copy(alpha = 0.30f), + gradientStop * 0.72f to color.copy(alpha = 0.14f), + gradientStop * 0.88f to color.copy(alpha = 0.05f), + gradientStop to color.copy(alpha = 0f), center = Offset(size.width / 2f, size.height), radius = size.width, ) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt index a63c9450..2ae0779d 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -245,8 +245,8 @@ private fun DrawScope.drawVoiceChromeWaveBaseline( drawLine( color = color, - start = Offset(x = size.width / 2f, y = 0f), - end = Offset(x = size.width / 2f, y = size.height), + start = Offset(x = 0f, y = size.height / 2f), + end = Offset(x = size.width, y = size.height / 2f), strokeWidth = strokeWidth, ) }