안드로이드 : diary – RecyclerView

이번 포스트에서는 RecyclerView 를 사용하는 방법을 알아보려고 해.

RecyclerView 는 다수의 동일한 모양의 뷰를 실제 아이템의 갯수만큼 만드는게 아니라 한 화면에 표시할 수 있을만큼의 갯수만 미리 만들어놓고 데이터를 변경하면서 다시 사용하는 방법이야.

조금 더 손쉬운 예를 들어보자면, 핸드폰 안의 주소록에 정말 많은 수의 지인 연락처가 있잖아? 지인 연락처 갯수가 1,000개라고 치자. 그런데, 연락처가 보여줄 데이터 종류는 뻔하잖아? 이름, 핸드폰 번호, 직장이름 등. 그러면 1,000명의 연락처를 보여주기 위해서 1,000개의 뷰를 생성하는게 아니야. 한 화면에는 고작 10개 정도의 연락처만 보여줄 수 있는데, 10개정도의 뷰만 생성해놓고, 1,000명의 데이터를 바꿔 보여주면서 처리할 수 있도록 하는 것이 바로 RecyclerView 인 거지.

diary 프로그램에서 RecyclerView 가 필요한 부분은 ListFragment 야. 여러 개의 일기 데이터를 목록 형태로 보여주자면 일기 데이터 갯수만큼의 뷰를 생성하는 것이 아니라, 한 화면에 보여질 만큼의 뷰만 생성해놓고, 데이터를 변경하면서 재사용하는거지.

ListFragment 에 연결되는 뷰 레이아웃 파일인 fragment_list.xml 을 열어서 RecyclerView 를 사용할 수 있도록 준비를 해볼께.

우선 이미 배치되어 있는 Component 들을 모두 삭제하고, RecyclerView 를 선택해서 배치해줄께.


뷰에 배치된 RecyclerView 에 Item 0 부터 Item 9 까지가 표시되고 있는데, 나중에 실제로 보여질 데이터 표시 컴포넌트 구조대로 미리보기할 수 있어. 그러기 위해서는 개별 아이템을 표시할 뷰를 디자인해주어야겠지. RecyclerView 는 개별 아이템을 표시하는 뷰의 단순한 리스트 뷰인거야.

나중에 RecyclerView 를 사용해야 하기 때문에 id 를 vw_recycler 라고 부여해줄께.

...
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/vw_recycler"
        android:layout_width="409dp"
        android:layout_height="729dp"
        tools:layout_editor_absoluteX="1dp"
        tools:layout_editor_absoluteY="1dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

layout 리소스에 item_diary.xml 이라는 이름으로 뷰를 하나 생성해볼께.



한 개의 일기 데이터를 표시하기 위한 뷰의 구성요소로 날짜를 표시할 TextView, 해당 날짜의 요일을 표시할 TextView, 마지막으로 일기 내용 데이터를 표시할 TextView 등 3개의 TextView 가 필요할거야.


이 뷰의 높이는 화면 전체를 차지할 필요가 없으니까 배치한 컴포넌트가 보여질 만큼 수정해볼께.

각 컴포넌트를 담고 있는 ConstraintLayout 의 layout_height 속성값을 match_parent 에서 wrap_content 로 변경하면 아래 그림처럼 일기 내용을 표시하기 위한 TextView 의 bottom line 까지 높이가 줄어들게 돼.


이제 이 뷰에 배치한 각 컴포넌트의 id 를 설정해줄께.


3개의 컴포넌트 옆에 표시되고 있는 빨간색 느낌표는 컴포넌트간의 배치관계가 설정되지 않아서 표시되는건데, Constraint Widget 에서 상하좌우 + 버튼을 눌러서 배치설정을 마치도록 해보자.

컴포넌트간 배치 연결설정을 마친 결과는 아래 그림과 같아.


