동기
프로젝트에서 드롭다운 메뉴를 구현했어야 했는데요!

드롭다운 메뉴의 기본 UX는 다음과 같습니다.
- 필드 클릭 시 메뉴 오픈 또는 클로즈 토글
- 메뉴 아이템 클릭 시 선택값 반환 및 메뉴 클로즈
- 메뉴 바깥 영역 클릭 시 메뉴 클로즈
- 메뉴가 열릴 때 레이아웃 내 다른 요소가 밀리면 안 됨. 즉, 레이아웃과 별개로 동동 떠 있어야 함
여기까지는 컴포즈가 제공하는 DropdownMenu로 구현이 가능해요!!
그러나 저는 DropdownMenu의 몇가지 제약으로 인해, 직접 활용하지 못하고 커스텀 드롭다운을 구현해야했어요.
DropdownMenu 제약
1. 애니메이션
디자인 요구사항으로 메뉴를 여닫을 때 슬라이드 애니메이션 적용이 필요했어요.
컴포즈의 DropdownMenu는 디폴트로 크기/투명도 관련 애니메이션을 제공하며, 해당 애니메이션 속성은 호이스팅 되지 않아 원하는 애니메이션 적용이 불가합니다.
2. 내부 패딩
메뉴에 디폴트로 패딩이 적용되며 이 또한 호이스팅 되지 않고 있어, 디자인 스펙 대로 UI 구현이 어려워요.
DropdownMenu 내부 코드를 보면 `DropdownMenu > Popup > DropdownMenuContent` 순으로 래핑하는 형태이고,
DropdownMenuContent에 애니메이션 속성, VerticalPadding이 기본값으로 박혀 있는 모습을 확인할 수 있습니다.
@Composable
actual fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier,
offset: DpOffset,
scrollState: ScrollState,
properties: PopupProperties,
shape: Shape,
containerColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
border: BorderStroke?,
content: @Composable ColumnScope.() -> Unit
) {
val expandedState = remember { MutableTransitionState(false) }
expandedState.targetState = expanded
if (expandedState.currentState || expandedState.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider =
remember(offset, density) {
DropdownMenuPositionProvider(offset, density) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties
) {
DropdownMenuContent(
expandedState = expandedState,
transformOriginState = transformOriginState,
scrollState = scrollState,
shape = shape,
containerColor = containerColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
modifier = modifier,
content = content,
)
}
}
}
@Composable
internal fun DropdownMenuContent(
modifier: Modifier,
expandedState: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
scrollState: ScrollState,
shape: Shape,
containerColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
border: BorderStroke?,
content: @Composable ColumnScope.() -> Unit
) {
// Menu open/close animation.
@Suppress("DEPRECATION") val transition = updateTransition(expandedState, "DropDownMenu")
val scale by
transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = InTransitionDuration, easing = LinearOutSlowInEasing)
} else {
// Expanded to dismissed.
tween(durationMillis = 1, delayMillis = OutTransitionDuration - 1)
}
}
) { expanded ->
if (expanded) ExpandedScaleTarget else ClosedScaleTarget
}
val alpha by
transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}
) { expanded ->
if (expanded) ExpandedAlphaTarget else ClosedAlphaTarget
}
val isInspecting = LocalInspectionMode.current
Surface(
modifier =
Modifier.graphicsLayer {
scaleX =
if (!isInspecting) scale
else if (expandedState.targetState) ExpandedScaleTarget else ClosedScaleTarget
scaleY =
if (!isInspecting) scale
else if (expandedState.targetState) ExpandedScaleTarget else ClosedScaleTarget
this.alpha =
if (!isInspecting) alpha
else if (expandedState.targetState) ExpandedAlphaTarget else ClosedAlphaTarget
transformOrigin = transformOriginState.value
},
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
border = border,
) {
Column(
modifier =
modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(scrollState),
content = content
)
}
}
개인적으로 좀 많이 아주 정말 아쉬운 API입니다..
사실 드롭다운 메뉴를 직접 구현하기 어려운 이유는 터치 관련 UX 관리, 레이아웃 제약을 피하기 위한 팝업 활용 및 팝업 위치 계산이라고 생각해요.
팝업까지만 제공해주고 팝업의 content를 통채로 파라미터로 전달할 수 있게 열어주면 좋았을텐데 !!
컴포즈는 DropdownMenuContent 사용을 강제하고 있으며,
DropdownMenuContent에 애니메이션과 패딩을 기본적으로 넣어둔 건 이 프로젝트가 아닌 그 어느 프로젝트여도 디자인 요구사항 대응이 불가능할 것 같아요.
그러나 코드 자체는 정말 잘 짜여진 코드이기 때문에, 이를 최대한 참고해 드롭다운 메뉴를 직접 구현해보았습니다.
설계
본 프로젝트에서 드롭다운 메뉴는 항상 텍스트 필드에 걸려있다는 점과 컴포즈 설계를 참고해 `DropdownField > DropdownMenu > Popup > DropdownMenuContent` 래핑 구조로 만들었어요.
Content 강제 싫다고 하지 않았나요.. ❔
그것은 컴포즈 API치고 확장성 고려가 부족했다는 저의 생각이고 ㅎㅎ..
본 커스텀 컴포넌트는 프로젝트에서 활용되는 드롭다운 메뉴를 모두 파악한 후에, 그에 맞추어 사용하기 쉽도록 커스텀해 만드는 것이라 Field부터 Content까지 래핑해도 괜찮다는 판단입니다!
또한 하나의 컴포저블로 묶지 않고 Content를 분리해 설계했기 때문에 추후 드롭다운 메뉴가 확장되더라도 Content만 스위칭하면 되어 수정이 용이하다는 장점이 있습니다.
저는 기본적인 드롭다운 메뉴 UX를 충족하면서도, 다음 조건을 만족해야 했습니다.
- 드롭다운 메뉴의 width는 필드의 width와 동일하다
- 드롭다운 메뉴는 필드 하단 12dp 떨어져 위치한다
- 메뉴 여닫을 시 슬라이드 애니메이션이 적용된다
커스텀 DropdownField
목적
뷰에서 드롭다운 필드를 사용하기 편하게 추상화하는 것이 1순위
뷰에서 관리되는 데이터가 필드와 상호작용될 수 있도록 연결하는 것이 집중
역할
1. 내부적으로 필드와 드롭다운 메뉴를 Box로 묶어줌
Box로 묶는 이유❔
아래의 DropdownMenu 코드를 보면 더 이해하기 쉬울텐데, 드롭다운 메뉴는 Popup으로 띄워지고, Popup의 적절한 위치를 계산하기 위해 Anchor라는 개념이 있어요.
앵커란 팝업 위치의 기준점이 되는 컴포저블입니다.
본 프로젝트에서는 필드를 기준으로 하단 12dp 떨어진 곳에 드롭다운 메뉴가 위치하므로, 필드가 앵커가 됩니다.
팝업의 위치는 앵커의 좌표를 기반으로 계산되어 매우 중요하며, 팝업이 앵커의 좌표를 얻는 가장 쉬운 방법은 단순히 Box 내부에 두 컴포저블이 연속하도록 묶어주는 것입니다!
Box { Field() Popup() } 과 같이 작성해주면 Popup 내부에서 PopupPositionProvider를 활용할 때, Field의 좌표를 읽을 수 있도록 컴포즈가 팝업을 설계해두었어요.
2. 필드 또는 메뉴 아이템 클릭 시 메뉴가 닫히도록 내부적으로 상태 관리
PopupProperties( focusable = true) 사용한 이유❔
클릭 이벤트 관련해 난관이 있었는데요..
다음 두 가지 콜백이 충돌해 심각한 UX 문제가 있었어요.
- 필드 클릭 시 메뉴 오픈 여부 토글 (onClick)
- 메뉴 외부 클릭 시 메뉴 클로즈 (onDismiss)
의도한 동작은 필드 클릭 시 메뉴가 닫히는 것이었는데요,
- 필드 클릭 시, 메뉴 외부 클릭으로 인식되어 메뉴 클로즈
- 필드 클릭 콜백 호출되어 오픈 여부 토글, 즉 클로즈가 다시 오픈 상태로 됨
정리하면 메뉴가 열린 상태에서 필드를 클릭하면, 메뉴가 닫혔다 바로 열려버리는 문제였어요.
필드 클릭 시 두 콜백 중 하나만 실행되면 해결되는 문제이기도 합니다.
onClick, onDismiss에 로그를 심어 확인해본 결과 항상 onDismiss가 먼저 호출된다는 것을 확인했습니다.
onDismiss는 팝업 자체에 전달되는 콜백으로, 팝업(여기서는 드롭다운 메뉴) 외부를 클릭하면 무조건 우선적으로 호출되어 onDismiss를 막는 것은 어려웠어요.
이를 해결한 것이 focusable = true 였습니다.
이는 Popup이 포커스를 가질 수 있는 속성으로, 터치 이벤트 발생 시 팝업이 이 이벤트 터치 이벤트를 받을 수 있게 합니다. 쉽게 말해 터치 이벤트를 먹어버려요!
따라서 필드를 터치한 이벤트를 팝업이 받고, 필드는 받지 못해 필드의 onClick이 실행되지 않고 패스되어 문제가 해결됩니다.
3. 필드의 width를 계산해, DropdownMenu 파라미터로 전달
Width를 전달하는 이유❔
팝업은 레이아웃 트리를 벗어나는 컴포저블이어서, fillMaxWidth + padding과 같은 modifier 조건이 먹히지 않아요.
정확한 원리는 모르겠지만 horizontalPadding값을 넣어도 전혀 적용되지 않고 무조건 화면 너비를 가득 채우는 현상이 있었어요.
예상하기로는, 필드는 Box의 레이아웃 제약을 명확히 받고 있어요. 따라서 fillMaxWidth + padding 제약에 대해, Box 내 너비를 채운 후 패딩을 적용하는 것이 가능합니다.
그러나 팝업은 부모의 개념, 레이아웃 제약이라는 것이 없어요. 같은 fillMaxWidth이지만 필드는 최대 너비가 존재해 "채우고 남은 공간"이라는 것이 있고, 팝업은 화면을 채울 수 있는 대로 채우는 것이 가능합니다. 따라서 팝업은 언제까지나 폭을 최대로 늘리는게 가능해 "폭을 가능한 늘리고 여백을 만든다"라는 매커니즘이 작동하지 않은 것 같습니다.
이 문제를 해결하기 위해 필드에 onGloballyPositioned 적용해 필드 폭 픽셀값을 구하고, DropdownMenu에 전달해주었습니다.
@Composable
fun PotiDropdownField(
value: String,
placeholder: String,
onItemClick: (FieldMenuItem) -> Unit,
menuItems: List<FieldMenuItem>,
selectedIds: Set<String>,
modifier: Modifier = Modifier,
initialOpenState: Boolean = false,
closeOnItemClick: Boolean = true,
scrollState: LazyListState = rememberLazyListState(),
offset: DpOffset = DpOffset(x = 0.dp, y = 12.dp),
shape: Shape = RoundedCornerShape(8.dp),
border: BorderStroke = BorderStroke(1.dp, PotiTheme.colors.gray700),
) {
val expandedState = remember { MutableTransitionState(initialOpenState) }
var parentWidth by remember { mutableIntStateOf(0) }
val density = LocalDensity.current
Box(
modifier = modifier
.fillMaxWidth(),
) {
PotiBasicField(
value = value,
onValueChanged = {},
placeholder = placeholder,
modifier = Modifier
.fillMaxWidth()
.heightIn(52.dp)
.onGloballyPositioned { coordinates ->
parentWidth = coordinates.size.width
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) {
expandedState.targetState = !expandedState.currentState
},
trailingIcon = {
Crossfade(
targetState = expandedState.targetState,
) { opened ->
Icon(
imageVector = ImageVector.vectorResource(if (opened) R.drawable.ic_arrow_up_lg else R.drawable.ic_arrow_down_lg),
contentDescription = null,
modifier = Modifier
.size(24.dp),
tint = PotiTheme.colors.gray700,
)
}
},
enabled = false,
)
PotiDropdownMenu(
expandedState = expandedState,
onDismissRequest = {
expandedState.targetState = false
},
scrollState = scrollState,
parentWidth = parentWidth,
offset = offset,
shape = shape,
border = border,
popupProperties = PopupProperties(
focusable = true,
),
) {
itemsIndexed(menuItems) { index, item ->
PotiMenuItem(
option = item.option,
onClick = {
onItemClick(item)
if (closeOnItemClick) {
expandedState.targetState = false
}
},
isSelected = item.id in selectedIds,
price = item.price,
disabled = item.disabled,
showBottomBorder = index < menuItems.size,
)
}
}
}
}
커스텀 DropdownMenu
목적
앵커 기반 팝업 좌표를 계산, 내부적으로 팝업을 가지며 좌표값 전달
역할
1. expandedState에 따라 팝업 노출 여부 관리
2. popupPositionProvider 활용 팝업 좌표 계산
좌표 계산 방법❔
단순히 앵커 하단에 위치하면 되므로 x 좌표는 앵커의 x 좌표와 동일하게 설정합니다.
y 좌표는 앵커의 y 좌표에 offset을 더해준 값으로 설정합니다.
이때 앵커의 x/y 좌표는 스크롤 등을 고려해 실시간으로 계산되는 것이 맞으나, y 좌표 offset의 경우 "12dp"라는 고정값입니다.
따라서 고정값인 offset 픽셀 변환식은 remebmer 내에서 수행해 한 번만 계산되게 했습니다.
3. 팝업 호출 및 파라미터 전달
@Composable
internal fun PotiDropdownMenu(
expandedState: MutableTransitionState<Boolean>,
onDismissRequest: () -> Unit,
parentWidth: Int,
offset: DpOffset,
scrollState: LazyListState,
shape: Shape,
border: BorderStroke,
maxHeight: Dp?,
popupProperties: PopupProperties = PopupProperties(),
content: LazyListScope.() -> Unit,
) {
if (expandedState.currentState || expandedState.targetState) {
val density = LocalDensity.current
val popupPositionProvider = remember(offset, density) {
val offsetYPx = with(density) { offset.y.roundToPx() }
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
return IntOffset(
x = anchorBounds.left,
y = anchorBounds.bottom + offsetYPx,
)
}
}
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = popupProperties,
) {
PotiDropdownMenuContent(
expandedState = expandedState,
scrollState = scrollState,
shape = shape,
border = border,
parentWidth = parentWidth,
maxHeight = maxHeight,
content = content,
)
}
}
}
커스텀 DropdownMenuContent
목적
디자인 요구사항에 부합하는 팝업 내부 레이아웃 제공
역할
1. expanded 상태에 따른 AnimatedVisibility 제공
2. 전달받은 절대값 width 적용 및 기본 레이아웃 구성
@Composable
private fun PotiDropdownMenuContent(
expandedState: MutableTransitionState<Boolean>,
scrollState: LazyListState,
shape: Shape,
border: BorderStroke?,
parentWidth: Int,
maxHeight: Dp?,
content: LazyListScope.() -> Unit,
) {
val density = LocalDensity.current
AnimatedVisibility(
visibleState = expandedState,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(120, easing = LinearOutSlowInEasing),
),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(75, easing = LinearOutSlowInEasing),
),
) {
Surface(
modifier = Modifier
.width(with(density) { parentWidth.toDp() }),
shape = shape,
border = border,
) {
LazyColumn(
state = scrollState,
content = content,
)
}
}
}
소감
우선 제 코드의 잘못된 점 지적해주고, 더 자연스럽고 성능적으로 좋은 코드 제안해주신 지현님 민성님 정말 감사합니다ㅠㅠ
이 컴포넌트를 구현하며, "나는 지금까지 트러블 슈팅이라는 걸 한 번도 해본 적이 없구나"라는 생각이 들만큼 너무 어려웠어요.
그럼에도 어떻게든 해내서 다행이라는 생각이 들고, 분명 이보다 더 좋게 작성할 수 있겠지만 지금은 구체적인 방법이 떠오르지 않아 ㅎㅎ 나중에 다시 열어보려 합니다.
컴포즈 드롭다운 코드를 분석하고, 팝업 좌표를 직접 계산하거나 onGloballyPositioned를 활용해보는 등 평소 쉬운 UI를 짤 때보다 비교도 안 되게 깊이가 있었어요.
실제로 이후로 커스텀 툴팁을 구현하거나, 컴포저블의 크기를 구해서 활용함에 있어
이전이라면 감도 못잡았었겠지만 이 경험으로 복잡한 뷰도 빠르게 구현할 수 있었던 것 같아요!
'Android' 카테고리의 다른 글
| [Android][Jetpack Compose] 이미지를 불러와, 최적화하고, 서버에 전송해보자 (feat. presigned-url) (0) | 2026.02.08 |
|---|---|
| [Android] 업데이트 안 해도 아이콘을 요리조리 바꾸는 법 (Dynamic App Icon / feat. 듀오링고) (0) | 2025.12.21 |
| [Kotlin] Kotlin in Action 3장. 함수 정의와 호출 (0) | 2025.11.12 |
