AndroidX - CoodinatorLayout with Scroll

강성우, 10 August 2020

안드로이드에서 많이 볼 수 있는 UI 디자인의 패턴 중 스크롤의 위치 에 따라 상단 ToolBar와 다른 뷰들에 대해 처리하는 방법을 정리 하였다. 기존에는 ScrollView에 listener를 추가 하여 콜백으로 전달 받는 스크롤 위치에 대해서 후처리를 하여 다른 뷰를 갱신하였지만 android x에서 제공 하는 방법은 더 접근하기 쉬운 방법으로 스크롤이벤트에 따른 뷰의 변화를 처리 해 줄 수있다.

0. 예제 앱

간단한 뷰에 대한 처리만 있으며 데이터를 따로 핸들링 할 필요가 없기 때문에 MVVM이나 다른 아키텍쳐는 적용하지 않고 app모듈 하나만 두었다. 그렇기 때문에 따로 ViewModel이나 다른 모듈등은 없다.

예제앱의 동작 은 아래 gif를 참고 하도록 하자

ex

0.1 Navigation graph

One Activity를 기준으로 1개의 예제 프래그먼트를 하나 두었으며 이들을 Navigation Graph 에서 Home으로 설정 하였다. 이 화면 외에 다른 화면은 없기 때문에 따로 정해진 Direction Action이나 Safe args는 없다.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/nav_main"
    app:startDestination="@id/mainFragment">
    <fragment
        android:id="@+id/mainFragment"
        android:name="com.swkang.exmotioneditor.MainFragment"
        android:label="MainFragment" />
</navigation>

1. CoodinatorLayout

CoodinatorLayout을 사용하기에 앞서, app 모듈의 build.gradle파일에 아래와 같이 라이브러리 의존을 추가 해 준다.

dependencies {
    implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
}

그리고, Activity의 레이아웃 xml파일에 CoodinatorLayout을 최상위 부모 레이아웃으로 변경 혹은 추가 해 준다. CoodinatorLayout은 앱 레이아웃에 최상위로 존재하는 decor view의 개념으로서, 자식뷰들간의 특정 인터렉션을 지원하는 컨테이너이다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

</androidx.coordinatorlayout.widget.CoordinatorLayout>    

1.1 CoodinatorLayout.Behavior

CoodinatorLayout의 하위뷰에 지정할 수 있는 Behavior등을 통해 여러 인터렉션등을 설정할 수 있다. 예를 들면 DrawerLayout의 제스쳐 애니메이션이나 상단 툴바의 collapse/expand 애니메이션등 이다.

제공되는 Behavior는 아래와 같다.

  • BottomSheetBehavior : Bottom에서 Top으로 제스쳐 액션으로 올라오는 View
  • FloatingActionButton.Behavior
  • SwipeDismissBehavior : 특정 뷰 를 스와이프 하는 액션의 애니메이션. 보통 swipe했을 때 뷰를 제거 하는 액션을 대상으로 적용 한다.
  • AppBarLayout.ScrollingViewBehavior : 이 문서에서 NestedScrollView에 적용된 Behavior.
  • AppBarLayout.Behavior : AppBarLayout의 자식들에 대한 Behavior.

Behavior을 xml에서 지정할 때 에는 String타입으로 지정할 Behavior의 패키지 + 클래스명을 적으면 된다.

2. AppBarLayout

AppBarLayoutCoordinatorLayout의 자식으로 추가 한다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/main_appbarlayout"
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
    </com.google.android.material.appbar.AppBarLayout>        
</androidx.coordinatorlayout.widget.CoordinatorLayout>    

2.1 style.xml

앱의 style.xml에서 엡에 적용될 테마를 변경 해 주어야 한다. 기본적으로 안드로이드에서 제공되는 ActionBar테마가 아닌 NoActionBar계열의 테마를 적용 하면 된다.

<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
    <!-- ... -->
</style>    

만약 NoActionBar계열의 테마를 지정하지 않았다면 실행시 IllegalStateException예외가 발생하고 앱이 종료된다. 로그캣 내용을 보면 아래와 같다.

java.lang.IllegalStateException: This Activity already has an action bar supplied by the window decor. Do not request Window.FEATURE_SUPPORT_ACTION_BAR and set windowActionBar to false in your theme to use a Toolbar instead.

이미 Activity에 ActionBar가 있으므로 테마에서 NoActionBar를 적용 하자.

3. CollapsingToolbarLayout

CollapsingToolbarLayoutAppbarLayout의 자식으로 추가 한다. 좀 의아한게 있다면 CollapsingToolbarLayout을 처음 보았을때 이 레이아웃이 AppbarLayout을 대체하여 사용되는 것 으로 생각 되었다. 하지만 CollapsingToolbarLayout만을 사용 해 보면 정상적으로 동작하지 않는다.

그 이유는 CollapsingToolbarLayout는 부모인 AppbarLayoutoffsetChangeListener콜백을 기반으로 동작하고 있기 때문이다. CollapsingToolbarLayoutonAttachedToWindow()메소드 내부 구현을 보면 parent view를 가져와 AppbarLayout으로 캐스팅 하여 addOnOffsetChangedListener()메소드를 통해 콜백을 추가함을 확인 할 수 있다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/main_appbarlayout"
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/main_collapsingtoolbarlayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginEnd="64dp"
            app:expandedTitleMarginStart="48dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>        
</androidx.coordinatorlayout.widget.CoordinatorLayout>    

