본문 바로가기
안드로이드

[Android] Compose를 이용하여 스크롤 가능한 Appbar 만들기 (How to make scrollable appbar)

by algosketch 2024. 3. 10.

이 글에서는 스크롤과 상호작용하는 앱바를 구현할 예정이다. 스크롤과 함께 앱바의 일부가 사라지며, 다시 반대방향으로 끝까지 스크롤하면 다시 등장하는 코드를 작성해 보자.

이 글에서는 nestedScroll를 이용할 생각이다. ConstraintSets과 MotionLayout을 이용하는 방법도 고려해 보았으나 이 예제에서는 nestedScroll이 더 적절한 방법이다. nestedScroll 공식문서에서는 우리의 요구사항과 유사한 동작의 예시 코드를 제공한다. 먼저 이 코드를 기반으로 구현한 뒤 우리의 요구사항대로 변경하고, 그 과정에서 발생할 수 있는 문제들을 살펴볼 예정이다.

 

샘플 코드

이 글의 샘플 코드는 아래 리포지토리에서 확인할 수 있다.

https://github.com/HamBP/scrollable-appbar-sample

 

GitHub - HamBP/scrollable-appbar-sample: 스크롤 가능한 앱 바 샘플 코드입니다.

스크롤 가능한 앱 바 샘플 코드입니다. Contribute to HamBP/scrollable-appbar-sample development by creating an account on GitHub.

github.com

 

Modifier.nestedScroll 예시 코드

우선은 공식문서의 예시 코드대로 구현하되, 검색바 부분은 사라지지 않도록 변경할 것이다.

이 Modifier는 자식 컴포저블이 스크롤(Lazy 컴포저블 등)을 가지고 있을 경우, 자식이 스크롤을 사용하기 전에 해당 스크롤 이벤트를 가로챌 수 있다. NestedScrollConnection 구현체를 파라미터로 전달해야 하며, NestedScrollConnection은 다음 네 가지 메서드를 오버라이드 할 수 있다.

  • onPreScroll
  • onPostScroll
  • onPreFling
  • onPostFling

여기서 onPreScroll을 통해 자식의 스크롤을 가로챌 수 있다. 메서드의 파라미터와 반환 값으로 각각 Offset을 사용하는데, 파라미터의 Offset은 발생한 스크롤 이벤트에 대한 Offset이고, 반환하는 Offset은 이 메서드에서 소비한 Offset이다. 만약 스크롤 이벤트는 훔쳐보되, 자식 스크롤에 영향을 끼치고 싶지 않다면 Offset.Zero를 반환하면 된다. 참고로 fling은 사전적 의미로 "내던지다"라는 뜻을 갖고 있는데, 여기서는 스크롤 도중 손가락을 떼었을 때 스크롤 관성을 어떻게 처리할 지 정의하는 메서드이다.

@Composable
fun SearchAppBar(
    scrollableHeight: Dp,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier
    ) {
        Text(
            modifier = Modifier
                .height(scrollableHeight)
                .fillMaxWidth()
                .background(Color.Gray),
            text = "스크롤 가능한 부분",
            style = MaterialTheme.typography.titleLarge
        )
        TextField(
            modifier = Modifier.fillMaxWidth(),
            value = "",
            onValueChange = {},
        )
    }
}

위 코드는 예제에서 사용한 앱바 코드로, 검색바로 사용할 TextField가 있다는 것 정도만 인지하고 넘어가면 된다.

