Programming/Android

[Android] 첫 오픈소스 라이브러리 배포 - HoldableSwipeHandler 원리 및 출시 후기

YK Choi 2022. 1. 3. 01:24

Holdable Swipe을 시작하게 된 계기 

쿠링 앱에서 추가 기능을 개발하다가 디자인 요구사항 중에 RecyclerView에서 부분 스와이프(partial swipe)가 필요하다는 것을 알게 되었다.

 

 

경험해보지 않은 UI였지만 RecyclerView에서 Expandable Recyclerview, 한 View의 스와이프처리 가능 여부는 알고 있었기에 기본적으로 제공되는/이미 다른 누군가가 만들어 놓은 Open Source Library를 쓰면 금방 구현할 수 있으리라 생각했다.

 

그렇게 구글링을 하던 도중 몇몇 비슷한 고민을 하고 있던 Stack Overflow글이나 블로그들을 보게 되었고 ItemTouchHelper를 이용해서 ViewHolder를 완전히 넘겨버리는 것은 가능하지만, partial-swipe에 대해서는 고려하지 않거나/불완전하게 해결하고 있었다.

 

나에게 필요했던 UI적 기능은

1. RecyclerView에서 스와이프하여 왼쪽에 고정시킬 수 있어야함

2. 걸쳤을 때(Holded) 오른쪽에 남은 공간에 Image Icon을 두어 클릭 이벤트를 처리할 수 있어야함 (해당 뷰홀더의 아이템을 local DB에서 삭제해야함)

이었다.

 

시행착오와 깨달음

1. 뷰홀더의 escape를 불가하게 하자!

https://medium.com/@kitek/recyclerview-swipe-to-delete-easier-than-you-thought-cff67ff5e5f6

위 링크를 보면서 처음 RecyclerView에 ItemTouchHelper를 attach 하는 법과 onChildDraw, swipe의 개념들에 대해 조금씩 이해할 수 있었다.

 

처음엔 위의 방법을 응용하여 swipe하여 해당 뷰홀더를 escape시키되, 완전히 화면에서 안보이게하는것이 아니라 부분만 escape되도록 하면 되리라 생각했다. 하지만 그 방법으로는 손가락을 이용해 터치→스와이프를 할 때 Holding되는 부분까지 스와이프하는 것이 아니라 임계값을 반드시 넘어야 escape가 되고, 그것은 escape에서 돌아올때도 마찬가지였다.

즉, ItemTouchHelper의 getSwipeThreshold()를 오버라이딩해서 임계값을 default인 0.5에서 0.1로 바꾸면 손쉽게 escape되도록은 할 수 있지만 다시 원복시키려면 0.9만큼을 당겨야하고, 터치→스와이프가 굉장히 힘들어진다. (여기서 언급한 임계값인 0.5, 0.1, 0.9는 RecyclerView 전체를 1이라 두고, 스와이프해야하는 화면상의 비율이다)

 

그렇다면 임계값을 0.5로 두어야 대칭이 맞는데, 임계값을 0.5로 두면 안되는 이유는 이렇다. 예를 들면, 사용자가 Holding하고 싶은 Width가 200dp 이고, RecyclerView의 전체 Width가 1200dp라고 생각해보자. 그렇다면 escape를 위한 임계값은 1200*0.5인 600dp 이다. 200dp를 홀딩해야하는데 600dp만큼 스와이프해야한다는 것은 사용자에게 분명 헷갈림을 줄 수 있는 요소라고 생각됐다. 어쩌면 버그라고 생각될 지도 모른다.

 

따라서 나는 뷰홀더의 escape를 쓰지 않기로 하였고, getSwipeThreshold()는 2.0, getSwipeVelocityThresold()는 0.0 을 반환되도록 오버라이딩 함으로써 뷰홀더가 절대로 escape되지 않도록 하였다.

 

2. 뷰홀더가 Swipe되는 순간엔, 빈 공간은 더이상 뷰홀더가 아니다!

이게 무슨 말이냐하면 아래의 그림과 같이 특정 뷰홀더가 왼쪽으로 swipe되면 오른쪽의 남은 공간은 뷰홀더가 아닌 영역이 되고, 클릭을 해도 뷰홀더의 클릭이벤트가 발생하지 않는다.

 

 

그렇다면 HoldingBackground의 터치이벤트는 어떻게 발생시킬 수 있을 것인가에 대한 고민이 시작된다.

