커뮤니티의 댓글 2번째 포스트입니다.
안드로이드 개발을 진행하다 보면 댓글은 자주 만들지만 대댓글을 만드는 경우는 흔하지 않습니다.
먼저 백엔드와 대댓글에 대한 API정의를 내려야 하며 인터랙션은 어떻게 받아올 것인지에 대한 이야기도 논의되어야 하는 부분입니다.

하지만 많은 커뮤니티에서 사용중이기에 한번쯤 알아두면 좋을것 같아서 포스팅하게 되었습니다.


구현아이디어

구현을 하기 위해서 어떻게 접근해야 할지 생각해 보아야 합니다.

접근 방식의 예시는 다음과 같습니다.

  • 유튜브와 같이 댓글 페이지가 따로 있는 경우

  • 구현하기 편합니다.

  • 1depth에서 댓글을 보여주고, 댓글을 클릭시 댓글 페이지로 이동

  • 2depth에서 댓글을 상단에 보여주고 하단에 따로 리사이클러뷰를 이용하여 대댓글을 구현합니다.

    많은 커뮤니티에서 채용하는 방식입니다.
    주로 컨텐츠가 메인인 앱들에게서 보여지는 방식입니다.
    구현의 난이도가 가장 쉽습니다.

  • 인스타그램과 같이 댓글과 대댓글이 한 페이지에 있는 경우

    • 1depth에서 댓글을 보여주고 대댓글 보기를 클릭시 숨겨져있던 대댓글이 나타납니다.

    • 대댓글 보기를 클릭시 대댓글을 호출하는 API를 요청합니다.

      주로 유저 - 유저간의 관계가 중요한 커뮤니티에서 보여지는 방식입니다.

    • 대댓글이 한번 펼쳐지고 접히지 않는 경우

    • 대댓글이 펼쳐지고 접히는 것이 자유로운 경우

첫번째 방식은 기존 리사이클러뷰와 다를것이 없기 때문에 이번 포스팅에서 다룰 내용은 2번째 방식입니다.


아이템 설정

보통의 리사이클러뷰에는 한개의 어댑터가 반드시 필요합니다.
그리고 어댑터에 붙여줄 아이템이 필요합니다.

여기서 생각해 볼 점은 댓글 아이템내부에 대댓글 아이템이 있어야 한다는점입니다.

그래서 아이템을 다음과 같이 구성합니다.


<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/commentContainer"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginBottom="16dp"
    android:orientation="vertical"
    app:layout_constraintStart_toStartOf="parent">

    <ImageView
        android:id="@+id/userProfile"
        android:layout_width="40dp"
        android:layout_height="40dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/userName"
        style="@style/H712LeftBlack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:includeFontPadding="false"
        android:text="@{comment.username}"
        app:layout_constraintStart_toEndOf="@id/userProfile"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/commentEtc"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/ic_comment_etc"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/commentContent" />

    <TextView
        android:id="@+id/commentContent"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:includeFontPadding="false"
        android:text="@{comment.comment}"
        android:textAlignment="textStart"
        app:layout_constraintEnd_toStartOf="@id/commentEtc"
        app:layout_constraintStart_toStartOf="@id/userName"
        app:layout_constraintTop_toBottomOf="@id/userName" />

    <TextView
        android:id="@+id/commentDate"
        style="@style/H712LeftGrey"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:includeFontPadding="false"
        app:layout_constraintStart_toStartOf="@id/userName"
        app:layout_constraintTop_toBottomOf="@id/commentContent" />

    <TextView
        android:id="@+id/nestedComment"
        style="@style/H712LeftBlack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:includeFontPadding="false"
        android:visibility="gone"
        app:layout_constraintStart_toEndOf="@id/commentDate"
        app:layout_constraintTop_toTopOf="@id/commentDate" />

    <TextView
        android:id="@+id/btnNestedComment"
        style="@style/H712LeftGrey"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginBottom="4dp"
        android:includeFontPadding="false"
        android:text="@string/nestedComment"
        app:layout_constraintStart_toEndOf="@id/nestedComment"
        app:layout_constraintTop_toTopOf="@id/commentDate" />

    <ImageView
        android:id="@+id/btnLike"
        android:layout_width="20dp"
        android:layout_height="20dp"
        android:layout_marginStart="8dp"
        android:background="@drawable/ic_commentlike_off"
        app:layout_constraintBottom_toBottomOf="@id/btnNestedComment"
        app:layout_constraintStart_toEndOf="@id/btnNestedComment"
        app:layout_constraintTop_toTopOf="@id/btnNestedComment" />

    <View
        android:id="@+id/btnLikeZone"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginStart="-12dp"
        android:layout_marginTop="-12dp"
        android:layout_marginEnd="-12dp"
        android:layout_marginBottom="-8dp"
        app:layout_constraintBottom_toBottomOf="@id/btnLike"
        app:layout_constraintEnd_toEndOf="@id/btnLike"
        app:layout_constraintStart_toStartOf="@id/btnLike"
        app:layout_constraintTop_toTopOf="@id/btnLike" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/nestedRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/btnNestedComment" />

</androidx.constraintlayout.widget.ConstraintLayout>

여기서 주목해야할 부분은 노란색으로 강조한 부분입니다.
댓글 아이템 내부에 대댓글 아이템이 위치할 리사이클러뷰를 포함시켜주어야 합니다.

따라서 대댓글에 대한 어댑터와 대댓글 아이템 xml이 필요하게 될 것입니다.


