CoordinatorLayout 뷰 겹침, 버그가 아닌 핵심 원리 파헤치기

안드로이드 앱 개발 여정에서 세련되고 동적인 UI를 구현하려 할 때, 우리는 필연적으로 `CoordinatorLayout`이라는 강력한 도구를 만나게 됩니다. 사용자의 스크롤에 따라 우아하게 사라지는 툴바, 스크롤 방향에 반응하여 나타나거나 숨는 플로팅 액션 버튼(FAB), 그리고 다양한 UI 요소들이 마치 한 몸처럼 유기적으로 움직이는 화면은 이제 현대 앱의 표준이 되었습니다. 그리고 이 모든 마법의 중심에 바로 `CoordinatorLayout`이 있습니다.

하지만 많은 개발자들이 `CoordinatorLayout`을 처음 접하며 겪는 좌절의 순간이 있습니다. 바로 `AppBarLayout` 아래에 `RecyclerView`나 `NestedScrollView` 같은 스크롤 가능한 뷰를 배치했을 때, 콘텐츠의 최상단이 무심하게 `AppBarLayout` 뒤로 숨어버리는 '뷰 겹침' 현상입니다. 수많은 개발자들이 이 문제를 '버그'로 오인하고 해결책을 찾아 헤매지만, 사실 이것은 버그가 아닙니다. 오히려 `CoordinatorLayout`이 동적인 상호작용을 만들어내는 가장 근본적이고 의도된 설계이며, 그 잠재력을 이해하는 첫 번째 관문입니다.

이 글에서는 단순히 `app:layout_behavior` 한 줄을 추가하여 문제를 해결하는 데 그치지 않습니다. 왜 뷰들이 겹쳐 보이는 것이 기본 동작인지, 그 배경에 있는 `Behavior`라는 개념이 어떻게 `CoordinatorLayout`을 특별하게 만드는지, 그리고 이를 자유자재로 활용하여 경쟁력 있는 사용자 경험을 창조하는 방법까지, 그 깊은 곳을 함께 탐험할 것입니다. 기본적인 문제 해결을 넘어, `CoordinatorLayout`의 진정한 힘을 깨우는 여정을 지금 바로 시작하겠습니다.

1. UI 오케스트라의 마에스트로, CoordinatorLayout의 본질

`CoordinatorLayout`은 그 이름이 암시하듯 '조정자(Coordinator)'의 역할을 수행하는 레이아웃입니다. 단순히 뷰를 좌우, 상하로 배치하는 `LinearLayout`이나 `RelativeLayout`과는 차원이 다릅니다. `CoordinatorLayout`은 자신에게 속한 자식 뷰들 사이의 상호작용을 지휘하고 조정하는, 마치 UI 오케스트라의 마에스트로와 같은 존재입니다. 스크롤, 드래그, 다른 뷰의 크기나 상태 변화와 같은 동적인 '이벤트'를 감지하고, 그에 맞춰 특정 뷰의 위치, 크기, 투명도 등을 실시간으로 변경하는 데 특화되어 있습니다.

모든 것의 시작: FrameLayout의 유산, '겹침'

`CoordinatorLayout`의 동작을 이해하기 위해 가장 먼저 알아야 할 사실은, 이 레이아웃이 `FrameLayout`을 직접 상속받았다는 점입니다. `FrameLayout`의 가장 원초적인 특징은 무엇일까요? 바로 자식 뷰들을 차곡차곡 겹쳐 쌓는(stack) 것입니다. 별도의 `layout_gravity` 속성을 지정하지 않으면, `FrameLayout`에 추가되는 모든 자식 뷰는 좌상단 (0, 0) 좌표에서부터 그려지기 시작합니다.

