먼저 RecyclerView 란 무엇인지와 기본적인 구현 방법을 알아보자!
RecyclerView
는 ViewHolder
라는 개념을 이용하여 대량의 데이터를 효율적으로 표현할 수 있도록 도와주는 Android의 구성요소이다.
개발자가 데이터와 각 항목의 모양을 제공하면 RecyclerView 라이브러리가 필요할 때 요소를 동적으로 생성한다.
이름에서 알 수 있듯이 RecyclerView는 ViewHolder
를 이용하여 뷰를 재활용
한다. 항목이 스크롤되어 화면에서 벗어나더라도 RecyclerView는 뷰를 제거하지 않는다. 대신 RecyclerView는 화면에서 스크롤된 새 항목의 뷰를 재사용한다.
RecyclerView
는 데이터에 해당하는 뷰를 포함하는 ViewGroup
이다.
따라서 다른 UI 요소와 마찬가지로 레이아웃에 RecyclerView 를 추가하여 사용하면 된다.
ViewHolder는 목록에 있는 개별 항목의 레이아웃을 포함하는 View의 래퍼이다.
각 뷰 객체를 뷰 홀더에 보관함으로써 findViewById()와 같은 반복적 호출 메서드를 줄여 효과적으로 속도 개선을 할 수 있다.
뷰 홀더가 생성되었을 때는 뷰 홀더에 연결된 데이터가 없다. 뷰 홀더가 생성된 후 RecyclerView가 어댑터를 사용하여 뷰 홀더를 뷰의 데이터에 바인딩한다. (참고로 바인딩 이란, 뷰를 데이터에 연결하는 프로세스이다.) RecyclerView.ViewHolder를 확장하여 뷰 홀더를 정의할 수 있다.
어댑터는 데이터를 뷰홀더의 뷰와 연결하는 역할을 한다.
RecyclerView는 뷰를 요청한 다음, 어댑터
에서 메서드를 호출하여 뷰를 뷰의 데이터에 바인딩
한다. RecyclerView.Adapter를 확장하여 어댑터를 정의할 수 있다.
레이아웃 관리자는 목록의 개별 요소를 정렬한다. RecyclerView 라이브러리에서 제공하는 레이아웃 관리자 중 하나를 사용하거나 레이아웃 관리자를 직접 정의할 수도 있다. 레이아웃 관리자는 모두 라이브러리의 LayoutManager 추상 클래스를 기반으로 한다.
이 두 클래스가 함께 작동하여 데이터 표시 방식을 정의한다. Adapter는 필요에 따라 ViewHolder 객체를 만들고 이러한 뷰에 데이터를 설정하기도 한다.
어댑터를 정의할 때는 다음 세가지 메서드를 오버라이딩 해야한다.
onCreateViewHolder()
: RecyclerView는 ViewHolder를 새로 만들어야 할 때마다 이 메서드를 호출한다. 이 메서드는 ViewHolder와 그에 연결된 View를 생성하고 초기화하지만 뷰의 콘텐츠를 채우지는 않는다. ViewHolder가 아직 특정 데이터에 바인딩된 상태가 아니기 때문이다.
처음 화면에 나타나는 아이템의 개수 + α 번만 호출된다. 스크롤을 내려야 나타나는 아이템들은 뷰홀더를 새로 생성하지 않고 이때 만들어지는 ViewHolder들을 재사용하게 된다.
onBindViewHolder()
: RecyclerView는 ViewHolder를 데이터와 연결할 때 이 메서드를 호출한다. 함수의 position 인자를 사용해 해당하는 데이터를 가져와 이 데이터를 사용하여 뷰 홀더의 레이아웃을 채운다. 예를 들어 RecyclerView가 이름 목록을 표시하는 경우 메서드는 목록에서 적절한 이름을 찾아 뷰 홀더의 TextView 위젯을 채울 수 있다.
getItemCount()
: RecyclerView는 데이터 세트 크기를 가져올 때 이 메서드를 호출한다. 예를 들어 주소록 앱에서는 총 주소 개수가 여기에 해당할 수 있다. RecyclerView는 이 메서드를 사용하여, 항목을 추가로 표시할 수 없는 상황을 확인한다.
class CustomAdapter(private val dataSet: Array<String>) :
RecyclerView.Adapter<CustomAdapter.ViewHolder>() {
/**
* Provide a reference to the type of views that you are using
* (custom ViewHolder).
*/
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView
init {
// Define click listener for the ViewHolder's View.
textView = view.findViewById(R.id.textView)
}
}
// Create new views (invoked by the layout manager)
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
// Create a new view, which defines the UI of the list item
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.text_row_item, viewGroup, false)
return ViewHolder(view)
}
// Replace the contents of a view (invoked by the layout manager)
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
// Get element from your dataset at this position and replace the
// contents of the view with that element
viewHolder.textView.text = dataSet[position]
}
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = dataSet.size
}
기존에는 하나의 RecyclerView 에 같은 타입의 레이아웃만 띄워줬지만 여러 타입의 레이아웃을 띄울 수 있다. 아래 그림 처럼 여러 ViewType을 가지는 Multi-ViewType RecyclerView를 구현해보자.
각 아이템에 해당하는 레이아웃을 만들어준다.
xml 코드는 생략한다.
목록에 들어갈 수 있는 데이터의 종류를 MovieListItem sealed 클래스를 사용하여 제한해줬다.
sealed class 를 사용하면 컴파일 타임에 MovieListItem 을 상속 받는 클래스의 종류가 정해지기 때문에 when 절을 사용할 때 모든 자식 클래스에 대해 분기 처리가 되고 있는지 검증이 가능해진다.
sealed class MovieListItem : java.io.Serializable {
data class MovieModel(
@DrawableRes val image: Int,
val title: String,
val startDate: String,
val endDate: String,
val runningTime: Int,
val description: String
) : MovieListItem() {
companion object {
const val MOVIE_DATE_FORMAT: String = "yyyy.M.d"
}
}
data class AdModel(
@DrawableRes val image: Int,
val url: String
) : MovieListItem()
}
아이템의 뷰들을 저장할 ViewHolder를 만들어준다.
이 또한 각 레이아웃에 해당하는 ViewHolder를 각각 만들어줘야 한다.
class MovieViewHolder(view: View) : CustomViewHolder(view) {
private val image: ImageView = view.findViewById(R.id.img_movie)
private val title: TextView = view.findViewById(R.id.text_title)
private val playingDate: TextView = view.findViewById(R.id.text_playing_date)
private val runningTime: TextView = view.findViewById(R.id.text_running_time)
private val ticketingButton: Button = view.findViewById(R.id.btn_ticketing)
override fun bind(item: MovieListItem, clickListener: ItemClickListener) {
item as MovieListItem.MovieModel
val context = itemView.context
image.setImageResource(item.image)
title.text = item.title
val playingDateText = context.getString(R.string.playing_time, item.startDate, item.endDate)
playingDate.text = playingDateText
val runningTimeText = context.getString(R.string.running_time, item.runningTime)
runningTime.text = runningTimeText
ticketingButton.setOnClickListener {
clickListener.onClick(item)
}
}
}
class AdViewHolder(view: View) :
CustomViewHolder(view) {
private val adImage: ImageView = view.findViewById(R.id.img_ad)
override fun bind(item: MovieListItem, clickListener: ItemClickListener) {
item as MovieListItem.AdModel
adImage.setImageResource(item.image)
adImage.setOnClickListener { clickListener.onClick(item) }
}
}
나 같은 경우는 ViewHolder 가 뷰와 데이터를 bind 하는 기능도 가지도록 설계를 했다.
package woowacourse.movie.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import woowacourse.movie.R
import woowacourse.movie.adapter.viewholder.AdViewHolder
import woowacourse.movie.adapter.viewholder.CustomViewHolder
import woowacourse.movie.adapter.viewholder.MovieViewHolder
import woowacourse.movie.listener.ItemClickListener
import woowacourse.movie.model.MovieListItem
class MovieListAdapter(
private val items: List<MovieListItem>,
private val movieClickListener: ItemClickListener,
private val adClickListener: ItemClickListener
) :
RecyclerView.Adapter<CustomViewHolder>() {
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is MovieListItem.MovieModel -> MOVIE_ITEM_VIEW_TYPE
is MovieListItem.AdModel -> AD_ITEM_VIEW_TYPE
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
return when (viewType) {
MOVIE_ITEM_VIEW_TYPE -> {
val view =
LayoutInflater.from(parent.context).inflate(R.layout.movie_item, parent, false)
MovieViewHolder(view)
}
AD_ITEM_VIEW_TYPE -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.ad_item, parent, false)
AdViewHolder(view)
}
else -> throw IllegalArgumentException(VIEW_TYPE_ERROR)
}
}
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
when (holder) {
is MovieViewHolder -> {
holder.bind(items[position], movieClickListener)
}
is AdViewHolder -> {
holder.bind(items[position], adClickListener)
}
}
}
override fun getItemCount(): Int = items.size
companion object {
private const val MOVIE_ITEM_VIEW_TYPE = 0
private const val AD_ITEM_VIEW_TYPE = 1
private const val VIEW_TYPE_ERROR = "알 수 없는 뷰타입"
}
}
ItemClickListener 인터페이스 코드는 다음과 같다. 영화 아이템과 광고 아이템 클릭 시 수행될 동작을 생성자로 주입받도록 설계했다.
interface ItemClickListener {
fun onClick(item: MovieListItem)
}
getItemViewType()
: 사용하여 각 아이템에 대한 ViewType을 설정해준다.
onCreateViewHolder()
: 각 ViewType에 대해 적절한 ViewHolder 객체를 생성해서 리턴해준다. ViewHolder 객체 생성 시 개별 뷰 객체가 뷰 홀더에 저장된다.
onBindViewHolder()
: ViewHolder에 저장된 뷰와 데이터를 연결해준다. 연결할 데이터와, 뷰 클릭시 수행될 동작을 넘겨준다.
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
val adapter = MovieListAdapter(
getMovieListData(),
object : ItemClickListener {
override fun onClick(item: MovieListItem) {
val intent = Intent(this@MovieListActivity, MovieDetailActivity::class.java)
intent.putExtra(MOVIE_KEY, item)
this@MovieListActivity.startActivity(intent)
}
},
object : ItemClickListener {
override fun onClick(item: MovieListItem) {
item as MovieListItem.AdModel
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(item.url))
this@MovieListActivity.startActivity(intent)
}
}
)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter