[안드로이드] Groupie로 확장 가능한 RecyclerView 만들기

2021. 6. 21. 14:50개발을 파헤치다/Android

반응형

앱 서비스를 개발하다 보면 가장 흔하게 사용하는 것이 바로 RecyclerView인데요.

데이터를 목록 형태로 보여주는 레이아웃이죠. 어느 서비스에서나 들어갑니다.

그런데 가끔 서비스 기획을 보다 보면 복잡한 형태의 데이터 목록을 만들어야 할 때가 있어요.

예를 들어, 아이템을 누르면 그에 속하는 새로운 목록이 나오는 형태 말이죠.

 

Expandable Recyclerview라고 불립니다만

 

오늘은 이렇게 복잡한 형태의 RecyclerView를 아주 간편하게 구현할 수 있는 방법을 알려드릴 겁니다.

설치부터 구현까지 상세하게 알려드릴 테니 보면서 천천히 따라 해 보세요.

마지막에 실전 예제까지 넣어놨으니 Groupie 사용하는데 무조건 도움이 되실 겁니다.

 

설치하기

먼저 프로젝트 Level의 build.gradle 파일에 아래와 같이 추가해줍니다.

// Project Level build.gradle
buildscript {
    ext.kotlin_version = '1.4.21'

    repositories {
        mavenCentral()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url "https://jitpack.io" }
    }
}

다음으로 애플리케이션 Level의 build.gradle 파일에 아래와 같이 추가해줍니다.

 

// App Level build.gradle

dependencies{
   ...
   def groupie_version = "2.9.0"
   implementation "com.github.lisawray.groupie:groupie:$groupie_version"
   // View Binding을 사용할 경우 추가
   implementation "com.github.lisawray.groupie:groupie-viewbinding:$groupie_version"
   ...
}

이제 안드로이드 스튜디오 오른쪽 상단에 뜬 Sync Now 버튼을 눌러주면 설치는 끝이 납니다.

참 쉽죠?

 

 

Group Item 구현

이제 본격적으로 그룹형 RecyclerView를 구현해볼 텐데요.

가장 먼저 Group Item을 구현할 겁니다.

Groupie에는 Group이라는 개념이 있는데, 쉽게 얘기해서 데이터 목록을 가질 수 있는 아이템이라고 보시면 됩니다.

 

기존의 Recycler View에서 Adapter에 새로운 데이터를 넣으면 아이템이 추가됐죠?

마찬가지로 Groupie Adapter에 새로운 Group Item을 추가하면 데이터 목록에 아이템이 추가됩니다.

여기까지는 같지만 이 Group Item 안에 새롭게 Child Item을 넣을 수 있다는 것이 차이가 있죠.

 

이제 코드를 볼까요?

이 예제에서는 댓글 목록을 구현할 건데요. 댓글을 누르면 대댓글 목록이 나오도록 구현할 계획입니다. 

편의상 부모 댓글과 자식 댓글이라고 부를게요.

class ParentCommentItem(val item: Comment): BindableItem<ParentCommentListItemBinding>(),
    ExpandableItem {
    // 자식 댓글 그룹
    private lateinit var childCommentGroup: ExpandableGroup
    
    // 사용할 레이아웃을 설정
    override fun getLayout(): Int = R.layout.parent_comment_list_item

    // ViewBinding을 활용해 데이터를 View에 매핑시키는 메서드
    override fun bind(viewBinding: ParentCommentListItemBinding, position: Int) {
        // 댓글의 UI 컴포넌트들을 데이터(item)과 연결시켜준다
        val exampleText = "ParentComment".plus(position.toString())
        viewBinding.parentComment.text = exampleText
        // 목록 아이템을 누르면 자식 댓글 목록이 나오도록 설정한다
        viewBinding.root.setOnClickListener(View.OnClickListener {
            childCommentGroup.onToggleExpanded()
        })
    }

    // View Binding을 위해 View 객체를 초기화
    override fun initializeViewBinding(view: View): ParentCommentListItemBinding =
        ParentCommentListItemBinding.bind(view)

    override fun setExpandableGroup(onToggleListener: ExpandableGroup) {
        this.childCommentGroup = onToggleListener
    }

}

부모 댓글 클래스에서는 ExpandableItem을 상속하는데요. 이것을 상속해야 자식 댓글 그룹을 가질 수 있습니다.

기존 RecyclerView 구현과는 다르게 Groupie에서는 Adapter가 아닌 Item이라는 것을 구현합니다.

그리고 구현한 Item을 Group Adapter에 추가해주면 알아서 다 처리를 해주죠.

데이터가 삭제되거나 추가된 경우에서 notify 메서드를 알아서 호출해주니 정말 편리합니다.

 

 

Child Item 구현

이제 Group Item에 포함될 Child Item을 구현할 차례입니다.

여기 예제에서는 댓글의 댓글인 대댓글이 되겠네요.

 

class ChildCommentItem(val item: Comment): BindableItem<ChildCommentListItemBinding>() {
    override fun getLayout(): Int = R.layout.child_comment_list_item
    

