[Android, Kotlin] 댓글, 답글 기능 구성하기 +Swipe 메뉴로 댓글 삭제, 추가 기능 구현 [1]

 

Comment + Child Comment

 

댓글 기능과 답글 기능을 구현할 때 depth를 우선적으로 생각해야 한다.

댓글의 답글의 답글을 어디까지 달 수 있는지 설정해야 하며, depth가 2인 경우를 일반적으로 한다.

댓글과 그 댓글의 답글 까지의 깊이를 허용하며, 이를 리싸이클러 뷰로 구현하는 코드를 프로젝트에 적용했다.

 

레벨 2까지의 트리 구조처럼  댓글을 구성해야하며, 각 노드(댓글)의 상위 노드에 대한 정보를 저장해야 한다.

 

class Comment(
    val commentId: Int,
    var reviewInfo: ReviewInComment,
    var userInfo: UserInfo?,
    var body: String,
    var likeCount : Int = 0,
    val parentCommentId : Int?,
    var userLike : Boolean,
    var createTime: String,
    var updateTime: String,
) : Serializable

 

댓글 하나의 model을 담당하는 Comment data class이다.

parentCommentId를 통해서 상위 댓글에 대한 정보를 가지며, null값이 들어가는 경우를 대비해서 nullable Int로 선언했다.

 

View 구성

우선 각 댓글의 view를 나타낼 item_comment 레이아웃을 생성한다.

2가지 방법으로 답글 기능을 구현할 수 있는데, 첫 번째 방법은 중첩 리싸이클러 뷰를 이용한 구성이다.

같은 부모 뷰를 가지는 리스트를 생성해서 새로운 리싸이클러 뷰에 연결하는 방법이다.

이 방법의 간단하게 UI를 구성할 수 있다는 장점을 가지지만, swipe 메뉴를 구성하기 어렵다는 단점을 가진다.

item을 삭제하는 기능을 추가 할 경우 해당 뷰를 좌우로 스와이프해서 숨겨둔 삭제 버튼을 나타나게 한다.

 

이 경우 일반적으로 삭제 버튼을 viewHolder의 크기에 맞도록 설정한다.

중첩 리싸이클러 뷰를 사용할 경우 뷰 홀더가 자식 뷰들도 감싸기 때문에,

버튼의 크기가 아이템의 크기 + 자식뷰들의 크기만큼 커지게 된다.

 

자식 뷰가 있을 경우 크기를 다르게 설정하는 방법도 있지만, 더 쉬운 방법을 찾아봤다.

 

두 번째 방법은 답글의 마진을 다르게 설정하는 것이다.

일반적으로 댓글의 답글을 다는 경우 아래 그림처럼 들여쓰기를 적용한 뷰로 나타낸다.

 

같은 부모 뷰를 가지는 자식 뷰들을 부모 뷰의 인덱스 뒤에 배치한 후, 

부모 뷰를 가지는 item들의 좌측 마진 값을 다르게 하여 들여쓰기를 적용하는 방법이다.

 

두 번째 방법을 통해서 뷰를 구성하였는데, item을 정렬하기 위한 알고리즘이 필요했다.

 

