본문 바로가기
[Developer]/Android

[Compose] ScrollIndicator Composable 만들기

by 해피빈이 2026. 1. 12.

사진: Unsplash 의 Taylor Flowe

1. ScrollIndicator란

ScrollIndicator는 Jetpack Compose로 구현된 커스텀 스크롤 인디케이터 컴포넌트입니다. 주로 한국어 초성(ㄱ, ㄴ, ㄷ, ...) 목록을 우측에 표시하고, 사용자가 드래그하여 리스트의 특정 섹션으로 빠르게 이동할 수 있게 해주는 UI 컴포넌트이다.

연락처 앱이나 멤버 목록 앱에서 흔히 볼 수 있는 **알파벳 인디케이터(A-Z)**의 한국어 버전이라고 보면 된다. 물론 영문자도 소화가 가능하며, 특별히 한국어에만 국한된 것은 아니다. 

2. 핵심 기능

  1. 초성 인디케이터 표시: 우측에 초성 리스트를 세로로 배치
  2. 드래그 제스처 지원: 인디케이터를 드래그하여 스크롤 위치 선택
  3. 자동 표시/숨김: 스크롤 중이거나 드래그 중일 때만 표시
  4. 선택된 초성 강조: 드래그 중 선택된 초성을 확대 및 강조 표시
  5. 부드러운 애니메이션: 투명도, 크기 변화에 애니메이션 적용

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
    )
}

 

핵심 로직 설명

  1. 초성 리스트 생성
    • 멤버 목록에서 고유한 초성 추출
    • 초성별 첫 번째 인덱스 맵 생성 (빠른 스크롤을 위해)
  2. 드래그 위치 처리
    • position (0.0~1.0)을 초성 인덱스로 변환
    • 예: position=0.5, initials.size=7 → targetIndex=3
    • val targetIndex = (position * initials.size).toInt()
  3. 리스트 스크롤
    • 해당 초성으로 시작하는 첫 번째 아이템으로 부드럽게 스크롤
    • listState.animateScrollToItem(targetItemIndex)
  4. 상태 관리
    • 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는 다음과 같은 원리로 동작

  1. 드래그 제스처 감지 → detectDragGestures 사용
  2. 위치 정규화 → y 좌표를 0.0~1.0 비율로 변환
  3. 초성 배치 → 균등 분배 알고리즘으로 세로 배치
  4. 애니메이션 → animateFloatAsState로 부드러운 전환
  5. 상태 동기화 → 콜백을 통해 부모와 상태 공유

이 컴포넌트는 재사용 가능하고 유연한 설계로, 다양한 리스트 스크롤 시나리오에 적용할 수 있다.

 

** 코드 공유

반응형

댓글