    override fun bind(viewBinding: ChildCommentListItemBinding, position: Int) {
        // UI 컴포넌트와 데이터를 연결시켜주는 부분
        val exampleText = "Child Comment".plus(position.toString())
        viewBinding.childComment.text = exampleText
    }

    override fun initializeViewBinding(view: View): ChildCommentListItemBinding = 
        ChildCommentListItemBinding.bind(view)
}

이제 구현이 끝났습니다. 따로 Adapter를 구현할 필요가 없다는 게 얼마나 편리한지~

지금부터는 Groupie에서 제공하는 Group Adapter를 통해 데이터를 넣고 삭제하는 걸 어떻게 하는지 살펴보도록 하겠습니다.

 

Group 아이템 추가하기

기존 Recycler View를 사용할 때에는 Adapter에 데이터를 넣어주고 notify 메서드를 통해 데이터가 바뀐 것을 알려줬었습니다. 하지만 Groupie에서는 Group Item들 각각에 대해 여러 개의 Child Item을 가질 수 있기 때문에 Group이라는 형태로 객체를 Adapter에 넣어줘야 합니다.

val groupAdapter = GroupAdapter<GroupieViewHolder>()
for(i in data){
	// 데이터 값을 넣어서 Expandable Item을 생성한다
    val parentCommentItem = ParentCommentItem(i)
    // 해당 아이템을 Expandable Group(확장이 가능한 형태의 Group)으로 만들어
  	// Adapter에 넣어준다
    groupAdapter.add(ExpandableGroup(parentCommentItem))
}

 

 

Group에 Child 아이템 추가하기

Group Item에 Child Item을 추가하려면 먼저 Group Item을 가져온 뒤, 해당 그룹에 Child Item을 넣어줘야 합니다.

즉, 대댓글 목록을 댓글에 추가할 때 아래와 같은 방식으로 하면 됩니다.

 

// Child Item을 추가하기 위해 확장이 가능한 Expandable Group 형태로
// 타입을 변경해주어야한다
val groupItem = groupAdapter.getGroupAtAdapterPosition(position) as ExpandableGroup
groupItem.add(ChildCommentItem(data))

주의해야 할 점은 인덱스 값(position)을 통해 Group Item을 가져오면 Group이라는 형태로 리턴된다는 점인데요.

Child Item을 넣으려면 자신이 정의한 Group 형태(이 예제에서는 확장이 가능한 Expandable Group)로 타입을 변경해주어야 합니다. 그리고 Child Item을 데이터를 넣어 생성한 뒤, add 메서드를 통해 Adapter에 넣어줍니다.

 

Group 아이템 삭제하기

기존 Recycler View를 사용할 때에는 아이템의 위치를 가져와서 remove 메서드를 통해 삭제가 가능했는데요.

Groupie에서는 아무래도 Group Item과 Child Item이 엮여있는 만큼 다른 메서드를 사용해야 합니다.

삭제에서 꽤나 긴 삽질을 해서 좀 더 깊게 분석을 해보았는데요.

 

// 처음엔 이렇게 시도를 해보았다
val item = groupAdapter.getItem(position)
groupAdapter.remove(item)	// 에러가 발생!!

왜 에러가 발생하는지 살펴봤더니 index 때문이더군요.

왜 index id 값에서 문제가 생기는지 좀 더 세밀하게 파헤쳐보았습니다.

 

일단 getItem 메서드를 통해 가져온 Item의 id를 확인해보니 음수 값을 가지고 있습니다.

정확한 이유는 모르겠지만 이 Item을 remove 메서드를 통해 없애려고 하면 이 음수값 때문에 에러가 발생하더라고요.

 

이 문제를 해결하기 위해 처음으로 다시 돌아가 봐야겠다는 생각이 들었습니다.

groupAdapter에 Item이 들어가긴 했는데 잘못된 값의 Item이 들어갔기 때문에 발생한 에러였기 때문이죠.

이 부분에서 뭔가 잘못한 부분이 있을 것이라 생각했습니다. 

위의 화면을 보면 groupAdapter에는 ExpandableGroup 타입의 Item이 들어갑니다. 따라서 삭제할 때에도

해당 타입의 아이템을 삭제해줘야 한다는 의미이죠. getItem 메서드를 통해 가져온 값은 다른 값이었던 겁니다.

 

 public void remove(@NonNull Group group) {
        if (group == null) throw new RuntimeException("Group cannot be null");
        int position = groups.indexOf(group);
        remove(position, group);
    }

    public void removeAll(@NonNull Collection<? extends Group> groups) {
        for (Group group : groups) {
            remove(group);
        }
    }

    /**
     * Remove a Group at a raw adapter position
     * @param position raw adapter position of Group to remove
     */
    public void removeGroupAtAdapterPosition(int position) {
        Group group = getGroupAtAdapterPosition(position);
        remove(position, group);
    }

잠시 Groupie의 삭제 관련 메서드들을 살펴볼까요.

remove 메서드에 들어가는 타입이 Group임을 확인할 수 있습니다.

아래에 보니 위치 값(index)을 통해 바로 Group 아이템을 삭제할 수 있는 메서드도 있네요.