이 뷰는 뷰홀더(ViewHolder)라는 역할을 하게 될건데, RecyclerView 에 표시되는 여러 개의 아이템들 갯수 정보를 참고하면서 일정한 갯수(실제 데이터 갯수보다 적은)만 생성된 상태에서 여러 개의 데이터를 표시하는 용도로 사용될거야.

잠시 RecyclerView 에 데이터를 표시하는 과정에서 사용되는 구성요소에 대한 관계를 살펴보고 넘어갈께.


ListFragment 의 레이아웃 파일인 fragment_list.xml 에 이미 RecyclerView 를 배치했지. 그리고 1개의 데이터를 표시하기 위한 ViewHolder 로 사용될 레이아웃 item_diary.xml 을 구성했어. Data 는 DB 에서 가져오면 될 것이야. 그러면 아직 구현하지 않은 것이 RecyclerView 와 ViewHolder 간의 연결 요소인 Adapter 야.

Adapter 는 RecyclerView.Adapter 를 상속받아 구현하지.

DiaryAdapter 라는 이름으로 클래스를 하나 만들어서 RecyclerView.Adapter 를 상속받도록 코딩해볼께.

package com.woohahaapps.androiddiary.adapter

import androidx.recyclerview.widget.RecyclerView

class DiaryAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}

RecyclerView.java 를 보면 Type parameter 로 ViewHolder 를 상속받은 클래스를 지정하도록 되어 있어.


나중에 다시 RecyclerView.ViewHolder 를 상속받은 클래스를 정의할테니 여기에서는 일단 위와 같이 코딩해보기로 해.

여기까지 작성했을 때 class DiaryAdapter 에 빨간 밑줄이 그어져 있을거야.


그 이유는 추상 클래스인 RecyclerView.Adapter 에 정의되어 있는 메소드가 아직 구현되지 않아서이기 때문이야.


Implement members 를 선택하면

이렇게 3개의 멤버를 구현할 수 있게 해주지.

DiaryAdapter.kt
...
class DiaryAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        TODO("Not yet implemented")
    }

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

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


이 3개의 멤버함수는 RecyclerView 와 데이터뷰(뷰홀더)를 연결하기 위한 핵심 요소야.

onCreateViewHolder 에서는 데이터를 표시하기 위한 뷰홀더 생성에 관한 코드를 작성하지.

getItemCount 는 표시할 데이터의 갯수를 알려주면 돼.

onBindViewHolder 에서는 데이터의 갯수보다 적게 생성된 뷰에 어떤 데이터를 표시할 것인지에 대한 바인딩을 해주는거야.

이 어댑터 클래스에서 데이터 목록을 관리할 수 있게 해야 할텐데, 생성자 함수의 파라미터로 List<Diary> 형 변수를 받도록 수정해보자.