이것이 바로 `AppBarLayout`과 `RecyclerView`를 `CoordinatorLayout` 안에 배치했을 때, `RecyclerView`가 `AppBarLayout`의 뒤에 그려지는 근본적인 이유입니다. `CoordinatorLayout`은 일단 모든 자식 뷰를 한곳에 겹쳐 놓은 뒤, "자, 이제부터 내가 너희들 사이의 관계와 움직임을 정의하겠다"라고 선언하는 것과 같습니다. 이 '겹침'은 해결해야 할 골칫거리가 아니라, 복잡하고 아름다운 애니메이션을 구현하기 위한 가장 효율적인 출발점, 즉 '기회'인 셈입니다.

겹쳐진 상태를 기본으로 삼기 때문에, 스크롤에 따라 특정 뷰가 사라질 때 다른 뷰가 그 빈자리를 자연스럽게 채우거나, 한 뷰의 크기 변화에 맞춰 다른 뷰가 유기적으로 위치를 이동하는 등의 복잡한 상호작용을 매우 효율적으로 구현할 수 있는 토대가 마련됩니다.

CoordinatorLayout의 영혼: `Behavior`

`CoordinatorLayout`이 수행하는 모든 정교한 상호작용의 비밀은 `CoordinatorLayout.Behavior`라는 추상 클래스에 담겨 있습니다. `Behavior`는 특정 자식 뷰에 '부착'되어, `CoordinatorLayout` 내에서 발생하는 다양한 이벤트(다른 뷰의 변화, 스크롤 이벤트 등)를 가로채고, 그에 대한 구체적인 반응을 정의하는 일종의 플러그인(Plug-in)입니다. 마치 특정 배우에게 역할을 부여하고 연기 지시를 내리는 스크립트와 같습니다.

우리가 흔히 접하는 다음과 같은 동작들이 모두 이 `Behavior`를 통해 구현됩니다:

  • 사용자가 콘텐츠를 위로 스크롤할 때, `AppBarLayout`이 화면 밖으로 함께 스크롤되어 사라지는 동작
  • 화면 하단에 `Snackbar`가 불쑥 나타날 때, `FloatingActionButton`(FAB)이 `Snackbar`에 가려지지 않도록 위로 스르륵 올라오는 동작
  • 화면 하단의 `BottomSheet`를 위로 드래그하여 펼칠 때, 뒤에 있는 메인 콘텐츠가 점차 어두워지는 디밍(dimming) 효과

개발자는 XML 레이아웃 파일에서 `app:layout_behavior`라는 속성을 사용하여 특정 뷰에 원하는 `Behavior`를 간단하게 지정할 수 있습니다. 안드로이드 Material Design 라이브러리는 `AppBarLayout.ScrollingViewBehavior`, `FloatingActionButton.Behavior`, `BottomSheetBehavior` 등과 같이 자주 사용되는 상호작용을 위한 수많은 기본 `Behavior`들을 미리 정의하여 제공하고 있습니다. 물론, 우리의 상상력을 실현하기 위해 직접 커스텀 `Behavior`를 작성하는 것도 가능합니다.

2. 겹침 문제의 해답: `AppBarLayout.ScrollingViewBehavior`의 역할

이제 가장 흔하게 마주치는 문제, 즉 `AppBarLayout`과 그 아래 스크롤 가능한 콘텐츠 뷰(`RecyclerView`, `NestedScrollView` 등)의 겹침 현상으로 돌아와 보겠습니다. 이 문제를 해결하는 표준적인 방법은 스크롤 가능한 콘텐츠 뷰에 다음과 같이 단 한 줄의 속성을 추가하는 것입니다.

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior" />

여기서 `app:layout_behavior="@string/appbar_scrolling_view_behavior"` 가 바로 마법의 주문입니다. 이 코드는 `RecyclerView`에 `AppBarLayout.ScrollingViewBehavior`라는 이름의 `Behavior`를 부착하라는 명시적인 지시입니다. 그렇다면 이 `Behavior`는 내부적으로 정확히 어떤 일을 수행하여 겹침 문제를 해결하고, 스크롤 연동까지 구현하는 것일까요?

