CoordinatorLayout 을 위한 Custom Behavior 구현

안녕하세요. Mobile Platform 개발팀의 김은성입니다. 오늘 포스팅을 통해 Android material design spec, 그 중에서도 scrolling techniques과 그 구현 방법에 대해 말씀 드리겠습니다.

Scrolling Techniques

Scrolling techniques에 정의된 스크롤링 패턴은 현재 많은 앱에서 사용되고 있으며 대표적으로 Play Store를 예로 들 수 있습니다.

Scrolling techniques에 소개된 레이아웃을 살펴보면 크게 Header와 Contents 영역으로 나뉜다고 볼 수 있습니다. Header에는 타이틀이나 메뉴 버튼, 서치 버튼, 탭 레이아웃 등이 있고, Contents는 스크롤이 가능한 View로 되어 있습니다. 여기서 스크롤 관련 이벤트(Drag, Fling)가 발생하면 스크롤에 따라 Contents가 최대한 많이 보일 수 있도록 Header의 높이가 줄어들거나 반대로 Header의 기능을 사용할 수 있도록 원래의 높이를 회복합니다.

이 패턴을 구현하려면 Contents에 스크롤 리스너를 추가하고 스크롤이 변경될 때 Header나 그 부모 뷰가 Contents의 스크롤에 따라 필요한 동작을 하도록 하면 됩니다. 하지만 Contents의 높이를 설정하는 것은 기존의 ViewGroup들로는 어려움이 있습니다(Contents의 layout_height를 어떻게 설정해야 할까요?). 그리고 Contents의 스크롤하는 뷰의 스크롤을 감시(listen)하기 위한 View들의 관계가 복잡해질 수 있습니다.

CoordinatorLayout

Android design support library는 Android material design spec 구현을 돕기 위한 라이브러리인데, 여기에 포함된 CoordinatorLayout은 위에서 말했던 스크롤과 Contents의 배치 등의 문제를 해결할 수 있는 ViewGroup입니다. CoordinatorLayout은 child View 들이 다른 View의 레이아웃이나 스크롤 오프셋 변경 등의 정보들을 child View들에게 전달합니다. 각 View는 전달 받은 정보들을 참고하여 필요한 일(위치를 변경하거나 alpha를 변경하는 것 등)을 할 수 있습니다. CoordinatorLayout는 이러한 정보를 child View의 LayoutParams에 포함된  CoordinatorLayout.Behavior라는 helper에게 전달하여 처리할 수 있도록 합니다.

CoordinatorLayout을 사용하여 레이아웃을 구성하는 예는 아래와 같습니다.  이 레이아웃은 TextView와 TabLayout으로 구성된 Header 영역과 ViewPager로 구성된 Contents 영역으로 나누어져 있으면서 각각 Behavior를 가지고 있습니다.

<android.support.design.widget.CoordinatorLayout
    android:id="@+id/coordinatorLayout"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- Header 영역 -->
    <com.skplanet.coordinatorlayoutsample.HeaderLayout
        android:id="@+id/header_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_behavior="com.skplanet.coordinatorlayoutsample.HeaderScrollBehavior">
        <TextView
            android:id="@+id/text_view"
            android:layout_width="match_parent"
            android:layout_height="56dp"
            android:layout_marginLeft="10dp"
            android:gravity="start|center_vertical"
            android:textSize="20dp"
            android:text="@string/app_name"/>
        <android.support.design.widget.TabLayout
            android:id="@+id/tab_layout"
            android:layout_width="match_parent"
            android:layout_height="48dp"/>
    </com.skplanet.coordinatorlayoutsample.HeaderLayout>

    <!-- Contents 영역 -->
    <android.support.v4.view.ViewPager
        android:id="@+id/viewpager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.skplanet.coordinatorlayoutsample.ContentsLayoutBehavior">
    </android.support.v4.view.ViewPager>
</android.support.design.widget.CoordinatorLayout>

Custom Behavior 구현

이 레이아웃은 Header / Contents 구조로 배치되고, 스크롤 이벤트가 발생했을 때 Header가 먼저 스크롤 가능한 범위까지 스크롤된 후에 Contents가 스크롤되어야 하는데, 앞서 말한 것처럼 Behavior 구현을 통해 처리할 수 있습니다.

Contents의 Behavior 구현

먼저 Header의 높이가 가장 낮을 때 Contents가 화면을 채울 수 있도록 Contents의 크기를 지정해야 합니다. CoordinatorLayout의 onMeasure가 실행되어 View들의 크기가 정해진 후 호출되는 onMeasureChild()에서 Contents의 크기를 계산할 수 있으므로 ContentsLayoutBehavior의 onMeasureChild()를 아래와 같이 구현합니다.