ViewHolder를 그리는 xml의 최상단 ViewGroup의 기저에 하나의 ImageView를 두면 해결이 될까? 그렇지 않았다. 그것 또한 뷰홀더에 속해있으며 뷰홀더가 이동할때 함께 움직인다.

RecyclerView내의 적당한 위치에 ImageView를 올려 두어야 할까? 이것도 애매하다. 각 뷰홀더의 Item마다 높이가 다르며, 스크롤에 의해 어떤 위치에 ImageView를 둘 지, 몇개의 ImageView를 만들어야할지 고민해야될텐데 고려할 것이 많아진다.

 

https://codeburst.io/android-swipe-menu-with-recyclerview-8f28a235ff28

이 블로그에서 알려주는 방식은 몇가지의 버그가 있지만(MotionEvent의 터치다운과 터치업이 적절하지 못한 부분 등) 나에게 어떻게 해결해 나갈지에 대한 아이디어를 제시해 주었고, 버그들을 수정하면서 완성도를 높혀나갔다.

 

이 사이트도 도움이 됐다.

https://velog.io/@trycatch98/Android-RecyclerView-Swipe-Menu

 

 

3. View가 아닌 Drawable에 클릭이벤트 추가!

다음 방향성은 ImageView를 만드는 것이 아니라 Drawable을 가져와서 Canvas에 그린 뒤, RecyclerView의 onDraw()를 적절하게 오버라이딩함으로써 onDraw()가 발생할때 사라지지 않도록 하는 것이다.

또한 Drawable로 어떻게 클릭이벤트를 감지할 것이냐에 대한 문제는 조금 더 원초적인 방법으로 해결이 가능했다. RecyclerView에 터치다운 이벤트 리스너를 달고, 클릭되는 좌표가 Drawable이 위치한 영역에 있을 경우 또 다시 터치업 이벤트 리스너를 추가한다. 터치업 이벤트가 발생했을 때에도 해당 좌표가 Drawable이 위치한 영역에 있는 경우 → Drawable을 클릭했다는 콜백으로 처리하는 것이다!

(터치 다운 이벤트 : 스크린에 손가락이 닿는 이벤트, 터치 업 이벤트 : 스크린에 손가락에 떼어지는 이벤트) 

 

4. RecyclerView에서 뷰홀더의 재활용이 문제가 된다!

RecyclerView의 Recycle의 뜻은 재활용이다. 뷰홀더를 재활용하는 것인데 이것이 문제가 되었다.

이것은 하나의 뷰홀더를 Holding하는 것이 성공한 뒤의 이야기이다. 뷰홀더를 스와이프하여 홀딩한 상태에서 스크롤을 내리다 보면 뜬금없는 아이템이 홀딩되어 있음을 발견할 수 있었다. 이는 RecyclerView의 특징 중 모든 Item의 View를 각각 만들지 않고, 스크롤되어 화면상에 보이지 않을 정도로 지나간 뷰홀더는 다시 재활용하는 것 때문에 발생했다. ListView를 쓰면 이 문제가 없을것도 같았지만 RecyclerView를 포기할 순 없었다.

이것을 위해 한가지 규칙을 추가하였다. 그것은 반드시 1개의 Item만이 Holdable하도록 구현하는 것이다. 그렇게하면 RecyclerView에 다른 영역 터치 이벤트나 스크롤 이벤트가 있을때 현재 뷰홀더의 Holding을 해제하고 스크롤해서 내렸을 때 이러한 버그를 신경쓰지 않아도 된다.

이 문제를 해결하기 위해 삼성폰과 아이폰의 Notification UI(카톡 알림, 날씨 알림 창등)를 유심히 관찰해 보았고, 둘이 공통적으로 하나의 Item에 대해서만 부분 스와이프가 가능함을 참고하였다.

 

Code Level에서의 부연 설명

현재 나는 HoldableSwipeHandler를 크게 3개의 클래스로 나누었다.

swipe되는 뷰홀더를 제어하는 HoldableSwipeHelper,

swipe 되고 남은 공간을 그리는 SwipedBackgroundHolder,

이벤트 처리를 위한 인터페이스인 SwipeButtonAction (현재는 onClickFirstButton() 하나의 추상 메소드만 있다.)

 

1. HoldableSwipeHelper

