grep

Android

Compose NestedScrollConnection 을 이용한 딜허브 화면 구현

Jonas여기어때

2025년 10월 16일

원문에서 보기 ↗

Android Compose NestedScrollConnection 을 커스텀하여 활용하면 기존 스크롤 패턴으로는 구현하기 어려운 개성 있는 인터랙션을 만들 수 있습니다. 특히 딜허브 화면처럼 스크롤과 탭이 유기적으로 연결되어 동작하는 구조는 “어떻게 구현한 걸까?”라는 궁금증을 갖게 됩니다.

이번 글에서는 그러한 궁금증을 해소하기 위해, 실제로 딜허브 화면의 독특한 탭 동작을 Compose 기반에서 어떻게 구현했는지, 그리고 그 과정에서 NestedScrollConnection을 어떤 방식으로 커스텀했는지를 자세히 공유해보려고 합니다.

딜허브 화면의 디자인 가이드입니다. 한 화면에 3가지 탭 (초특가 숙소, 할인 쿠폰, 프리미엄 호텔)을 가지고 있으며 각 탭마다 각각의 Screen 을 가지고 있습니다. 내부 화면은 Vertical Scroll 이 되는 List 영역을 가지고 있으며 스크롤에 따라 다음과 같은 인터렉션 가이드가 추가되었습니다.

디자인 가이드를 통해 화면을 설계할 때 Tab + HorizontalPager 사용을 하면 간단하게 구현이 가능할 것이라 판단이 되어 다음과 같은 화면 구조를 가지도록 하였습니다.

Box 내부에 Collapsing 이 가능한 Tab 과 하단에는 HorizontalPager를 구성하도록 하였습니다.

우선, 해당 구조를 기반으로 다음과 같이 HorizontalPage 영역의 스크롤에 따른 Collapsing Tab의 결과를 먼저 보여 드리겠습니다.

결과를 보면 다음과 같은 고민을 할 수 있을 것 같습니다.

  1. 어떻게 하면 Tab에서 Icon 영역만 Collapsing 처리를 할 수 있을까?
  2. Collapsing을 위해 HorizontalPager 내부의 모든 스크린에서의 스크롤을 간단하게 처리될 수가 있을까?

해답은 NestedScrollConnection 에 있었습니다!

NestedScrollConnection 을 설명하기 이전에 화면의 구조에 대해 먼저 부연 설명을 드리도록 하겠습니다.

앞서 설명드린 구조에서 Box 안에 Tab 과 HorizontalPager 가 존재합니다. Box 내부에 담겼기 때문에 화면과 같이 Tab > HorizontalPager 순서를 가지기 위해선 다음과 같은 트릭이 필요합니다.

// Tab 높이 측정 값
var tabLineHeight by rememberSaveable { mutableIntStateOf(0) }

Box {
  Column(
    modifier = Modifier
      .onLayoutRectChanged { tabLineHeight = it.height }
      .offset { IntOffset(x = 0, y = nestedScroll.offset.value.roundToInt()) }
      .wrapContentHeight()
      .fillMaxWidth(),
  ) {
    CollapsingTabLine(
      collapsingHeight = { nestedScroll.updateMaxHeight(it.toFloat()) },
      progress = { nestedScroll.progress },
    )
  }

  HorizontalPager(
    modifier = Modifier
      .nestedScroll(nestedScroll)
      .offset {
          IntOffset(x = 0, y = nestedScroll.offset.value.roundToInt() + tabLineHeight)
      },

Box 내부에 Tab 다음 HorizontalPager 가 존재하며 Expanded 상태일 때 HorizontalPager의 offset은 Tab의 높이만큼 하단에 여백이 존재하게 됩니다. 그리고 Collapsed 상태 일 때는 Icon 영역만큼만 처리되기 때문에 하단에 Tab의 Text 영역만큼만 여백이 존재합니다.

하단 여백은 각 Screen 영역에서 별도로 측정을 통해 Padding 추가 처리를 하도록 해야 하며 이 부분은 크게 중요하지 않아 생략합니다.

NestedScrollConnection

NestedScrollConnection 은 Developer 사이트에서 다음과 같이 설명이 되어있습니다.

Interface to connect to the nested scroll system.

Pass this connection to the nestedScroll modifier to participate in the nested scroll hierarchy and to receive nested scroll events when they are dispatched by the scrolling child (scrolling child - the element that actually receives scrolling events and dispatches them via NestedScrollDispatcher).

해당 인터페이스 사용하여 우리가 원하는 Collapsing 처리를 할 수 있도록 커스텀 하도록 하겠습니다.

Member 변수

private val splineBasedDecay = SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec<Float>()
private var maxHeight = 0f // 최대 높이
private var minHeight = 0f // 최소 높이
val offset = Animatable(0f) // 변화 될 offset

이때, offset 변수가 collapsing 을 처리하는 역할을 하게 됩니다.

onPreScroll

onPreScroll 함수는 “자식이 스크롤을 하기 전에 부모가 개입할 수 있는 기회”를 제공합니다. 스크롤을 올리면 자식의 스크롤과 상관 없이 먼저 collapsing 이 처리가 되어야 하므로 해당 함수 사용이 적합합니다.

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
  return (available.y).takeIf { it < 0f }?.let { delta ->
    val newOffset = (offset.value + delta).coerceIn(-maxHeight, minHeight)
    val consumed = newOffset - offset.value
    scope.launch { offset.snapTo(newOffset) }
    Offset(x = 0f, y = consumed)
  } ?: Offset.Zero
}

onPostScroll

onPostScroll 함수는 자식이 스크롤을 다 소비 후 남은 스크롤을 처리합니다. HorizontalPager 내부 List 스크롤을 내릴 때 최상단으로 이동되었을때 Expand가 되어야 하기 때문에 해당 함수 사용이 적합합니다.

  override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
    return (available.y).takeIf { it > 0f }?.let { delta ->
      val newOffset = (offset.value + delta).coerceIn(-maxHeight, minHeight)
      val consumedOffset = newOffset - offset.value
      scope.launch { offset.snapTo(newOffset) }
      Offset(x = 0f, y = consumedOffset)
    } ?: Offset.Zero
  }