이 사실을 바탕으로 Item을 삭제하는 방법을 정리하면 아래와 같습니다.

 

// 위치 Index를 통해 Group 아이템을 삭제
groupAdapter.removeGroupAtAdapterPosition(position)

// Group Item을 위치 Index 값을 통해 가져온 뒤 삭제
val groupItem = groupAdapter.getGroupAtAdapterPosition(position)
groupAdapter.remove(groupItem)

 

특정 위치에 Group 아이템 넣기

앞서 소개한 Group Item 추가하기와 거의 흡사합니다.

 

// 넣고싶은 위치에 새로운 Group Item을 추가
val updatedItem = ParentCommentItem(newData)
groupAdapter.add(position, ExpandableGroup(updatedItem))

특정 위치에 Group Item을 넣을 때에는 위치 값(index)만 파라미터에 추가해주면 됩니다.

삭제와 함께 사용하면 리스트에서 수정 기능을 구현할 수 있는데요.

Item의 데이터 값이 변경되었을 때 기존 Item을 삭제하고 수정된 새로운 데이터로 Item을 생성하여

그 자리(position)에 넣어주는 방법입니다. 

기본 메서드이니만큼 필요에 따라 응용하시면 될 것 같네요.

 

 

실제 Activity 적용 예제

이제 앞에서 알려드린 Groupie 기본 메스드들을 활용해서 Activity에 적용을 해보도록 하겠습니다.

앞에서 잠깐 설명했지만 다시 한번 시나리오를 말씀드릴게요.

현재 Groupie를 통해 댓글과 대댓글 목록을 보여주는 화면을 만들 겁니다.

레이아웃(특별한 게 없어서 생략)과 Groupie관련 Item들은 구현이 되었다고 가정합니다.

위에서 ParentCommentItem, ChildCommentItem 구현했던 거 기억하시죠? ㅎㅎ

이제 실제로 groupAdapter에 어떻게 Item을 추가하고 삭제하는지 한번 살펴보죠.

 

 

// MVP 아키텍처를 사용한다고 가정했을 때의 코드
// Groupie 구현과는 크게 상관없으니 무시해도 좋습니다
class CommentActivity: BaseActivity(), CommentContract.View{
	val groupAdapter = GroupAdapter<GroupieViewHolder>()
    var mBinding : ActivityCommentBinding? = null
    
    override fun onCreate(savedInstanceState: Bundle?){
    	super.onCreate(savedInstanceState)
        // View Binding 활용 -> 처음 접하시는 분들은 제 다른 포스팅을 참고하세요
        mBinding = ActivityCommentBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        // 테스트용 Group Item 데이터
        // Comment는 댓글을 나타내기 위한 Data Class입니다
        val parentCommentList = listOf(
        	Comment(1,"댓글댓글1", "http://audio.com",
            "active", Date(),1, null, 22),
            Comment(2,"댓글댓글2", "http://audio.com",
                "active", Date(),1, null, 22),
            Comment(3,"댓글댓글3", "http://audio.com",
                "active", Date(),1, null, 22),
            Comment(4,"댓글댓글4", "http://audio.com",
                "active", Date(),1, null, 22)
        )
        
        // 댓글 아이템을 초기화
        initParentCommentList(parentCommentList)
    }
    
    fun initParentCommentList(data: List<Comment>){
    	// Recycler View 초기화
        val commentRecyclerView = mBinding!!.commentRecyclerView
        commentRecyclerView.layoutMaanger = LinearLayoutManager(this)
        commentRecyclerView.adapter = groupAdapter
        
        // 데이터 삽입
        groupAdapter.apply{
        	for(i in data){
            	val parentCommentItem = ParentCommentItem(i)
                this.add(ExpandableGroup(parentCommentItem))
            }
        }
        
        // 테스트용 대댓글 데이터 초기화
        initChildCommentList(0, listOf(
            Comment(1,"댓글댓글1", "http://audio.com",
                "active", Date(),1, null, 22),
            Comment(2,"댓글댓글2", "http://audio.com",
                "active", Date(),1, null, 22),
            Comment(3,"댓글댓글3", "http://audio.com",
                "active", Date(),1, null, 22),
            Comment(4,"댓글댓글4", "http://audio.com",
                "active", Date(),1, null, 22)
        ))
    }
    
    fun initChildCommentList(position: Int, data: List<Comment>){
        for(i in data){
        	val group = groupAdapter.getGroupAtAdapterPosition(position) as ExpandableGroup	
            group.add(ChildCommentItem(data[i]))
        }
    }
    
    // Item 삭제
    fun deleteCommentItem(position: Int){
    	groupAdapter.removeGroupAtAdapterPosition(position)
    }
    
    // 특정 위치에 Item 삽입
    fun insertCommentItem(position: Int, data: Comment){
    	groupAdapter.add(position, ParentCommentItem(data))
    }
}

 

여기까지 Groupie를 활용하여 복잡한 RecyclerView를 만드는 방법을 살펴보았습니다.

더 궁금하신 부분이 있다면 언제든지 여기로 물어봐주세요 :)

반응형