`ScrollingViewBehavior`의 4단계 작동 메커니즘

`AppBarLayout.ScrollingViewBehavior`는 이름에서도 알 수 있듯이, 스크롤되는 뷰가 `AppBarLayout`과 올바르게 상호작용하도록 특별히 설계된 `Behavior`입니다. 그 작동 원리는 크게 4단계로 나눌 수 있습니다.

  1. 1단계: 의존성 탐색 (Dependency Scanning)
    `CoordinatorLayout`이 레이아웃을 구성할 때, `ScrollingViewBehavior`는 먼저 `CoordinatorLayout`의 모든 자식 뷰들을 스캔하여 `AppBarLayout` 타입의 뷰를 찾습니다. 그리고 찾아낸 `AppBarLayout`을 자신의 '의존성(dependency)'으로 등록합니다. 이제부터 이 `Behavior`는 `AppBarLayout`의 모든 상태 변화(크기, 위치 등)를 주시하게 됩니다.
  2. 2단계: 초기 레이아웃 재배치 (Initial Layout Repositioning)
    레이아웃이 화면에 처음 그려지는 시점에, `ScrollingViewBehavior`는 자신이 의존성으로 등록한 `AppBarLayout`의 초기 높이를 측정합니다. 그리고 자신이 부착된 뷰(이 예제에서는 `RecyclerView`)의 레이아웃 파라미터를 동적으로 수정하여, `AppBarLayout`의 높이만큼 상단 여백(top margin)을 주거나 위치를 아래로 밀어냅니다. 이 자동 재배치 과정 덕분에 사용자의 눈에는 콘텐츠가 `AppBarLayout` 바로 아래에서 시작되는 것처럼 보이게 되어, 뷰 겹침 문제가 해결됩니다.
  3. 3단계: 스크롤 이벤트 중계 (Scroll Event Delegation)
    사용자가 `RecyclerView`를 스크롤하면, 이 스크롤 이벤트는 안드로이드의 중첩 스크롤(Nested Scrolling) 메커니즘을 통해 상위 뷰인 `CoordinatorLayout`에 전달됩니다. `CoordinatorLayout`은 이 이벤트를 받아서, 스크롤과 관련된 `Behavior`들에게 전파합니다. 가장 먼저 `AppBarLayout`에 부착된 기본 `Behavior`가 이 이벤트를 받아, 자신의 `layout_scrollFlags` 속성에 정의된 규칙에 따라 `AppBarLayout`을 화면 밖으로 스크롤시키거나 다시 나타나게 합니다.
  4. 4단계: 동적인 레이아웃 동기화 (Dynamic Layout Synchronization)
    `AppBarLayout`이 스크롤 이벤트에 의해 크기(화면에 보이는 영역)나 위치가 변경되면, 1단계에서 의존성으로 등록해 두었기 때문에 `ScrollingViewBehavior`는 이 변화를 즉시 감지합니다. 그리고 변경된 `AppBarLayout`의 상태에 맞춰 `RecyclerView`의 위치와 크기를 실시간으로 다시 조정합니다. 예를 들어, `AppBarLayout`이 완전히 사라지면 `RecyclerView`는 화면 최상단까지 스크롤 영역이 확장되어 사용자에게 끊김 없는 스크롤 경험을 제공합니다.

결론적으로, `app:layout_behavior` 속성 한 줄은 단순히 "겹치지 않게 해줘"라는 요청이 아니라, "이 뷰는 `AppBarLayout`의 모든 움직임에 유기적으로 반응하여 자신의 위치와 크기를 자동으로 조절해야 하는 정교한 관계를 맺고 있다"는 규칙을 선언하는 것과 같습니다.

완벽한 상호작용을 위한 전체 레이아웃 구조 예시

