Jetpack Compose로 세그먼트 토글 애니메이션 만들기 (움직이는 인디케이터 + 글자색 전환)
안녕하세요. 오늘은 Compose에서 주황색 바가 좌우로 슬라이드되면서 글자색이 바뀌는 세그먼트 토글을 구현해봤습니다.
iOS의 UISegmentedControl 같은 느낌입니다. 디자인 시안에서 자주 나오지만, 막상 만들려고 하면 바를 어떻게 움직일지, 글자색은 어떻게 자연스럽게 전환할지 고민되는 컴포넌트입니다.
결과물은 이런 모습입니다.

1. Compose 애니메이션 기본기 훑어보기
본격적으로 구현하기 전에 Compose가 제공하는 애니메이션 API를 간단히 정리하고 가겠습니다. 이걸 알아야 "왜 이 API를 썼는지"가 이해됩니다.
animate*AsState — 값 하나 애니메이트
제일 자주 쓰는 API입니다. 값이 바뀌면 자동으로 부드럽게 보간해줍니다.
val offset by animateDpAsState(
targetValue = if (selected) 100.dp else 0.dp,
label = "offset",
)
selected 값이 바뀌면 offset이 0dp → 100dp로 부드럽게 이동합니다. 타입별로 다양한 변종이 있습니다.
API용도
| animateDpAsState | 크기, 위치 (dp 단위) |
| animateFloatAsState | 투명도, 회전각, 진행률 |
| animateColorAsState | 색상 전환 |
| animateIntAsState | 정수 값 |
| animateOffsetAsState | 2D 좌표 |
AnimationSpec — 애니메이션 "느낌" 설정
어떻게 움직일지를 정하는 부분입니다. 크게 두 가지를 기억하면 됩니다.
tween — 정해진 시간 동안 일정한 곡선으로 움직임
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
예측 가능하고 깔끔합니다. 색상 전환이나 투명도에 잘 어울립니다.
spring — 스프링처럼 탄력 있게 움직임
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
)
물리 기반이라 시간을 지정하지 않습니다. dampingRatio가 낮을수록 통통 튀고, stiffness가 높을수록 빨리 멈춥니다. iOS 느낌의 인터랙션에 잘 어울립니다.
updateTransition — 여러 값을 한 번에 애니메이트
상태 하나에 연결된 여러 속성을 같이 애니메이트할 때 사용합니다. 이번 예시에서는 animate*AsState만 써도 충분해서 생략하겠습니다.
rememberInfiniteTransition — 무한 반복
로딩 인디케이터나 펄스 효과처럼 계속 반복되는 애니메이션에 사용합니다. 오늘 주제는 아닙니다.
2. 만들려는 컴포넌트 분석

