Android Kotlin 기초 07.4: RecyclerView 항목과 상호작용

이 Codelab은 Android Kotlin 기초 과정의 일부입니다. Codelab을 순서대로 진행하면 이 과정의 학습 효과를 극대화할 수 있습니다. 모든 과정 Codelab은 Android Kotlin 기본사항 Codelab 방문 페이지에 나열되어 있습니다.

소개

항목을 표시하는 목록과 그리드를 사용하는 대부분의 앱에서는 사용자가 항목과 상호작용할 수 있습니다. 목록에서 항목을 탭하고 항목의 세부정보를 확인하는 것은 이러한 유형의 상호작용에서 매우 일반적인 사용 사례입니다. 이를 위해 세부정보 뷰를 표시하여 항목에 대한 사용자 탭에 응답하는 클릭 리스너를 추가할 수 있습니다.

이 Codelab에서는 이전 Codelab 시리즈의 수면 추적기 앱 확장 버전을 기반으로 RecyclerView에 상호작용을 추가합니다.

기본 요건

  • 활동, 프래그먼트, 뷰를 사용하여 기본 사용자 인터페이스를 빌드합니다.
  • 프래그먼트 간 탐색 및 safeArgs을 사용하여 프래그먼트 간 데이터 전달
  • 모델, 모델 팩토리, 변환, LiveData 및 관찰자를 확인합니다.
  • Room 데이터베이스를 만들고, 데이터 액세스 객체 (DAO)를 만들고, 항목을 정의하는 방법
  • 데이터베이스 및 기타 장기 실행 작업에 코루틴을 사용하는 방법
  • Adapter, ViewHolder, 항목 레이아웃으로 기본 RecyclerView를 구현하는 방법
  • RecyclerView의 데이터 바인딩을 구현하는 방법
  • 바인딩 어댑터를 만들어 데이터를 변환하는 방법
  • GridLayoutManager 사용 방법

학습할 내용

  • RecyclerView의 항목을 클릭 가능하게 만드는 방법 항목을 클릭하면 세부정보 뷰로 이동하는 클릭 리스너를 구현합니다.

실습할 내용

  • 이 시리즈의 이전 Codelab에서 확장된 TrackMySleepQuality 앱을 기반으로 빌드합니다.
  • 목록에 클릭 리스너를 추가하고 사용자 상호작용을 수신 대기합니다. 목록 항목을 탭하면 클릭한 항목의 세부정보가 있는 프래그먼트로의 이동이 트리거됩니다. 시작 코드는 세부정보 프래그먼트와 탐색 코드의 코드를 제공합니다.

시작 수면 추적기 앱에는 아래 그림과 같이 프래그먼트로 표시되는 두 개의 화면이 있습니다.

왼쪽에 표시된 첫 번째 화면에는 추적을 시작하고 중지하는 버튼이 있습니다. 화면에는 사용자의 수면 데이터가 일부 표시됩니다. 지우기 버튼은 앱이 사용자를 위해 수집한 모든 데이터를 완전히 삭제합니다. 오른쪽에 표시된 두 번째 화면은 수면의 질 평가를 선택하는 화면입니다.

이 앱은 UI 컨트롤러, 뷰 모델, LiveData, Room 데이터베이스를 사용하여 수면 데이터를 유지하는 간소화된 아키텍처를 사용합니다.

이 Codelab에서는 사용자가 그리드에서 항목을 탭할 때 응답하는 기능을 추가하여 아래와 같은 세부정보 화면을 표시합니다. 이 화면의 코드 (프래그먼트, 뷰 모델, 탐색)는 스타터 앱과 함께 제공되며 클릭 처리 메커니즘을 구현합니다.

1단계: 시작 앱 다운로드

  1. GitHub에서 RecyclerViewClickHandler-Starter 코드를 다운로드하고 Android 스튜디오에서 프로젝트를 엽니다.
  2. 시작 수면 추적기 앱을 빌드하고 실행합니다.

[선택사항] 이전 Codelab의 앱을 사용하려면 앱 업데이트