이러한 개념들이 실제 XML 레이아웃에서 어떻게 조화롭게 구성되는지 전체적인 구조를 살펴보는 것은 매우 중요합니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".MainActivity">

    <!-- 1. 스크롤에 반응하며 축소/확장될 상단 영역 -->
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBarLayout"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

        <!-- CollapsingToolbarLayout: Toolbar를 감싸며 유연한 공간을 제공 -->
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerCrop"
                android:src="@drawable/header_background"
                android:fitsSystemWindows="true"
                app:layout_collapseMode="parallax" />
                
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin" />

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <!-- 2. 스크롤 가능한 콘텐츠. Behavior를 통해 AppBarLayout과 연동 -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:listitem="@layout/item_list" />

    <!-- 3. 다른 뷰(AppBarLayout)에 '고정(Anchor)'되어 위치를 잡는 뷰 -->
    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:src="@drawable/ic_add"
        app:layout_anchor="@id/appBarLayout"
        app:layout_anchorGravity="bottom|end" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

위 코드에서 각 컴포넌트의 역할을 명확히 이해해야 합니다. `CoordinatorLayout`이 전체 상호작용의 무대를 만들고, `AppBarLayout`은 스크롤에 반응할 상단 영역을 정의합니다. 그리고 `RecyclerView`는 `app:layout_behavior`를 통해 `AppBarLayout`과의 관계를 설정하여 자신의 위치를 조정합니다. 마지막으로 `FloatingActionButton`은 `app:layout_anchor`를 통해 `AppBarLayout`에 자신을 고정시켜 함께 움직입니다. 이 모든 요소들이 `Behavior`라는 보이지 않는 접착제를 통해 하나의 유기적인 시스템으로 동작하게 됩니다.

3. 스크롤 디테일을 결정하는 `app:layout_scrollFlags` 완전 정복

`ScrollingViewBehavior`가 제 역할을 하려면, 연동의 대상이 되는 `AppBarLayout` 자신이 스크롤 이벤트에 반응할 수 있어야 합니다. `AppBarLayout` 자체는 스크롤 기능이 없으며, 그 내부에 포함된 자식 뷰들(주로 `Toolbar`, `CollapsingToolbarLayout`, `TabLayout` 등)에 `app:layout_scrollFlags` 속성을 지정해주어야 비로소 스크롤 상호작용이 활성화됩니다. 이 플래그들은 `AppBarLayout`의 자식 뷰들이 스크롤 이벤트에 어떻게 반응할지를 결정하는 매우 중요하고 강력한 속성입니다.

주요 `scrollFlags` 값들은 다음과 같으며, `|` (파이프) 연산자를 사용하여 여러 효과를 조합할 수 있습니다. 각 플래그의 역할을 명확히 비교하기 위해 표로 정리했습니다.

