본문 바로가기

코딩/안드로이드

헤더가 찰싹 달라 붙는 스크롤 뷰 (Sticky Scroll View)

Fixed view in scroll view

 

 

네이버 앱을 보면

 

 

이렇게, 검색창이 위로 올라가다가 맨 위에 도달하면 더이상 올라가지 않고 고정이 된다.

 

실제로 네이버 앱 내부적으로는 CollapsingToolbarLayout을 이용하고 있을 수도 있다. 

 

하지만, 만약 단순히 뷰를 고정시키기만 하고싶거나, 툴바는 다른 이유로 따로 쓰고 싶을 경우에는 CollapsingToolbarLayout를 사용하지 못한다.

 

그래서 이번에는 스크롤 뷰 내부의 임의의 뷰를 천장에 찰싹 달라 붙게 하는 커스텀 뷰를 만들어 보기로 하자.

 

 

전체 코드는 https://github.com/5seunghoon/Sticky_Scroll_View_Example 에 있고,

 

이번에는 https://github.com/amarjain07/StickyScrollView 를 조금 참고하였다. 

만약 오픈 소스를 바로 활용해도 괜찮다면 지금 링크한 이 라이브러리를 이용하여도 좋을 것 같다.

 

 

결과부터 보자

 

스크롤을 올리면 찰싹 붙고

 

스크롤을 내리면 다시 떨어진다

 

 

 

고정될 뷰를 클릭하면 위로 올라가면서 찰싹 붙는다

 

 

 

레이아웃의 경우 따로 설명할 것이 없다. 

 

ScrollView 대신에, 지금부터 우리가 만들 커스텀 뷰를 활용하고 일반적인 ScrollView처럼 쓰면 된다.

 

먼저 Activity를 보자.

 

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

        main_scroll_view.run {
            header = header_view
            stickListener = { _ ->
                Log.d("LOGGER_TAG", "stickListener")
            }
            freeListener = { _ ->
                Log.d("LOGGER_TAG", "freeListener")
            }
        }
    }
}

main_scroll_view는 우리가 만든 커스텀 뷰이다.

헤더로 스크롤 뷰의 내부에 들어가는 뷰 하나를 지정해서 넣어주고, 리스너를 두개 넣어준다.

 

stickListener는 헤더가 천장에 달라붙을 때 콜되는 리스너, free는 천장에서 다시 떨어질 때 콜 되는 리스너다.

 

 

그럼, 중요한 커스텀 뷰를 보자.

 

package com.tistory.deque.stickyscrollviewtest

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewTreeObserver
import android.widget.ScrollView

class NewScrollView : ScrollView, ViewTreeObserver.OnGlobalLayoutListener {

    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attr: AttributeSet?) : this(context, attr, 0)
    constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attr,
        defStyleAttr
    ) {
        overScrollMode = OVER_SCROLL_NEVER
        viewTreeObserver.addOnGlobalLayoutListener(this)
    }

    var header: View? = null
        set(value) {
            field = value
            field?.let {
                it.translationZ = 1f
                it.setOnClickListener { _ ->
                    //클릭 시, 헤더뷰가 최상단으로 오게 스크롤 이동
                    this.smoothScrollTo(scrollX, it.top)
                    callStickListener()
                }
            }
        }

    var stickListener: (View) -> Unit = {}
    var freeListener: (View) -> Unit = {}

    private var mIsHeaderSticky = false

    private var mHeaderInitPosition = 0f

    override fun onGlobalLayout() {
        mHeaderInitPosition = header?.top?.toFloat() ?: 0f
    }

    override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
        super.onScrollChanged(l, t, oldl, oldt)

        val scrolly = t

        if (scrolly > mHeaderInitPosition) {
            stickHeader(scrolly - mHeaderInitPosition)
        } else {
            freeHeader()
        }
    }

    private fun stickHeader(position: Float) {
        header?.translationY = position
        callStickListener()
    }

    private fun callStickListener() {
        if (!mIsHeaderSticky) {
            stickListener(header ?: return)
            mIsHeaderSticky = true
        }
    }

    private fun freeHeader() {
        header?.translationY = 0f
        callFreeListener()
    }

    private fun callFreeListener() {
        if (mIsHeaderSticky) {
            freeListener(header ?: return)
            mIsHeaderSticky = false
        }
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        viewTreeObserver.removeOnGlobalLayoutListener(this)
    }

}

 

변수부터 살펴보자

 

    var header: View? = null
        set(value) {
            field = value
            field?.let {
                it.translationZ = 1f
                it.setOnClickListener { _ ->
                    //클릭 시, 헤더뷰가 최상단으로 오게 스크롤 이동
                    this.smoothScrollTo(scrollX, it.top)
                    callStickListener()
                }
            }
        }

헤더를 설정할 때, translationZ를 1로 설정해준다. 이렇게 하지 않으면 천장에 붙은 뷰가 다른 스크롤 뷰의 레이아웃 뒤에 가려지게 된다.

 