어댑터 내부

inner class ViewHolderComment(private val binding: ItemCommentBinding) :
        RecyclerView.ViewHolder(binding.root) {
        private val nestedAdapter = NestedCommentAdapter()

				init {
            binding.nestedRecyclerView.apply {
                adapter = nestedAdapter
                layoutManager = LinearLayoutManager(context)
            }
            nestedAdapter.apply {
                setItemLikeClickListener(object : ItemClickListener {
                    override fun onClick(item: ItemNestedComment) {
                        itemClickListener?.onClick(item)
                    }
                })
            }
        }

어댑터 내부의 일부분입니다.
어댑터 내부에서 ViewHolder클래스를 생성시 대댓글어댑터를 생성해주고 아이템 내부의 대댓글 리사이클러뷰에 붙여주어야 합니다.
각각의 뷰홀더에는 서로 다른 리사이클러뷰와 서로다른 어댑터들이 존재해야 하기 때문입니다.


대댓글 호출 및 어댑터에 데이터를 넣는 작업

그렇다면 궁금한 점이 생길겁니다.
리사이클러뷰에 데이터를 넣어야하는데 이 부분을 어떻게 해결하지?

실제로 저는 개발하는 중에 이 부분을 리사이클러뷰 어댑터 내부에서 해결하려고 했으나
그렇다면 뷰모델을 어댑터와 연결해야하는 상황이 생기고 flow를 collect하기 위해 또 다른 lifecyclescope를 생성해야 했으며 이 부분을 어댑터 내부에서 진행해야 하는 상황이 부담스러웠습니다.

따라서 인터페이스와 스냅샷을 이용하여 해결하기로 했습니다.

먼저 대댓글을 보여줄 버튼과 인터페이스를 엮은 후 해당 이벤트를 fragment로 전달시킵니다.

이 코드는 fragment에서 호출하고 있습니다.
Fragment와 연결된 ViewModel에서 대댓글을 요청합니다.
그리고 연결된 데이터 flow가 변경될 시 adapter의 메서드를 실행시킵니다.

private suspend fun getNestedCommentInfo() {
        commentViewModel.nestedCommentInfo.collect {
            if(it.isNotEmpty()){
                commentAdapter.addItem(it,itemPosition)
            }
        }
    }

어댑터 내부의 코드입니다.

대댓글이 달린 아이템을 찾은 후 해당 아이템의 nestedcomments : List을 변경시켜줍니다.

fun addItem(nestedComments: List<Comment>, itemPosition: Int) {
        val comment = snapshot().find {
            it?.id == nestedComments[0].parentId
        }
        comment?.nestedComments = nestedComments
        notifyItemChanged(itemPosition)
    }

bindViewHolder에 다음을 지정해놓습니다.

이 코드를 통해 대댓글 어댑터에 데이터를 넣어줍니다.

				if (obj.nestedComments?.isNotEmpty() == true) {
                if (obj.expanded) {
                    nestedAdapter.submitList(obj.nestedComments) {
                        binding.nestedRecyclerView.isVisible = true
                    }
                } else {
                    binding.nestedRecyclerView.isVisible = false
                }
            } else {
                binding.nestedRecyclerView.isVisible = false
            }

서술형으로 했을때 데이터 구조가 이해가 잘 안될수 있어 정리를 하면 다음과 같습니다.


데이터 구조 정리

댓글 아이템은 댓글에 대한 정보를 가지고 있습니다.
또한, 대댓글 리스트에 대한 정보도 가지고 있습니다.
parentId는 다음과 같은 역할을 합니다.

  1. 대댓글이 있는지 없는지에 대한 여부
  2. 대댓글을 등록할때 댓글의 id값입니다.(댓글과 대댓글 매핑)
data class ItemComment(
    var id: Long = 0,
    var likeId: Long? = 0,
    val username: String,
    val comment: String,
    val createdId: Long,
    val blockId: Long?,
    var userProfile: String,
    var date: Date,
    var nestedCommentCount: Int,
    var nestedComments: List<Comment>? = emptyList(),
    var parentId: Long?,
    val mentionInfo: List<Mention>? = null,
    var expanded : Boolean = false
)
  • 댓글 아이템은 대댓글 리사이클러뷰를 포함하고 있습니다.
  • 댓글 아이템의 특정 버튼(답글보기)를 클릭 시 인터페이스를 세팅합니다.
  • 인터페이스를 통해 Fragment로 이동된 이벤트는 대댓글을 호출합니다.(parentId를 이용하여)
  • 대댓글 호출의 결과가 들어오면 어댑터의 메서드를 통해 대댓글을 호출한 댓글 아이템을 찾은 후 nestedComments부분을 변경시켜줍니다.
  • 특정 댓글을 refresh해줍니다.
  • 대댓글이 펼쳐졌을때 어댑터에 데이터를 넣어줍니다.

대댓글 부분에 대해서는 시행착오가 많이 진행되어야 합니다.

대댓글을 호출하는 부분이나 어댑터 연결, 터치시마다 대댓글 부분을 접고 펼쳐지게 하기 위해서는 많은 테스트를 진행해야 합니다.

해당 포스트가 큰 도움이 되진 않겠지만 적어도 시행착오를 줄여주길 바랍니다.

profile
러닝커브를 따라서 등반중입니다.

1개의 댓글

comment-user-thumbnail
2023년 5월 2일

좋은 글 감사합니다~

답글 달기