댓글,대댓글 기능(인스타 같은..)

그렌실·2024년 4월 25일
post-thumbnail

위처럼 동작하게 하기 위해 굉장히 많은 시련(?)을 겪었다. 시행착오도 많았고,,
구현하기 전에 먼저 설계를 해야한다는 것을 느꼈다..
먼저 bottomSheetDialogFragment 를 사용해본적이 없었고
또 그안에서 리싸이클러뷰만 단순히 보이는 것이 아니라, 펼쳐지고 늘어나고...
페이징 처리하고.. 근데 대댓글도 보여줘야하네..? 아 근데 댓글 추가도 되고 삭제도 된다구요?
아 대댓글도 마찬가지라구요? ㅎㅎㅎㅎ
아 근데... 수평으로 swipe해서 신고 기능도 있다구요? ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
네 그냥 인스타 참고해서 최대한 똑같이 만들어볼게요;;;

많은 과정을 겪었지만.. 여기서는 결론적으로 어떻게 해결했는지 위주로만 적겠습니다ㅎㅎ

  1. bottomSheetDialogFragment에서 recyclerView의 적용은 문제 없었다. 그러면 이제 댓글과 대댓글을 어떤식으로 표현해줄 것이냐가 관건이었다.
companion object {
        private const val VIEW_TYPE_SKELETON = 0
        private const val VIEW_TYPE_COMMENT_ITEM = 1
        private const val VIEW_TYPE_REPLY_ITEM = 2
    }
    
    
     override fun getItemViewType(position: Int): Int {
        return if (isDataLoading) {
            VIEW_TYPE_SKELETON
        } else {
            currentList[position].viewType
        }
    }
    
    
     data class CommentData(
        var comment: CommentListItem? = null,
        var viewType: Int = VIEW_TYPE_SKELETON,
        var isClamped: Boolean = false,
    )

-위 3개면 이해갈까요? view_type을 상수로 정해놓고 CommentData라는 내가 사용할 비즈니스 로직에 필요한 data 객체를 이용해서 전달해주었다. 그러면 CommentData 가 갖고있는 viewType에 따라 다른 viewType을 그릴것이다.

-commentAdapter 내에서 item의 type을 3개로 정의했다. 왜 3개냐면 스켈레톤, 댓글, 대댓글인 경우 3개 이다.

 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_SKELETON -> {
                val skeletonView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_comment_skeleton, parent, false)
                return SkeletonViewHolder(ItemCommentSkeletonBinding.bind(skeletonView))
            }

            VIEW_TYPE_COMMENT_ITEM -> {
                val itemView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_comment, parent, false)
                return CommentItemViewHolder(
                    ItemCommentBinding.bind(itemView),
                    itemCommentLikeClickListener,
                    itemCommentProfileClickListener,
                    itemReplyWriteClickListener,
                    itemReplyShowClickListener,
                    itemReplyExpandClickListener
                )
            }

            VIEW_TYPE_REPLY_ITEM -> {
                val itemView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_reply, parent, false)
                return ReplyItemViewHolder(
                    ItemReplyBinding.bind(itemView),
                    itemReplyLikeClickListener,
                    itemReplyProfileClickListener
                )
            }

            else -> throw IllegalArgumentException("Unknown view type: $viewType")
        }
    }

위처럼 만들어놓고 이제 view단에서 타입 잘 전달해주고, 리스트와 포지션을 제 때에 제값으로만 주면 된다. adapter 구현 보다는 view에서 비즈니스 로직이 굉장히 꼬일 수 있는데 그걸 잘 처리하는게 관건인 것 같다.(당시엔 못 느꼈지만 완성한 지금입장에서 느끼는 회고)

  1. 자 그러면 어떻게 어떤 식으로 전달하고,전달 받았는지
private fun addNewCommentItems(comments: List<CommentListItem>) {
        binding.commentRcview.visibility = View.VISIBLE
        binding.noItemTv.visibility = View.GONE
        binding.noItemTv2.visibility = View.GONE

        commentAdapter?.setDataLoading(false)

        val newCommentList = comments.flatMap {
            listOf(
                CommentData(
                    comment = it.copy(
                        comment = it.comment?.copy(
                            parentId = it.comment?.id,
                            replyPage = 0,
                            isExpand = false,
                            remainReply = it.comment?.replyCount,
                            isNew = false,
                            replies = emptyList()
                        )
                    ), viewType = VIEW_TYPE_COMMENT_ITEM
                )
            )
        }
        noMoreItemInList = newCommentList.isEmpty()

        newCommentList.forEachIndexed { index, item ->
            commentList.add(item)
        }
        binding.commentRcview.apply {
            (adapter as CommentAdapterV2).apply {
                submitList(commentList) {
                    notifyItemRangeChanged(
                        0,
                        commentList.size
                    )
                }
            }
        }
        isLoading = false
    }

