[Android] CollapsingToolbarLayout 응용하기 - Google Calendar App 클론 코딩
Android

[Android] CollapsingToolbarLayout 응용하기 - Google Calendar App 클론 코딩

728x90

 

 

이번 포스트에서는 CollapsingToolbarLayout을 응용하여 구글 캘린더 앱의 아래와 같은 효과를 따라 해 볼 것이다.

 

 구현하고자 하는 기능은 위와 같이 월간 달력이 접히거나 펼치는 기능이 있고, 일간 화면을 스크롤하여 월간 달력을 접거나 펼칠 수 있다. 또한 월간 달력이 펼쳐진 상태에서는 일간 화면의 스크롤이 동작하지 않아야 하며, 접힌 상태에서는 동작이 되어야 한다.

 전체적인 구조는 다음과 같다.

 AppBarLayout과 상호작용 하기 위한 NestedScrollView와 FrameLayout이 있다. FrameLayout 안의 Fragment에서 일간 화면을 그려준다. NestedScrollView와 FrameLayout 둘 다 AppBarLayout 접히거나 펼쳐지면 함께 움직여야 하기 때문에 layout_behavior를 설정해준다.

 다음은 AppBarLayout의 상태 변경을 감지할 수 있는 AppBarStateChangeListener를 만든다. AppBarStateChangeListener는  AppBarLayout.OnOffsetChangedListener를 오버라이드 해서 구현한다. AppBarStateChangeListener를 통해 AppBarLayout이 접혔는지, 펼쳐져 있는지를 판단할 수 있다.

 마지막으로 월간 달력이 접힌 상태에서는 NestedScrollView의 스크롤을 막아 Fragment 안에 들어 있는 ScrollView의 스크롤이 적용되어야 하기 때문에 NestedScrollView를 상속받아 CustomScrollView를 만들어 준다.

 

 

 

 우선 예제를 만들기 위해 [File -> New -> Activity -> Scrolling Activity] 경로로 이동해 새로운 프로젝트를 만들어 준다. Scrolling Activity로 새 프로젝트를 만들게 되면 기본적인 구조를 자동으로 만들어줘서 테스트 하기 편리하다.

activity_scrolling.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=".ScrollingActivity">

    <com.google.android.material.appbar.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">

        <com.google.android.material.appbar.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|snap"
            app:toolbarId="@+id/toolbar">

            <androidx.appcompat.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" />

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

    <FrameLayout
        android:id="@+id/fl_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    <com.leveloper.scrollingactivity.CustomScrollView
        android:id="@+id/custom_scrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fillViewport="true"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:context=".ScrollingActivity"
        tools:showIn="@layout/activity_scrolling">

        <View
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </com.leveloper.scrollingactivity.CustomScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

 layout_scrollFlags는 scroll, exitUntilCollapsed, snap으로 설정한다.

 AppBarLayout과 상호작용 하기 위한 FrameLayout과 CustomScrollView를 추가한다. 이때 주의할 점은 CustomScrollView의 스크롤이 우선적으로 동작해야 하기 때문에 FrameLayout보다 CustomScrollView를 아래에 작성한다.

 CustomScrollView의 사이즈를 match_parent로 하기 위하여 자식 뷰로 View를 하나 추가한다. 이때 ScrollView의 크기를 자식 뷰와 같게 하려면 fillViewport 값을 true로 줘야 한다.

 위에서 설명한 대로 FrameLayout과 CustomScrollView 둘 다 layout_behavior를 설정해서 AppBarLayout과 상호작용이 되도록 한다.

 

 

CustomScrollView

class CustomScrollView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
): NestedScrollView(context, attrs, defStyleAttr) {

    /* 스크롤 가능한지 */
    var scrollable = true

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        return when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                super.onTouchEvent(ev) && scrollable
            }
            else -> super.onTouchEvent(ev)
        }
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return super.onInterceptTouchEvent(ev) && scrollable
    }
}

 scrollable 변수를 하나 둬서 스크롤 가능 여부를 판단한다. onTouchEvent 메서드와 onInterceptTouchEvent 메서드를 오버라이드 해서 스크롤을 막아준다.

 

AppBarStateChangeListener

abstract class AppBarStateChangeListener: AppBarLayout.OnOffsetChangedListener {
    enum class State {
        EXPANDED,
        COLLAPSED,
        IDLE
    }

    private var currentState = State.IDLE

    override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
        appBarLayout?.let {
            when {
                verticalOffset == 0 -> {
                    if (currentState != State.EXPANDED) {
                        onStateChanged(appBarLayout, State.EXPANDED)
                        currentState = State.EXPANDED
                    }
                }
                abs(verticalOffset) >= appBarLayout.totalScrollRange -> {
                    if (currentState != State.COLLAPSED) {
                        onStateChanged(appBarLayout, State.COLLAPSED)
                        currentState = State.COLLAPSED
                    }
                }
                else -> {
                    if (currentState != State.IDLE) {
                        onStateChanged(appBarLayout, State.IDLE)
                        currentState = State.IDLE
                    }
                }
            }
        }
    }

    /**
     * Notifies on state change
     * @param appBarLayout Layout
     * @param state Collapse state
     */
    abstract fun onStateChanged(appBarLayout: AppBarLayout, state: State)
}

 AppBarLayout의 상태를 판단할 수 있는 AppBarStateChangeListener이다. AppBarLayout.OnOffsetChangedListener 인터페이스의 onOffsetChanged() 메서드를 오버라이드 해준다.

 AppBarLayout의 verticalOffset 값에 따라 EXPANDED, COLLAPSED, IDLE 세 가지 상태로 나뉜다.

 변경된 상태는 onStateChanged 메서드를 통해 외부에서 변경을 감지할 수 있다.

 

ScrollingActivity

class ScrollingActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_scrolling)

        setToolbar()

        supportFragmentManager.beginTransaction().replace(R.id.fl_container, BlankFragment(), "").commitNowAllowingStateLoss()

        app_bar.addOnOffsetChangedListener(appBarStateChangeListener)
    }

    private fun setToolbar() {
        setSupportActionBar(toolbar)
        toolbar_layout.title = title

        toolbar.setOnClickListener {
            custom_scrollView.scrollable = true

            app_bar.setExpanded(true, true)
        }
    }

    private val appBarStateChangeListener: AppBarStateChangeListener =
        object : AppBarStateChangeListener() {
            override fun onStateChanged(appBarLayout: AppBarLayout, state: State) {
                when (state) {
                    State.EXPANDED -> custom_scrollView.scrollable = true
                    State.COLLAPSED -> custom_scrollView.scrollable = false
                    State.IDLE -> {}
                }
            }
        }
}

 app_bar에 addOnOffsetChangedListener()로 앞서 만들어준 AppBarStateChangeListener를 추가해준다. AppBarLayout의 상태가 EXPANDED가 되면 CustomScrollView의 스크롤 상태를 true로 설정해주고, COLLAPSED가 되면 false로 설정해준다.

 

Fragment, fragment.xml

class BlankFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_blank, container, false)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".BlankFragment">

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

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

 FrameLayout에 들어갈 Fragment를 설정해준다. 레이아웃 안에는 ScrollView가 들어있다.

 

 

결과 화면

728x90