2018년 3월 15일 목요일

[안드로이드] CoordinatorLayout 기본 동작 원리

CoordinatorLayout

개요

CoordinatorLayout은 매우 강력한 기능을 가진 FrameLayout 이다. 이 레이아웃은 다음의 2가지 기본사용 사례를 위해 만들어졌다.
  1. 애플리케이션에서 최상위의 decor View로써 사용
  2. 자식 뷰들간의 특정한 인터렉션을 지원하는 컨테이너로써 사용
CoordinatorLayout의 자식뷰에 Behavior를 지정하는 방식으로, 하나의 부모 안에서 여러 다른 인터렉션을 지원할 수 있고, 자식뷰들간에도 서로 인터렉션 할 수 있다. Behavior 는 슬라이딩 드로어나 스와이프 해제액션 등 뷰의 다양한 움직임이나 애니메이션에 따른 상호작용을 구현하기 위해 사용된다.
CoordinatorLayout의 자식들은 anchor를 가질 것이다. 이 anchor는 일반적으로 다른 뷰를 가리키며, 뷰의 id 값으로 표시된다. anchor는 반드시 CoordinatorLayout 하위의 자손 뷰중 하나의 아이디여야 하지만, 고정된 자손뷰가 아닐 수도 있다. 이는 다른 임의의 화면의 상대위치에 뷰를 띄우는 용도로 사용될 수도 있다.
insetEdge속성을 이용해 CoordinatorLayout 안에 자식뷰들이 어떻게 배치될지 지정할 수 있다. 만약 자식뷰가 겹칠 것을 대비해, dodgeInsetEdges속성을 주어 적절하게 뷰가 겹치지 않도록 배치할 수 있다.

Behavior

CoordinatorLayout 구현의 가장 핵심 개념이다. 자식뷰에 Behavior가 지정되어 있으면, CoordinatorLayout은 그것을 토대로 부모뷰 - 자식뷰 / 자식뷰 - 자식뷰 의 소통을 구현한다. CoordinatorLayout에서 자식뷰와 소통해야 하는 부분(인터렉션, 뷰 배치 등)에서 Behavior를 사용하는 코드를 확인할 수 있다. 아래의 예제와 같이 곳곳에서 Behavior를 사용하고 있다.
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent {
    ...
    // Behavior가 인터렉션에 활용되는 예시
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        ...
        final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT);
        ...
        return intercepted;
    }
    
    private boolean performIntercept(MotionEvent ev, final int type) {
        boolean intercepted = false;
        boolean newBlock = false;

        MotionEvent cancelEvent = null;

        final int action = MotionEventCompat.getActionMasked(ev);

        final List<View> topmostChildList = mTempList1;
        getTopSortedChildren(topmostChildList);

        // Let topmost child views inspect first
        final int childCount = topmostChildList.size();
        for (int i = 0; i < childCount; i++) {
            final View child = topmostChildList.get(i);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            final Behavior b = lp.getBehavior();

            if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) {
                // Cancel all behaviors beneath the one that intercepted.
                // If the event is "down" then we don't have anything to cancel yet.
                if (b != null) {
                    if (cancelEvent == null) {
                        final long now = SystemClock.uptimeMillis();
                        cancelEvent = MotionEvent.obtain(now, now,
                                MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
                    }
                    switch (type) {
                        case TYPE_ON_INTERCEPT:
                            b.onInterceptTouchEvent(this, child, cancelEvent);
                            break;
                        case TYPE_ON_TOUCH:
                            b.onTouchEvent(this, child, cancelEvent);
                            break;
                    }
                }
                continue;
            }

            ...
        }
        topmostChildList.clear();
        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        boolean handled = false;
        boolean cancelSuper = false;
        MotionEvent cancelEvent = null;

        final int action = MotionEventCompat.getActionMasked(ev);

        if (mBehaviorTouchView != null || (cancelSuper = performIntercept(ev, TYPE_ON_TOUCH))) {
            // Safe since performIntercept guarantees that
            // mBehaviorTouchView != null if it returns true
            final LayoutParams lp = (LayoutParams) mBehaviorTouchView.getLayoutParams();
            final Behavior b = lp.getBehavior();
            if (b != null) {
                handled = b.onTouchEvent(this, mBehaviorTouchView, ev);
            }
        }

        ...
        return handled;
    }
    
    ...
    
    // Behavior가 뷰의 배치에 활용되는 예시
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        ...
        
        final int childCount = mDependencySortedChildren.size();
        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);
            if (child.getVisibility() == GONE) {
                // If the child is GONE, skip...
                continue;
            }

            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            
            ...
            
            final Behavior b = lp.getBehavior();
            if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
                    childHeightMeasureSpec, 0)) {
                onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
                        childHeightMeasureSpec, 0);
            }

            widthUsed = Math.max(widthUsed, widthPadding + child.getMeasuredWidth() +
                    lp.leftMargin + lp.rightMargin);

            heightUsed = Math.max(heightUsed, heightPadding + child.getMeasuredHeight() +
                    lp.topMargin + lp.bottomMargin);
            childState = ViewCompat.combineMeasuredStates(childState,
                    ViewCompat.getMeasuredState(child));
        }

        ...

        setMeasuredDimension(width, height);
    }
    
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int layoutDirection = ViewCompat.getLayoutDirection(this);
        final int childCount = mDependencySortedChildren.size();
        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);
            if (child.getVisibility() == GONE) {
                // If the child is GONE, skip...
                continue;
            }

            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            final Behavior behavior = lp.getBehavior();

            if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
                onLayoutChild(child, layoutDirection);
            }
        }
    }
}

