Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/src/main/java/com/yapp/ndgl/navigation/BottomNavTab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ enum class BottomNavTab(
val route: Route,
) {
TRAVEL_HELPER(
icon = R.drawable.ic_nav_helper,
icon = R.drawable.ic_24_tool,
label = "여행 도구",
route = Route.TravelHelper,
),
HOME(
icon = R.drawable.ic_nav_home,
icon = R.drawable.ic_24_home,
label = "홈",
route = Route.Home,
),
TRAVEL(
icon = R.drawable.ic_nav_travel,
icon = R.drawable.ic_24_bag,
label = "여행",
route = Route.Travel,
),
Expand Down
129 changes: 115 additions & 14 deletions app/src/main/java/com/yapp/ndgl/ui/BottomNavigationBar.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
package com.yapp.ndgl.ui

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.yapp.ndgl.core.ui.theme.NDGLTheme
import com.yapp.ndgl.core.ui.util.dropShadow
import com.yapp.ndgl.navigation.BottomNavTab
import com.yapp.ndgl.navigation.Route

Expand All @@ -15,20 +48,88 @@ internal fun BottomNavigationBar(
currentTab: Route,
onTabSelected: (Route) -> Unit,
) {
// FIXME 네비게이션 바 디자인 수정 및 추상화
NavigationBar {
BottomNavTab.entries.forEach { topLevelRoute ->
NavigationBarItem(
selected = currentTab == topLevelRoute.route,
onClick = { onTabSelected(topLevelRoute.route) },
icon = {
val tabs = BottomNavTab.entries
val selectedIndex = tabs.indexOfFirst { it.route == currentTab }
Comment thread
mj010504 marked this conversation as resolved.

BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(bottom = 20.dp)
.padding(horizontal = 24.dp)
.height(68.dp)
.dropShadow(
shape = RoundedCornerShape(34.dp),
color = Color.Black.copy(alpha = 0.15f),
offsetX = 1.dp,
offsetY = 6.dp,
blur = 12.dp,
)
.background(NDGLTheme.colors.white, RoundedCornerShape(34.dp))
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
val totalWidth = maxWidth
val selectedWidth = maxWidth * 0.56f
Comment thread
jihee-dev marked this conversation as resolved.
val unselectedWidth = (totalWidth - selectedWidth) / (tabs.size - 1)
val indicatorOffset by animateDpAsState(
targetValue = when (selectedIndex) {
0 -> 0.dp
1 -> unselectedWidth
else -> totalWidth - selectedWidth
},
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
)

Box(
modifier = Modifier
.offset(x = indicatorOffset)
.width(selectedWidth)
.fillMaxHeight()
.background(NDGLTheme.colors.black900, CircleShape),
)

Row(modifier = Modifier.fillMaxSize()) {
tabs.forEach { tab ->
val isSelected = currentTab == tab.route

Row(
modifier = Modifier
.width(if (isSelected) selectedWidth else unselectedWidth)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onTabSelected(tab.route) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
Icon(
imageVector = ImageVector.vectorResource(id = topLevelRoute.icon),
contentDescription = topLevelRoute.label,
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(id = tab.icon),
contentDescription = tab.label,
tint = if (isSelected) NDGLTheme.colors.white else NDGLTheme.colors.black600,
)
},
label = { Text(text = topLevelRoute.label) },
)

AnimatedVisibility(
visible = isSelected,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Text(
text = tab.label,
color = NDGLTheme.colors.white,
style = NDGLTheme.typography.bodyLgMedium,
maxLines = 1,
)
}
}
}
}
Comment on lines +89 to 125
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

탭 너비가 애니메이션 없이 즉시 변경됨 — indicator와 시각적 불일치 가능

Line 74-81에서 indicator의 offset은 spring 애니메이션으로 부드럽게 이동하지만, Line 97에서 각 탭의 widthif/else로 즉시 전환됩니다. 이로 인해 indicator는 슬라이딩 중인데 탭 콘텐츠(아이콘+라벨)는 갑자기 위치가 바뀌는 현상이 발생할 수 있습니다.

탭 너비도 animateDpAsState로 감싸면 indicator와 동기화됩니다:

♻️ 탭 너비 애니메이션 적용 제안
             tabs.forEach { tab ->
                 val isSelected = currentTab == tab.route
+                val tabWidth by animateDpAsState(
+                    targetValue = if (isSelected) selectedWidth else unselectedWidth,
+                    animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
+                )

                 Row(
                     modifier = Modifier
-                        .width(if (isSelected) selectedWidth else unselectedWidth)
+                        .width(tabWidth)
                         .fillMaxHeight()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Row(modifier = Modifier.fillMaxSize()) {
tabs.forEach { tab ->
val isSelected = currentTab == tab.route
Row(
modifier = Modifier
.width(if (isSelected) selectedWidth else unselectedWidth)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onTabSelected(tab.route) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
Icon(
imageVector = ImageVector.vectorResource(id = topLevelRoute.icon),
contentDescription = topLevelRoute.label,
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(id = tab.icon),
contentDescription = tab.label,
tint = if (isSelected) NDGLTheme.colors.white else NDGLTheme.colors.black600,
)
},
label = { Text(text = topLevelRoute.label) },
)
AnimatedVisibility(
visible = isSelected,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Text(
text = tab.label,
color = NDGLTheme.colors.white,
style = NDGLTheme.typography.bodyLgMedium,
maxLines = 1,
)
}
}
}
}
Row(modifier = Modifier.fillMaxSize()) {
tabs.forEach { tab ->
val isSelected = currentTab == tab.route
val tabWidth by animateDpAsState(
targetValue = if (isSelected) selectedWidth else unselectedWidth,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
)
Row(
modifier = Modifier
.width(tabWidth)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onTabSelected(tab.route) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(id = tab.icon),
contentDescription = tab.label,
tint = if (isSelected) NDGLTheme.colors.white else NDGLTheme.colors.black600,
)
AnimatedVisibility(
visible = isSelected,
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Text(
text = tab.label,
color = NDGLTheme.colors.white,
style = NDGLTheme.typography.bodyLgMedium,
maxLines = 1,
)
}
}
}
}
🤖 Prompt for AI Agents
In `@app/src/main/java/com/yapp/ndgl/ui/BottomNavigationBar.kt` around lines 91 -
127, 현재 탭 너비가 즉시 바뀌어 indicator의 spring 애니메이션과 시각적으로 어긋나므로, tabs.forEach 블록 안에서
isSelected 기반으로 selectedWidth/unselectedWidth 값을 animateDpAsState로 래핑해 탭의 width에
적용하세요; 예를 들어 val tabWidth = animateDpAsState(targetValue = if (isSelected)
selectedWidth else unselectedWidth, animationSpec = spring(...)) 형태로 생성하고 기존
Row의 Modifier.width(...)를 .width(tabWidth.value)로 교체해 indicator의 offset 애니메이션과
동기화되도록 만드세요 (참조: tabs.forEach, isSelected, selectedWidth, unselectedWidth,
onTabSelected, AnimatedVisibility).

}
}

@Preview(showBackground = true)
@Composable
private fun BottomNavigationBarPreview() {
BottomNavigationBar(currentTab = BottomNavTab.HOME.route, onTabSelected = {})
}
27 changes: 27 additions & 0 deletions core/ui/src/main/java/com/yapp/ndgl/core/ui/util/DropShadow.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.yapp.ndgl.core.ui.util

import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp

fun Modifier.dropShadow(
shape: Shape,
color: Color = Color.Black.copy(alpha = 0.25f),
blur: Dp = 0.dp,
offsetY: Dp = 0.dp,
offsetX: Dp = 0.dp,
spread: Dp = 0.dp,
) = this.dropShadow(
shape = shape,
shadow = Shadow(
radius = blur,
color = color,
spread = spread,
offset = DpOffset(x = offsetX, y = offsetY),
),
)
19 changes: 0 additions & 19 deletions core/ui/src/main/res/drawable/ic_nav_helper.xml

This file was deleted.

10 changes: 0 additions & 10 deletions core/ui/src/main/res/drawable/ic_nav_home.xml

This file was deleted.

10 changes: 0 additions & 10 deletions core/ui/src/main/res/drawable/ic_nav_travel.xml

This file was deleted.