요구사항을 쪼개봅시다.
- 두 개(또는 그 이상)의 텍스트 라벨이 가로로 배치됨
- 선택된 라벨 아래에 주황색 바가 위치
- 다른 라벨을 탭하면 주황 바가 슬라이드해서 이동
- 선택된 라벨은 흰색 글자, 나머지는 회색 글자
- 바 이동과 글자색 전환이 동시에 일어남
핵심 질문 두 개입니다.
- Q1. 주황 바를 어떻게 움직일까?
- Q2. 바와 글자를 어떻게 겹쳐 배치할까?
3. 잘못된 접근 — 처음에 해봤다가 실패한 방법
처음엔 이렇게 짜려고 했습니다.
Row {
options.forEachIndexed { index, label ->
Box(
modifier = Modifier
.background(
if (index == selectedIndex) Color(0xFFFF6B00)
else Color.Transparent
)
) {
Text(label)
}
}
}
"선택된 Box만 주황색으로 칠하면 되지 않나?" 싶었는데, 이렇게 하면 바가 움직이는 게 아니라 깜빡이면서 색이 바뀝니다. 왼쪽 Box의 배경색이 사라지고 오른쪽 Box의 배경색이 나타나는 방식이기 때문입니다.
우리가 원하는 건 "하나의 바가 슬라이드해서 이동하는 것"입니다. 접근을 바꿔야 합니다.
4. 올바른 접근 — 레이어 분리
해결책은 바와 텍스트를 다른 레이어로 분리하는 것입니다.
┌─────────────────────────────────┐
│ Box (컨테이너) │
│ ┌──────────────┐ │
│ │ 주황 바 │ ← 하단 레이어 │
│ │ (offset 이동)│ │
│ └──────────────┘ │
│ ┌──────┬──────┐ │
│ │ 탭1 │ 탭2 │ ← 상단 레이어 │
│ │(텍스트) │ │
│ └──────┴──────┘ │
└─────────────────────────────────┘
바는 절대 위치(offset)로 움직이고, 텍스트는 고정된 Row로 배치합니다. 바가 지나가면서 뒤에 깔리고, 텍스트는 그 위에서 색만 바뀌는 구조입니다.
5. 구현
5-1. 전체 코드
@Composable
fun SegmentedToggle(
options: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
var containerWidth by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// 세그먼트 하나당 너비
val segmentWidth = containerWidth / options.size.coerceAtLeast(1)
// 주황 바의 x 위치 애니메이션
val animatedOffset by animateDpAsState(
targetValue = segmentWidth * selectedIndex,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "indicator_offset",
)
Box(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFF1F1F1))
.onSizeChanged {
containerWidth = with(density) { it.width.toDp() }
},
) {
// 1. 움직이는 주황 바 (하단 레이어)
if (containerWidth > 0.dp) {
Box(
modifier = Modifier
.offset(x = animatedOffset)
.width(segmentWidth)
.fillMaxHeight()
.padding(4.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFFFF6B00))
)
}
// 2. 텍스트 레이어 (상단 레이어)
Row(Modifier.fillMaxSize()) {
options.forEachIndexed { index, label ->
val textColor by animateColorAsState(
targetValue = if (index == selectedIndex) Color.White
else Color(0xFF7A7A7A),
animationSpec = tween(durationMillis = 250),
label = "text_color_$index",
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onSelect(index) },
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = textColor,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
5-2. 사용 예시
@Composable
fun MyScreen() {
var selected by remember { mutableIntStateOf(0) }
SegmentedToggle(
options = listOf("왼쪽", "오른쪽"),
selectedIndex = selected,
onSelect = { selected = it },
modifier = Modifier.padding(16.dp),
)
}

6. 코드 파헤치기 — 중요한 부분 하나씩
✅ onSizeChanged로 런타임 너비 측정
.onSizeChanged {
containerWidth = with(density) { it.width.toDp() }
}
주황 바를 움직이려면 "세그먼트 하나가 몇 dp인가?"를 알아야 합니다. 그런데 fillMaxWidth()를 쓰기 때문에 코드 짤 때는 너비를 모릅니다. 화면에 따라 다르기 때문입니다.
그래서 Compose가 실제로 측정한 뒤 onSizeChanged로 알려주면 그걸 containerWidth에 저장합니다. it.width는 px 단위이므로 density를 이용해 dp로 변환해야 합니다.
측정 전에는 containerWidth가 0이라 바가 0dp 크기로 보이면 이상하기 때문에 if (containerWidth > 0.dp) 조건을 달아서 첫 프레임에만 바를 그리지 않습니다.
✅ 바는 Row가 아니라 offset()으로 움직임
바를 Row의 자식으로 넣고 정렬로 움직이는 방식으로는 부드러운 슬라이드가 안 됩니다. Row는 자식 순서대로 레이아웃하는 구조이기 때문입니다.
대신 이렇게 합니다.
Box(
modifier = Modifier
.offset(x = animatedOffset) // 이 한 줄이 핵심
.width(segmentWidth)
...
)
animatedOffset이 0dp → 160dp로 애니메이트되면, 바가 그 경로를 따라 자연스럽게 슬라이드합니다.
✅ spring vs tween — 용도에 맞게 선택
// 바 이동: spring (탄력 있는 느낌)
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
)
// 글자색: tween (일정한 시간, 깔끔하게)
animationSpec = tween(durationMillis = 250)
바는 spring을 썼습니다. 터치했을 때 약간 통통 튀는 느낌이 있으면 "인터랙티브하다"는 감각이 살아납니다.
글자색은 tween을 썼습니다. 색깔이 통통 튀면 부자연스럽습니다. 250ms 동안 일정하게 전환되는 게 자연스럽습니다.
시안에 따라서는 바도 tween(300)이 더 맞을 수 있습니다. 디자이너가 어떤 느낌을 원했는지에 따라 바꿔서 맞춰주면 됩니다.
✅ dampingRatio와 stiffness 감 잡기
값 조합느낌
| DampingRatioNoBouncy + StiffnessMedium | 탄력 없이 부드럽게 |
| DampingRatioMediumBouncy + StiffnessLow | iOS 스러운 탄력 (추천) |
| DampingRatioHighBouncy + StiffnessHigh | 과하게 통통 튐 |
값을 바꿔가며 Preview로 확인하면서 감을 잡는 게 제일 빠릅니다.
✅ ripple 제거
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onSelect(index) }
indication = null로 클릭 시 물결 효과를 없앴습니다. 세그먼트 토글은 바 이동 자체가 피드백이기 때문에 ripple이 있으면 오히려 지저분해집니다.
만약 ripple이 필요하다면 indication = null 줄만 지우면 됩니다.
✅ label 파라미터는 왜 있는가?
val animatedOffset by animateDpAsState(
targetValue = ...,
label = "indicator_offset",
)
label은 Android Studio의 Animation Preview 도구에서 이 애니메이션을 식별하기 위한 이름입니다. 디버깅이나 튜닝할 때 편해집니다. 필수는 아니지만 습관적으로 넣어주면 좋습니다.
7. 확장 — 3개 이상 탭으로 늘리기
위 코드는 options.size로 세그먼트 너비를 계산하기 때문에, 옵션이 3개든 4개든 그대로 동작합니다.
SegmentedToggle(
options = listOf("전체", "진행중", "완료"),
selectedIndex = selected,
onSelect = { selected = it },
)
8. 한 걸음 더 — updateTransition으로 리팩토링
지금 코드는 상태 하나(selectedIndex)에 대해 animateDpAsState, animateColorAsState를 따로 호출하고 있습니다. updateTransition을 쓰면 이걸 묶을 수 있습니다.
val transition = updateTransition(selectedIndex, label = "segment_transition")
val animatedOffset by transition.animateDp(
transitionSpec = { spring(...) },
label = "offset",
) { index -> segmentWidth * index }
큰 장점이 있는 건 아니지만, 한 상태에서 여러 값이 파생될 때 코드가 조금 더 정돈됩니다. 예제가 더 복잡해지면 고려해볼만 합니다.
9. 정리
요소사용한 API이유
| 주황 바 이동 | animateDpAsState + spring | 탄력 있는 이동감 |
| 글자색 전환 | animateColorAsState + tween | 색은 일정한 전환이 자연스러움 |
| 레이아웃 | Box로 레이어 분리 | 바는 offset, 텍스트는 Row 위에 |
| 너비 측정 | onSizeChanged | 런타임 너비에 세그먼트 크기 맞추기 |
핵심은 "움직이는 것과 고정된 것을 레이어로 분리한다" 입니다. 이 패턴은 탭 인디케이터, 프로그레스 바, 스위치 등 Compose에서 흔히 쓰이는 애니메이션에 그대로 응용할 수 있습니다.
참고 자료
'Android' 카테고리의 다른 글
| Kotlin Coroutine에서 cancel()과 cancelChildren()차이 정리 (0) | 2026.05.07 |
|---|---|
| Android 개발자가 써본 Claude Skills (0) | 2026.05.07 |
| Android BLE 통신하기 (0) | 2025.11.28 |
| 2025.11 Android gradle 빌드 최신 버전 (0) | 2025.11.05 |
| Android Compose TextField 천 단위 콤마로 표현하기 (0) | 2024.10.24 |
댓글