플래그(Flag) 핵심 역할 상세 설명 및 사용 사례
scroll 스크롤 반응의 전제 조건 가장 기본이 되는 플래그입니다. 이 플래그가 설정된 뷰는 스크롤 이벤트에 반응하여 화면 밖으로 밀려 나갈 수 있습니다. 이 플래그가 없으면 다른 어떤 플래그도 동작하지 않습니다. 보통 스크롤 효과를 주려는 `AppBarLayout`의 모든 자식 뷰에 기본으로 포함시켜야 합니다.
exitUntilCollapsed 최소 높이까지 축소 후 고정 이 플래그가 설정된 뷰는 위로 스크롤될 때, 자신의 `minHeight`(최소 높이) 속성에 정의된 높이에 도달할 때까지만 축소되고 그 이후에는 화면 상단에 고정됩니다. 주로 `CollapsingToolbarLayout`과 함께 사용하여, 큰 헤더 이미지는 스크롤되어 사라지지만 상단의 `Toolbar`는 계속 남아있도록 하는 '고정 헤더' 효과를 구현할 때 필수적으로 사용됩니다.
enterAlways 아래 스크롤 시 즉시 등장 (Quick Return) 콘텐츠를 위로 스크롤하여 뷰가 완전히 사라진 상태에서, 사용자가 손가락을 아래로 조금이라도 스크롤하면 이 플래그가 설정된 뷰가 즉시 다시 나타나기 시작합니다. 'Quick Return' UI 패턴을 구현할 때 매우 유용하며, 사용자가 스크롤을 맨 위까지 올리지 않아도 툴바의 메뉴나 기능에 빠르게 접근할 수 있게 해줍니다.
enterAlwaysCollapsed 최소 높이로 먼저 등장 반드시 enterAlways와 함께 사용해야 합니다. 아래로 스크롤하여 뷰가 다시 나타날 때, 전체 높이로 한 번에 나타나는 것이 아니라 자신의 `minHeight` 크기로 먼저 나타납니다. 그리고 사용자가 스크롤을 계속해서 맨 위까지 올려야만 전체 높이로 부드럽게 확장됩니다. 이는 `exitUntilCollapsed`와 완벽한 대칭을 이루는 동작을 제공합니다.
snap '어중간한' 상태 방지 스크롤 동작이 끝났을 때 뷰가 '애매하게' 걸쳐있는 상태(예: 절반만 보이는 상태)를 방지합니다. 사용자가 스크롤을 멈췄을 때, 뷰의 보이는 부분이 50%를 넘으면 완전히 펼쳐지는 애니메이션을, 50% 미만이면 완전히 사라지는 애니메이션을 자동으로 실행하여 항상 깔끔하게 정돈된 최종 상태를 만들어줍니다. 사용자 경험의 완성도를 높여주는 중요한 디테일입니다.

플래그 조합을 통한 다채로운 스크롤 경험 설계

이 `scrollFlags`들을 어떻게 조합하느냐에 따라 앱의 스크롤 경험이 완전히 달라집니다. 몇 가지 대표적인 조합 예시를 통해 그 효과를 살펴보겠습니다.

  • app:layout_scrollFlags="scroll|enterAlways"
    가장 일반적인 'Quick Return' 툴바입니다. 위로 스크롤하면 툴바가 화면 공간을 확보하기 위해 사라지고, 아래로 조금만 스크롤해도 툴바가 즉시 다시 나타납니다. 사용자가 언제든지 메뉴나 검색 버튼에 접근해야 하는 화면에 매우 효과적입니다.
  • app:layout_scrollFlags="scroll|exitUntilCollapsed"
    `CollapsingToolbarLayout`의 교과서적인 사용법입니다. 프로필 화면이나 상세 정보 화면에서 큰 이미지를 가진 헤더 영역이 스크롤과 함께 자연스럽게 축소되다가, 최종적으로는 `Toolbar` 높이만큼만 남아 제목을 표시하는 데 사용됩니다.
  • app:layout_scrollFlags="scroll|enterAlways|snap"
    'Quick Return' 동작에 `snap` 효과를 더한 조합입니다. 툴바가 나타나거나 사라지는 중간에 사용자가 스크롤을 멈추더라도, 툴바가 어중간하게 걸쳐있지 않고 완전히 나타나거나 완전히 사라지는 상태로 자동으로 애니메이션됩니다. UI를 더욱 정돈되고 예측 가능하게 만들어 줍니다.
  • app:layout_scrollFlags="scroll|exitUntilCollapsed|enterAlways|enterAlwaysCollapsed|snap"
    모든 플래그를 활용한 복합적인 예시입니다. 위로 스크롤하면 최소 높이까지 축소되고, 아래로 스크롤하면 최소 높이로 먼저 등장했다가 최상단에서 완전히 펼쳐지며, 모든 동작의 끝은 `snap`으로 깔끔하게 마무리됩니다. 매우 정교하고 다이내믹한 스크롤 경험을 제공할 수 있습니다.

