
1. ScrollIndicator란
ScrollIndicator는 Jetpack Compose로 구현된 커스텀 스크롤 인디케이터 컴포넌트입니다. 주로 한국어 초성(ㄱ, ㄴ, ㄷ, ...) 목록을 우측에 표시하고, 사용자가 드래그하여 리스트의 특정 섹션으로 빠르게 이동할 수 있게 해주는 UI 컴포넌트이다.
연락처 앱이나 멤버 목록 앱에서 흔히 볼 수 있는 **알파벳 인디케이터(A-Z)**의 한국어 버전이라고 보면 된다. 물론 영문자도 소화가 가능하며, 특별히 한국어에만 국한된 것은 아니다.
2. 핵심 기능
- 초성 인디케이터 표시: 우측에 초성 리스트를 세로로 배치
- 드래그 제스처 지원: 인디케이터를 드래그하여 스크롤 위치 선택
- 자동 표시/숨김: 스크롤 중이거나 드래그 중일 때만 표시
- 선택된 초성 강조: 드래그 중 선택된 초성을 확대 및 강조 표시
- 부드러운 애니메이션: 투명도, 크기 변화에 애니메이션 적용
3. 구현 원리
// 컴포저블 함수 시그니처
@Composable
fun ScrollIndicator(
initials: List<Char>, // 표시할 초성 리스트
selectedInitial: Char?, // 현재 선택된 초성
isDragging: Boolean, // 드래그 중인지 여부
onInitialSelected: (Float) -> Unit, // 위치 선택 콜백 (0.0~1.0)
onDragStateChanged: (Boolean) -> Unit, // 드래그 상태 변경 콜백
modifier: Modifier = Modifier
)
핵심 동작 흐름
| 사용자 드래그 시작 ↓ detectDragGestures의 onDragStart 호출 ↓ handleDrag()로 y 좌표를 0.0~1.0 위치로 변환 ↓ onInitialSelected 콜백 호출 (부모 컴포넌트로 전달) ↓ 부모에서 해당 위치의 초성 계산 및 리스트 스크롤 ↓ selectedInitial 업데이트 ↓ ScrollIndicator가 선택된 초성을 확대 표시 |
4. 상세 구현 분석
1) 드래그 제스처 처리
- detectDragGestures: Compose의 제스처 감지 API 사용
- onDragStart: 드래그 시작 시 한 번 호출
- onDrag: 드래그 중 계속 호출 (실시간 위치 추적)
- onDragEnd: 드래그 종료 시 호출
.pointerInput(initials) {
detectDragGestures(
onDragStart = { offset ->
onDragStateChanged(true) // 드래그 시작 알림
handleDrag(offset.y, indicatorHeight) { position ->
onInitialSelected(position) // 위치 전달
}
},
onDrag = { change, _ ->
handleDrag(change.position.y, indicatorHeight) { position ->
onInitialSelected(position) // 드래그 중 계속 위치 전달
}
},
onDragEnd = {
onDragStateChanged(false) // 드래그 종료 알림
}
)
}
2) 위치 계산 로직
- 드래그한 y 좌표를 인디케이터 높이로 나누어 0.0~1.0 사이의 비율로 변환
- coerceIn으로 범위를 제한하여 안전하게 처리
- 부모 컴포넌트는 이 비율을 받아서 실제 리스트 인덱스로 변환
private fun handleDrag(
y: Float, // 드래그한 y 좌표 (픽셀)
totalHeight: Float, // 인디케이터 전체 높이
onPositionChanged: (Float) -> Unit
) {
val clampedY = y.coerceIn(0f, totalHeight) // 범위 제한
val position = clampedY / totalHeight // 0.0 ~ 1.0으로 정규화
onPositionChanged(position)
}
3) 초성 배치 알고리즘
핵심 포인트:
- 각 초성을 균등하게 분배하여 배치
- 첫 번째 초성은 위쪽 끝(0.0), 마지막 초성은 아래쪽 끝(1.0)
- itemPosition - 0.5f: 중앙을 기준으로 위/아래로 배치
- * 0.32f: App Bar와 Navigation Bar 공간을 고려한 조정값
0.32를 곱하는 이유
- 주석에 따르면, 0.5만 곱하면 초성들이 너무 멀어짐
- App Bar와 Navigation Bar 공간을 고려하여 0.18을 더 제외
- 결과적으로 0.32를 곱하여 적절한 상하 폭 유지
initials.forEachIndexed { index, initial ->
// 첫 번째 아이템은 0.0, 마지막 아이템은 1.0
val itemPosition = if (initials.size == 1) {
0.5f // 아이템이 하나일 때는 중앙
} else {
index.toFloat() / (initials.size - 1).toFloat() // 0.0 ~ 1.0
}
// 중앙 기준으로 offset 계산
val offsetY = ((itemPosition - 0.5f) * indicatorHeight) * 0.32f
}
4) 애니메이션 처리
// 1. 전체 인디케이터 투명도
// : 드래그 중일 때만 표시 (alpha: 0 → 1)
// : 700ms 동안 부드럽게 나타나고 사라짐
val indicatorAlpha by animateFloatAsState(
targetValue = if (isDragging) 1f else 0f,
animationSpec = tween(700), // 700ms 애니메이션
label = "indicatorAlpha"
)
// 2. 선택된 초성 확대
// : 선택된 초성은 1.5배로 확대
// : 200ms 동안 부드럽게 확대/축소
val scale by animateFloatAsState(
targetValue = if (selectedInitial != null && isDragging) 1.5f else 1f,
animationSpec = tween(200), // 200ms 애니메이션
label = "initialScale"
)
// 3. 선택된 초성 투명도
// : 선택된 초성: alpha 1.0 (완전 불투명)
// : 일반 초성: alpha 0.6 (약간 투명)
val alpha by animateFloatAsState(
targetValue = if (selectedInitial != null && isDragging) 1f else 0.7f,
animationSpec = tween(200),
label = "initialAlpha"
)
5) 초성 렌더링
Box(
modifier = Modifier
.align(Alignment.CenterEnd) // 우측 정렬
.offset(y = offsetY) // 계산된 위치로 이동
.scale(if (isSelected) scale else 1f) // 선택 시 확대
.alpha(if (isSelected) alpha else 0.6f), // 선택 시 더 진하게
contentAlignment = Alignment.Center
) {
Text(
text = initial.toString(),
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.primary,
)
}
5. 사용 방법
이곳에서 입력되는 값인 initials는 앞서 포스팅한 글을 참고하면 된다.
https://blog.soobinpark.com/312
한국어 초성을 추출하는 유틸리티 함수
개인적으로 개발하는 프로젝트에서 특수한 요구사항을 생성했다.연락처 앱, 카카오톡 등 사용자 목록을 표현하지만, 그 목록을 알파벳 혹은 한글 초성 순으로 표현해야할 필요가 있을 때, 그것
blog.soobinpark.com
ScrollIndicator(
initials = listOf('ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ'),
selectedInitial = 'ㄴ', // 현재 선택된 초성
isDragging = true, // 드래그 중인지 여부
onInitialSelected = { position ->
// position은 0.0 ~ 1.0 사이의 값
// 이 값을 이용해 리스트를 스크롤
},
onDragStateChanged = { dragging ->
// 드래그 상태 변경 시 호출
},
modifier = Modifier.align(Alignment.CenterEnd)
)
파라미터 설명
| 파라미터 | 타입 | 설명 |
| initials | List<Char> | 표시할 초성 리스트 (예: ['ㄱ', 'ㄴ', 'ㄷ']) |
| selectedInitial | Char? | 현재 선택된 초성 (확대 표시용) |
| isDragging | Boolean | 드래그 중인지 여부 (인디케이터 표시 제어) |
| onInitialSelected | (Float) -> Unit | 드래그 위치 선택 시 호출 (0.0~1.0) |
| onDragStateChanged | (Boolean) -> Unit | 드래그 상태 변경 시 호출 |
| modifier | Modifier | 레이아웃 수정자 |
6. 실제 사용 예제
@Composable
fun MemberContent(...) {
val listState = rememberLazyListState()
var initials by remember { mutableStateOf<List<Char>>(emptyList()) }
var selectedInitial by remember { mutableStateOf<Char?>(null) }
var isDragging by remember { mutableStateOf(false) }
var isIndicatorDragging by remember { mutableStateOf(false) }
// 초성 리스트 생성
LaunchedEffect(pagingItems.itemCount) {
val members = (0 until pagingItems.itemCount)
.mapNotNull { index -> pagingItems[index] }
// 고유한 초성 추출
initials = KoreanInitialExtractor.getUniqueInitials(members) { it.name }
// 초성별 첫 번째 인덱스 맵 생성
val map = mutableMapOf<Char, Int>()
members.forEachIndexed { index, member ->
val initial = KoreanInitialExtractor.getInitial(member.name)
if (!map.containsKey(initial)) {
map[initial] = index
}
}
initialToIndexMap = map
}
// 스크롤 인디케이터 표시
if (initials.isNotEmpty()) {
ScrollIndicator(
initials = initials,
selectedInitial = if (isIndicatorDragging) selectedInitial else visibleInitial,
isDragging = isDragging,
onInitialSelected = { position ->
// 드래그 위치를 초성 인덱스로 변환
val targetIndex = (position * initials.size).toInt()
.coerceIn(0, initials.size - 1)
val targetInitial = initials[targetIndex]
selectedInitial = targetInitial
// 해당 초성으로 시작하는 첫 번째 아이템으로 스크롤
initialToIndexMap[targetInitial]?.let { targetItemIndex ->
coroutineScope.launch {
listState.animateScrollToItem(targetItemIndex)
}
}
},
onDragStateChanged = { dragging ->
isIndicatorDragging = dragging
},
modifier = Modifier.align(Alignment.CenterEnd)
)
}
// 확대된 초성 오버레이
EnlargedInitialOverlay(
initial = selectedInitial,
isVisible = isIndicatorDragging && selectedInitial != null
)
}
핵심 로직 설명
- 초성 리스트 생성
- 멤버 목록에서 고유한 초성 추출
- 초성별 첫 번째 인덱스 맵 생성 (빠른 스크롤을 위해)
- 드래그 위치 처리
- position (0.0~1.0)을 초성 인덱스로 변환
- 예: position=0.5, initials.size=7 → targetIndex=3
- val targetIndex = (position * initials.size).toInt()
- 리스트 스크롤
- 해당 초성으로 시작하는 첫 번째 아이템으로 부드럽게 스크롤
- listState.animateScrollToItem(targetItemIndex)
- 상태 관리
- isDragging: 스크롤 중이거나 드래그 중일 때 true
- isIndicatorDragging: 인디케이터를 직접 드래그할 때만 true
- selectedInitial: 현재 선택된 초성
7. 주요 특징과 설계 의도
1) 상태 관리 분리
- isDragging은 부모에서 관리 (스크롤 상태와 연동)
- onDragStateChanged로 드래그 상태를 부모에 알림
- 의도: 인디케이터 표시/숨김을 부모에서 제어하여 유연성 확보
2) 위치 정규화 (0.0~1.0)
- 드래그 위치를 0.0~1.0으로 정규화하여 전달
- 의도: 인디케이터 크기와 무관하게 동작하도록 추상화
3) 애니메이션 타이밍
- 전체 인디케이터: 700ms (부드러운 등장/퇴장)
- 선택된 초성: 200ms (빠른 피드백)
- 의도: 사용자 경험 최적화
4) 초성 배치 알고리즘
- 첫 번째와 마지막 초성을 끝에 배치
- 0.32 배율로 App Bar/Navigation Bar 공간 고려
- 의도: 실제 사용 가능한 공간에 맞춰 배치
5) 확대 오버레이 (EnlargedInitialOverlay)
- 드래그 중 선택된 초성을 화면 중앙에 크게 표시
- 의도: 사용자가 현재 선택한 초성을 명확히 인지
8. 요약
ScrollIndicator는 다음과 같은 원리로 동작
- 드래그 제스처 감지 → detectDragGestures 사용
- 위치 정규화 → y 좌표를 0.0~1.0 비율로 변환
- 초성 배치 → 균등 분배 알고리즘으로 세로 배치
- 애니메이션 → animateFloatAsState로 부드러운 전환
- 상태 동기화 → 콜백을 통해 부모와 상태 공유
이 컴포넌트는 재사용 가능하고 유연한 설계로, 다양한 리스트 스크롤 시나리오에 적용할 수 있다.
** 코드 공유
반응형
'[Developer] > Android' 카테고리의 다른 글
| [Wear OS] 시계 모드에 컴플리케이션 및 제스처 추가 (1) | 2022.04.11 |
|---|---|
| [Wear OS] 시계 모드 컴플리케이션 개요 (0) | 2022.04.05 |
| scrcpy 제대로 활용하기(2/2) (12) | 2022.02.20 |
| scrcpy 제대로 활용하기(1/2) (2) | 2022.02.19 |
| scrcpy: 안드로이드 화면을 PC에서 미러링 하기 (5) | 2022.02.19 |
댓글