public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    if(child.getLayoutParams().height == ViewGroup.LayoutParams.MATCH_PARENT) {
        List dependencies = parent.getDependencies(child);
        if(dependencies.isEmpty()) {
            return false;
        }

        HeaderLayout headerLayout = findHeaderLayout(dependencies);
        if(headerLayout != null && ViewCompat.isLaidOut(headerLayout)) {
            int scrollRange = headerLayout.getScrollRange();
            int height = parent.getHeight() - headerLayout.getMeasuredHeight() + scrollRange;
            int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.AT_MOST);
            parent.onMeasureChild(child, parentWidthMeasureSpec, widthUsed, heightMeasureSpec, heightUsed);
            return true;
        }
    }

    return false;
}

Contents가 항상 Header 아래에 위치하도록 하는 부분도 구현해야 합니다. Behavior의 onDependentViewChanged()는 layoutDepensOn()을 통해 정해진 dependency View의 크기가 변경되거나 스크롤 이벤트가 발생했을 때 호출됩니다. onDependentViewChanged()가 호출되었을 때 Header의 위치에 따라 Contents의 위치가 변경되도록 아래와 같이 구현합니다.

public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
    CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams)dependency.getLayoutParams()).getBehavior();
    if(behavior instanceof HeaderScrollBehavior) {
        int headerOffset = ((HeaderScrollBehavior)behavior).getTopAndBottomOffset();
        int contentsOffset = dependency.getHeight() + headerOffset;
        setTopAndBottomOffset(contentsOffset);
    }

    return false;
}

public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
    return dependency instanceof HeaderLayout;
}

Header의 Behavior 구현

다음으로 Contents 가 스크롤되기 전에 Header가 먼저 가능한 범위까지 스크롤되도록 Header의 Behavior를 구현해야 합니다. 즉 Contents가 스크롤되는 것을 Header가 알 수 있어야 합니다. CoordinatorLayout을 구성하는 어떤 View가 스크롤되는 것은 Behavior의 onNested…() 메소드들을 통해 전달됩니다. 이것이 전달되는 과정을 이해하기 위해서는 먼저 NestedScrollingParent, NestedScrollingChild interface에 대해 이해해야 하는데 이 두 interface가 서로 연결되는 과정을 간단하게 보면 아래와 같습니다.

이것을 구현하고 있는 레이아웃에 적용해보면 NestedScrollingChild는 ViewPager의 각 페이지들이 implements하고 있고, CoordinatorLayout은 NestedScrollingParent를 implements하고 있습니다. 그리고 ViewPager의 페이지 스크롤 이벤트와 관련된 정보들은 CoordinatorLayout을 통해 Behavior에게 전달 됩니다.

호출되는 메소드들 중에서 onNestedPreScroll()은 Contents View가 드래그되어 스크롤이 변경되기 직전에 호출됩니다. 이 메소드가 호출되면 가능한 범위만큼 Header의 위치를 변경하고 Header가 위치를 변경하는데 사용한 만큼의 오프셋을 consumed[] 파라미터에 담아 다시 전달합니다. 이렇게 되면 Contents는 consumed[] 만큼의 양을 제외하고 스크롤되게 됩니다.

public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, HeaderLayout child, View target, int dx, int dy, int[] consumed) {
   // ...
   if (mIsScrolling && dy != 0) {
      int min = -child.getScrollRange();
      int max = 0;

      int currentOffset = getTopAndBottomOffset();
      int newOffset = Math.min(Math.max(min, currentOffset - dy), max);

      consumed[1] = newOffset - currentOffset;

      setTopAndBottomOffset(newOffset);
   }
}

이 밖에 fling에 대한 처리 등을 추가하면 Header의 Behavior와 Contents의 Behavior의 구현이 완료됩니다. 자세한 내용은 첨부. CoordinatorLayoutSample을 보시면 됩니다.

마치며

CoordinatorLayout는 Android Developers Blog의 Android Design Support Library 페이지에서 소개되고 있는데 여기에는 CoordinatorLayout과 같이 사용할 수 있는 다른 component와 behavior들도 포함되어 있습니다. Android Developers Blog와 오늘 내용이 UI 구현할 때 많은 도움이 되었으면 좋겠습니다. 감사합니다.

김은성 Mobile Platform개발팀

안녕하세요. Mobile Platform 개발팀에서 Android와 iOS UI를 개발하고 있습니다.

공유하기