본문 바로가기

프로그래밍/영화 TMDB API

영화 정보 앱 만들기 - TMDB API 사용, 검색 기능

반응형

영화/TV 검색 기능

 

완성하면 아래 앱이 됩니다.

https://play.google.com/store/apps/details?id=com.enigmah2k.movieinfo

 

영화정보 - Google Play 앱

영화 또는 TV 시리즈 정보를 검색할 수 있습니다. TMDB API를 사용하여 만들었습니다. 한국에 소개되지 않은 컨텐츠를 찾을 수 있습니다.

play.google.com

 

Search API를 사용하여 영화와 TV 정보를 검색하는 기능을 구현해 보겠습니다.

아쉬운 점은 영어 검색만 가능합니다.

 

이미 Movie와 TV 정보를 가져오는 기능을 구현해 놓았으므로 최대한 활용해 보겠습니다.

이미 만들어져 있는 NotificationsFragment 에 검색 기능을 구현하겠습니다.

DashboardFragment 는 나중에 API 사용법을 테스트 할 때 임시로 결과값을 받는 화면으로 활용하겠습니다.

 

 

1. Api.kt 에 사용 API 추가

원래는 개별 결과 아이템 Class 를 만들고, 결과 전체를 받는 Class를 먼저 만든 다음 API를 추가했지만,

이미 Movie.kt 와 TV.kt 를 만들었고, GetMoviesResponse.kt 와 GetTVResponse.kt가 만들어져 있는 상태이므로,

search API 만 추가하면 됩니다.

 

추후 다른 API를 사용할 때 결과값 구성이 다른 형태로 사용하려면 결과 Class를 따로 만들면 됩니다.

 

Search API 코드입니다.

    @GET("search/movie")
    fun getSearchMovies(
        @Query("api_key") apiKey: String = "348eefabae0631d8003f24551c45a05c",
        @Query("page") page : Int,
        @Query("query") query : String,
        @Query("language") language : String = "ko,en-US"
    ): Call<GetMoviesResponse>

    @GET("search/tv")
    fun getSearchTV(
        @Query("api_key") apiKey: String = "348eefabae0631d8003f24551c45a05c",
        @Query("page") page : Int,
        @Query("query") query : String,
        @Query("language") language : String = "ko,en-US"
    ): Call<GetTVResponse>

 

 

2. getSearchMovies() 함수 추가

 

MoviesRepository.kt 에 추가한 API를 호출하는 함수를 추가합니다.

fun getSearchMovies( page: Int = 1, query: String,
                           onSuccess: (movies: List<Movie>) -> Unit,
                           onError: () -> Unit ) {
        api.getSearchMovies(page = page, query = query)
            .enqueue(object : Callback<GetMoviesResponse> {
                override fun onResponse(
                    call: Call<GetMoviesResponse>,
                    response: Response<GetMoviesResponse>
                ) {
                    if (response.isSuccessful) {
                        val responseBody = response.body()

                        if (responseBody != null) {
                            onSuccess.invoke(responseBody.movies)
                        } else {
                            onError.invoke()
                        }
                    } else {
                        onError.invoke()
                    }
                }

                override fun onFailure(call: Call<GetMoviesResponse>, t: Throwable) {
                    onError.invoke()
                }
            })
    }

 

차이점은 검색 keyword를 받은 query 파라메터를 추가하였습니다.

나머지는 동일합니다.

 

 

3. getSearchTV() 함수 추가

 

TVRepository.kt 에 추가한 API를 호출하는 함수를 추가합니다.

    fun getSearchTV(page: Int = 1, query: String,
                      onSuccess: (tvlist: List<TV>) -> Unit,
                      onError: () -> Unit){
        api.getSearchTV(page = page, query = query)
            .enqueue(object : Callback<GetTVResponse> {
                override fun onResponse(
                    call: Call<GetTVResponse>,
                    response: Response<GetTVResponse>
                ) {
                    if(response.isSuccessful) {
                        val responseBody = response.body()
                        if(responseBody != null) {
                            onSuccess.invoke(responseBody.tvlist)
                        } else {
                            onError.invoke()
                        }
                    } else {
                        onError.invoke()
                    }
                }

                override fun onFailure(call: Call<GetTVResponse>, t: Throwable) {
                    onError.invoke()
                }
            })
    }

 

Movie와 마찬가지로 차이점은 검색 keyword를 받은 query 파라메터를 추가하였습니다.

나머지는 동일합니다.

 

 

4. Layout 구성