comment List 한 페이지당 collect 될때 실행하는 메소드이다.
천천히 살펴보면.. 우선 visible,gone 처리는 리스트 없을때 역으로 gone,visible 처리 해놓은게 있어서..
언제든 다시 요청했을때 그사이 댓글이 생겼을 수 있딴 생각에 일일이 visible 처리도 굳이? 해놓았다.
그리고 setDataLoading(boolean: isLoading) 이라는 adapter내에 내가 메소드로 스켈레톤을 표현하거나 해제하기도한다.
그다음부터는 본격적으로 비즈니스로직인데... 이걸 useCase나 viewModel 에서 할까 싶었지만 이 viewModel은 특히나 다른 videoList 에서 다른 안드 개발자 분들도 쓰시는 viewModel 이기도하고,, useCase도 마찬가지.. 그래서 그냥 view단에서 비즈니스 로직을 짰다.(나쁜가?)

먼저 flatmap 메소드 내에서 백단에서 오는 list는 내가 원하는 식으로 바꾸었다. (Entity -> DTO -> DAO를 얻는 과정을 여기서 했다고 보면 될듯, CommentData를 만들때 여러 파라미터들을.. 내가 추가해서 사용하고 있다. 아래 Comment 클래스 참고)


@Parcelize
data class Comment(
    val id: String? = null,
    val content: String? = null,
    var likeCount: Int? = null,
    var replyCount: Int? = null,
    var liked: Boolean = false,
    val createdAt: String? = null,
    var parentId: String? = null,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var replyPage: Int? = 0,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var remainReply: Int? = 0,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isExpand: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isNew: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isLastReply: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var replies: List<CommentListItem>? = null /** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
) : Parcelable
  1. 자 그러면 댓글은 전달했으니, 대댓글은 어떻게 전달하느냐? 만약 10번째와 11번째 댓글 사이의 대댓글이 불려지는데, 이 대댓글이 5개다?! 그러면 10번쨰 (대댓글 0~4번쨰 요소 추가-> 11번째~ 15번쨰)가 되고 원래 11번쨰 댓글은 16번째 요소가 되는거다!
 var replies: List<CommentListItem> = uiState.data.first.map { it.toCommentListItem() }
                            val targetComment = commentList.find { it.comment?.comment?.id == uiState.data.second }
                            targetComment?.comment?.comment?.replies?.forEach { originComment ->
                                replies.forEach { newComment ->
                                    if (originComment.comment?.id == newComment.comment?.id) {
                                        replies = replies.minus(newComment)
                                    }
                                }
                            }
                            addReplyItems(uiState.data.second, replies)
                            
                            
                            
 private fun addReplyItems(targetCommentId: String, replies: List<CommentListItem>) {
        val targetComment = commentList.find { it.comment?.comment?.id == targetCommentId }
        var targetCommentPosition = commentList.indexOfFirst { comment ->
            comment.comment?.comment?.id == targetCommentId
        }

        if(targetCommentPosition ==-1 || targetCommentPosition>=commentList.size) return

        commentList[targetCommentPosition].comment?.comment?.replies =
            commentList[targetCommentPosition].comment?.comment?.replies?.plus(replies) ?: replies
        commentList[targetCommentPosition].comment?.comment?.replies?.forEach {
            it.comment?.parentId = targetCommentId
        }
        commentList[targetCommentPosition].comment?.comment?.replyPage = targetReplyPage
        commentList[targetCommentPosition].comment?.comment?.remainReply =
            commentList[targetCommentPosition].comment?.comment?.remainReply?.minus(replies.size)
        commentList[targetCommentPosition].comment?.comment?.isExpand = true

        isLoading = false
        commentAdapter?.setDataLoading(false)
        when (isReplyBtnFromComment) {
            true -> {
                /** comment btn **/
                val newReplyList = replies.flatMapIndexed { index, comment ->

                    /** comment의 id가 내가 가진 replies 중에 같은 id를 같고 있으면 skip**/
                    listOf(
                        CommentData(
                            comment = comment.copy(
                                comment = comment.comment?.copy(
                                    parentId = targetCommentId,
                                    replyPage = targetReplyPage,
                                    remainReply = targetComment?.comment?.comment?.replyCount?.minus(
                                        replies.size
                                    ),
                                    isExpand = true,
                                    isLastReply = replies.lastIndex == index,
                                    isNew = false
                                )
                            ), viewType = VIEW_TYPE_REPLY_ITEM
                        )
                    )
                }
                noMoreItemInList = newReplyList.isEmpty()

                newReplyList.forEachIndexed { index, item ->
                    commentList.add(targetCommentPosition + 1 + index, item)
                }
                binding.commentRcview.apply {
                    (adapter as CommentAdapterV2).apply {
                        submitList(commentList) {
                            notifyItemRangeChanged(
                                0,
                                commentList.size
                            )
                        }
                    }
                }
                isLoading = false
            }

            else -> {
                /** reply  btn **/
                val newReplyList = replies.flatMapIndexed { index, comment ->
                    listOf(
                        CommentData(
                            comment = comment.copy(
                                comment = comment.comment?.copy(
                                    parentId = targetComment?.comment?.comment?.id,
                                    replyPage = targetReplyPage,
                                    remainReply = targetComment?.comment?.comment?.remainReply ?: 0,
                                    isExpand = true,
                                    isLastReply = replies.lastIndex == index,
                                    isNew = false
                                )
                            ), viewType = VIEW_TYPE_REPLY_ITEM
                        )
                    )
                }

                noMoreItemInList = newReplyList.isEmpty()
                targetCommentPosition += (targetComment?.comment?.comment?.replies?.size?.minus(
                    replies.size
                )) ?: 0
                newReplyList.forEachIndexed { index, item ->
                    commentList.add(targetCommentPosition + 1 + index, item)
                }
                if(targetCommentPosition>commentList.size) return
                commentList[targetCommentPosition].comment?.comment?.isLastReply = false
                binding.commentRcview.apply {
                    (adapter as CommentAdapterV2).apply {
                        submitList(commentList) {
                            notifyItemRangeChanged(
                                0,
                                commentList.size
                            )
                        }
                    }
                }
                isLoading = false
            }
        }
    }