[ 댓글의 레이아웃 ]

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/white"
    android:clickable="true"
    android:focusable="true"
    android:orientation="vertical">

    <LinearLayout
        android:id="@+id/item_linear"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_10"
        android:layout_marginBottom="@dimen/margin_10"
        android:orientation="horizontal">

        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/user_image"
            android:layout_width="@dimen/item_size_50"
            android:layout_height="@dimen/item_size_50"
            android:layout_margin="@dimen/margin_10"
            android:background="@drawable/circle"
            android:src="@drawable/ic_baseline_person_outline_24" />

        <LinearLayout
            android:layout_width="@dimen/top_len_210"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/margin_10"
            android:orientation="vertical">

            <TextView
                android:id="@+id/user_name"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="username"
                android:textSize="@dimen/text_size_17"
                android:textStyle="bold" />

            <TextView
                android:id="@+id/text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="5"
                android:textSize="@dimen/text_size_17"
                tools:text="text" />

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="@dimen/margin_5"
                android:gravity="center_vertical"
                android:orientation="horizontal">

                <TextView
                    android:id="@+id/time_text"
                    android:layout_width="@dimen/item_size_60"
                    android:layout_height="wrap_content"
                    android:ellipsize="end"
                    android:lines="1"
                    android:textSize="@dimen/text_size_12"
                    tools:text="30년 전 " />

                <TextView
                    android:id="@+id/like_num"
                    android:layout_width="@dimen/top_len_70"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_2"
                    android:ellipsize="end"
                    android:lines="1"
                    android:text="좋아요 x개"
                    android:textSize="@dimen/text_size_12" />

                <TextView
                    android:id="@+id/re_comment"
                    android:layout_width="@dimen/top_len_70"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_2"
                    android:text="답글 달기"
                    android:textSize="@dimen/text_size_12" />
            </LinearLayout>
        </LinearLayout>

        <View
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <ImageView
            android:id="@+id/like_comment"
            android:layout_width="@dimen/item_size_25"
            android:layout_height="@dimen/item_size_25"
            android:layout_gravity="center_vertical"
            android:layout_marginStart="@dimen/margin_10"
            android:src="@drawable/ic_baseline_favorite_border_24" />
    </LinearLayout>
</LinearLayout>

 

ChildCommentAlgorithm 

각 부모 뷰의 자식들을 부모 뷰 뒤로 정렬하는 알고리즘이다.

다른 곳에서도 해당 기능이 사용되므로, ChildCommentAlgorithm 코틀린 파일을 생성했다.

 

makeChildComment 함수를 통해서 아이템을 정렬하는 알고리즘을 구성했다.

fun makeChildComment(dataSet : ArrayList<Comment>): ArrayList<Comment>

댓글의 리스트를 받아서 새로 정렬한 리스트를 전송해주는 기능을 한다.

 

val newDataSet = ArrayList<Comment>()
val hashList = HashMap<Int,ArrayList<Comment>>()

새로운 댓글 리스트를 구성하는 newDataSet 리스트와 부모 뷰에 해당하는 자식 뷰들을 관리하기 위한 hash 리스트를 생성했다.

 

댓글의 리스트를 하나씩 열어서 같은 부모뷰를 가지는 댓글들을 해쉬함수를 통해서 묶어주었다.

    for (data in dataSet){
        val parentId = data.parentCommentId ?: -1

        if (parentId<1){ //부모 뷰
            newDataSet.add(data)
        }else{ //자식 뷰
            if (hashList.containsKey(parentId)){ //이미 존재
                hashList[parentId]!!.add(data)
            }else{ //0번째 인덱스 추가
                hashList[parentId] = arrayListOf(data)
            }
        }
    }

parentId가 null일 경우 -1로 초기화하여 null 값이 캐스팅 되는 경우를 없애주었다.

이후에 parentId 값을 검사하여 부모 뷰인 경우에는 새로운 데이터셋에 데이터를 추가한다.

부모 뷰를 가지는 자식 뷰인 경우는 hash맵에 해당 부모 뷰의 아이디를 통해서 묶어주었다.

 

이 과정을 통해서 같은 부모를 가지는 자식들이 부모의 commentId를 통해서 순서대로 묶이게 된다.

 

이제 자식 뷰들을 부모 뷰가 추가된 리스트에 넣어주어야 한다.

newDataSet에 자식 뷰들을 순서대로 넣어야하는데, 반드시 while문을 사용해야 한다.

이유는 newDataSet의 크기가 자식 뷰를 추가하는 순간 변하기 때문이다.

i값을 증가하면서 매번 newDataSet을 갱신해야 하므로 while문을 사용해야한다.

i값을 증가시키면서 부모뷰의 아이디 값이 해쉬 키로 존재한다면 그 다음 위치에 해쉬에 저장된 데이터들을 추가해준다.

    var i = 0
    while(i < newDataSet.size){
        // 자식 뷰 발견 -> 위치에 자식뷰 리스트 추가
        if (hashList.containsKey(newDataSet[i].commentId)){
            val subData = hashList[newDataSet[i].commentId]!!
            newDataSet.addAll(i+1,subData)
            i+=subData.size-1
        }
        i+=1
    }

