diff --git a/Prezel/core/designsystem/build.gradle.kts b/Prezel/core/designsystem/build.gradle.kts index 232071f4..75aa9036 100644 --- a/Prezel/core/designsystem/build.gradle.kts +++ b/Prezel/core/designsystem/build.gradle.kts @@ -12,4 +12,6 @@ dependencies { implementation(libs.coil.kt.compose) implementation(libs.kotlinx.datetime) implementation(libs.timber) + + implementation(libs.balloon.compose) } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt new file mode 100644 index 00000000..2ee04914 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/feedback/tooltip/PrezelTooltipBox.kt @@ -0,0 +1,206 @@ +package com.team.prezel.core.designsystem.component.feedback.tooltip + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.skydoves.balloon.ArrowPositionRules +import com.skydoves.balloon.Balloon +import com.skydoves.balloon.BalloonAnimation +import com.skydoves.balloon.compose.balloon +import com.skydoves.balloon.compose.rememberBalloonBuilder +import com.skydoves.balloon.compose.rememberBalloonState +import com.skydoves.balloon.compose.setBackgroundColor +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.PreviewScaffold +import com.team.prezel.core.designsystem.theme.PrezelColorScheme +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +fun PrezelTooltipBox( + text: String, + modifier: Modifier = Modifier, + showDismissIcon: Boolean = true, + showArrow: Boolean = true, + content: @Composable BoxScope.() -> Unit, +) { + val builder = rememberBalloonBuilder(showArrow) + val state = rememberBalloonState(builder) + val view = LocalView.current + val visibleFrame = remember { android.graphics.Rect() } + var shouldRestoreTooltip by remember { mutableStateOf(false) } + var isAnchorVisibleInWindow by remember { mutableStateOf(true) } + + LaunchedEffect(shouldRestoreTooltip, isAnchorVisibleInWindow) { + if (shouldRestoreTooltip && isAnchorVisibleInWindow) state.showAlignTop() else state.dismiss() + } + + Box( + content = content, + modifier = modifier + .noRippleClick { shouldRestoreTooltip = true } + .onGloballyPositioned { coordinates -> + view.getWindowVisibleDisplayFrame(visibleFrame) + isAnchorVisibleInWindow = coordinates.boundsInWindow().intersects(visibleFrame.toComposeRect()) + }.balloon(state) { + TooltipContent( + text = text, + showDismissIcon = showDismissIcon, + modifier = Modifier.noRippleClick { + shouldRestoreTooltip = false + state.dismiss() + }, + ) + }, + ) +} + +@Composable +private fun rememberBalloonBuilder(showArrow: Boolean): Balloon.Builder = + rememberBalloonBuilder { + setIsVisibleArrow(showArrow) + setArrowWidth(12) + setArrowHeight(6) + setArrowPosition(0.5f) + setArrowPositionRules(ArrowPositionRules.ALIGN_ANCHOR) + setCornerRadius(6f) + setPaddingVertical(4) + setPaddingLeft(8) + setPaddingRight(6) + setDismissWhenTouchOutside(false) + setBalloonAnimation(BalloonAnimation.NONE) + setBackgroundColor(PrezelColorScheme.Dark.bgMedium) + } + +private fun Rect.intersects(other: Rect): Boolean = left < other.right && right > other.left && top < other.bottom && bottom > other.top + +@Composable +private fun Modifier.noRippleClick(onClick: () -> Unit): Modifier = + this.clickable( + interactionSource = null, + indication = null, + onClick = onClick, + ) + +@Composable +private fun TooltipContent( + text: String, + modifier: Modifier = Modifier, + showDismissIcon: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Text( + text = text, + style = PrezelTheme.typography.caption1Regular, + color = PrezelColorScheme.Dark.textLarge, + ) + + if (showDismissIcon) { + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V2)) + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = stringResource(R.string.core_designsystem_tooltip_cancel_btn_content_desc), + tint = PrezelColorScheme.Dark.iconLarge, + modifier = Modifier + .size(14.dp) + .offset(x = 2.dp), + ) + } + } +} + +@BasicPreview +@Composable +private fun PrezelTooltipBoxPreview() { + PrezelTheme { + PreviewScaffold(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Text(text = "정확도", style = PrezelTheme.typography.title2Bold) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PrezelTooltipBox(text = "SPM은 1분당 말하는 음절의 수에요.") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "Label", style = PrezelTheme.typography.body3Regular) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(PrezelIcons.Blank), + contentDescription = "", + ) + } + } + + PrezelTooltipBox(text = "SPM은 1분당 말하는 음절의 수에요.") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "Label", style = PrezelTheme.typography.body3Regular) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(PrezelIcons.Blank), + contentDescription = "", + ) + } + } + + PrezelTooltipBox(text = "SPM은 1분당 말하는 음절의 수에요.") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "Label", style = PrezelTheme.typography.body3Regular) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(PrezelIcons.Blank), + contentDescription = "", + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1000.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = "This is Blank for Scroll") + } + } + } + } +} diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index f994348e..9e7e3bda 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ 체크박스 선택하기 %1$d년 %2$d월 + 툴팁 닫기 diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 0b64faba..2529bd56 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -28,6 +28,7 @@ kotlinxCollectionsImmutable = "0.4.0" kotlinxSerialization = "1.9.0" kakao = "2.23.2" lottie = "6.6.7" +balloonCompose = "1.7.6" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -77,7 +78,9 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" } + lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } +balloon-compose = { module = "com.github.skydoves:balloon-compose", version.ref = "balloonCompose" } # Dependencies of the included build-logic android-gradleApiPlugin = { group = "com.android.tools.build", name = "gradle-api", version.ref = "agp" }