이처럼 `scrollFlags`는 `CoordinatorLayout`의 동적 UI를 구현하는 데 있어 핵심적인 역할을 담당합니다. 다양한 조합을 직접 테스트해보며 자신의 앱 디자인에 가장 적합한 동작을 찾아내는 과정은 매우 중요하며, 개발자의 창의력이 발휘될 수 있는 부분이기도 합니다.

4. 상호작용의 확장: 앵커링(Anchoring)과 커스텀 Behavior의 세계

`CoordinatorLayout`의 진정한 힘은 스크롤 상호작용에만 국한되지 않습니다. `Behavior`를 통해 뷰들을 서로 '고정(anchor)' 시키거나, 기존에 없던 완전히 새로운 맞춤형 상호작용을 정의함으로써 UI의 가능성을 무한히 확장할 수 있습니다.

뷰를 다른 뷰에 고정하기: `app:layout_anchor` 와 `app:layout_anchorGravity`

특정 뷰를 다른 뷰의 위치에 상대적으로 배치하고, 앵커가 되는 뷰가 움직일 때 함께 반응하도록 만들고 싶을 때 `app:layout_anchor` 속성을 사용합니다. `FloatingActionButton`을 `AppBarLayout`의 우측 하단에 마치 붙어있는 것처럼 고정하는 경우가 가장 대표적인 예시입니다.

<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fab"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_anchor="@id/appBarLayout"
    app:layout_anchorGravity="bottom|end"
    android:layout_marginEnd="16dp"
    android:src="@drawable/ic_edit" />
  • `app:layout_anchor="@id/appBarLayout"`: 이 FAB를 `appBarLayout`이라는 ID를 가진 뷰에 고정(앵커)시킵니다. 이제 이 FAB의 위치 기준은 `CoordinatorLayout`의 전체 좌표가 아닌, `appBarLayout`이 됩니다.
  • `app:layout_anchorGravity="bottom|end"`: 앵커 뷰(`AppBarLayout`)를 기준으로 어느 지점에 위치할지를 결정합니다. '아래쪽(bottom)' 그리고 '끝(end, RTL 환경을 고려한 오른쪽)'에 맞추어 위치를 조정합니다.

이렇게 설정하면 `AppBarLayout`이 스크롤되어 축소되거나 확장될 때 FAB도 그에 맞춰 자연스럽게 Y축 위치를 이동하게 됩니다. 이는 `FloatingActionButton`에 기본적으로 내장된 `FloatingActionButton.Behavior`가 내부적으로 `AppBarLayout`의 움직임을 감지하고 FAB의 `translationY` 속성 값을 실시간으로 조정해주기 때문입니다. 이처럼 많은 Material Design 컴포넌트들은 `CoordinatorLayout` 내에서 기대되는 동작을 수행하는 기본 `Behavior`를 이미 포함하고 있어, 개발자는 간단한 XML 속성 설정만으로도 복잡한 상호작용을 구현할 수 있습니다.

나만의 상호작용 창조하기: 커스텀 Behavior 구현

기본으로 제공되는 `Behavior`들만으로는 구현할 수 없는, 우리 앱만의 독특하고 창의적인 상호작용이 필요하다면 어떻게 해야 할까요? 바로 이때가 `CoordinatorLayout.Behavior`를 직접 상속받아 우리만의 `Behavior`를 만들 차례입니다. 커스텀 `Behavior`를 구현하는 과정은 몇 가지 핵심 메소드를 오버라이드하여 상호작용 로직을 채워 넣는 것으로 이루어집니다.

커스텀 Behavior의 핵심 메소드