이 후에 i값이 추가된 데이터들 만큼 이동해야 하므로 i+=subData.size-1를 해준다.

    return newDataSet

새로 생성 된 리스트를 리턴하고, recyclerView에서 해당 데이터를 받으면 된다.

 

ItemCommentAdapter

새로 생성 된 리스트를 어댑터를 통해서 데이터를 보여준다.

 

class ItemCommentAdapter(
    private val mainActivity: MainActivity,
    itemList: ArrayList<Comment>
) : RecyclerView.Adapter<ItemCommentAdapter.ItemCommentViewHolder>() {
    private lateinit var binding: ItemCommentBinding
    var itemSet = makeChildComment(itemList)

}

어댑터는 activity와 데이터 리스트를 인자로 받으며, 데이터 리스트는 makeChildComment 함수를 통해서 재정렬한다.

 

이제 ViewHolder를 통해서 자식 뷰와 부모 뷰를 구분하는 코드를 작성한다.

dataParent 변수를 선언해서 자식 뷰인지 검사를 진행한다.

해당 뷰가 자식 뷰인 경우 layoutParams을 재설정하여 동적으로 마진을 넣어준다.

이 후에 답글의 답글달기 기능은 없으므로 답글을 다는 기능을 하는 reComment를 View.GONE 처리한다.

    inner class ItemCommentViewHolder(private val binding: ItemCommentBinding) :
        RecyclerView.ViewHolder(binding.root) {
        @SuppressLint("SetTextI18n")
        fun bind() {
            val data = itemSet[absoluteAdapterPosition]
            val dataParent = data.parentCommentId?:-1
            if (dataParent != -1) { //자식 뷰
                val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT
                ,ViewGroup.LayoutParams.WRAP_CONTENT)
                layoutParams.setMargins(100,0,0,0)
                binding.itemLinear.layoutParams = layoutParams
                binding.reComment.visibility = View.GONE
            }
            binding.text.text = data.body
            binding.timeText.text = getTimeText(data.createTime)
            binding.likeNum.text = "좋아요 ${data.likeCount}개"

            //좋아요 검사
            if (data.userLike) {
                binding.onLike()
            } else {
                binding.offLike()
            }

            data.userInfo?.let { user ->
                binding.userName.text = user.id
                if (user.profileFile != null) {
                    Glide.with(mainActivity)
                        .load(user.profileFile)
                        .into(binding.userImage)
                }
                //유저 프로필 보기
                binding.userImage.setOnClickListener {
                    //User image click
                    onItemClickListener?.userClick(user)
                }
            }

            // 좋아요 기능 추가
            binding.likeComment.setOnClickListener {
                //add like
                data.userLike = !data.userLike
                if (data.userLike) {
                    binding.onLike()
                    data.likeCount += 1
                } else {
                    binding.offLike()
                    data.likeCount -= 1
                }
                notifyItemChanged(absoluteAdapterPosition)
                onItemClickListener?.likeClick(data.commentId, data.userLike)
            }

            binding.reComment.setOnClickListener {
                onItemClickListener?.addSubComment(data)
            }
        }
    }

 

[ 어댑터의 나머지 필수 오버라이드 기능 ]

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemCommentViewHolder {
        binding = ItemCommentBinding.inflate(LayoutInflater.from(mainActivity), parent, false)
        return ItemCommentViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ItemCommentViewHolder, position: Int) {
        holder.bind()
    }

    override fun getItemCount() = itemSet.size

어댑터를 연결해주면 댓글과 답글을 확인할 수 있다.

commentAdapter = ItemCommentAdapter(mainActivity, dataList)
binding.commentListView.commentList.adapter = commentAdapter

 

댓글과 답글을 보는 기능을 완성했으므로, 다음 포스팅에서 댓글의 추가, 삭제 기능을 작성한다.