오늘은 안드로이드 앱 개발을 하면서 가장 많이 사용하게 되는 View 중 하나인 리사이클러뷰(RecyclerView)에 대해 알아보자.
리사이클 러뷰(RecyclerView) 란?
RecyclerView란 한 화면에 표시할 수 없는 많은 데이터를 스크롤 가능한 리스트로 표시해주는 위젯이다.
RecyclerView 사용하면 대량의 데이터 셋을 효율적으로 표시할 수 있다.
개발자가 데이터를 제공하고(데이터 셋), 각 항목의 모양(아이템 뷰)을 정의하면 RecyclerView 라이브러리가 필요할 때
요소들을 동적으로 생성한다.
이름에 Recycler가 들어가 있듯이 RecyclerView는 ViewHolder를 사용하여 뷰를 재활용하므로 앱 성능과 메모리 관리 측면에서도 매우 유용하다.
이와 비슷한 위젯으로 ListView가 있는데, RecyclerView는 ListView에 유연함과 성능을 더한 ListView의 확장판으로 생각하면 된다.
구글에서도 현재 ListView 대신 RecyclerView를 리스트 표시를 위한 UI 구성에 사용하도록 권고하고 있다.
구성요소
- RecyclerView는 각 데이터에 해당하는 View가 포함된 ViewGroup이다.
- RecyclerView 리스트의 각 항목은 ViewHolder 객체로 정의되며, RecyclerView가 ViewHolder를 View의 데이터에 바인딩한다.
- RecyclerView는 View를 요청한 뒤, Adapter에서 메서드를 호출하여 View를 View의 데이터에 바인딩한다. RecyclerView.Adapter를 상속하여 Adapter를 새로 정의할 수도 있다.
- LayoutManager은 리스트의 개별 요소들을 정렬한다.
- LinerLayoutManager는 가로 혹은 세로 리스트로 요소들을 정리한다. (일반 리스트 뷰)
- GridLayoutManager는 모든 항목을 2차원 그리드로 정렬한다. (ex 갤러리 사진)
- StaggeredGridLayoutManager은 GridLayoutManager과 비슷하지만 각 아이템들의 높이들을 서로 다르게 지정해 줄 수 있다.
뷰의 재사용
위 그림처럼 RecyclerView는 스마트폰의 화면의 높이에 따라 적절한 개수의 View를 생성한 뒤
사용자가 위아래로 리스트를 스와이프 할 때 기존에 생성했던 View 객체를 재사용한다.
이를 위해서는 기존 View 객체들을 가지고 있어야 하며, 재사용한 View 객체에 데이터를 바인딩해야 하는데
ViewHolder가 바로 그 역할을 수행한다.
구현하기
실제 코드로 RecyclerView를 구현하면서 Adapter와 ViewHolder의 역할을 알아보자.
패키지 구조
구현 순서
구현 순서는 크게 다음과 같다.
- layout xml 파일에 RecyclerView 선언
- 리스트의 각 아이템에 해당하는 dto 클래스와 item_view 생성
- Adapter 및 ViewHolder 클래스 생성
- Activity에서 RecyclerView 초기화 및 설정
프로젝트 생성
프로젝트는 Empty Activity로 설정하여 생성한다.
ViewBinding 추가
ViewBinding을 이용하기 위해 모듈 단위 gradle 파일에 다음 코드를 추가한다.
buildFeatures {
viewBinding true
}
Activity에서 ViewBinding
MainActivity에서 ViewBinding을 위해 다음과 같이 코드를 작성한다.
class MainActivity : AppCompatActivity() {
lateinit var binding : ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
...
RecyclerView 레이아웃 설정
activity_main.xml 레이아웃 파일에 RecyclerView 위젯을 추가한다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/todo_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
아이템 뷰 생성
리스트의 아이템은 간단하게 텍스트뷰와 체크박스를 추가한다.
이때 최상단 레이아웃의 layout_height는 wrap_content로 설정해야 아이템의 높이만큼 설정이 된다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="16dp">
<TextView
android:id="@+id/todo_title_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="할일목록" />
<CheckBox
android:id="@+id/completed_check_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
DTO 클래스 생성
리스트의 각 아이템은 하나의 텍스트뷰와 체크박스를 가지고 있으므로
data class의 프로퍼티는 String 타입과 Boolean 타입으로 선언한다.
data class Todo(
val title: String,
var completed: Boolean
)
Adapter 클래스 및 ViewHolder 클래스 생성
class TodoAdapter(private val todos: List<Todo>) : RecyclerView.Adapter<TodoAdapter.TodoViewHolder>() {
companion object {
private const val TAG = "TodoAdapter_고기"
}
// ViewHolder 생성하는 함수, 최소 생성 횟수만큼만 호출됨 (계속 호출 X)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
Log.d(TAG, "onCreateViewHolder: ")
val binding = ItemTodoBinding.inflate(
LayoutInflater.from(parent.context), // layoutInflater 를 넘기기위해 함수 사용, ViewGroup 는 View 를 상속하고 View 는 이미 Context 를 가지고 있음
parent, // 부모(리싸이클러뷰 = 뷰그룹)
false // 리싸이클러뷰가 attach 하도록 해야함 (우리가 하면 안됨)
)
return TodoViewHolder(binding).also { holder ->
binding.completedCheckBox.setOnCheckedChangeListener { _, isChecked ->
todos.getOrNull(holder.adapterPosition)?.completed = isChecked
}
}
}
// 만들어진 ViewHolder에 데이터를 바인딩하는 함수
// position = 리스트 상에서 몇번째인지 의미
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
Log.d(TAG, "onBindViewHolder: $position")
holder.bind(todos[position])
}
override fun getItemCount(): Int = todos.size
class TodoViewHolder(private val binding: ItemTodoBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(todo: Todo) {
binding.todoTitleText.text = todo.title
binding.completedCheckBox.isChecked = todo.completed
}
}
}
TodoAdapter 클래스는 생성자로 데이터 셋인 todo 리스트를 받아오며, RecyclerView.Adapter를 상속한다.
RecyclerView.Adapter는 abstract class(추상 클래스)이므로 위 코드와 같이 3개의 메서드를 오버라이딩해야 한다.
TodoViewHolder 클래스는 중첩 클래스로 선언되어 RecyclerView.ViewHolder를 상속한다.
가끔 Adpater 클래스의 생성자로 context를 받는 코드를 보곤 하는데 이는 권장하지 않는 방식이다.
Activity의 context를 다른 곳에서 참조하게 되면 Activity의 생명주기가 끝나도 계속 참조가 남아있어 메모리 릭이 발생할 수도 있으며, 결합성이 높아지는 단점이 존재하므로 지양해야 하는 방식이다.
Adapter 클래스
override fun onCreateViewHolder
// ViewHolder 생성하는 함수, 최소 생성 횟수만큼만 호출됨 (계속 호출 X)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
Log.d(TAG, "onCreateViewHolder: ")
val binding = ItemTodoBinding.inflate(
LayoutInflater.from(parent.context), // layoutInflater 를 넘기기위해 함수 사용, ViewGroup 는 View 를 상속하고 View 는 이미 Context 를 가지고 있음
parent, // 부모(리싸이클러뷰 = 뷰그룹)
false // 리싸이클러뷰가 attach 하도록 해야함 (우리가 하면 안됨)
)
return TodoViewHolder(binding).also { holder ->
binding.completedCheckBox.setOnCheckedChangeListener { _, isChecked ->
todos.getOrNull(holder.adapterPosition)?.completed = isChecked
}
}
}
위 함수는 ViewHolder를 생성하는 함수로, 최초 생성 횟수만큼만 호출된다. (계속 호출 X)
파라미터 중 ViewGroup 타입의 parent는 RecyclerView를 의미한다.
ViewHolder를 생성할 때 view를 inflate는 과정에서 context가 필요한데 이때는 parent.context를 사용하면 된다.
parent는 ViewGroup타입이며, ViewGroup는 View를 상속하고 View는 activity에 inflate 돼있으므로
이미 context를 가지고 있기 때문이다. 그래서 Adapter의 생성자로 activity의 context를 받아올 필요가 없다.
ViewBinding을 사용하므로 binding 객체를 생성하여 inflate 한 뒤 ViewHoler에 넘겨준다.
이때 아이템 뷰의 체크박스의 CheckedChangeListener를 함께 달아준다.
코틀린 표준 함수 also를 사용하여 람다식의 매개변수로 holder 자기 자신을 사용한다.
이때 아이템의 position을 알아내는 방법은 holder.adapterPosition을 사용하면 된다.
override fun onBindViewHolder
// 만들어진 ViewHolder에 데이터를 바인딩하는 함수
// position = 리스트 상에서 몇번째인지 의미
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) {
Log.d(TAG, "onBindViewHolder: $position")
holder.bind(todos[position])
}
위 함수는 만들어진 ViewHolder에 데이터를 바인딩하는 역할을 한다.
onCreateViewHolder와 달리 뷰가 재사용될 때마다 호출된다.
매개변수 중 holder는 재사용되는 뷰 객체를 가지고 있으며,
position는 리스트 상에서 몇 번째인지를 의미한다.
(holder.adapterPosition의 값도 position의 값과 일치한다)
onBindViewHolder 내에 클릭 리스너를 선언하는 경우가 종종 있는데, 이는 함수가 호출될 때마다
리스너를 새로 등록하므로 객체를 계속해서 새로 생성하는 것과 같다. 따라서 메모리가 낭비되며 성능 저하를 불러일으키므로 onCreateViewHolder 함수에 리스너를 달아준다.
override fun getItemCount
override fun getItemCount(): Int = todos.size
위 함수는 데이터 셋의 개수를 리턴한다.
ViewHolder 클래스
fun bind
fun bind(todo: Todo) {
binding.todoTitleText.text = todo.title
binding.completedCheckBox.isChecked = todo.completed
}
위 함수는 인자로 받은 dto 객체를 뷰에 바인딩해주는 역할을 한다.
ViewBinding을 사용했으므로 binding 객체를 사용하여 뷰에 접근한다.
RecyclerView 초기화 및 설정
Adapter 클래스, ViewHolder 클래스, dto 클래스와 아이템 뷰 모두 생성이 되었다면
Activity에서 RecyclerView를 설정해준다.
class MainActivity : AppCompatActivity() {
lateinit var binding : ActivityMainBinding
private val todos = listOf(
Todo("리싸이클러뷰 부시기 #1", false),
Todo("리싸이클러뷰 부시기 #2", false),
Todo("리싸이클러뷰 부시기 #3", false),
Todo("리싸이클러뷰 부시기 #4", false),
Todo("리싸이클러뷰 부시기 #5", false),
Todo("리싸이클러뷰 부시기 #6", false),
Todo("리싸이클러뷰 부시기 #7", false),
Todo("리싸이클러뷰 부시기 #8", false),
Todo("리싸이클러뷰 부시기 #9", false),
Todo("리싸이클러뷰 부시기 #10", false),
Todo("리싸이클러뷰 부시기 #11", false),
Todo("리싸이클러뷰 부시기 #12", false),
Todo("리싸이클러뷰 부시기 #13", false),
Todo("리싸이클러뷰 부시기 #14", false),
Todo("리싸이클러뷰 부시기 #15", false),
Todo("리싸이클러뷰 부시기 #16", false),
Todo("리싸이클러뷰 부시기 #17", false),
Todo("리싸이클러뷰 부시기 #18", false),
Todo("리싸이클러뷰 부시기 #19", false),
Todo("리싸이클러뷰 부시기 #20", false)
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initViews()
}
private fun initViews() {
binding.todoList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
binding.todoList.adapter = TodoAdapter(todos)
}
}
RecyclerView의 id는 todo_list 이므로 binding.todoList로 접근한다.
LayoutManager는 1차원 수직 스크롤을 사용할 것이므로 LinearLayoutManager.VERTICAL 속성을 사용하여 설정한다.
Adapter은 만들어 놓은 TodoAdapter를 사용하며 Dummy data인 todos를 인자로 넘긴다.
여기까지가 RecyclerView 기본 구현 완성이다. 코드를 실행해서 확인해보자!
뷰의 재사용 확인
애뮬레이터 실행 후 로그를 확인하면 초기에는
onCreateViewHolder와 onBindViewHolder가 같은 횟수로 번갈아 호출되지만
ViewHolder가 어느 정도 생성되면 onBindViewHolder만 호출되는 것을 확인할 수 있다.
따라서 뷰가 재사용된다는 것을 로그를 통해 눈으로 확인했다!
이것으로 RecyclerView 사용하기를 마친다.
다음 포스팅에서는 인터페이스를 통해 Activity에서 RecyclerView 아이템의 클릭 리스너를 구현하는 방법에
대해 알아볼 예정이다!
혹시 틀린부분이 있으면 댓글 남겨주세요!
전체 코드는 여기서 확인할 수 있습니다.
https://github.com/SangWoo-Han97/AndroidPractice/tree/main/RecyclerViewEx
참고
https://developer.android.com/guide/topics/ui/layout/recyclerview?hl=ko
'Android > UI' 카테고리의 다른 글
[Android] 투명 상태 바 만들기 (Transparent Status Bar) (6) | 2021.11.19 |
---|