@Composable
fun SampleScreen() {
    val scrollableHeight = 80.dp // 문서와는 다르게 스크롤 가능한 영역을 커스텀했다.
    val appBarHeight = 160.dp
    val scrollableHeightPx = with(LocalDensity.current) { scrollableHeight.roundToPx().toFloat() }
    var appbarOffsetHeightPx by remember { mutableStateOf(0f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val newOffset = appbarOffsetHeightPx + available.y
                appbarOffsetHeightPx = newOffset.coerceIn(-scrollableHeightPx, 0f)

                return Offset.Zero // 여기서 소비하지 않았기 때문에 자식은 스크롤을 온전히 이용할 수 있다.
            }
        }
    }

    Scaffold { innerPadding ->
        Box(
            modifier = Modifier
                .padding(innerPadding)
                .nestedScroll(nestedScrollConnection),
        ) {
            // Box와 contentPadding을 통해 LazyColumn의 위치를 잡아준다.
            LazyColumn(
                modifier = Modifier.fillMaxWidth(),
                contentPadding = PaddingValues(top = appBarHeight)
            ) {
                // 실제 구현에는 성능 개선을 위해 key 값을 설정해야 한다.
                items(100) {
                    Text(
                        text = "Hello Compose! $it",
                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
                    )
                }
            }
            // offset을 통해 마치 앱바가 스크롤 되는 것처럼 구현한다.
            SearchAppBar(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(appBarHeight)
                    .offset { IntOffset(x = 0, y = appbarOffsetHeightPx.roundToInt()) },
                scrollableHeight = scrollableHeight,
            )
        }
    }
}

동작을 확인해 보자.

예상대로 스크롤시 앱바가 같이 스크롤되고, 검색바는 남기는 동작을 구현할 수 있다. 하지만 요구사항과는 다르게 반대 방향 스크롤시 앱바가 즉시 등장한다. 

 

앱바의 재등장 시점 변경

스크롤을 끝까지 올려야 앱바가 다시 등장하게 하려면 어떻게 해야할까? 이를 ScrollState의 canScrollBackward 프로퍼티를 이용하여 구현하면, 어색한 동작을 확인할 수 있다. 스크롤 가능한 상태에서 불가능한 상태로 넘어가기 위해서는 손가락을 한 번 떼어야 하기 때문에 불편한 사용성 제공하게 된다.

이를 수정하는 방법은 간단하다. 단지 offset 값을 보정하기 위한 coerceIn 메서드 호출 시점을 offset을 사용하는 앱바로 변경하면 된다.

@Composable
fun SampleScreen() {
    // ...
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            	// 여기에 있던 coerceIn을 SearchAppBar로 이동
                appbarOffsetHeightPx += available.y

                return Offset.Zero
            }
        }
    }
    
    // ...
    
    SearchAppBar(
        modifier = Modifier
            .fillMaxWidth()
            .height(appBarHeight)
            .offset {
                IntOffset(
                    x = 0,
                    y = appbarOffsetHeightPx
                        .coerceIn(-scrollableHeightPx, 0f) // 여기로 이동
                        .roundToInt()
                )
            },
        scrollableHeight = scrollableHeight,
    )
}

이렇게 하면 얼마나 스크롤 했는지 기억하고 있다가, 다시 스크롤 위치가 scrollableHeight보다 작아지는 시점부터 앱바가 다시 등장하기 시작한다.

 

스크롤 offset 문제 해결하기

이 문제는 주로 빠르게 위아래로 스크롤했을 때 발견할 수 있었다. 위 이미지에서 빠르게 스크롤한 뒤에는, 앱바가 스크롤되기 전 컨텐츠 일부가 앱바에 가려지는 것을 확인할 수 있다. 정확히는 스크롤의 끝부분에 다다를 때 발생하는 문제인데, 빠르게 스크롤할 수록 문제가 더 커진다. 스크롤을 더이상 할 수 없는 지점에 부딪히면 LazyList는 이벤트로 들어온 Offset의 일부를 버린다. 즉, 이 버려진 Offset까지 appbarOffsetHeightPx에 점점 쌓이게 된다. 이 문제는 위뿐만 아니라 아래로 스크롤할 때에도 발생한다.

다행히 (버려진)스크롤 잔량은 NestedScrollConnection의 onPostScroll 메서드를 통해 감지할 수 있다. 이 onPreScroll에서 변경한 Offset에서 onPostScroll에 들어온 Offset만큼 빼 주면 스크롤 잔량을 보정할 수 있다.

object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        // ...
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        appbarOffsetHeightPx -= available.y

        return Offset.Zero
    }
}

 

 

검색 시점에 발생하는 문제

