선생님, 개발을 잘하고 싶어요.

[안드로이드 잡학] Android ScrollView, ScrollTo 정복하기 + Custom Smooth Scroll 본문

개발/android 개발

[안드로이드 잡학] Android ScrollView, ScrollTo 정복하기 + Custom Smooth Scroll

알고싶은 승민 2020. 1. 14. 01:56

도입

  회원 가입 페이지처럼 많은 입력 필드가 있고, 무결성이 필요한 기능을 개발할 때, 필연적으로 UX를 위해 비어있는 필드로 스크롤이 필요하다.

  확인 버튼을 눌렀을 때, 해당 빈 필드로 스크롤이 되는 상황 말이다.

 

  하지만 Android 공식 ScrollView가 제공하는 기능은, ScrollTo처럼 완전 기본적인 기능이다. 따라서 기본적인 기능부터, 우리가 원하는 커스텀 스크롤 기능까지 구현해보면서 한방에 Scroll을 뿌수고 편안하고 손쉽게 UX를 높일 수 있는 개발 생활을 해보자. 시작하자.

ScrollTo

오늘 우리가 살펴볼 함수는 ScrollTo 이다. 이 함수는 인자로 xy값을 받는데, 이는 scrollView가 스크롤될 픽셀 값을 의미한다.

y는 위에서부터 아래로 스크롤되며 0부터 시작한다. 그리고 오늘 포스트에서는 위에서 아래로의 스크롤만 다룰 예정이다. (가로 스크롤은 해당 포스트를 응용하여 처리할 수 있다.)

 

기본적으로 우리가 하려는 것은 ScrollView상의 특정 View로 스크롤되는 기능이다. 따라서 우리는

scrollView.scrollTo(x = 0,y = `View의 y position`)

따위의 함수를 실행하면 된다. 그리고 View의 y 포지션을 얻는 방법은 매우 간단한데, getTopgetBottom이 바로 그것이다. 그러면, getTop과 getBottom일 때 어떤 스크롤이 되는지 한번 확인해보자.

editText3과 editText4가 딱 붙어있다고 했을 때, 위의 이미지처럼 스크롤되려면

scrollView.scrollTo(0, editText3.bottom) // eidtText3의 하단 pixel까지 스크롤 된다.

scrollView.scrollTo(0, editText4.top) // eidtText4의 상단 pixel까지 스크롤 된다.

따라서 우리가 스크롤되었을 때 원하는 뷰가 제일 상단에 나와서 강조하고 싶다면

scrollView.scrollTo(0, view.top)

이런 코드 뭉치를 사용해야 한다는 의미이다.

여러 Depth를 무시하고 원하는 View로 Scroll 하기

그런데 문제가 있다. view.top은 자신의 부모 layout에 상대적인 위치를 뱉어 낸다.

상대적 top 값이 나오게 된다

이게 무슨 말이냐면, ScrollView안에, 다양한 계층 구조로 xml을 구성해 놓았다면, view.top은 우리가 정말로 원하는 스크롤 위치가 아니라는 것이다.

  그림에서 scrollView.scrollTo(0, editText1.top)을 실행하면, Layout1의 최상단으로 스크롤될 것이라는 이야기이다.

따라서 우리는 단순히 top을 지정하는 것만이 해결책이 아니라는 것을 알게 되었다. 그러면 어떻게 해야 할까?

 

기본적인 아이디어는 스크린 상에서 절대 좌표를 찾아내서 비교하는 것이다. 이것은 Layout깊이와는 상관이 없다. 그러면 어떻게 구할 수 있을까?

 

필자는 아래와 같은 코드 뭉치를 사용해서 구했다.

internal fun ScrollView.computeDistanceToView(view: View): Int {
    return abs(calculateRectOnScreen(this).top - (this.scrollY + calculateRectOnScreen(view).top))
}

internal fun calculateRectOnScreen(view: View): Rect {
    val location = IntArray(2)
    view.getLocationOnScreen(location)
    return Rect(
            location[0],
            location[1],
            location[0] + view.measuredWidth,
            location[1] + view.measuredHeight
    )
}