// HoldableSwipeHelper
override fun onChildDraw(
    canvas: Canvas,
    recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    dX: Float,
    dY: Float,
    actionState: Int,
    isCurrentlyActive: Boolean,
) {
    absoluteDx = dX
    swipedBackgroundHolder.updateHolderWidth()

    val isHolding = getViewHolderTag(viewHolder)
    scopedX = holdViewPositionHorizontal(dX, isHolding)

    viewHolder.itemView.translationX = scopedX

    swipedBackgroundHolder.drawHoldingBackground(canvas, viewHolder, scopedX.toInt())
    currentViewHolder = viewHolder
}

onChildDraw는 recyclerView의 view가 반응하여 onDraw()할 때 콜백되는 함수이다. 함수명에서 알수 있듯이 child이므로 ViewHolder를 이야기한다.

  • isHolding은 해당 뷰홀더가 홀딩되어 있는지에 대한 Boolean 값이고,
  • holdViewPositionHorizontal()은 최대로 Swipe되는 Width값과 최소값(0)과 현재 swipe되고 있는 dX값 사이에서 적절한 position을 계산하기 위한 함수이다. (isHolding 여부를 받아서 계산에 쓰인다.)
  • onChildDraw()는 뷰홀더가 interaction이 없으면 호출되지 않는다. 따라서 Activity/Fragment가 화면상에 보이지 않게 되었거나 그러면 HoldingBackground가 보이지 않게 된다.(View가 아니므로 onDraw()에서 그려줘야함)
    관련 문서 : https://developer.android.com/training/custom-views/custom-drawing
    이것을 위해 RecyclerView에 addItemDecoration()을 추가하여 onDraw()를 재정의해야한다. 

 

// HoldableSwipeHelper
fun addRecyclerViewListener(recyclerView: RecyclerView) {
    recyclerView.addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
        override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
            if (e.action == MotionEvent.ACTION_DOWN) {
                rv.findChildViewUnder(e.x, e.y)?.let {
                    val viewHolder = rv.getChildViewHolder(it)
                    if (viewHolder.absoluteAdapterPosition != currentViewHolder?.absoluteAdapterPosition) {
                        releaseCurrentViewHolder()
                    }
                }
            }
            return false
        }
        override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { }
        override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { }
    })

    recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            releaseCurrentViewHolder()
        }
    })
}
  • addRecyclerViewListener()는 2가지의 Listener가 추가된다.
  • 첫번째로는 ItemTouchListener이다. 어떤 한 뷰홀더를 터치했을 때 다른 뷰홀더가 Holding되고 있다면 그 홀딩을 해제하는 것이다. 위에서 언급한 단 하나의 뷰홀더가 Holdable해야함을 위한 것이다.
  • 두번째로는 ScrollListener이다. 스크롤의 이동이 생기면 현재 Holing되고 있는 뷰홀더의 홀딩을 해제한다. 이것은 위에서 언급한 RecyclerView의 뷰홀더의 재활용 문제를 해결하기 위한 것이다.
// HoldableSwipeHelper
private fun addFirstItemClickListener(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
    recyclerView.setOnTouchListener { _, event ->
        if (event.action == MotionEvent.ACTION_DOWN) {
            if (swipedBackgroundHolder.isFirstItemArea(event.x.toInt(), event.y.toInt())) {
                recyclerView.setOnTouchListener { _, event2 ->
                    if (event2.action == MotionEvent.ACTION_UP) {
                        if (swipedBackgroundHolder.isFirstItemArea(event2.x.toInt(), event2.y.toInt())) {
                            buttonAction.onClickFirstButton(viewHolder.absoluteAdapterPosition)
                        }
                    }
                    false
                }
            }
        }
        return@setOnTouchListener false
    }
}
  • Drawable의 클릭이벤트를 처리하는 부분이다. RecyclerView에 터치이벤트를 넣고, 터치다운이 발생했을때 해당 영역이 Drawable에 포함되면 터치업 이벤트를 추가한다. 터치업이벤트는 반드시 다시 발생하게 될텐데 그때의 좌표가 또다시 Drawable에 포함되면 클릭했다고 간주하고 클릭 콜백을 호출한다.

 

2. SwipedBackgroundHolder

// SwipedBackgroundHolder
fun drawHoldingBackground(canvas: Canvas, viewHolder: RecyclerView.ViewHolder, x: Int) {
    val itemView = viewHolder.itemView

    // holding 되는 background 그린다
    drawBackground(canvas, itemView, x)

	// holding 되는 background 에서 버튼의 위치를 계산하고 그린다
    drawFirstItem(canvas, itemView)
}
  • drawHoldingBackground()는 2개의 private 함수를 호출한다. background를 먼저 그리고 (drawBackground()), Drawable을 그린다. (drawFirstItem())

 

