07
23

이 페이지를 구현해야됐다. 처음 떠오른 생각은 댓글 영역을 RecyclerView로, 그 이전까지의 콘텐츠 영역을 AppBarLayout에 담아서 `CoordinatorLayout`로 감싸 처리하자는 것이었다. 그게 아래 XML이다.

<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/night">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/ab_app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/transparent"
        app:elevation="0dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

                <!- 여기는 생략 -->

        </androidx.constraintlayout.widget.ConstraintLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_comment"
        android:layout_marginTop="4dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:listitem="@layout/item_comment" />

    <!- 여기는 생략 -->

</androidx.coordinatorlayout.widget.CoordinatorLayout>

 

언뜻보면 layout_behavior와 scroll을 잘 엮어 좋아보인다. 문제는 리사이클러뷰의 아이템 개수가 적어서 스크롤이 생기지 않을 때다. 이 경우 앱바에 해당하는 스크롤은 살아있어서 behavior가 먹히고, 따라서 리사이클러뷰가 스크롤 될 필요가 없음에도 콘텐츠 영역이 스크롤되어 휑한 모습을 보여주게 되었다.(나중에 보니 recyclerview를 wrap_content로 해주면 끝나는 일이었다. 🥚 리사이클러뷰 숙련도를 늘렸다고 생각하자)

 

처음에 생각한 건 리사이클러뷰의 아이템 개수를 세서, 직접 화면 계산을 돌려 스크롤을 제어하는 것이었다. 문제는 이렇게 하니까 리사이클러뷰에 대한 제어는 잘 잡히는데 앱바에 대한 제어는 여전히 안잡혔다는 것이다.

 

그 다음 방법으로 생각한건 AppBarLayout의 behavior를 제어하는 건데, recyclerview의 스크롤리스너를 기준으로 해서 스크롤 되지않으면 behavior에 0을 주는 것이었다. 실행해보니 아무런 변화가 나오지않아서 이건 포기했다...

# 그렇다면 ViewType을 나눠서 넣어버리자

콘텐츠 부분과 댓글 부분의 Dataclass가 달랐지만, ViewType을 쪼개서 사용하는 게 구현 난이도도 그렇고, 디자인 의도대로 구현하는 것도 그렇고 최적이라는 생각이 들었다.

Sealed Class를 사용하자

내가 이해한 바로는 "컴파일러가 컴파일 시점에 Sealed Class에 정의한 모든 하위 클래스를 알게하는 수단"이다. 그래서 약간 Enum과 비슷하다고 할 수 있겠다. 이렇게 하면 else branch를 생성하지 않아도 돼서 타입 안정성을 갖는다. 사용하지 않아도 큰 문제는 없을 것이지만 보다 견고하고 가독성 높은 코드를 위해서는 Sealed로 감싸주는 게 좋지않을 까 생각한다.

sealed class FeedDetail {
    data class HEADER(val feed: Feed) : FeedDetail()
    data class BODY(val comment: Comment) : FeedDetail()
}

나는 이미 만들어둔 Feed, Comment 데이터 클래스를 한번 더 감싸서 FeedDetail이라는 걸로 만들었다. 이제 이걸 기준으로 데이터에 접근해야된다. 지금은 이렇게 data class만 넣었는데, 당연히 object도 넣을 수 있다. 예를 들어보자면, 로딩 뷰타입같은 걸 설정할 때 굳이 복잡하게 들어갈 필요없이 object로 선언해버리면 된다.

sealed class FeedDetail {
    data class HEADER(val feed: Feed) : FeedDetail()
    data class BODY(val comment: Comment) : FeedDetail()
    object LOADING: FeedDetail()
}

 

먼저 view type을 나눠줘야한다. `RecyclerView.ListAdapter`를 사용할 것이므로 DiffUtil 또한 만들어준다.

companion object {
    private const val VIEW_TYPE_HEADER = 0
    private const val VIEW_TYPE_BODY = 1
    private val sealedDiffUtil = object : DiffUtil.ItemCallback<FeedDetail>() {
        override fun areItemsTheSame(oldItem: FeedDetail, newItem: FeedDetail): Boolean {
            return when {
                oldItem is FeedDetail.HEADER && newItem is FeedDetail.HEADER ->
                    oldItem.feed.hashCode() == newItem.feed.hashCode()

                oldItem is FeedDetail.BODY && newItem is FeedDetail.BODY ->
                    oldItem.comment.hashCode() == newItem.comment.hashCode()

                else -> false
            }
        }

        override fun areContentsTheSame(oldItem: FeedDetail, newItem: FeedDetail): Boolean {
            return oldItem == newItem
        }
    }
}

이때 중요한 점이 캐스팅을 한번 거쳐줘야 한다는 것이다. oldItem, newItem 모두 FeedDetail 객체이고, 그 안의 프로퍼티로 접근해야 원래 의도한 데이터 클래스에 접근 할 수 있게된다. 나는 view type을 헤더와 바디로 나눠봤다.

override fun getItemViewType(position: Int): Int {
    return when (getItem(position)) {
        is FeedDetail.HEADER -> VIEW_TYPE_HEADER
        is FeedDetail.BODY -> VIEW_TYPE_BODY
    }
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    return when (viewType) {
        VIEW_TYPE_HEADER -> {
            val binding = ItemFeedDetailBinding.inflate(layoutInflater, parent, false)
            FeedDetailViewHolder(
                binding,
                onEmotionClickListener = onEmotionClickListener,
                onReportClickListener = onReportClickListener,
                onUserClickListener = onUserClickListener,
            )
        }

        VIEW_TYPE_BODY -> {
            val binding = ItemCommentBinding.inflate(layoutInflater, parent, false)
            CommentViewHolder(
                binding,
                onEditClickListener,
                onDeleteClickListener
            )
        }

        else -> throw IllegalArgumentException("없는 ViewType")
    }
}

값을 따로 처리하지않는 부분이기 때문에 프로퍼티를 안써도 된다. 

각각의 ViewHolder에 묶을 때 이제 프로퍼티를 사용해서 꺼내준다.

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    when (val currentItem = getItem(position)) {
        is FeedDetail.HEADER -> (holder as FeedDetailViewHolder).bind(currentItem.feed)
        is FeedDetail.BODY -> (holder as CommentViewHolder).bind(currentItem.comment)
    }
}

 

일단 이렇게 해서 Adapter세팅이 끝났다. 사용은 더 쉽다. 나는 최상단에만 피드 콘텐츠가 보이면 돼서, 리스트의 첫번째 아이템으로 Feed를 넣고, 그다음은 Comment를 넣어줬다. 보기 쉽게 dummy data로 작성해봤다. 

testComment.add(
    FeedDetail.HEADER(
        Feed(
            feedId = 1138,
            title = "sed",
            userName = "George Foster",
            userProfileImg = null,
            contentImg = null,
            emotionMode = "definitionem",
            emotionTotal = 1729,
            commentTotal = 8645,
            uploadedDate = Date()
        )
    )
)
repeat(10) {
    testComment.add(
        FeedDetail.BODY(
            Comment(
                commentId = 5906,
                name = "Roscoe Bowman",
                uploadDate = Date(),
                lastEditDate = null,
                content = "eruditi"
            )
        )
    )
}
feedDetailAdapter.submitList(testComment)

 

 

도움이 됐다면 댓글이나 공감 버튼 한 번씩 누르고 가주세요!

 

반응형
COMMENT