본문 바로가기

개발일기

8월 한달 토이 프로젝트를 해보자! -04 : List UI

다음은 리스트 ui 구성이다. 내가 생각한 구성은 GridLayout을 활용, 한 줄에 CardView를 2개씩 배치하고, 각각의 CardView 안에는 사진, 제목, 최종 업데이트 날짜, 좋아요 수, 댓글 수를 배치할 것이다.

list_fragment.xml에는 우선 RecyclerView만 배치하고, CardView 내부의 레이아웃을 짜야 한다고 생각, Adapter를 만들기로 하였다.

써놓고 보니 Glide와 Paging 라이브러리를 써야겠다, 는 생각이 든다. 아무튼 그건 다음에 기능/성능 파트에서 고민하기로 하고.

우선 list_fragment 및 ListFragment에서 tmpCardView를 없애준다. Adapter로 대체할 부분이니까. 그 다음 RecyclerView를 배치해줄 건데, 이때, 위로 당겨서 새로고침 하는 ui가 요즘 많이 보이므로, 나도 SwipeRefreshLayout을 만들어 그 안에 RecyclerView를 배치하겠다. 이를 위해 앱의 gradle에 관련 종속성을 추가해준다.

dependencies {
    ...
    implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
}

list_fragment.xml

        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/list_swipe_refresh_lyt"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/list_recycler"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:clipToPadding="false" />

        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

ListFragment.kt

    private val swipeLyt: SwipeRefreshLayout by lazy {
        view?.findViewById(R.id.list_swipe_refresh_lyt) as SwipeRefreshLayout
    }
    private val listRecycler: RecyclerView by lazy {
        view?.findViewById(R.id.list_recycler) as RecyclerView
    }

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initUIComponents()
    }

    private fun initUIComponents() {
        listRecycler.layoutManager = GridLayoutManager(requireContext(), 2)
        swipeLyt.isRefreshing = false
    }

이때, initUIComponents() 함수를 onCreateView에서 실행하지 않도록 하자. View가 생성되기 전에 해당 요소는 null로, null인 요소에 대해 Casting을 하는 셈이기 때문에 Exception이 발생하며 앱이 죽어버린다.

다음으로, 어댑터를 만들어주자. 다만 아직 데이터 클래스가 존재하지 않기 때문에 리스트가 존재하지 않고, 따라서 바인드 하거나 어떤 함수를 설정해줄 수도 없다. TODO 투성이인 어댑터가 하나 만들어진다.

DiaryListAdpater

class DiaryListAdapter : RecyclerView.Adapter<DiaryListAdapter.ListViewHolder>() {

    inner class ListViewHolder(view: View): RecyclerView.ViewHolder(view) {
        fun bind() {
            // TODO
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
        TODO("Not yet implemented")
    }

    override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
        TODO("Not yet implemented")
    }

    override fun getItemCount(): Int {
        TODO("Not yet implemented")
    }
}

Adapter의 레이아웃을 만들어보자. CardView 안에 ConstraintLayout을 넣어 요소들을 배치할 것이다. 또, 서로 구분이 가게 하게 위하여 (그래야 나도 UI 확인할 때 편하고) border drawable을 만들어준다. Root element는 Shape다.

radius_violet_border.xml

<shape xmlns:android="http://schemas.android.com/apk/res/android">

    <corners android:radius="5dp" />
    <stroke android:width="1dp" android:color="@color/light_violet" />

</shape>

사진이 없을 때의 디폴트 이미지, 좋아요, 댓글 표시를 위한 vector drawable도 추가해주자. Clip Art에서 가져올 것이다.

결과물은 아래와 같다. 디자인도, 컬러 감각도 꽝이지만.. 어쨌든 존재는 한다.

이제 데이터 클래스가 없어도, onCreateViewHolder는 채울 수 있게 되었다!

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ListViewHolder(
        LayoutInflater.from(parent.context).inflate(R.layout.diary_list_item, parent, false)
    )

이렇게 하고나니 이제는 빌드, 컴파일을 위하여 TODO를 제거해줘야 한다. 그렇게 하려면... 데이터가 필요하다. 더 이상 데이터클래스 생성을 미룰 수 없다. 일단은 모듈을 분리하지 않고 데이터 클래스를 디렉토리만 나눠 생성하되, 추후에는 core 모듈로 분리하는 작업을 수행할 것이다.