기본적으로 제공되는 Behavior

  • CoordinatorLayout.Behavior
    • BottomSheetBehavior : Bottom Sheet처럼 동작하도록 지원하는 Behavior
    • FloatActionButton.Behavior
    • SwipeDismissBehavior : swipe-to-dismiss제스쳐를 지원하는 Behavior
      • BaseTransientBottomBar.Behavior
    • ViewOffsetBehavior : 뷰들의 offset을 지정
      • HeaderScrollingViewBehavior : 수직으로 된 레이아웃에서 다른 뷰 아래에 있으면서 스크롤되는 뷰를 위한 Behavior
        • AppBarLayout.ScrollingViewBehavior
      • HeaderBehavior : 수직으로 스크롤되는 뷰 위에 놓이는 뷰를 위한 Behavior
        • AppBarLayout.Behavior

기본 Behavior 동작영상

Anchor

Anchor는 자식뷰들간의 연관성을 표현하는 개념이다. CoordinatorLayout 안에서 어떤 자식뷰의 배치나 인터렉션이 있을 때, 연관된(Anchor로 등록된) 자식뷰들에게 상태변화를 알려주도록 구현하기 위해 사용된다. 연관성을 표현하기 위해 내부적으로 그래프를 이용한다. 아래의 예제는 CoordinatorLayout 안에서의 Anchor개념을 사용하는 코드이다.
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent {
    ...
    private final DirectedAcyclicGraph<View> mChildDag = new DirectedAcyclicGraph<>();
    ...
    
    // onMeasure()에서 anchor 그래프 준비
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        prepareChildren();
        ...
    }

    private void prepareChildren() {
        mDependencySortedChildren.clear();
        mChildDag.clear();

        for (int i = 0, count = getChildCount(); i < count; i++) {
            final View view = getChildAt(i);

            final LayoutParams lp = getResolvedLayoutParams(view);
            lp.findAnchorView(this, view);

            mChildDag.addNode(view);

            // Now iterate again over the other children, adding any dependencies to the graph
            for (int j = 0; j < count; j++) {
                if (j == i) {
                    continue;
                }
                final View other = getChildAt(j);
                final LayoutParams otherLp = getResolvedLayoutParams(other);
                if (otherLp.dependsOn(this, other, view)) {
                    if (!mChildDag.contains(other)) {
                        // Make sure that the other node is added
                        mChildDag.addNode(other);
                    }
                    // Now add the dependency to the graph
                    mChildDag.addEdge(view, other);
                }
            }
        }

        // Finally add the sorted graph list to our list
        mDependencySortedChildren.addAll(mChildDag.getSortedList());
        // We also need to reverse the result since we want the start of the list to contain
        // Views which have no dependencies, then dependent views after that
        Collections.reverse(mDependencySortedChildren);
    }

    ...

    // 연관된 View들에게 상태변화를 알려줌
    public void dispatchDependentViewsChanged(View view) {
        final List<View> dependents = mChildDag.getIncomingEdges(view);
        if (dependents != null && !dependents.isEmpty()) {
            for (int i = 0; i < dependents.size(); i++) {
                final View child = dependents.get(i);
                CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams)
                        child.getLayoutParams();
                CoordinatorLayout.Behavior b = lp.getBehavior();
                if (b != null) {
                    b.onDependentViewChanged(this, child, view);
                }
            }
        }
    }
    
    ...
}

