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,