일단 분리를 해놓고, Diary를 채워준다.

data class Diary(
    val title: String,
    val content: String,
    val thumbnail: Bitmap?,
    val likesCount: Int,
    val isLiked: Boolean = false,
    val comments: List<String>,
    val createdTime: Long,
    val updatedTime: Long? = null 
)

thumbnail은 옵셔널이기에 nullable이며, 수정하지 않았을 경우 updatedTime을 null로 처리한다. updatedTime이 null이면 createdTime을, 아니면 updatedTime을 어댑터에 표시해줄 것이다.

수정한 어댑터 총 코드는 아래와 같다.

data Binding을 사용하면 코드 수를 비약적으로 줄일 수 있다. 이는 나중에 활용하여 보다 간결하게 만들 것이다.

DiaryListAdapter.kt

class DiaryListAdapter(private val list: List<Diary>) : RecyclerView.Adapter<DiaryListAdapter.ListViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ListViewHolder(
        LayoutInflater.from(parent.context).inflate(R.layout.diary_list_item, parent, false)
    )

    override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
        holder.bind(list[position])
    }

    override fun getItemCount() = list.size

    inner class ListViewHolder(view: View): RecyclerView.ViewHolder(view) {

        private val title: TextView = view.findViewById(R.id.diary_title)
        private val thumbnail: ShapeableImageView = view.findViewById(R.id.diary_thumbnail)
        private val likeIcon: ImageView = view.findViewById(R.id.diary_like_icon)
        private val likeCount: TextView = view.findViewById(R.id.diary_like_count)
        private val commentCount: TextView = view.findViewById(R.id.diary_comment_count)
        private val updateTime: TextView = view.findViewById(R.id.diary_updateTime)

        fun bind(item: Diary) {
            title.text = item.title
            thumbnail.setImageBitmap(item.thumbnail ?: getDefaultThumbnail(itemView.context))

            likeIcon.setColorFilter(
                if (item.isLiked) ContextCompat.getColor(itemView.context, R.color.light_violet)
                else ContextCompat.getColor(itemView.context, R.color.light_grey)
            )

            likeCount.text = item.likesCount.toString()
            commentCount.text = item.comments.size.toString()
            updateTime.text = makeLastUpdateTime(item)
        }
    }

    private fun getDefaultThumbnail(context: Context) = ContextCompat.getDrawable(context, R.drawable.ic_default_photo)?.toBitmap()

    private fun makeLastUpdateTime(diary: Diary): String {
        val date = SimpleDateFormat("MMM dd, HH:mm:ss")
            .format(Date(diary.updatedTime ?: diary.createdTime))
        return "Last Updated: $date"
    }
}

테스트를 위하여 DiaryTEST() 함수를 생성 후 ListFragment에 붙여준다.

Diary.kt

...
{
    companion object {
        fun TEST() = Diary("title", "content", null, 58, true, listOf("Haha"), 20220808203800, null)
    }
}

ListFragment.kt

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initUIComponents()
        initTestList()
    }

    private fun initUIComponents() {
        listRecycler.layoutManager = GridLayoutManager(requireContext(), 2)
        swipeLyt.isRefreshing = false
    }

    private fun initTestList() {
        val testList = listOf(
            Diary.TEST(), Diary.TEST(), Diary.TEST(), Diary.TEST(), Diary.TEST()
        )
        listRecycler.adapter = DiaryListAdapter(testList)
        listRecycler.adapter?.notifyDataSetChanged()
    }

실행결과 성공적으로 뜨는 것을 확인할 수 있다.

다만, 현재 CardView 하나하나의 높이가 화면 크기로 설정되어 있기 때문에 비율이 그다지 좋아보이지는 않는다. 이에 마무리 작업 때에는 로그인 시 기기의 높이픽셀 값을 전역변수로 저장하고, 해당 값을 어댑터 뷰 생성 시 이용하는 것으로 시작할 것이다. action의 argument로 보내지 않는 이유는, DiaryListAdapter에서 ListFragment를 인자로 이용하지 않기 위해서이다.