class DiaryAdapter(private val diaries: List<Diary>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
...

Diary 라는 클래스는 아직 만들어져있지 않아서 빨간색으로 표시가 될텐데, Diary 는 1개의 일기 데이터정보를 저장하기 위한 클래스로 아래와 같이 data class 로 정의해줄 수 있어.

Diary.kt
package com.woohahaapps.androiddiary.domain

data class Diary(
    @SerializedName("id") val id: Long
    , @SerializedName("diary_date") val date: String
    , @SerializedName("diary_content") val content: String
)

이제 DiaryAdapter 클래스파일에 Diary 를 import 해주면 빨간색이 사라지게 돼.

DiaryAdapter 에 데이터 멤버를 설정해 주었으니 데이터 갯수를 리턴하는 getItemCount 함수의 본체를 아래와 같이 작성할 수 있을거야.

...
    override fun getItemCount(): Int {
        return diaries.size
    }
...

아직 작성하지 못한 onCreateViewHolder 와 onBindViewHolder 함수는 뷰홀더에 대한 정보가 필요한데, 이 뷰홀더의 레이아웃은 item_diary.xml 로 이미 작성해 두었고, 이 레이아웃을 사용하는 뷰홀더 클래스를 작성해주는 일이 남아있어. 뷰홀더 클래스는 어댑터 클래스 DiaryAdapter 의 inner class 로 RecyclerView.ViewHolder 를 상속받아서 아래와 같이 작성해주면 돼.

DiaryAdapter.kt
...
class DiaryAdapter(private val diaries: List<Diary>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    inner class DiaryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        TODO("Not yet implemented")
    }
...

이제 뷰홀더 클래스를 정의했으니까 임시로 작성해 둔 RecyclerView.ViewHolder 대신 DiaryItemViewHolder 클래스로 수정해볼께.

DiaryAdapter.kt
...
class DiaryAdapter(private val diaries: List<Diary>) : RecyclerView.Adapter<DiaryAdapter.DiaryItemViewHolder>() {

    inner class DiaryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    }

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

    override fun getItemCount(): Int {
        return diaries.size
    }

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

onCreateViewHolder 함수에서는 DiaryItemViewHolder 클래스형 변수를 생성해서 리턴해주면 되고, onBindViewHolder 함수에서는 DiaryItemViewHolder 에 배치된 컴포넌트에 데이터를 설정해주면 되는거야.

이번에는 onCreateViewHolder 함수에서 DiaryItemViewHolder 클래스형 변수를 생성시켜주는 코드를 작성해볼께.

...
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DiaryItemViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_diary, parent, false)
        return DiaryItemViewHolder(view)
    }
...

LayoutInflater.from 함수를 사용해서 item_diary.xml 레이아웃을 inflate 해서 DiaryItemViewHolder 클래스형으로 형변환해서 리턴해주면 돼.

그러면 DiaryItemViewHolder 클래스에서는 item_diary.xml 레이아웃에 배치된 컴포넌트를 액세스할 수 있지.

...
    inner class DiaryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val tvdate: TextView = itemView.findViewById(R.id.tv_diarydate)
        val tvweekname: TextView = itemView.findViewById(R.id.tv_diaryweekname)
        val tvcontent: TextView = itemView.findViewById(R.id.tv_diarycontent)
    }
...

마지막으로 onBindViewHolder 에서 뷰홀더 뷰의 컴포넌트에 데이터를 설정하는 코드를 작성해볼께.

...
    override fun onBindViewHolder(holder: DiaryItemViewHolder, position: Int) {
        holder.tvdate.text = diaries[position].date

        holder.tvweekname.text = ""
        val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
        dateFormat.parse(diaries[position].date)?.let {
            val cal = Calendar.getInstance()
            cal.time = it
            val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
            val weekNames = arrayListOf("일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일")
            holder.tvweekname.text = weekNames[dayOfWeek - 1]
        }

        holder.tvcontent.text = diaries[position].content
    }
}

여기까지 작성했으면 아래 그림에서 초록색 영역에 대한 코드는 다 작성된 상태인거야.


이제 RecyclerView 와 Adapter 간의 관계를 맺어주어야 하지.

ListFragment.kt 파일로 이동해서 onCreateView 함수의 코드를 수정해볼께.

...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val v: View = inflater.inflate(R.layout.fragment_list, container, false)

//        val buttonNewDiary : Button = v.findViewById(R.id.button_new_diary)
//        buttonNewDiary.setOnClickListener {
//            findNavController().navigate(R.id.action_listFragment_to_editFragment)
//        }

        val recycler : RecyclerView = v.findViewById(R.id.vw_recycler)
        recycler.layoutManager = LinearLayoutManager(this.context)
        recycler.adapter = DiaryAdapter(diaries)

        return v
    }
...

기존에 배치했었던 버튼 관련 코드는 주석처리하고, 새로 배치한 RecyclerView 에 대해서 layoutManager 와 adapter 를 설정해주는 코드를 작성했어.

DiaryAdapter 에 파라미터로 넘기는 일기 데이터 목록은 ListFragment 클래스의 멤버로 아래와 같이 선언해주고 초기화해주었지.

class ListFragment : Fragment() {
    // TODO: Rename and change types of parameters
    private var param1: String? = null
    private var param2: String? = null