여태까지의 코드에서는 편의를 위해 하드코딩된 데이터를 사용했다. 실 사용에 문제가 없으면 좋겠으나, 실 서비스에서 사용하기엔 문제가 있다. 예를 들어 "스크롤된 상태로 데이터 새로고침"이 발생하면 스크롤과 appbarOffsetHeightPx를 초기화 해 주어야 한다. 이를 재현하기 위해 간단한 검색 기능을 구현해 보자.

private val originData = (1..100).map { "Hello Compose! $it" }

class SearchViewModel : ViewModel() {

    private val _keyword = MutableStateFlow("")
    val keyword: StateFlow<String> = _keyword.asStateFlow()

    // LazyList의 Text에 들어갈 데이터
    private val _titles = MutableStateFlow(emptyList<String>())
    val titles: StateFlow<List<String>> = _titles.asStateFlow()

    init {
        search()
        setupAutoSearch()
    }

    @OptIn(FlowPreview::class)
    private fun setupAutoSearch() {
        keyword.debounce(300.milliseconds)
            .onEach { search() }
            .launchIn(viewModelScope)
    }

    private fun search() {
        viewModelScope.launch {
            delay(100)
            _titles.value = originData.filter { it.contains(keyword.value) }
        }
    }

    fun updateKeyword(newKeyword: String) {
        _keyword.value = newKeyword
    }
}

 

검색을 위한 ViewModel 코드는 위와 같이 작성했다. originData는 데이터베이스에 저장된 데이터를 의미하고, search 함수를 통해 데이터를 불러온다. 검색바에 텍스트가 입력되고 0.3초간 변화가 없으면 검색되는 방식으로 구현했다. UI쪽 코드 에서는 ViewModel의 데이터와 연결시켰다. UI 코드 변경 사항은 여기에 첨부하지 않았으나 샘플 코드 리포지토리를 통해 확인할 수 있다. 블로그에 첨부한 각 스탭별 코드는 커밋으로 구분해 두었다.

스크롤 후 검색을 시도하면 위와 같이 사라진 앱바 부분을 다시 볼 수 없게 된다. 이를 해결하기 위해 데이터 변경 시점에 Offset 정보를 초기화하여 문제를 해결했다.

private val originData = (1..300).map { "Hello Compose! $it" }

class SearchViewModel : ViewModel() {
    // ...

    private val _refresh = Channel<Unit>()
    val refresh = _refresh.receiveAsFlow()

    // ...

    private fun sendRefreshEvent() {
        viewModelScope.launch {
            _refresh.send(Unit)
        }
    }
    
    @OptIn(FlowPreview::class)
    private fun setupAutoSearch() {
        keyword.debounce(300.milliseconds)
            .onEach {
                search()
                sendRefreshEvent()
            }
            .launchIn(viewModelScope)
    }

    // ...
}
@Composable
fun SampleScreen(viewModel: SearchViewModel) {
    // ...

    LaunchedEffect(Unit) {
        viewModel.refresh.collect {
            lazyColumnState.animateScrollToItem(0, 0)
            appbarOffsetHeightPx = 0f
        }
    }
    // ...
}

 

결론

처음에 보았던 결과 화면이다. 이 글에서는 다음과 같이 앱바 스크롤을 구현했고, 발생하는 문제와 해결 방법까지 확인해 봤다.

  • 공식 문서의 nestedScroll 예시 코드를 변경하여 구현
  • 요구사항에 맞게 커스텀
  • 스크롤 잔량이 남는 문제 해결
  • 검색시 Offset을 초기화하지 않으면 발생하는 문제 해결

 

아래는 이 UI를 고민하게 만든 실제 서비스이다.

https://github.com/Nexters/Boolti

 

GitHub - Nexters/Boolti: 더 타올라라 후 후후후 꺼지지 않게 붉디붉은 채 더 크게 번져 후 후후 지금

더 타올라라 후 후후후 꺼지지 않게 붉디붉은 채 더 크게 번져 후 후후 지금 가장 뜨거운 내 안의 작고 작은 불티야 불티야 꺼지지 말고 피어나 불티야 불티야 새벽을 훨훨 날아가 새 불티야 불

github.com