fragment_notifications.xml 에 아래와 같이 Layout을 구성합니다.

    <LinearLayout
        android:id="@+id/linearLayout2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintTop_toTopOf="parent"
        tools:layout_editor_absoluteX="1dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="검색어"
            android:textAlignment="center"
            android:textSize="20sp" />

        <EditText
            android:id="@+id/eSearchWord"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:ems="10"
            android:inputType="textPersonName"
            android:text="" />

        <Button
            android:id="@+id/bSearch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="검색" />
    </LinearLayout>


    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintTop_toBottomOf="@+id/linearLayout2">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:text="Movie"
            android:textSize="18sp"
            android:textStyle="bold" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:text="영화 검색 결과" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/search_movies"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:clipToPadding="false"
            android:paddingStart="16dp"
            android:paddingEnd="16dp" />

    </LinearLayout>

    <LinearLayout
        android:id="@+id/linearLayout3"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintTop_toBottomOf="@+id/linearLayout">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="16dp"
            android:text="TV"
            android:textSize="18sp"
            android:textStyle="bold" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:text="TV 검색 결과" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/search_tv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:clipToPadding="false"
            android:paddingStart="16dp"
            android:paddingEnd="16dp" />

    </LinearLayout>

 

키워드 받는 부분을 상단에 배치 하였고, 

결과 표시는 기존과 동일하게 RecyclerView로 구성하였습니다.

 

 

5. Search 기능 구현

NotificationsFragment.kt 에서 검색어를 받아 Movie 와 TV 정보를 호출하는 기능을 구현합니다.

 

class NotificationsFragment : Fragment() {
    lateinit var root : View

    var searchKeyword = ""

    private lateinit var searchMovies: RecyclerView
    private lateinit var searchMoviesAdapter: MoviesAdapter
    private lateinit var searchMoviesLayoutMgr: LinearLayoutManager
    private var searchMoviesPage = 1

    private lateinit var searchTV: RecyclerView
    private lateinit var searchTVAdapter: TVAdapter
    private lateinit var searchTVLayoutMgr: LinearLayoutManager
    private var searchTVPage = 1

    override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle? ): View? {

        root = inflater.inflate(R.layout.fragment_notifications, container, false)

        searchMovies = root.findViewById(R.id.search_movies)
        searchMoviesLayoutMgr = LinearLayoutManager(
            context,
            LinearLayoutManager.HORIZONTAL,
            false
        )
        searchMovies.layoutManager = searchMoviesLayoutMgr
        searchMoviesAdapter = MoviesAdapter(mutableListOf()) { movie -> showMovieDetails(movie) }
        searchMovies.adapter = searchMoviesAdapter

        searchTV = root.findViewById(R.id.search_tv)
        searchTVLayoutMgr = LinearLayoutManager(
            context,
            LinearLayoutManager.HORIZONTAL,
            false
        )
        searchTV.layoutManager = searchTVLayoutMgr
        searchTVAdapter = TVAdapter(mutableListOf()) { tv -> showTVDetails(tv) }
        searchTV.adapter = searchTVAdapter

        root.bSearch.setOnClickListener() {
            searchKeyword = root.eSearchWord.text.toString()

            if(searchKeyword == "") {
                Toast.makeText(activity, "input keyword", Toast.LENGTH_SHORT).show()
            } else {
                Toast.makeText(activity, searchKeyword, Toast.LENGTH_SHORT).show()
                getSearchMovies()
                getPopularTV()
            }
        }

        return root
    }


    private fun getSearchMovies() {
        MoviesRepository.getSearchMovies(
            searchMoviesPage,
            searchKeyword,
            ::onSearchMoviesFetched,
            ::onError
        )
    }

    private fun attachSearchMoviesOnScrollListener() {
        searchMovies.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                val totalItemCount = searchMoviesLayoutMgr.itemCount
                val visibleItemCount = searchMoviesLayoutMgr.childCount
                val firstVisibleItem = searchMoviesLayoutMgr.findFirstVisibleItemPosition()

                if (firstVisibleItem + visibleItemCount >= totalItemCount / 2) {
                    searchMovies.removeOnScrollListener(this)
                    searchMoviesPage++
                    getSearchMovies()
                }
            }
        })
    }

    private fun onSearchMoviesFetched(movies: List<Movie>) {
        searchMoviesAdapter.appendMovies(movies)
        attachSearchMoviesOnScrollListener()
    }


    private fun getPopularTV() {
        TVRepository.getSearchTV(
            searchTVPage,
            searchKeyword,
            ::onSearchTVFetched,
            ::onError
        )
    }
    private fun onSearchTVFetched(tvlist: List<TV>) {
        searchTVAdapter.appendTV(tvlist)
        attachSearchTVOnScrollListener()
    }


    private fun attachSearchTVOnScrollListener() {
        searchTV.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                val totalItemCount = searchTVLayoutMgr.itemCount
                val visibleItemCount = searchTVLayoutMgr.childCount
                val firstVisibleItem = searchTVLayoutMgr.findFirstVisibleItemPosition()

                if (firstVisibleItem + visibleItemCount >= totalItemCount / 2) {
                    searchTV.removeOnScrollListener(this)
                    searchTVPage++
                    getPopularTV()
                }
            }
        })
    }


    private fun onError() {
        Toast.makeText(activity, "error error", Toast.LENGTH_SHORT).show()
    }

    private fun showMovieDetails(movie: Movie) {
        val intent = Intent(activity, MovieDetailsActivity::class.java)
        intent.putExtra(MainActivity.MOVIE_BACKDROP, movie.backdrop_path)
        intent.putExtra(MainActivity.MOVIE_POSTER, movie.poster_path)
        intent.putExtra(MainActivity.MOVIE_TITLE, movie.title)
        intent.putExtra(MainActivity.MOVIE_RATING, movie.rating)
        intent.putExtra(MainActivity.MOVIE_RELEASE_DATE, movie.releaseDate)
        intent.putExtra(MainActivity.MOVIE_OVERVIEW, movie.overview)
        startActivity(intent)
    }

    private fun showTVDetails(tv: TV) {
        val intent = Intent(activity, MovieDetailsActivity::class.java)
        intent.putExtra(MainActivity.MOVIE_BACKDROP, tv.backdrop_path)
        intent.putExtra(MainActivity.MOVIE_POSTER, tv.poster_path)
        intent.putExtra(MainActivity.MOVIE_TITLE, tv.name)
        intent.putExtra(MainActivity.MOVIE_RATING, tv.rating)
        intent.putExtra(MainActivity.MOVIE_RELEASE_DATE, tv.first_air_date)
        intent.putExtra(MainActivity.MOVIE_OVERVIEW, tv.overview)
        startActivity(intent)
    }
}

 

 

