diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 48ea7696..2c6ea3d8 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -5,10 +5,7 @@ - - - + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/kotlin/com/yourssu/handy/demo/DialogPreview.kt b/app/src/main/kotlin/com/yourssu/handy/demo/DialogPreview.kt index fe7c059a..7c2ee5c7 100644 --- a/app/src/main/kotlin/com/yourssu/handy/demo/DialogPreview.kt +++ b/app/src/main/kotlin/com/yourssu/handy/demo/DialogPreview.kt @@ -19,7 +19,7 @@ import com.yourssu.handy.compose.HandyTheme import com.yourssu.handy.compose.Icon import com.yourssu.handy.compose.OneButtonDialog import com.yourssu.handy.compose.TwoButtonDialog -import com.yourssu.handy.compose.button.BoxButton +import com.yourssu.handy.compose.button.FilledButton import com.yourssu.handy.compose.icons.HandyIcons import com.yourssu.handy.compose.icons.filled.Add @@ -130,9 +130,9 @@ private fun DialogOnScreenPreview() { .fillMaxWidth() .fillMaxHeight() ) { - BoxButton(text = "원버튼 다이알로그", onClick = { showOneButtonDialog = true }) + FilledButton(text = "원버튼 다이알로그", onClick = { showOneButtonDialog = true }) Spacer(modifier = Modifier.height(10.dp)) - BoxButton(text = "투버튼 다이알로그", onClick = { showTwoButtonDialog = true }) + FilledButton(text = "투버튼 다이알로그", onClick = { showTwoButtonDialog = true }) } if (showOneButtonDialog) { diff --git a/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt b/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt new file mode 100644 index 00000000..189cab15 --- /dev/null +++ b/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt @@ -0,0 +1,53 @@ +package com.yourssu.handy.demo + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.yourssu.handy.compose.ErrorSnackBar +import com.yourssu.handy.compose.HandyTheme +import com.yourssu.handy.compose.InfoSnackBar + +@Preview(showBackground = true) +@Composable +fun InfoSnackBarPreview() { + HandyTheme { + Column( + modifier = Modifier.padding(10.dp) + ) { + InfoSnackBar( + text = "한 줄짜리 정보성 메세지가 들어갑니다.", + onDismiss = {} + ) + Spacer(modifier = Modifier.height(10.dp)) + InfoSnackBar( + text = "줄 수가 두 줄 이상이 되는 스낵바 메시지입니다. 좌측 정렬을 해주세요.", + onDismiss = {} + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ErrorSnackBarPreview() { + HandyTheme { + Column( + modifier = Modifier.padding(10.dp) + ) { + ErrorSnackBar( + text = "에러 메세지가 들어갑니다", + onClose = {} + ) + Spacer(modifier = Modifier.height(10.dp)) + ErrorSnackBar( + text = "두 줄 이상의 에러 메세지가 들어갈 경우 아이콘은 모두 위로 정렬해주세요.", + onClose = {} + ) + } + } +} \ No newline at end of file diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/Dialog.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/Dialog.kt index 06dfef34..4e7612f1 100644 --- a/compose/src/main/kotlin/com/yourssu/handy/compose/Dialog.kt +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/Dialog.kt @@ -19,9 +19,9 @@ import com.yourssu.handy.compose.DialogDefaults.dialogButtonSpacing import com.yourssu.handy.compose.DialogDefaults.dialogPadding import com.yourssu.handy.compose.DialogDefaults.dialogTextInsidePadding import com.yourssu.handy.compose.DialogDefaults.dialogWidth -import com.yourssu.handy.compose.button.BoxButton -import com.yourssu.handy.compose.button.BoxButtonSize -import com.yourssu.handy.compose.button.BoxButtonType +import com.yourssu.handy.compose.button.FilledButton +import com.yourssu.handy.compose.button.FilledButtonSize +import com.yourssu.handy.compose.button.FilledButtonType import com.yourssu.handy.compose.foundation.HandyTypography import com.yourssu.handy.compose.foundation.Radius import com.yourssu.handy.compose.icons.HandyIcons @@ -95,11 +95,11 @@ fun OneButtonDialog( } } - BoxButton( + FilledButton( modifier = Modifier.fillMaxWidth(), text = positiveText, onClick = { onPositiveClick() }, - sizeType = BoxButtonSize.L, + sizeType = FilledButtonSize.L, ) } } @@ -180,21 +180,21 @@ fun TwoButtonDialog( Row( modifier = Modifier.align(Alignment.CenterHorizontally) ) { - BoxButton( + FilledButton( modifier = Modifier.weight(1f), text = negativeText, onClick = { onNegativeClick() }, - sizeType = BoxButtonSize.L, - buttonType = BoxButtonType.Secondary + sizeType = FilledButtonSize.L, + buttonType = FilledButtonType.Secondary ) Spacer(modifier = Modifier.width(dialogButtonSpacing)) - BoxButton( + FilledButton( modifier = Modifier.weight(1f), text = positiveText, onClick = { onPositiveClick() }, - sizeType = BoxButtonSize.L, + sizeType = FilledButtonSize.L, ) } } diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt new file mode 100644 index 00000000..c6c62853 --- /dev/null +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt @@ -0,0 +1,190 @@ +package com.yourssu.handy.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.yourssu.handy.compose.foundation.HandyTypography +import com.yourssu.handy.compose.icons.HandyIcons +import com.yourssu.handy.compose.icons.filled.AlertTriangle +import com.yourssu.handy.compose.icons.line.Close +import kotlin.math.roundToInt + +/** + * 드래그 위치를 나타냅니다. + * Start는 초기 위치, End는 스낵바가 사라질 위치를 나타냅니다. + */ +enum class DragValue { + Start, End +} + +/** + * 정보성 스낵바의 UI를 그린 함수입니다. + * + * 유저의 행동에 대한 단순 결과를 나타낼 때 사용합니다. + * + * 특정 시간(기본 5초) 노출 후에 자동으로 사라집니다. + * 아래로 스와이프 할 경우에도 사라집니다. + * + * @param text 스낵바의 문구를 나타내는 텍스트, 최대 두 줄까지 입력 가능 + * @param modifier Modifier + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +fun InfoSnackBar( + text: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val state = remember { + AnchoredDraggableState( + initialValue = DragValue.Start, + anchors = DraggableAnchors { + with(density) { + DragValue.Start at 0f + DragValue.End at 20.dp.toPx() + } + }, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { with(density) { 50.dp.toPx() } }, + animationSpec = tween(), + ) + } + val offsetY = remember { Animatable(0f) } + + if (state.currentValue == DragValue.End) { + onDismiss() + } + + Column( + modifier = modifier + .offset { + IntOffset( + x = 0, + y = offsetY.value.roundToInt() + ) + } + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(HandyTheme.colors.snackBarInfo) + .padding(16.dp) + .anchoredDraggable( + state = state, + orientation = Orientation.Vertical, + ) + ) { + text.split("\n").forEach { line -> + FlowRow { + line.split(" ").forEachIndexed { index, word -> + Text( + text = word, + color = HandyTheme.colors.textBasicWhite, + maxLines = 2, + style = HandyTypography.B3Sb14 + ) + if (index != line.split(" ").lastIndex) { + Text( + text = " ", + color = HandyTheme.colors.textBasicWhite, + style = HandyTypography.B3Sb14 + ) + } + } + } + } + } +} + +/** + * 에러 스낵바의 UI를 그린 함수입니다. + * + * 사용자의 수행 과정에 부정적인 결과가 발생하거나 + * 정보성 스낵바보다 강조해야 할 메시지를 담아야 할 때 사용합니다. + * + * X 버튼을 눌러야만 사라집니다. + * + * @param text 스낵바의 문구를 나타내는 텍스트, 최대 두 줄까지 입력 가능 + * @param onClose 스낵바의 X 버튼을 눌렀을 때 호출되는 함수 + * @param modifier Modifier + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ErrorSnackBar( + text: String, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(HandyTheme.colors.snackBarError) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = HandyIcons.Filled.AlertTriangle, + tint = HandyTheme.colors.bgStatusNegative, + modifier = Modifier.align(Alignment.Top) + ) + text.split("\n").forEach { line -> + FlowRow(modifier = Modifier.weight(1f)) { + line.split(" ").forEachIndexed { index, word -> + Text( + text = word, + color = HandyTheme.colors.textStatusNegative, + maxLines = 2, + style = HandyTypography.B3Sb14 + ) + if (index != line.split(" ").lastIndex) { + Text( + text = " ", + color = HandyTheme.colors.textStatusNegative, + style = HandyTypography.B3Sb14 + ) + } + } + } + } + Icon( + imageVector = HandyIcons.Line.Close, + tint = HandyTheme.colors.textBasicTertiary, + modifier = Modifier + .clickable(onClick = onClose) + .align(Alignment.Top) + ) + } +} + +object SnackBarDefaults { + const val SNACK_BAR_DURATION = 5000L + const val FADE_IN_DURATION = 500 + const val FADE_OUT_DURATION = 300 + const val TARGET_VALUE = -16f +} \ No newline at end of file diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt new file mode 100644 index 00000000..085fe6af --- /dev/null +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt @@ -0,0 +1,161 @@ +package com.yourssu.handy.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.State +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.alpha +import androidx.compose.ui.unit.dp +import com.yourssu.handy.compose.SnackBarDefaults.FADE_IN_DURATION +import com.yourssu.handy.compose.SnackBarDefaults.FADE_OUT_DURATION +import com.yourssu.handy.compose.SnackBarDefaults.TARGET_VALUE + +data class SnackBarTransitionItem( + val snackBarData: SnackBarData?, + val opacityTransition: OpacityTransition +) + +typealias OpacityTransition = @Composable (snackBar: @Composable () -> Unit) -> Unit + +/** + * 스낵바에 애니메이션을 적용합니다. + * + * @param newSnackBarData 새로운 스낵바 데이터 + * @param modifier Modifier + * @param snackBar 스낵바 UI의 컴포저블 함수 + */ +@Composable +fun FadeInFadeOut( + newSnackBarData: SnackBarData?, + modifier: Modifier = Modifier, + snackBar: @Composable (SnackBarData) -> Unit +) { + var scheduledSnackBarData by remember { mutableStateOf(null) } + val snackBarTransitions = remember { mutableListOf() } + var scope by remember { mutableStateOf(null) } + + if (newSnackBarData != scheduledSnackBarData) { + scheduledSnackBarData = newSnackBarData + + val snackBarDataList = snackBarTransitions.map { it.snackBarData }.toMutableList() + + snackBarDataList.add(newSnackBarData) + + snackBarTransitions.clear() + + snackBarDataList.filterNotNull() + .mapTo(destination = snackBarTransitions) { appearedSnackBarData -> + SnackBarTransitionItem(appearedSnackBarData) { snackBar -> + val isVisible = appearedSnackBarData == newSnackBarData + val animateInSpec: AnimationSpec = + tween(durationMillis = FADE_IN_DURATION) + val animateOutSpec: AnimationSpec = + tween(durationMillis = FADE_OUT_DURATION, easing = EaseOut) + + val opacity = animatedOpacity( + visible = isVisible, + animateInSpec = animateInSpec, + animateOutSpec = animateOutSpec + ) + + val offsetY = animatedOffset( + visible = isVisible, + animateInSpec = animateInSpec, + animateOutSpec = animateOutSpec + ) + + Box( + modifier = Modifier + .offset(y = offsetY.value.dp) + .alpha(opacity.value) + ) { + snackBar() + } + } + } + } + + Box( + modifier = modifier + ) { + scope = currentRecomposeScope + snackBarTransitions.forEach { (snackBarData, opacity) -> + key(snackBarData) { + opacity { + snackBar(snackBarData ?: return@opacity) + } + } + } + } +} + +/** + * 투명도 애니메이션을 위한 함수입니다. + * + * @param visible 애니메이션의 시작 여부 + * @param animateInSpec 애니메이션이 시작될 때 적용되는 스펙 + * @param animateOutSpec 애니메이션이 종료될 때 적용되는 스펙 + * @return 현재 투명도 상태 + */ +@Composable +private fun animatedOpacity( + visible: Boolean, + animateInSpec: AnimationSpec, + animateOutSpec: AnimationSpec, +): State { + val alpha = remember { Animatable(0f) } + + LaunchedEffect(visible) { + alpha.animateTo( + if (visible) 1f else 0f, + animationSpec = if (visible) animateInSpec else animateOutSpec + ) + } + return alpha.asState() +} + +/** + * Y축 오프셋 애니메이션을 위한 함수입니다. + * + * @param visible 애니메이션 시작 여부 + * @param animateInSpec 애니메이션이 시작될 때 적용되는 스펙 + * @param animateOutSpec 애니메이션이 종료될 때 적용되는 스펙 + * @return 현재 오프셋 상태 + */ +@Composable +private fun animatedOffset( + visible: Boolean, + animateInSpec: AnimationSpec, + animateOutSpec: AnimationSpec, +): State { + val offsetY = remember { Animatable(0f) } + + LaunchedEffect(visible) { + if (visible) { + offsetY.animateTo( + targetValue = TARGET_VALUE, + animationSpec = animateInSpec + ) + } else { + offsetY.animateTo( + targetValue = 0f, + animationSpec = animateOutSpec + ) + } + } + + return offsetY.asState() +} diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt new file mode 100644 index 00000000..89345c62 --- /dev/null +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt @@ -0,0 +1,117 @@ +package com.yourssu.handy.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +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 com.yourssu.handy.compose.SnackBarDefaults.SNACK_BAR_DURATION +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume + +interface SnackBarData { + val message: String + val type: SnackBarType + + fun dismiss() +} + +enum class SnackBarResult { + Dismissed, +} + +enum class SnackBarType { + Info, + Error, +} + +@Stable +class SnackBarHostState { + private val mutex = Mutex() + + internal var currentSnackBarData by mutableStateOf(null) + private set + + suspend fun showSnackBar( + message: String, + type: SnackBarType + ): SnackBarResult = mutex.withLock { + try { + return suspendCancellableCoroutine { continuation -> + currentSnackBarData = SnackBarDataImpl( + message = message, + type = type, + continuation = continuation + ) + } + } finally { + currentSnackBarData = null + } + } +} + +@Composable +fun rememberSnackBarHostState(): SnackBarHostState = remember { SnackBarHostState() } + +@Stable +private class SnackBarDataImpl( + override val message: String, + override val type: SnackBarType, + private val continuation: CancellableContinuation +) : SnackBarData { + + override fun dismiss() { + if (continuation.isActive) { + continuation.resume(SnackBarResult.Dismissed) + } + } +} + +/** + * SnackBar를 보여주기 위해선 SnackBarHost를 통해 상태를 관리해야 합니다. + * 스낵바의 타입에 따라 정보성 스낵바또는 에러 스낵바를 표시합니다. + * + * @param snackBarHostState 스낵바 상태 관리 객체 + * @param modifier Modifier + * @param snackBar 스낵바 UI의 컴포저블 함수 + */ +@Composable +fun SnackBarHost( + snackBarHostState: SnackBarHostState, + modifier: Modifier = Modifier, + snackBar: @Composable (SnackBarData) -> Unit = { snackBarData -> + when (snackBarData.type) { + SnackBarType.Info -> InfoSnackBar( + text = snackBarHostState.currentSnackBarData?.message.orEmpty(), + onDismiss = snackBarData::dismiss + ) + + SnackBarType.Error -> ErrorSnackBar( + text = snackBarHostState.currentSnackBarData?.message.orEmpty(), + onClose = snackBarData::dismiss + ) + } + }, +) { + val currentSnackBarData = snackBarHostState.currentSnackBarData + + if (currentSnackBarData?.type == SnackBarType.Info) { + LaunchedEffect(currentSnackBarData) { + delay(SNACK_BAR_DURATION) + currentSnackBarData.dismiss() + } + } + + FadeInFadeOut( + newSnackBarData = currentSnackBarData, + modifier = modifier, + snackBar = snackBar + ) +} diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt index 7b2ce281..5a50a8de 100644 --- a/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt +++ b/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt @@ -114,6 +114,10 @@ data class ColorScheme( val paginationBasicSelected: Color = ColorNeutralBlack, val paginationBasicUnselected: Color = ColorGray500, + // SnackBar + val snackBarInfo: Color = ColorGray800, + val snackBarError: Color = ColorStatusRedSub, + // Switch val switchUnselected: Color = ColorGray300, val switchSelected: Color = ColorViolet500,