view.getLocationOnScreen라는 함수를 이용해서 구했다. 이 함수는 스크린을 기준 삼아서 해당 view의 좌측 상단 좌표를 IntArray로 반환해 주는 함수이다.

 

computeDistanceToView 함수를 이용해서 최종적으로 ScrollView와 원하는 View 사이의 최종 거리를 구하는 함수를 작성하였는데,

 

논리적 흐름은 다음과 같다.

  1. ScrollView의 상단 절대 y 좌표를 구한다.
  2. view의 상단 절대 y좌표를 구한다.
  3. 2번의 절대 y좌표는 스크린 기준이기 때문에, 이미 스크롤된 영역은 계산하지 못한다. 따라서 별도로 이미 스크롤된 영역인 scrollY를 더해준다.
  4. 그리고 그 둘을 빼면 실제로 ScrollView의 최상단과 view의 최상단 사이의 논리적 거리가 구해진다.

그래서 이를 활용하여 다음과 같은 ScrollView에 대한 확장 함수를 만들면 어디서든 손쉽게 사용할 수 있다.

fun ScrollView.scrollToView(view: View) {
    val y = computeDistanceToView(view)
    this.scrollTo(0, y)
}

 

정상적으로 동작하고 우리가 원하는 대로 View를 보여준다. 하지만 심심하다. 스크롤이 한 번에 띡 하고 움직이는 느낌이 든다.

버튼 누르면 3으로 가긴 하는데... 밋밋하다.

그래서 우리는 다른 함수인 smoothScrollTo를 찾아냈고, 기쁜 마음으로 적용해보았다.

뭐, 나름 괜찮긴하네

흠... 정상적으로 동작하고 적절히 스무스하게 움직이니까 여기까지만 구현해도 괜찮은 스크롤링이다.

하지만. 좀 더 딥하게 들어가서. 더 부드럽고 커스텀 가능한 스크롤을 만들어보자.

Custom Smooth Scroll

우리는 다음과 같은 것들을 하고 싶다.

  • 스크롤돼야 하는 Distance에 따라 별개의 속도, 시간으로 스크롤되었으면 좋겠다.
  • 스크롤이 갈수록 천천히 되는지, 갈수록 빨라지는지, 혹은 바운스 되는지 지정할 수 있으면 좋겠다. (interpolator)
  • 스크롤이 끝난 후에 키보드를 올리던지, 간단한 애니메이션을 띄우던지, 이벤트를 지정하고 싶다.

  그리고 ScrollView 공식 문서상 위의 행위를 지원하는 함수는 없다. 너무 커스텀한 행동을 라이브러리가 제공할리가 없다. 하지만 위의 기능들은 별도의 애니메이션 코드로 제작 가능하다. 우리가 사용할 녀석은 ObjectAnimator이다.

어떤가 이제 좀 만족하나?

  가만히 생각해보면, 스크롤이라는 것은 View.scrollY를 변경시켜주는 것이다. 부드러운 스크롤은 scrollY를 빠른 속도로 증가시켜서 사람이 보기에 마치 부드럽게 이동하는 것처럼 보이게 만든 것이다.

  따라서 우리는 Animator(연속적인 value를 emit 해주는 객체)를 사용해서 연속적으로 scrollY를 교체한다는 아이디어로 이 문제에 접근할 수 있다.

 

  특히나 이런 식으로 Object의 특정 속성을 연속적으로 변경시키려는 경우를 특별히 지원하는 것이 바로 ObjectAnimator이다. 이를 이용한 다양한 애니메이션은 다른 블로그 포스팅도 많으니 참고하면 즐거운 안드로이드 개발 생활이 될 것이다. (필자도 관련 포스트를 올려야 할 것 같다. 관련 작업을 하면 올리도록 하겠다.)