    private val diaries: ArrayList<Diary> = arrayListOf()
...

이제 프로그램을 실행시키고 로그인을 하면 ListFragment 화면으로 전환이 되긴 하는데, 아직 아무것도 표시가 되질 않아. 서버에서 일기 데이터를 가져오는 로직이 아직 작성되지 않았기 때문이야.

diary 웹 프로그램에 이미 작성되어 있는 /diary/all API 를 호출하는 코드를 작성해볼께.


신규 코드를 작성하기에 앞서서 안드로이드 : diary – RestAPI (Retrofit) 포스트에서 LoginService 클래스 이름을 DiaryService 라고 수정할께. 이 클래스에 diary 웹 프로그램이 제공하는 모든 RestAPI 를 모아서 작성하려고 하는데, LoginService 라는 이름보다는 DiaryService 가 더 나을 것 같아서야.

DiaryService.kt
...
interface DiaryService {

    @Headers("Content-Type: application/json")
    @POST("/api/v1/login")
    fun login(
        @Body params: RequestLoginBody
    ): Call<ResponseLoginResult>

    @GET("/diary/all")
    fun getAllDiaries(
        @Header("Authorization") token: String
    ): Call<List<Diary>>
}

/diary/all URL 호출할 때에는 입력 파라미터는 정의되어 있지 않아. 하지만, 로그인되어 있는 상태에서만 호출이 가능한 API 이기 때문에 로그인정보로서 jwt 토큰을 헤더에 포함시켜서 전달해 주어야 하지. 헤더의 이름은 “Authorization” 으로 정의되어 있기 때문에 @Header(“Authorization”) 으로 String 데이터를 넘겨주도록 코딩했어.

이제 retrofit 을 이용해서 일기 데이터를 가져오는 코드를 작성해볼께.

class ListFragment : Fragment() {
    // TODO: Rename and change types of parameters
    private var param1: String? = null
    private var param2: String? = null

    private lateinit var mainActivity: MainActivity

    private val diaries: ArrayList<Diary> = arrayListOf()

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("http://diary.woohahaapps.com:8080")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)
        mainActivity = context as MainActivity
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val v: View = inflater.inflate(R.layout.fragment_list, container, false)

//        val buttonNewDiary : Button = v.findViewById(R.id.button_new_diary)
//        buttonNewDiary.setOnClickListener {
//            findNavController().navigate(R.id.action_listFragment_to_editFragment)
//        }

        val recycler : RecyclerView = v.findViewById(R.id.vw_recycler)
        recycler.layoutManager = LinearLayoutManager(this.context)
        recycler.adapter = DiaryAdapter(diaries)

        val token = "Bearer=" + mainActivity.getPref("TOKEN", "")
        Log.d("TOKEN", token)

        diaries.clear()

        val diaryService = retrofit.create(DiaryService::class.java)
        diaryService.getAllDiaries(token)
            .enqueue(object: Callback<List<Diary>> {

                override fun onResponse(call: Call<List<Diary>>,
                                        response: Response<List<Diary>>) {
                    Log.d("DATA", response.toString())
                    if (response.isSuccessful.not()) {
                        Log.d("ERROR", "CODE=" + response.code().toString())
                    }

                    response.body()?.let {
                        it.forEach {diary ->
                            Log.d("DATA", diary.toString())
                            diaries.add(diary)
                        }
                    } ?: run {
                        Log.d("ERROR", "body is null")
                    }

                    recycler.adapter?.notifyDataSetChanged()
                }

                override fun onFailure(call: Call<List<Diary>>,
                                       t: Throwable) {
                    Log.d("FAIL", t.toString())
                    Toast.makeText(mainActivity, t.toString(), Toast.LENGTH_LONG).show()
                }

            })

        return v
    }
...
}

프로그램을 빌드해서 실행시키면 로그인 성공 후에 일기 데이터 목록을 볼 수 있을거야.

Leave a Comment