이 Codelab의 GitHub에 제공된 시작 앱으로 작업하는 경우 다음 단계로 건너뛰세요.

이전 Codelab에서 빌드한 자체 수면 추적기 앱을 계속 사용하려면 아래 안내에 따라 세부정보 화면 프래그먼트의 코드가 포함되도록 기존 앱을 업데이트하세요.

  1. 기존 앱을 계속 사용하더라도 파일을 복사할 수 있도록 GitHub에서 RecyclerViewClickHandler-Starter 코드를 가져옵니다.
  2. sleepdetail 패키지의 모든 파일을 복사합니다.
  3. layout 폴더에서 fragment_sleep_detail.xml 파일을 복사합니다.
  4. sleep_detail_fragment의 탐색을 추가하는 업데이트된 navigation.xml 콘텐츠를 복사합니다.
  5. database 패키지의 SleepDatabaseDao에서 새 getNightWithId() 메서드를 추가합니다.
/**
 * Selects and returns the night with given nightId.
*/
@Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
fun getNightWithId(key: Long): LiveData<SleepNight>
  1. res/values/strings에서 다음 문자열 리소스를 추가합니다.
<string name="close">Close</string>
  1. 앱을 정리하고 다시 빌드하여 데이터 결합을 업데이트합니다.

2단계: 수면 세부정보 화면의 코드 검사

이 Codelab에서는 클릭한 수면 밤에 관한 세부정보를 표시하는 프래그먼트로 이동하는 클릭 핸들러를 구현합니다. 이 SleepDetailFragment의 프래그먼트와 탐색 그래프는 코드가 상당히 많고 프래그먼트와 탐색이 이 Codelab에 포함되지 않기 때문에 시작 코드에 이미 포함되어 있습니다. 다음 코드를 숙지하세요.

  1. 앱에서 sleepdetail 패키지를 찾습니다. 이 패키지에는 하룻밤의 수면 세부정보를 표시하는 프래그먼트의 프래그먼트, 뷰 모델, 뷰 모델 팩토리가 포함되어 있습니다.

  2. sleepdetail 패키지에서 SleepDetailViewModel의 코드를 열고 검사합니다. 이 뷰 모델은 생성자에서 SleepNight의 키와 DAO를 사용합니다.

    클래스 본문에는 지정된 키의 SleepNight을 가져오는 코드와 닫기 버튼을 누를 때 SleepTrackerFragment로 다시 이동하는 것을 제어하는 navigateToSleepTracker 변수가 있습니다.

    getNightWithId() 함수는 LiveData<SleepNight>를 반환하며 SleepDatabaseDao (database 패키지)에 정의되어 있습니다.

  3. sleepdetail 패키지에서 SleepDetailFragment의 코드를 열고 검사합니다. 데이터 바인딩, 뷰 모델, 탐색 관찰자의 설정을 확인합니다.

  4. sleepdetail 패키지에서 SleepDetailViewModelFactory.

    의 코드를 열고 검사합니다.
  5. 레이아웃 폴더에서 fragment_sleep_detail.xml을 검사합니다. <data> 태그에 정의된 sleepDetailViewModel 변수를 확인하여 뷰 모델에서 각 뷰에 표시할 데이터를 가져옵니다.

    레이아웃에는 수면의 질을 나타내는 ImageView, 질 평가를 나타내는 TextView, 수면 시간을 나타내는 TextView, 세부정보 프래그먼트를 닫는 Button가 포함된 ConstraintLayout가 포함되어 있습니다.

  6. navigation.xml 파일을 엽니다. sleep_tracker_fragment에서 sleep_detail_fragment의 새 작업을 확인합니다.

    새 작업 action_sleep_tracker_fragment_to_sleepDetailFragment은 수면 추적기 프래그먼트에서 세부정보 화면으로의 탐색입니다.

이 작업에서는 탭한 항목의 세부정보 화면을 표시하여 사용자 탭에 응답하도록 RecyclerView를 업데이트합니다.

클릭을 수신하고 처리하는 것은 두 부분으로 구성된 작업입니다. 먼저 클릭을 수신하고 클릭된 항목을 확인해야 합니다. 그런 다음 클릭에 대한 응답으로 작업을 실행해야 합니다.