4. Toolbar (+ImageView)

ToolbarCollapsingToolbarLayout의 자식으로 추가 한다. 맨 위 상단 예제 gif이미지 처럼 이미지뷰를 설정해 보여주고 싶다면 ImageView를 함께 추가 한다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/main_appbarlayout"
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/main_collapsingtoolbarlayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginEnd="64dp"
            app:expandedTitleMarginStart="48dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:src="@drawable/top_bg"
                app:layout_collapseMode="parallax" />
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/main_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:title="@string/app_name"
                app:titleTextColor="@color/white" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>        
</androidx.coordinatorlayout.widget.CoordinatorLayout>    

4.1 MainActivity

AppCompatActivity를 상속한 MainActivity에서는 Toolbar에 대한 뷰의 인스턴스를 얻고 setSupportActionBar()메소드를 이용해 사용할 AppBar와 Toolbar를 설정 해 주어야 한다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)

        val toolbar: Toolbar = findViewById(R.id.main_toolbar)
        setSupportActionBar(toolbar)
    }
}

4.2 app:toolbarId 설정

CollapsingToolbarLayout에서는 자식으로 추가될 Toolbar의 id를 app:toolbarId프로퍼티로 설정하여 약간의 퍼포먼스를 향상시킬 수 있다.

CollapsingToolbarLayout내부에서는 자식으로 추가될 Toolbar를 찾아야 하는데 이 때 id가 없으면 자식뷰들을 일일히 이터레이션 하여 Toolbar를 찾는다. 하지만 위 프로퍼티를 이용 하여 적용 하면 findViewById()를 통해서 바로 찾으므로 필요없는 이터레이션을 하지 않는다.

5. NestedScrollView with Contents

CoordinatorLayout에 추가될 컨텐츠의 영역을 추가 한다. 대부분의 뷰 컨테이너를 사용 할 수 있다. 하지만 위 예제 gif처럼 스크롤 되는 영역에 따라 CollapsingToolbarLayout과 함께 패럴랙스 스크롤 효과와 함께 사용 하려면 NestedScrollView를 추가 해 준다. 일반 ScrollView를 설정 하면 동작하지 않는다.

추가된 NestedScrollView혹은 해당 뷰 에 app:layout_behavior="@string/appbar_scrolling_view_behavior"프로퍼티를 추가 하여 CoordinatorLayout와 함께 동작하게 될 뷰임을 알려준다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/main_appbarlayout"
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/main_collapsingtoolbarlayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginEnd="64dp"
            app:expandedTitleMarginStart="48dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:src="@drawable/top_bg"
                app:layout_collapseMode="parallax" />
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/main_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:title="@string/app_name"
                app:titleTextColor="@color/white" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>    
    
    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:fitsSystemWindows="true"
        android:paddingTop="20dp"
        android:scrollbars="vertical"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <fragment
            android:id="@+id/fragmentContainer"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_main" />
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>    

6. FloatingActionButton

필요한 경우 Floating action button을 추가 해줄 경우도 있다. app:layout_anchor프로퍼티와 연계된 프로퍼티들의 속성 그리고 margin설정 값을 참고 하면 된다.

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_parent_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/main_appbarlayout"
        android:layout_width="match_parent"
        android:layout_height="256dp"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/main_collapsingtoolbarlayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginEnd="64dp"
            app:expandedTitleMarginStart="48dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">
            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                android:scaleType="centerCrop"
                android:src="@drawable/top_bg"
                app:layout_collapseMode="parallax" />
            <androidx.appcompat.widget.Toolbar
                android:id="@+id/main_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
                app:title="@string/app_name"
                app:titleTextColor="@color/white" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>    
    
    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:fitsSystemWindows="true"
        android:paddingTop="20dp"
        android:scrollbars="vertical"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
        <fragment
            android:id="@+id/fragmentContainer"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_main" />
    </androidx.core.widget.NestedScrollView>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="32dp"
        android:layout_marginRight="32dp"
        android:clickable="true"
        android:focusable="true"
        android:src="@android:drawable/ic_media_play"
        app:layout_anchor="@id/main_appbarlayout"
        app:layout_anchorGravity="bottom|right|end" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>    

7. 더 복잡한 레이아웃 구성

DrawerLayout이나 NavigationView, ViewPager등과 같이 사용 하는 경우 뷰의 구성은 아래처럼 구성 할 수 있다. 참고로 아래 의 코드들은 보기쉽게 긴 패키지와 내부 프로퍼티를 간소화하였다. 필요에 따라 추가적으로 프로퍼티를 사용 하면 될 것으로 생각 한다.

7.1 DrawerLayout을 갖는 Activity 의 xml

<DrawerLayout >
    <include layout=".../viewpager_container" />
    <NavigationView />
</DrawerLayout>

액티비티에서는 DrawerLayout를 배치 하고 실제 컨텐츠가 자리잡을 CoordinatorLayout와 하위 뷰들이 존재 하는 레이아웃의 xml을 include해서 적용 한다.

7.2 include 대상 viewpager_container.xml

<CoordinatorLayout>
    <AppBarLayout>
        <Toolbar />
        <TabLayout />
    </AppBarLayout>
    <ViewPager />
    <FloatingActionButton />
</CoordinatorLayout >

레이아웃의 구성을 보면 CollapsingToolbarLayout이 아닌 AppBarLayout만 사용 되었을뿐 본문의 예제와 레이아웃의 구성은 같음을 확인 할 수 있다.