onPostFling

onPostFling 함수는 사용자가 화면을 밀어서 발생한 플링(관성 스크롤) 이후의 동작을 제어하게 됩니다. 여기서 중요한 역할인 expand/collapse 처리 도 중 스크롤을 중단하게 되면 진행 방향으로 마무리가 되어야 합니다. 쉽게 말해 해당 함수가 없다면 collapsing 도 중 스크롤을 멈춘다면 반으로 보이다 만 상태로 남아있게 됩니다.

override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
      val initialDirection = (consumed.y + available.y)

      offset.animateDecay(
        initialVelocity = available.y,
        animationSpec = splineBasedDecay
      )

      val current = offset.value
      val collapse = -maxHeight
      val expand = minHeight

      if (current > collapse && current < expand) {
        val target = when {
          initialDirection < 0f -> collapse // 스크롤 업(접기) → 상단(완전 접힘) 앵커
          initialDirection > 0f -> expand // 스크롤 다운(펼치기) → 하단(완전 펼침) 앵커
          else -> {
              // 초기 방향 정보가 애매하면 가까운 쪽으로 스냅
              if (current - collapse < expand - current) collapse else expand
          }
        }

        if (target != current) {
          offset.animateTo(
            targetValue = target,
            animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
          )
        }
      }
      return Velocity.Zero
  }

결과

NestedScrollConnection 와 LazyList 의 Sticky 병합

초특가 숙소 및 프리미엄 호텔 화면의 디자인 가이드 내용입니다. 두 화면 내부에 스크롤 동작에 따라 캘린더(필터)의 노출/미노출이 정의되어있습니다.

디자인 가이드 내용에 따르면 화면의 스크롤 시 방향에 따라 사라졌다가 나타나는 단순한 구조를 나타나고 있습니다. 이는 LazyColumn 내부에서 stickyHeader를 통해 간단히 구현을 할 수 있었습니다.

LazyColumn 첫 아이템으로 stickyHeader을 써서 캘린더/필터를 sticky 처리 한 모습입니다. 하지만 우리가 원하는 인터렉션이 아닙니다. 어떤 트릭을 써야 할까요?

해당 인터렉션 또한 NestedScrollConnection을 커스텀 하여 어렵지 않게 구현을 할 수 있었습니다. LazyColumn의 스크롤의 상태를 가지고 같은 방향으로 offset을 같이 이동하도록 하였습니다. 그리고 enable 상태를 두어 offset 변화를 상태에 따라 변하도록 하였습니다.

// 최대 높이
private var maxHeight = 0f

// 최소 높이
private var minHeight = 0f

// 변화 될 offset
val offset = Animatable(0f)

// 활성화 여부
private var enable = true

fun updateEnable(value: Boolean) {
    enable = value
}

override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
    if (enable) {
        scope.launch { offset.snapTo((offset.value + consumed.y).coerceIn(-maxHeight, minHeight)) }
    }
    return Offset.Zero
}

Layout Inspector로 확인 시 스크롤 방향에 맞춰 스크롤 값에 따라 stickyHeader 아이템이 같이 이동하는 것을 확인할 수 있습니다.

다음은 프리미엄 호텔의 인터렉션 화면입니다. 특이하게 캘린더 로고 하단에 위치하고 있습니다.

어떻게 하면 위와 같이 인터렉션을 처리할 수 있을까요? 해답은 sticky의 여부 상태를 이용한 방법으로 해결하였습니다. 스크롤을 올리때는 sticky 상태 일 때만 동작을 하고 내릴 때는 항상 내리도록 처리하면 됩니다.

// title sticky 상태
val isSticky by remember(listState) {
  derivedStateOf {
    listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key == KEY_TITLE
  }
}

LaunchedEffect(isSticky) {
  nestedScroll.updateEnable(isSticky)
}

LazyColumn {
  item("logo") {
    PremiumTitle(...)
  }
  stickyHeader(KEY_TITLE) {
    Column {
      SearchPicker(...)
    }
}

결과

Summary

NestedScrollConnection 인터페이스를 통해 Collapsing 이 가능한 Tab 화면 구현 방법에 대해 알아보았습니다. NestedScrollConnection에서 제공해 주는 함수 onPre/PostScroll, onPre/PostFling에 대해 본문에서 자세히 설명을 하지 않았습니다. developer 사이트에 자세한 설명과 예시가 있으니 참고하여 필요한 기능에 사용하기를 권장드립니다.

긴 글 읽어주셔서 감사합니다.

참고 : https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll?hl=ko