그렇다면 이 앱의 클릭 리스너를 추가하기에 가장 적합한 위치는 어디일까요?

  • SleepTrackerFragment는 여러 뷰를 호스팅하므로 프래그먼트 수준에서 클릭 이벤트를 수신해도 어떤 항목이 클릭되었는지 알 수 없습니다. 클릭된 항목인지 다른 UI 요소인지도 알려주지 않습니다.
  • RecyclerView 수준에서 리스닝하면 사용자가 목록에서 클릭한 항목을 정확히 파악하기 어렵습니다.
  • 클릭된 항목에 관한 정보를 얻는 가장 좋은 방법은 ViewHolder 객체를 사용하는 것입니다. 이 객체는 하나의 목록 항목을 나타내기 때문입니다.

ViewHolder는 클릭을 수신 대기하기에는 좋지만 일반적으로 클릭을 처리하기에는 적합하지 않습니다. 그렇다면 클릭을 처리하기에 가장 적합한 곳은 어디일까요?

  • Adapter는 뷰에 데이터 항목을 표시하므로 개발자는 어댑터에서 클릭을 처리할 수 있습니다. 하지만 어댑터의 역할은 표시할 데이터를 조정하는 것이지 앱 로직을 처리하는 것이 아닙니다.
  • 일반적으로 ViewModel에서 클릭을 처리해야 합니다. 클릭에 대응하여 실행할 작업을 결정하기 위한 데이터 및 로직에 대한 액세스 권한이 ViewModel에 있기 때문입니다.

1단계: 클릭 리스너를 만들고 항목 레이아웃에서 트리거

  1. sleeptracker 폴더에서 SleepNightAdapter.kt를 엽니다.
  2. 파일 끝의 최상위 수준에서 새 리스너 클래스 SleepNightListener를 만듭니다.
class SleepNightListener() {
    
}
  1. SleepNightListener 클래스 내부에 onClick() 함수를 추가합니다. 목록 항목을 표시하는 뷰를 클릭하면 뷰가 이 onClick() 함수를 호출합니다. (나중에 뷰의 android:onClick 속성을 이 함수로 설정합니다.)
class SleepNightListener() {
    fun onClick() = 
}
  1. onClick()SleepNight 유형의 함수 인수 night를 추가합니다. 뷰는 표시하는 항목을 알고 있으며 클릭을 처리하기 위해 이 정보를 전달해야 합니다.
class SleepNightListener() {
    fun onClick(night: SleepNight) = 
}
  1. onClick()의 기능을 정의하려면 SleepNightListener의 생성자에 clickListener 콜백을 제공하고 이를 onClick()에 할당합니다.

    클릭을 처리하는 람다에 이름(clickListener)을 지정하면 클래스 간에 전달될 때 추적하는 데 도움이 됩니다. clickListener 콜백에는 데이터베이스의 데이터에 액세스하는 데 night.nightId만 필요합니다. 완성된 SleepNightListener 클래스는 아래 코드와 같이 표시됩니다.
class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
   fun onClick(night: SleepNight) = clickListener(night.nightId)
}
  1. list_item_sleep_night.xml.을 엽니다.
  2. data 블록 내에서 데이터 바인딩을 통해 SleepNightListener 클래스를 사용할 수 있도록 새 변수를 추가합니다. 새 <variable>clickListener.name을 부여합니다. 아래와 같이 typecom.example.android.trackmysleepquality.sleeptracker.SleepNightListener 클래스의 정규화된 이름으로 설정합니다. 이제 이 레이아웃에서 SleepNightListeneronClick() 함수에 액세스할 수 있습니다.
<variable
            name="clickListener"
            type="com.example.android.trackmysleepquality.sleeptracker.SleepNightListener" />
  1. 이 목록 항목의 어느 부분을 클릭하는지 리슨하려면 ConstraintLayoutandroid:onClick 속성을 추가하세요.

    다음과 같이 데이터 바인딩 람다를 사용하여 속성을 clickListener:onClick(sleep)로 설정합니다.
