본문 바로가기

안드로이드

[안드로이드] 예제: MVVM+AAC를 이용한 RecyclerView 7 - Retrofit을 통해 영화 정보를 얻어온 후 리사이클러뷰에 보여주기

개요

지난 문서까지 Todo리스트를 추가하였고, 잠깐 Retrofit 예제를 수행했다. 이번엔 둘을 결합하여 retrofit으로 영화 정보를 얻어온 뒤 리사이클러뷰에 뿌려주는 예제이다. 마찬가지로 livedata, databinding 등을 활용한다.

 

개발 과정

  1. Retrofit으로 데이터 얻어오기
  2. RecyclerView 관련 코드 수정 및 추가
  3. 그 외 소스 코드 수정

1. Retrofit으로 데이터 얻어오기

이전 문서에서 관련된 내용을 설명했으므로 자세한 설명은 생략한다.

 

MovieService 인터페이스

package com.example.sampleapp.retrofit

import androidx.lifecycle.LiveData
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
interface MovieService {
    @GET("movie/upcoming")
    fun getUpcomingMovie(
        @Query("api_key") api_key : String="API-KEY",
        @Query("language") language : String="en-US", //korean: ko-KR
        @Query("page") page : Int =1
    ): Call<UpComingMovie>
}

 

RetrofitAPI

package com.example.sampleapp.retrofit

import com.google.gson.GsonBuilder
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitAPI {
    private var instance : Retrofit? = null
    private val gson = GsonBuilder().setLenient().create()

    fun getInstnace() : Retrofit {
        if(instance == null){
            instance = Retrofit.Builder()
                .baseUrl("https://api.themoviedb.org/3/")
                .addConverterFactory(GsonConverterFactory.create())
                .build()
        }
        return instance!!
    }
}

 

DataModel

data class UpComingMovie(
    @SerializedName("page") val page: String,
    @SerializedName("results") val movieList: List<Movie>
)

data class Movie (
    @SerializedName("title") val title: String,
    @SerializedName("original_title") val original_title: String,
    @SerializedName("poster_path") val poster_path: String,
    @SerializedName("overview") val overview: String,
    @SerializedName("backdrop_path") val backdrop_path: String,
    @SerializedName("release_date") val release_date: String
)

 

Repository.kt 수정

package com.example.sampleapp


class Repository(application: Application) {
	...
    private val retrofit: Retrofit = RetrofitAPI.getInstnace()
    private val api = retrofit.create(MovieService::class.java)

	..
    
    fun getMovieData(): LiveData<List<Movie>> {
        val data = MutableLiveData<List<Movie>>()

        api.getUpcomingMovie().enqueue(object : Callback<UpComingMovie> {
            override fun onResponse(call: Call<UpComingMovie>, response: Response<UpComingMovie>) {
                data.value=response.body()!!.movieList
            }
            override fun onFailure(call: Call<UpComingMovie>, t: Throwable) {
                t.stackTrace
            }
        })
        return data
    }
}

d RetrofitAPI의 구현체를 이용하여 인터페이스의 getUpcomingMovie를 통해 UpComingMovie 객체를 얻어온 뒤, MutableLiveData<List<Movie>>movieList를 설정하여 반환한다. 이 함수는 후에 ViewModel에서 호출된다.


2. RecyclerView 관련 수정 및 추가

받아온 데이터를 리사이클러뷰에 보여주기 위한 ListAdpater를 구현한다. 또한 item_movie.xml 도 적절히 정의한다.

 

MovieAdapter.kt 

class MovieAdapter(val movieItemClick: (Movie) -> Unit, val movieItemLongClick: (Movie) -> Unit):
    ListAdapter<Movie, MovieAdapter.ViewHolder>(
        MovieDiffUtil
    ) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = DataBindingUtil.inflate<ItemMovieBinding>(layoutInflater,viewType,parent,false)
        return ViewHolder(binding)
    }

    override fun getItemViewType(position: Int): Int {
        return R.layout.item_todo
    }

    override fun getItemCount(): Int {
        return super.getItemCount()
    }


    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val movie = getItem(position)
        holder.bind(movie)
    }

    inner class ViewHolder(private val binding: ItemMovieBinding):RecyclerView.ViewHolder(binding.root){
        fun bind(movie: Movie) {
            binding.movie = movie
            binding.executePendingBindings() //데이터가 수정되면 즉각 바인딩

            binding.root.setOnClickListener {
                movieItemClick(movie)
            }
            binding.root.setOnLongClickListener {
                movieItemLongClick(movie)
                true
            }
        }
    }

    companion object MovieDiffUtil: DiffUtil.ItemCallback<Movie>(){
        override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
            //각 아이템들의 고유한 값을 비교해야 한다.
            return oldItem==newItem
        }

        override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
            return oldItem==newItem
        }
    }


}

기존의 TodoAdpater와 거의 변화가 없다. 다만 객체가 Todo에서 Movie로 바뀐 정도의 차이과 그 외 네이밍의 차이 뿐이다.

 