replies가 collect 될떄 호출하는 로직인데,

먼저 전달하기 전에 여러가지 비즈니스 로직을 거친다. 보면... 마찬가지로 Entity를 dao로 변환하는 과정이 있고,
targetComment는 어떤 댓글 아래에 삽입할지와 그 부모 댓글이 가진 replies를 넣어주는 부분이다.
왜 넣어주냐면... 댓글의 대댓글이 30개인데 30개를 다 불러와서 댓글 30개를 접었다가 다시 펼쳤을 때 -> 다시 api 1페이지부터 요청하는게 너무 별로였다. 인스타도 바로 되기도하고.. 그래서 부모 댓글 안에는 아직 replies를 갖고 있는거다. 또 댓글 12개씩 요청하고 남은 댓글 수라던가.. 남은 댓글이 없으면 숨기기 <->펼치기가 되어야 하니까 remainReply 등도 표현해주었다.중간부터 when(리플라이버튼에서왔는지) 문이 있을건데 이건.. 댓글 보기 버튼이 comment냐 reply냐에 따라 비즈니스 로직이 달라졌다. (처음에 대댓글을 불러온적이 없었을 때에도 댓글x개 보기가 있어야하고 나중에 대댓글의 마지막 부분에도 표현해야했다).

생각보다 신경써야할게 많았다. 다 적진 않았지만.... 내가 최근에 적은 댓글, 대댓글은 회색으로 하이라이트 표현을 해줘야 했기에 isNew 라는 파라미터도 있었고 commentPage 뿐 아니라 replyPage를 각 요소마다 관리해야했기 때문에 이 부분도 신경써야 했다. 흐흐... 버그가 아예 없는건 아니지만 대체적으로? 잘 동작하고 인스타도 보니 버그가 아예 없는건 아니었다;;; 우리끼린 버그생성기 뷰라고 불리운다... 
수평 swipe는 또 많은 부분? 이기에 일단 여기서 끗!

0개의 댓글