android:onClick="@{() -> clickListener.onClick(sleep)}"

2단계: 클릭 리스너를 뷰 홀더와 바인딩 객체에 전달

  1. SleepNightAdapter.kt를 엽니다.
  2. val clickListener: SleepNightListener를 수신하도록 SleepNightAdapter 클래스의 생성자를 수정합니다. 어댑터가 ViewHolder를 바인딩할 때 이 클릭 리스너를 제공해야 합니다.
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
  1. onBindViewHolder()에서 ViewHolder에 클릭 리스너도 전달하도록 holder.bind() 호출을 업데이트합니다. 함수 호출에 매개변수를 추가했으므로 컴파일러 오류가 발생합니다.
holder.bind(getItem(position)!!, clickListener)
  1. bind()clickListener 매개변수를 추가합니다. 이렇게 하려면 아래 스크린샷에 표시된 것처럼 오류에 커서를 두고 오류에서 Alt+Enter (Windows) 또는 Option+Enter (Mac)를 누릅니다.

  1. ViewHolder 클래스의 bind() 함수 내에서 클릭 리스너를 binding 객체에 할당합니다. 바인딩 객체를 업데이트해야 하므로 오류가 표시됩니다.
binding.clickListener = clickListener
  1. 데이터 바인딩을 업데이트하려면 프로젝트를 Clean하고 Rebuild합니다. (캐시를 무효화해야 할 수도 있습니다.) 따라서 어댑터 생성자에서 클릭 리스너를 가져와 뷰 홀더와 바인딩 객체에 전달했습니다.

3단계: 항목을 탭할 때 토스트 표시

이제 클릭을 캡처하는 코드가 있지만 목록 항목을 탭할 때 발생하는 상황은 구현하지 않았습니다. 가장 간단한 응답은 항목을 클릭할 때 nightId를 표시하는 토스트를 표시하는 것입니다. 이렇게 하면 목록 항목을 클릭할 때 올바른 nightId가 캡처되어 전달되는지 확인할 수 있습니다.

  1. SleepTrackerFragment.kt를 엽니다.
  2. onCreateView()에서 adapter 변수를 찾습니다. 이제 클릭 리스너 매개변수가 필요하므로 오류가 표시됩니다.
  3. SleepNightAdapter에 람다를 전달하여 클릭 리스너를 정의합니다. 이 간단한 람다는 아래와 같이 nightId를 보여주는 토스트를 표시합니다. Toast를 가져와야 합니다. 아래는 업데이트된 전체 정의입니다.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
   Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})
  1. 앱을 실행하고 항목을 탭하여 올바른 nightId가 포함된 토스트가 표시되는지 확인합니다. 항목의 nightId 값이 증가하고 앱에서 가장 최근의 밤을 먼저 표시하므로 nightId 값이 가장 낮은 항목이 목록 하단에 있습니다.

이 작업에서는 RecyclerView의 항목을 클릭할 때의 동작을 변경하여 토스트를 표시하는 대신 클릭한 밤에 관한 자세한 정보를 표시하는 세부정보 프래그먼트로 앱이 이동하도록 합니다.

1단계: 클릭 시 탐색

이 단계에서는 토스트를 표시하는 대신 SleepTrackerFragmentonCreateView()에서 클릭 리스너 람다를 변경하여 nightIdSleepTrackerViewModel에 전달하고 SleepDetailFragment로의 탐색을 트리거합니다.

클릭 핸들러 함수를 정의합니다.

  1. SleepTrackerViewModel.kt를 엽니다.
  2. SleepTrackerViewModel의 끝부분에서 onSleepNightClicked() 클릭 핸들러 함수를 정의합니다.
fun onSleepNightClicked(id: Long) {

}
  1. onSleepNightClicked() 내에서 클릭된 수면 밤의 전달된 id_navigateToSleepDetail를 설정하여 탐색을 트리거합니다.
fun onSleepNightClicked(id: Long) {
   _navigateToSleepDetail.value = id
}
  1. _navigateToSleepDetail를 구현합니다. 이전과 마찬가지로 탐색 상태의 private MutableLiveData를 정의합니다. 그리고 공개 gettable val도 있습니다.