item_movie.xml 생성

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>
        <variable
            name="movie"
            type="com.example.sampleapp.retrofit.Movie" />
    </data>

    <androidx.cardview.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="4dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">
            <ImageView
                android:layout_width="50dp"
                android:layout_height="50dp"
                android:id="@+id/item_tv_initial"
                android:textSize="30dp"
                android:padding="4dp"
                android:background="@android:color/darker_gray"
                android:layout_marginTop="16dp"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="16dp"
                tools:text="H"
                app:imageUrl="@{movie.poster_path}"
                android:gravity="center"
                app:layout_constraintBottom_toBottomOf="parent"
                android:layout_marginBottom="16dp"/>

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/item_tv_title"
                android:textSize="20dp"
                android:textStyle="bold"
                app:layout_constraintStart_toEndOf="@+id/item_tv_initial"
                android:layout_marginStart="16dp"
                tools:text="@{movie.title}"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toTopOf="@+id/item_tv_descript"/>

            <TextView
                android:id="@+id/item_tv_descript"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                app:layout_constraintTop_toBottomOf="@+id/item_tv_title"
                app:layout_constraintStart_toStartOf="@+id/item_tv_title"
                android:layout_marginBottom="8dp"
                app:layout_constraintBottom_toBottomOf="parent"
                tools:text="@{movie.overview}"/>
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.cardview.widget.CardView>
</layout>

레이아웃은 그대로 사용한다. 데이터 바인딩을 위해 movie 변수를 추가하고 이미지뷰에는 포스터, 텍스트뷰에는 타이틀과 오버뷰값을 설정해주엇다.

 

ImageBindingAdapter.kt 수정

package com.example.sampleapp.adpater

object ImageBindingAdapter {
    @BindingAdapter("imageUrl")
    @JvmStatic
    fun loadImage(imageView: ImageView, url: String){
        val baseUrl = "https://image.tmdb.org/t/p/w500/"
        Glide.with(imageView.context).load(baseUrl+url).error(R.drawable.ic_launcher_background).into(imageView)
    }
}

Retrofit으로 받아온 포스터 경로는  baseUrl 변수 뒤에 붙여서 전달되어야 한다.

 


그 외 소스코드 수정

 

MainViewModel.kt 수정

class MainViewModel(application: Application): AndroidViewModel(application) {
	...
    private val movies = repository.getMovieData()

	...
    fun getMovieData():LiveData<List<Movie>>{
        return movies
    }
}

레포지토리에서 데이터를 얻어온다. getMovieData는 xml에서 bindingAdapter(databinding)를 통해 호출될 예정이다.

 

MainActivity.kt 수정

 private fun deleteDialog(todo: Movie) {
        val builder = AlertDialog.Builder(this)
        builder.setMessage("Delete selected contact?")
            .setNegativeButton("취소") { _, _ -> }
            .setPositiveButton("편집") { _, _ ->
                val intent = Intent(this, AddActivity::class.java)
                intent.putExtra(AddActivity.EXTRA_TODO_TITLE, todo.title)
                //intent.putExtra(AddActivity.EXTRA_TODO_DESC, todo.description)
                //intent.putExtra(AddActivity.EXTRA_TODO_ID, todo.id)
                startActivity(intent)
            }.setNeutralButton("삭제"){_, _ ->
                //lifecycleScope.launch(Dispatchers.IO){viewModel.delete(todo)}
            }
        builder.show()
    }

    private fun setRecyclerView(){
        // Set contactItemClick & contactItemLongClick lambda
        val adapter =
            MovieAdapter({ movie -> deleteDialog(movie) },
                { movie -> deleteDialog(movie) })

        binding.recyclerView.adapter=adapter
        binding.recyclerView.setHasFixedSize(true)
        //viewModel.logMovieData()
    }

두 함수만 수정해준다. deleteDialog 함수는 어색함이 있지만 다음 문서에서 처리할 예정이다. 그리고 어답터는 TodoAdpater에서 MovieAdpater로 바뀌었다.

 

activity_main.xml 수정

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            app:layout_constraintBottom_toTopOf="@+id/main_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textview"
            app:listData="@{viewModel.movieData}"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            tools:listitem="@layout/item_todo" />

RecyclerView의 listData만 수정해준다. 기존의 <List<todo>>에서 todo가 movie로 바뀐 결과를 가져온다.

 

 

RecyclerViewBindingAdapter.kt 수정

object RecyclerViewBindingAdapter {
    @BindingAdapter("listData")
    @JvmStatic
    fun BindData(recyclerView: RecyclerView, movies: List<Movie>?){
        val adapter = recyclerView.adapter as MovieAdapter
        adapter.submitList(movies)
    }
}

xml에 movie 리스트 전달을 위해 Adater를 MovieAdapter로 바꾸어주고, 전달받는 매개변수도 movie 리스트로 매개 변수도 바꾸어 주었다.


마무리

이를 통해 아래와 같은 결과 화면을 볼 수 있다. 이제 추가할 부분은 탭을 두개로 나누어 하나는 Upcoming 영화목록을 보여주고 하나는 보고싶은 영화목록을 보여주는 탭을 구현할 예정이다. Upcoming 영화 목록 중 아이템을 선택하면 Room DB에 내용이 저장되어 두번째 탭인 보고싶은 영화목록에 나타난다. 이 탭을 두개로 나누는 과정은 이전에 학슴한 navigation 컴포넌트를 활용할 예정이다. 추가로 아래 오버뷰 텍스트를 보면 우측에 글자가 잘린 모습이다. 이 또한 수정할 예정이다. 그리고 더 많은 영화 목록을 보여주기 위해 무한 스크롤도 구현할 것이다.

 


참고1. https://acaroom.net/ko/blog/youngdeok/%EC%97%B0%EC%9E%AC-%EC%BD%94%ED%8B%80%EB%A6%B0-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-02-5%EB%8B%A8%EA%B3%84-retrofit%EC%9D%98-rest-api%EC%9D%98-%EC%B2%98%EB%A6%AC
참고 2. https://proandroiddev.com/mvvm-architecture-viewmodel-and-livedata-part-1-604f50cda1