메소드 역할 및 호출 시점
layoutDependsOn(...) 의존성 선언: 이 `Behavior`가 어떤 다른 뷰(dependency)의 변화에 의존하는지를 `CoordinatorLayout`에 알려주는 가장 중요한 메소드입니다. `CoordinatorLayout` 내부의 모든 뷰가 `dependency` 파라미터로 전달되며, 우리가 주시하고자 하는 뷰일 경우 `true`를 반환해야 합니다. 예를 들어, `Toolbar`의 움직임에 반응해야 한다면 `dependency is Toolbar`일 때 `true`를 반환합니다.
onDependentViewChanged(...) 상호작용 구현: `layoutDependsOn()`에서 `true`를 반환했던 `dependency` 뷰에 어떠한 변경(위치, 크기 등)이 발생할 때마다 호출됩니다. 실질적인 상호작용 로직이 구현되는 심장부입니다. `dependency`의 현재 상태(예: Y 좌표, 높이)를 읽어와서, 이 `Behavior`가 부착된 `child` 뷰의 속성(예: `alpha`, `translationX`, `scaleY` 등)을 변경하는 코드를 작성합니다.
onStartNestedScroll(...)
onNestedPreScroll(...)
onNestedScroll(...)
중첩 스크롤 이벤트 처리: `CoordinatorLayout` 내에서 스크롤 가능한 뷰가 스크롤될 때 발생하는 중첩 스크롤(Nested Scrolling) 이벤트를 직접 가로채고 처리할 때 사용됩니다. 아래로 스크롤할 때 FAB를 숨기고 위로 스크롤할 때 다시 나타나게 하는 동작이 바로 이 메소드들을 오버라이드하여 구현된 대표적인 예시입니다.
onDependentViewRemoved(...) 의존성 제거 처리: `layoutDependsOn()`에서 `true`를 반환했던 `dependency` 뷰가 레이아웃에서 제거될 때 호출됩니다. 이 뷰와 관련하여 유지하고 있던 상태나 리스너 등을 정리하는 로직을 여기에 구현할 수 있습니다.

실전 예제: 스크롤에 따라 투명해지고 작아지는 헤더 `Behavior`

이해를 돕기 위해, `AppBarLayout`이 스크롤되어 올라갈 때 중앙에 위치한 프로필 이미지가 부드럽게 작아지면서 사라지는 커스텀 `Behavior`를 Kotlin으로 구현해보겠습니다.

`ShrinkingHeaderBehavior.kt` (커스텀 Behavior 클래스):

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.appbar.AppBarLayout
import kotlin.math.max

// Behavior는 두 개의 제네릭 타입을 받을 수 있음. 첫 번째는 이 Behavior가 부착될 뷰의 타입.
class ShrinkingHeaderBehavior(context: Context, attrs: AttributeSet) :
    CoordinatorLayout.Behavior<ImageView>(context, attrs) {

    // 1. 의존성 정의: 이 Behavior는 AppBarLayout의 움직임에 의존한다.
    // 이 메소드에서 true를 반환해야 onDependentViewChanged가 호출됨.
    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: ImageView,
        dependency: View
    ): Boolean {
        return dependency is AppBarLayout
    }

    // 2. 의존성 뷰(AppBarLayout)에 변경이 발생했을 때 처리할 로직
    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: ImageView,
        dependency: View
    ): Boolean {
        // 의존성 뷰를 AppBarLayout으로 캐스팅
        val appBarLayout = dependency as AppBarLayout
        
        // AppBarLayout의 전체 스크롤 가능 범위
        val scrollRange = appBarLayout.totalScrollRange.toFloat()
        
        // AppBarLayout의 현재 Y 위치 (보통 0에서 음수 값으로 변함)
        // 수직 오프셋을 가져오기 위해 appBarLayout.y를 사용.
        val currentOffset = appBarLayout.y
        
        // 스크롤 진행률 계산 (0.0 ~ 1.0)
        // 0.0: 완전히 펼쳐진 상태, 1.0: 완전히 축소된 상태
        val percentage = 1.0f - (max(0f, -currentOffset) / scrollRange)
        
        // 진행률에 따라 이미지 뷰의 크기(scale)와 투명도(alpha)를 조절
        // 부드러운 효과를 위해 0.3 ~ 1.0 범위에서 조절
        val scale = 0.3f + 0.7f * percentage
        child.scaleX = scale
        child.scaleY = scale
        child.alpha = percentage

        return true // child 뷰의 속성을 변경했으므로 true 반환하여 다시 그리도록 함
    }
}