그 후 헤더에 클릭 리스너를 설정해준다.

헤더를 클릭하면 스크롤이 되면서 헤더가 천장에 찰싹 달라 붙게 구현했다.

smoothScrollTo를 이용하면 지정한 스크롤의 위치로 부드럽게 스크롤되어진다.

 

    var stickListener: (View) -> Unit = {}
    var freeListener: (View) -> Unit = {}

두가지 리스너를 일급함수로 설정해준다. 코틀린스럽다!

 

 

    private var mIsHeaderSticky = false

    private var mHeaderInitPosition = 0f

 

헤더가 천장에 달라 붙어 있는지 아닌지를 체크하는 flag를 하나 선언한다.

그리고 헤더의 초기 위치를 저장할 변수를 선언한다.

이 변수는 스크롤되는 위치와 비교되어서 헤더가 천장을 넘어서는 스크롤인지 아닌지 판단하는데 이용된다.

자세한건 아래서 다시 설명하겠다.

 

    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attr: AttributeSet?) : this(context, attr, 0)
    constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attr,
        defStyleAttr
    ) {
        overScrollMode = OVER_SCROLL_NEVER
        viewTreeObserver.addOnGlobalLayoutListener(this)
    }

 

커스텀 뷰의 생성자를 살펴보자.

overScrollMode를 NEVER로 설정한다. NEVER로 안하면 역스크롤시 또잉~하는 애니메이션이 만들어진다.

말로 표현이 힘들다.. 

 

viewTreeObserver에 globalLayoutListener를 추가해준다.

해당 함수는 레이아웃에 변경이 생길 때 일어날 것을 추가해주면 된다.

    override fun onGlobalLayout() {
        mHeaderInitPosition = header?.top?.toFloat() ?: 0f
    }

이렇게.

이 함수는 헤더의 y포지션을 저장하는 부분이다.

만약 이 로직을 생성자나 onAttachToWindow등에 넣어두면 올바르게 저장되지 않는다.

 

    override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
        super.onScrollChanged(l, t, oldl, oldt)

        val scrolly = t

        if (scrolly > mHeaderInitPosition) {
            stickHeader(scrolly - mHeaderInitPosition)
        } else {
            freeHeader()
        }
    }

 

이제 가장 중요한 부분이다.

onScrollChanged를 오버라이드해서 어느시점에 헤더를 천장에 붙일지, 다시 땔지 등을 구현한다.

 

먼저 scrolly가 헤더의 초기 y포지션보다 클 경우, 헤더를 천장에 붙인다고 표현되어 있다.

무슨 뜻이냐면, scrolly는 '스크롤 뷰가 스크롤 된 정도'라고 생각하면 이해가 쉬울 것이다.

따라서 ['스크롤 뷰가 스크롤 된 정도'가 '원래 있단 헤더의 y포지션' 보다 큰 경우] 라는 것은

[헤더가 천장을 넘어섰다], 라는 뜻이 된다.

 

따라서 헤더가 천장을 넘어섰을 때 stickHeader함수를 통해 천장에 붙이고, 그렇지 않을 경우 freeHeader를 통해 천장에서 다시 땐다.

 

    private fun stickHeader(position: Float) {
        header?.translationY = position
        callStickListener()
    }

천장에 붙이는 부분이다.

헤더 뷰의 translationY를 파라미터로 들어온 position으로 변경한다.

translationY는 뷰의 top포지션에 대한 상대적인 포지션을 의미한다. (기본은 0이다)

파라미터로 들어온 포지션은 scrolly - mHeaderInitPosition인데, 이것은 [헤더를 넘어서서 스크롤 된만큼]을 의마한다.

즉, 헤더의 위치를 아래로 쭈우우우욱 늘려주는 것이다.

 

그 후 리스너를 콜해준다.

 

 

 

여기까지를 요약하면 다음과 같다.

 

스크롤이 변화할 때마다 -> 헤더가 천장을 넘어섰는지 확인하고 -> 넘어섰으면 헤더를 천장으로 위치를 옮긴다

 

 

이제 리스너를 콜 하는 부분을 마저 보자.

 

    private fun callStickListener() {
        if (!mIsHeaderSticky) {
            stickListener(header ?: return)
            mIsHeaderSticky = true
        }
    }

붙어있지 않으면 -> 리스너 콜 -> flag를 true로.

 

 

    private fun freeHeader() {
        header?.translationY = 0f
        callFreeListener()
    }

freeHeader부분이다.

헤더의 translationY를 0으로 해서 복원해준다.

 

 

 

끝!

 

 

 

'코딩 > 안드로이드' 카테고리의 다른 글

Kotlin DSL을 이해해보자 - 1. 확장함수타입  (2) 2019.12.05
Android DiffUtil  (0) 2019.09.12
Gradle : implementation VS compileOnly  (0) 2019.08.03
RxJava와 Room DB  (0) 2019.05.26
Android Action Mode  (0) 2019.05.13