private val _navigateToSleepDetail = MutableLiveData<Long>()
val navigateToSleepDetail
   get() = _navigateToSleepDetail
  1. 앱이 탐색을 완료한 후 호출할 메서드를 정의합니다. 이름을 onSleepDetailNavigated()로 지정하고 값을 null로 설정합니다.
fun onSleepDetailNavigated() {
    _navigateToSleepDetail.value = null
}

클릭 핸들러를 호출하는 코드를 추가합니다.

  1. SleepTrackerFragment.kt를 열고 어댑터를 만들고 SleepNightListener를 정의하여 토스트를 표시하는 코드로 스크롤합니다.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
   Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})
  1. 항목을 탭할 때 sleepTrackerViewModel에서 클릭 핸들러 onSleepNighClicked()를 호출하도록 토스트 아래에 다음 코드를 추가합니다. nightId를 전달하여 뷰 모델이 가져올 수면 밤을 알 수 있도록 합니다. onSleepNightClicked()을 아직 정의하지 않았기 때문에 오류가 발생합니다. 원하는 대로 토스트를 유지하거나 주석 처리하거나 삭제할 수 있습니다.
sleepTrackerViewModel.onSleepNightClicked(nightId)

클릭을 관찰하는 코드를 추가합니다.

  1. SleepTrackerFragment.kt를 엽니다.
  2. onCreateView()에서 manager 선언 바로 위에 새 navigateToSleepDetail LiveData를 관찰하는 코드를 추가합니다. navigateToSleepDetail가 변경되면 night를 전달하여 SleepDetailFragment로 이동한 후 onSleepDetailNavigated()를 호출합니다. 이전 Codelab에서 이 작업을 수행한 적이 있으므로 코드는 다음과 같습니다.
sleepTrackerViewModel.navigateToSleepDetail.observe(this, Observer { night ->
            night?.let {
              this.findNavController().navigate(
                        SleepTrackerFragmentDirections
                                .actionSleepTrackerFragmentToSleepDetailFragment(night))
               sleepTrackerViewModel.onSleepDetailNavigated()
            }
        })
  1. 코드를 실행하고 항목을 클릭하면 앱이 비정상 종료됩니다.

결합 어댑터에서 null 값 처리:

  1. 디버그 모드에서 앱을 다시 실행합니다. 항목을 탭하고 로그를 필터링하여 오류를 표시합니다. 아래와 같은 내용이 포함된 스택 트레이스가 표시됩니다.
Caused by: java.lang.IllegalArgumentException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull, parameter item

안타깝게도 스택 트레이스에서는 이 오류가 트리거된 위치를 명확하게 알 수 없습니다. 데이터 바인딩의 한 가지 단점은 코드 디버깅이 더 어려워질 수 있다는 것입니다. 항목을 클릭하면 앱이 비정상 종료되고, 클릭을 처리하는 새 코드만 있습니다.

하지만 이 새로운 클릭 처리 메커니즘을 사용하면 이제 결합 어댑터가 itemnull 값으로 호출될 수 있습니다. 특히 앱이 시작되면 LiveDatanull로 시작되므로 각 어댑터에 null 검사를 추가해야 합니다.

  1. BindingUtils.kt에서 각 바인딩 어댑터에 대해 item 인수의 유형을 null 허용으로 변경하고 본문을 item?.let{...}로 래핑합니다. 예를 들어 sleepQualityString용 어댑터는 다음과 같습니다. 다른 어댑터도 마찬가지로 변경합니다.
@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight?) {
   item?.let {
       text = convertNumericQualityToString(item.sleepQuality, context.resources)
   }
}
  1. 앱을 실행합니다. 항목을 탭하면 세부정보 뷰가 열립니다.

Android 스튜디오 프로젝트: RecyclerViewClickHandler

RecyclerView의 항목이 클릭에 반응하도록 하려면 ViewHolder의 목록 항목에 클릭 리스너를 연결하고 ViewModel에서 클릭을 처리합니다.