XML 레이아웃에서 커스텀 Behavior 적용:

<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
    
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar" ...>
        ...
        <androidx.appcompat.widget.Toolbar ... />
    </com.google.android.material.appbar.AppBarLayout>

    <!-- 우리가 만든 커스텀 Behavior를 적용할 ImageView -->
    <ImageView
        android:id="@+id/profile_image"
        android:layout_width="120dp"
        android:layout_height="120dp"
        android:src="@drawable/profile_avatar"
        app:layout_anchor="@id/appbar"
        app:layout_anchorGravity="center"
        app:layout_behavior="com.example.myapp.behaviors.ShrinkingHeaderBehavior" /> 
        
    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" ... />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

위 코드에서 `app:layout_behavior` 속성에 우리가 만든 클래스의 전체 경로를 문자열로 지정해준 것이 핵심입니다. 이처럼 커스텀 `Behavior`는 `CoordinatorLayout`의 가능성을 무한히 확장시켜, 안드로이드 프레임워크가 기본으로 제공하지 않는 거의 모든 종류의 동적 상호작용을 우리 손으로 직접 창조할 수 있게 해주는 강력한 도구입니다.

결론: 겹침을 넘어 진정한 동적 UI의 설계자로

이제 우리는 `CoordinatorLayout`에서 뷰가 겹치는 현상이 해결해야 할 '버그'가 아니라, 동적이고 상호작용적인 UI를 구축하기 위한 지극히 자연스러운 '기능적 특성'임을 이해하게 되었습니다. `app:layout_behavior="@string/appbar_scrolling_view_behavior"`라는 한 줄의 코드는 단순히 뷰의 위치를 잡아주는 임시방편이 아니라, `CoordinatorLayout`이라는 거대한 상호작용 시스템의 문을 여는 첫 번째 열쇠와 같습니다.

이 글을 통해 우리는 다음과 같은 핵심 원리들을 깊이 있게 살펴보았습니다.

  • `CoordinatorLayout`은 `FrameLayout`의 특성을 물려받아 뷰를 겹쳐 놓는 것을 기본 동작으로 삼으며, 이것이 동적 UI의 출발점입니다.
  • `CoordinatorLayout.Behavior`는 뷰 간의 상호작용을 정의하는 핵심 메커니즘으로, `CoordinatorLayout`의 영혼과도 같습니다.
  • `AppBarLayout.ScrollingViewBehavior`는 `AppBarLayout`의 상태 변화에 따라 콘텐츠 뷰의 레이아웃을 자동으로 조정하여 스크롤 문제를 해결하고 아름다운 동적 효과를 만들어냅니다.
  • `app:layout_scrollFlags`는 `AppBarLayout` 내부 뷰들의 스크롤 방식을 세밀하게 제어하여, 'Quick Return'부터 'Collapsing Header'까지 다채로운 UI 경험을 제공합니다.
  • `app:layout_anchor`를 통해 뷰를 다른 뷰에 고정할 수 있으며, 더 나아가 커스텀 `Behavior`를 직접 구현하여 상상하는 거의 모든 종류의 상호작용을 만들어낼 수 있습니다.

더 이상 `CoordinatorLayout` 앞에서 좌절하지 마십시오. 그 원리를 이해하고 `Behavior`라는 강력한 도구를 손에 쥔 지금, 우리는 단순한 레이아웃 설계자를 넘어, 사용자의 손끝에서 살아 움직이는 생동감 넘치는 UI를 창조하는 진정한 '경험 설계자'로 거듭날 수 있을 것입니다.

Post a Comment