// SwipedBackgroundHolder
private fun drawBackground(canvas: Canvas, itemView: View, x: Int) {
    background.color = backgroundColor
    background.setBounds(itemView.right + x , itemView.top, itemView.right, itemView.bottom)
    background.draw(canvas)
}
  • viewHolder의 위치에서 얼마나 swipe되었는지 좌표변화값인 x 를 인자를 받아 Background를 그린다.

 

// SwipedBackgroundHolder
private fun drawFirstItem(canvas: Canvas, itemView: View) {
    val itemHeight = itemView.bottom - itemView.top

    // holding 되는 background 에서 버튼의 위치를 계산한다
    val firstIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
    val firstIconLeft = itemView.right - firstItemSideMargin - intrinsicWidth
    val firstIconRight = itemView.right - firstItemSideMargin
    val firstIconBottom = firstIconTop + intrinsicHeight

    // holding 되는 background 에서 버튼을 그린다.
    firstIcon.setBounds(firstIconLeft, firstIconTop, firstIconRight, firstIconBottom)
    firstIcon.draw(canvas)
}
  • Drawable의 상하좌우 값을 계산하고 그린다.
  • 처음에는 drawDeleteItem()이었지만 두개 또는 세개의 아이템을 지원할 확장성을 생각해서 drawFirstItem()으로 네이밍을 수정하였다. 아직도 적절한 네이밍인지 고민이 된다. drawRightItem()은 어떨까..? 

 

 

후기

처음으로 만든 오픈소스 라이브러리라 그런지 굉장히 뿌듯한 기분이 들고 앱을 개발하는 것과는 다른 종류의 뿌듯함이 느껴진다. 오픈소스라이브러리는 앱과는 달리 그 사용자가 측정될 수 없이 많아질 수 있다. 그렇기 때문에 더욱 예외 케이스와 확장성을 고려해야하고 클린한 코드가 중요한 것 같다. 

 

Retrofit도 그렇고 다른 라이브러리들을 보면 Builder패턴들이 많이 보이는데, HoldableSwipeHandler도 Builder패턴을 쓰기에 적절한 상황이지 않은가? 라는 생각도 든다. 이 오픈소스를 사용하는 개발자가 반드시 setting해야하는 부분이 있고 선택적인 부분도 있는데 그러한 면에서 Builder패턴을 적용을 고려해볼 수 있을 것같다. (반드시 setting해야하는 부분을 하지않았때 컴파일에러를 발생시키면 개발자가 빠르게 캐치할 수 있을것이다)

그리고 생성자를 통해 세팅을 해볼 수도 있는데 이때 3개 이상의 파라미터를 받을 수 있기 때문이다.(확장 용이, 가독성 증가)

이것은 조금 더 레퍼런스를 찾아보면서 고민해야겠다!

 

오픈소스라이브러리가 배포하는 것도 그렇고 어려운 듯 쉬운 것 같다. 처음이 어렵지 한 번 하고나니 재미있고 계속 들여다보게 된다. 벌써 1.0.82 버전까지 릴리즈했다!!

1.0.8에서 더 작은 이슈들은 1.0.81, 1.0.82 이런식으로 하려고했는데 다시 생각해보니 이러면 안되고 1.0.9, 1.0.10 이런식으로 버전을 증가시키는 것이 맞는것같다ㅜ

 

만들자마자 벌써 5개의 Star가 눌렸고, 자신의 앱에 이 라이브러리를 추가하였다는 기분 좋은 소식도 들었다. 더 많은 사람들이 이 라이브러리를 사용하고 피드백과 PR도 올라와서 나도 더 배울 수 있으면 좋겠다.

 

 

라이브러리 소스코드 및 사용법 : https://github.com/yeon-kyu/HoldableSwipeHandler

 

GitHub - yeon-kyu/HoldableSwipeHandler: Open Source Library for Holdable(partial-swipe) ViewHolder in RecyclerView

Open Source Library for Holdable(partial-swipe) ViewHolder in RecyclerView - GitHub - yeon-kyu/HoldableSwipeHandler: Open Source Library for Holdable(partial-swipe) ViewHolder in RecyclerView

github.com