LayoutParams

CoordinatorLayout은 다른 몇몇 레이아웃처럼, 자신에게 특화된 LayoutParams 정적 클래스를 가지고 있다. LayoutParams 클래스는 Behavior와 anchor 정보를 가지고 있어서, 부모가 요청할 때마다 그 정보를 넘겨주도록 구현되어 있다. 다음의 코드는 CoordinatorLayout.LayoutParams의 코드 일부분이다.
public static class LayoutParams extends ViewGroup.MarginLayoutParams {
    Behavior mBehavior;
    public int anchorGravity = Gravity.NO_GRAVITY;
    int mAnchorId = View.NO_ID;
    View mAnchorView;
    View mAnchorDirectChild;
    ...
    
    @Nullable
    public Behavior getBehavior() {
        return mBehavior;
    }
        
     public void setBehavior(@Nullable Behavior behavior) {
        if (mBehavior != behavior) {
            if (mBehavior != null) {
                // First detach any old behavior
                mBehavior.onDetachedFromLayoutParams();
            }

            mBehavior = behavior;
            mBehaviorTag = null;
            mBehaviorResolved = true;

            if (behavior != null) {
                // Now dispatch that the Behavior has been attached
                behavior.onAttachedToLayoutParams(this);
            }
        }
    }
    
    @IdRes
    public int getAnchorId() {
        return mAnchorId;
    }

    public void setAnchorId(@IdRes int id) {
        invalidateAnchor();
        mAnchorId = id;
    }

    View findAnchorView(CoordinatorLayout parent, View forChild) {
        if (mAnchorId == View.NO_ID) {
            mAnchorView = mAnchorDirectChild = null;
            return null;
        }

        if (mAnchorView == null || !verifyAnchorView(forChild, parent)) {
            resolveAnchorView(forChild, parent);
        }
        return mAnchorView;
    }
    ...
}

CoordinatorLayout 기본 사용법

  1. 모듈레벨의 build.gradle 에 다음을 추가한다.
...
dependencies {
    ...
    compile 'com.android.support:appcompat-v7:25.1.1'
    compile 'com.android.support:design:25.1.1'
    ...
}
...
  1. CoordinatorLayout을 지정할 레이아웃 xml의 루트 레이아웃으로 지정한다.
  2. CoordinatorLayout의 자식중 behavior를 지정할 자식에게 app:layout_behavior속성을 지정해 준다. 해당 속성은 String 타입으로, 지정할 Behavior의 패키지를 포함한 클래스명을 넣어야 한다.
<!-- activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.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">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/AppTheme.PopupOverlay" />

        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:showIn="@layout/activity_scrolling">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/text_margin"
            android:text="@string/large_text" />

    </android.support.v4.widget.NestedScrollView>

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/fab_margin"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end"
        app:srcCompat="@android:drawable/ic_dialog_email" />

</android.support.design.widget.CoordinatorLayout>

<!-- strings.xml -->
<resources>
    <string name="appbar_scrolling_view_behavior" translatable="false">android.support.design.widget.AppBarLayout$ScrollingViewBehavior</string>
</resources>

댓글 1개:

  1. 좋은 글 잘보았습니다.
    CoordinatorLayout에 대해 정리가 잘 되어있어서 도움이 되었습니다~ ^^

    답글삭제