검색 키워드를 받아 영화 정보와 TV 정보를 조회를 합니다.

 

 

6. 구색맞춤

탭 아이콘과 이름을 수정합니다.

 

String 수정

<string name="title_notifications">Notifications</string> 를 <string name="title_notifications">Search</string> 로 변경합니다.

 

Icon 추가

File > New > Vector Asset 을 선택하여 search 아이콘을 선택하고,

ic_search_black_24dp 로 Name을 정합니다.

 

bottom_nav_menu.xml 파일에서

ic_notifications_black_24dp 를 ic_search_black_24dp 로 수정합니다.

 

 

실행해 보면 아래와 같이 잘 실행 됩니다.

 

 

 

키워드는 필수 이므로 값을 꼭 기입해야 합니다.

아쉽지만 검색은 영어만 가능합니다.

 

 

그런데 한번 검색을 하고 다른 키워드로 검색을 하게 되면 Adapter 정보가 갱신이 되지 않아서 변화가 없습니다. 

그래서 Adapter 에 remove 함수를 추가합니다.

 

MoviesAdapter.kt

    fun removeMovies(movies: List<Movie>) {
        this.movies.removeAll(movies)
    }

 

TVAdapter.kt

    fun removeTV(tvlist: List<TV>) {
        this.tvlist.removeAll(tvlist)
    }

 

그리고 NotificationsFragment.kt에 아래 함수를 추가하여 getSearchMovies() 실행전에 실행해 줍니다.

    private fun removeData() {
        searchMovies.removeAllViews()
        searchTV.removeAllViews()

        searchMoviesAdapter.removeMovies(searchMoviesAdapter.movies)
        searchMoviesAdapter.notifyDataSetChanged()

        searchTVAdapter.removeTV(searchTVAdapter.tvlist)
        searchTVAdapter.notifyDataSetChanged()
    }

 

새로운 키워드로 검색하면 화면이 변경되는 것을 확인할 수 있습니다.

 

 

이것으로 기본 구성은 마치려고 합니다.

다음은 API 사용법을 설명해 보겠습니다.

100%는 아니어도 최대한 많이 살펴볼 예정입니다.

 

TMDB API 를 활용한 Android 앱 만들기

https://stockant.tistory.com/530

 

 

반응형