RecyclerView의 항목이 클릭에 반응하도록 하려면 다음 단계를 따르세요.

  • 람다를 가져와 onClick() 함수에 할당하는 리스너 클래스를 만듭니다.
class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
   fun onClick(night: SleepNight) = clickListener(night.nightId)
}
  • 뷰에 클릭 리스너를 설정합니다.
android:onClick="@{() -> clickListener.onClick(sleep)}"
  • 클릭 리스너를 어댑터 생성자, 뷰 홀더에 전달하고 바인딩 객체에 추가합니다.
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()
holder.bind(getItem(position)!!, clickListener)
binding.clickListener = clickListener
  • 어댑터를 만드는 리사이클러 뷰를 표시하는 프래그먼트에서 람다를 어댑터에 전달하여 클릭 리스너를 정의합니다.
val adapter = SleepNightAdapter(SleepNightListener { nightId ->
      sleepTrackerViewModel.onSleepNightClicked(nightId)
})
  • 뷰 모델에서 클릭 핸들러를 구현합니다. 목록 항목을 클릭하면 일반적으로 세부정보 프래그먼트로 이동합니다.

Udacity 과정:

Android 개발자 문서:

이 섹션에는 강사가 진행하는 과정의 일부로 이 Codelab을 진행하는 학생에게 출제할 수 있는 과제가 나열되어 있습니다. 다음 작업은 강사가 결정합니다.

  • 필요한 경우 과제를 할당합니다.
  • 과제 제출 방법을 학생에게 알립니다.
  • 과제를 채점합니다.

강사는 이러한 추천을 원하는 만큼 사용할 수 있으며 적절하다고 생각되는 다른 과제를 출제해도 됩니다.

이 Codelab을 직접 진행하는 경우 이러한 과제를 자유롭게 사용하여 배운 내용을 테스트해 보세요.

질문에 답하세요

질문 1

앱에 쇼핑 목록의 항목을 표시하는 RecyclerView이 포함되어 있다고 가정해 보겠습니다. 앱은 클릭 리스너 클래스도 정의합니다.

class ShoppingListItemListener(val clickListener: (itemId: Long) -> Unit) {
    fun onClick(cartItem: CartItem) = clickListener(cartItem.itemId)
}

데이터 바인딩에 ShoppingListItemListener를 제공하려면 어떻게 해야 하나요? 다음 중 하나를 선택하세요.

▢ 쇼핑 목록을 표시하는 RecyclerView가 포함된 레이아웃 파일에서 ShoppingListItemListener<data> 변수를 추가합니다.

▢ 쇼핑 목록의 단일 행 레이아웃을 정의하는 레이아웃 파일에서 ShoppingListItemListener<data> 변수를 추가합니다.

ShoppingListItemListener 클래스에서 데이터 결합을 사용 설정하는 함수를 추가합니다.

fun onBinding (cartItem: CartItem) {dataBindingEnable(true)}

ShoppingListItemListener 클래스의 onClick() 함수 내에서 데이터 결합을 사용 설정하는 호출을 추가합니다.

fun onClick(cartItem: CartItem) = { 
    clickListener(cartItem.itemId)
    dataBindingEnable(true)
}

질문 2

RecyclerView의 항목이 클릭에 반응하도록 하려면 android:onClick 속성을 어디에 추가하나요? 해당하는 항목을 모두 선택해 주세요.

RecyclerView를 표시하는 레이아웃 파일에서 <androidx.recyclerview.widget.RecyclerView>에 추가합니다.

▢ 관련 행에 있는 항목의 레이아웃 파일에 추가합니다. 전체 항목을 클릭 가능하게 하려면 관련 행에 있는 항목이 포함된 상위 뷰에 추가합니다.

▢ 관련 행에 있는 항목의 레이아웃 파일에 추가합니다. 항목에서 단일 TextView를 클릭 가능하게 만들려면 <TextView>에 추가합니다.

▢ 항상 MainActivity의 레이아웃 파일에 추가합니다.

다음 강의 시작: 7.5: RecyclerView의 헤더