fun ScrollView.smoothScrollToView(view: View) {
    val y = computeDistanceToView(view)
    ObjectAnimator.ofInt(this, "scrollY", y).apply { 
        duration = 1000L // 스크롤이 지속되는 시간을 설정한다. (1000 밀리초 == 1초)
    }.start()
}

  좋다. 우리는 이제 1초간 스크롤이 되는 함수를 만든 것이다. 하지만 우리가 하고 싶은 일을 하나 씩 추가하자.

 

  우선 스크롤되어야 하는 Distance에 따라 멀 수록 오래, 가까울수록 짧은 시간 동안 스크롤을 완수하고 싶다고 하자. 한마디로 스크롤을 해야 할 것이 없다면 0초 만에, 맨 밑바닥부터 스크롤을 한다면 2초 동안 스크롤되는 구현을 하고 싶은 것이다.

 

fun ScrollView.smoothScrollToView(
    view: View,
    maxDuration: Long
) {

    // 스크롤의 의미가 없다.
    if (this.getChildAt(0).height <= this.height) return

    val y = computeDistanceToView(view)

    // (스크롤 해야하는 거리 - 현재 스크롤 된 거리) / (스크롤 몸체의 높이 - 스크롤 뷰의 높이)
    val ratio = abs(y - this.scrollY) / (this.getChildAt(0).height - this.height).toFloat()

    ObjectAnimator.ofInt(this, "scrollY", y).apply {
        duration = (maxDuration * ratio).toLong()
    }.start()
}

위 식의 그림

  • (스크롤 몸체의 높이 - 스크롤 뷰의 높이)는 scrollY의 최대 값이다.
  • (스크롤해야 하는 거리 - 현재 스크롤된 거리)가 0이라면, 스크롤할 이유가 없는 것이다.

이렇게 비율을 정의하고 duration을 재 설정해주는 작업을 하였다.

 

그다음 Interplator 설정과 스크롤 완료 후 애니메이션 설정은, ObjectAnimator와 ValueAnimator를 공부하는 것에 더 가깝기 때문에 코드만 보여주고, 간단한 설명만 하도록 하겠다.

 

fun ScrollView.smoothScrollToView(
    view: View,
    marginTop: Int = 0,
    maxDuration: Long = 500L,
    onEnd: () -> Unit = {}
) {
    if (this.getChildAt(0).height <= this.height) { // 스크롤의 의미가 없다.
        onEnd()
        return
    }
    val y = computeDistanceToView(view) - marginTop
    val ratio = abs(y - this.scrollY) / (this.getChildAt(0).height - this.height).toFloat()
    ObjectAnimator.ofInt(this, "scrollY", y).apply {
        duration = (maxDuration * ratio).toLong()
        interpolator = AccelerateDecelerateInterpolator()
        doOnEnd {
            onEnd()
        }
        start()
    }
}

 

  • Animator의 interpolator를 커스텀하게 설정하였다. 여기서는 AccelerateDecelerateInterpolator를 지정하였는데, 다양한 Curve 함수를 제공하니 찾아보길 추천한다.
  • doOnEnd에 람다를 지정하여 스크롤 애니메이션이 끝났을 때 이벤트를 설정하였다. doOnStart, doOnCancel 등 다양한 상태에 대한 콜백들이 존재하니 원하는 대로 커스텀해서 사용할 수 있다.

결론

  쉽다고 그냥 넘기던 scroll이었는데, 이런 소소한 반응성에서 앱의 완성도가 갈린다.

  우리는 제한된 디스플레이에 점점 더 많은 데이터를 보여줘야 한다. 따라서 부드러운 스크롤과 스크롤 전-후 관련 이벤트 및 애니메이션이 중요해지고 있는데, 오늘은 scrollTo를 사용한 기본 스크롤 구현부터 Kotlin Extension Function과 ObjectAnimator를 사용한 좀 더 커스텀 가능한 스크롤까지 살펴보았다.

 

  사실 이 정도만 알면 한동안 스크롤 관련해서 손 볼일은 없지 않을까...? (그러길 바라며)

Comments