[연재] 코틀린 프로젝트 - 02 - 6단계: Infinite Scroll과 UI의 개선

Infinite Scroll은 데이터가 무한하게 지속될 때 한꺼번에 모든 것을 로딩 할 수 없으므로 보여지는 일정량만 로드 했다가 RecyclerView를 스크롤 하는 동작에 따라 동적으로 새로운 데이터를 지속적으로 보여 주는 것입니다. 새로운 데이터를 계속 스크롤 할 수 있으므로 '무한 스크롤'이라는 이름을 가지게 되었습니다. 

Scroll Listener의 설계

1. 이제 새롭게 스크롤 리스너 클래스를 만들고 RecyclerView의 OnScrollListener를 구현할 수 있게 합니다. 패키지 ui/ 하위에 InfiniteScrollListener.kt 파일을 만들고 다음과 같이 작성합니다.

무한 스크롤을 위한 클래스 생성 ui/InfiniteScrollListener.kt
...
class InfiniteScrollListener(
        val func: () -> Unit,
        val layoutManager: LinearLayoutManager) : RecyclerView.OnScrollListener(), AnkoLogger {

    private var previousTotal = 0
    private var loading = true
    private var visibleThreshold = 2
    private var firstVisibleItem = 0
    private var visibleItemCount = 0
    private var totalItemCount = 0

    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        if (dy > 0) {
            visibleItemCount = recyclerView.childCount
            totalItemCount = layoutManager.itemCount
            firstVisibleItem = layoutManager.findFirstVisibleItemPosition()

            if (loading) {
                if (totalItemCount > previousTotal) {
                    loading = false
                    previousTotal = totalItemCount
                }
            }
            if (!loading && (totalItemCount - visibleItemCount)
                    <= (firstVisibleItem + visibleThreshold)) {
                // 끝에 도달 했을 때
                info("Scroll end reached!") // Anko의 로그 기록용 함수
                func() // 매개변수로 넘겨받은 람다식 함수
                loading = true
            }
        }
    }

}

먼저 첫번째 매개변수로 람다식 함수를 사용할 수 있도록 했습니다. 이 클래스는 RecyclerView가 로드된 아이템의 제일 마지막에 도달할 때 호출됩니다. 다시 아이템이 채워지면 다시 도달할 때까지 대기하며 다시 마지막으로 도달하면 호출되며 다시 반복하게 됩니다. 마지막 아이템은 항상 ProgressBar에 의해 무언가 진행되고 있음을 보여 줄 필요가 있습니다.

OnScrollListener는 RecyclerView의 추상 클래스이며 본문에서 onScrolled() 메서드를 오버라이드 해 구현부에서 func()를 사용했습니다. 

2. 먼저 MovieManager를 변경해 page와 함께 넘길 수 있도록 수정하겠습니다. 

MovieManager의 수정 ui/MovieManager.kt
...
class MovieManager(private val api: RestApi = RestApi()) {
    fun getMovieList(page: String): Observable<MovieList> { // (1) page가 포함된 형식으로 변경
        return Observable.create { subscriber ->
            val param = mapOf(
                "page" to page, // (2) 페이지를 매개변수로부터 받기
                "api_key" to API_KEY,
                "sort_by" to "popularity.desc",
                "language" to "ko"
            )
            val call = api.getMovieListRetrofit(param)
            val response = call.execute()

            if (response.isSuccessful) {
                val movieListResults = response.body()?.results?.map { // (3) results를 가리키는 이름 변경
                    MovieItem(
                        it.vote_count,
                        it.vote_average,
                        it.title,
                        it.release_date,
                        it.poster_path,
                        it.overview
                        )
                }
                if (movieListResults != null) {
                    val resonsePage = response.body()?.page?.plus(1) // (4) page를 하나 더해 얻기
                    val movieList = MovieList(resonsePage, movieListResults) // (5) page + results

                    subscriber.onNext(movieList) // (6) page가 포함된 데이터를 넣고 이벤트 발생
                }
                subscriber.onComplete()
            } else {
                subscriber.onError(Throwable(response.message()))
            }
        }
    }
}

(1)번에서 기존에 results만 넘기기 위한 List를 page가 포함된 자료형을 넘기도록 바꿉니다. 각각 (2)번과 (3)번을 바꾸고 하위의 관련 이름들을 바꿉니다. (4)번의 내용을 추가해 page를 얻어오며 (5)번에서 page가 포함된 movieList를 (6)번의 onNext()에 지정합니다. 

3. MovieFragment 에서 스크롤 리스너를 설정하도록 다음을 추가 및 수정합니다. 

MovieFragment의 스크롤 리스너 추가 ui/MovieFragment.kt
...
class MovieFragment : RxBaseFragment() {

    private val movieManager by lazy { MovieManager() }
    private var theMovieList: MovieList? = null // (1)
...
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
...
        // RecyclerView의 리소스 id
        rv_movie_list.apply {
            setHasFixedSize(true)
            val linearLayout = LinearLayoutManager(context)
            layoutManager = linearLayout
            clearOnScrollListeners() // (2)
            addOnScrollListener(InfiniteScrollListener({ requestMovie() }, linearLayout)) // (3)
        }

        //  어댑터의 연결
        if (rv_movie_list.adapter == null) {
            rv_movie_list.adapter = MovieAdapter()
        }
...
        requestMovie()
    }

    private fun requestMovie() {
        val subscription = movieManager.getMovieList((theMovieList?.page).toString()) // (4)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread()) 
            .subscribe (
                { retrievedMovie ->
                    theMovieList = retrievedMovie // (5)
                    (rv_movie_list.adapter as MovieAdapter).addMovieList(retrievedMovie.results) // (6)
                },
                { e ->
                    rv_movie_list.snackbar(e.message ?: "")
                }
            )
        subscriptions.add(subscription)
    }
}

다음 페이지를 넘겨줄 수 있도록 (1)번에서 theMovieList변수를 만들고 (4)번에서 인자로 MovieManager의 getMovieList()에 넘겨줄 것입니다. (5)번에서 retrievedMovie는 page와 함께 넘어온 MovieList 객체이므로 (6)번과 같이 results를 읽습니다. (2)번과 (3)번을 추가해 아이템이 끝까지 도달 되었을 때 다시 requestMovie()가 호출되도록 해서 무한하게 스크롤이 가능한 상태가 되었습니다. 

이제 다시 실행을 해보면 20개의 영화 정보 아이템이 넘어갈 때 그 다음 페이지의 20개 아이템이 로드되는 것을 알 수 있습니다. 

Orientation의 변경과 상태 저장

안드로이드는 기기의 가로와 세로가 변경되면 센서에 의해 뷰를 다시 그려 구성합니다. 대부분 원래의 화면을 없애고 새롭게 다시 그려지는 것입니다. 이 때 화면상에 보여주던 데이터를 저장해 두지 않으면 화면이 변경 되었을 때마다 새롭게 데이터가 보이면 상당히 불편할 것입니다. 

이전 상태를 저장하기 위해 여러가지 기법을 사용할 수 있습니다. 데이터 클래스의 내용을 특정 저장 용도의 요소에 넣어 두는 것입니다. 바로 안드로이드의 onSaveInstanceState 에 저장해 두었다가 화면이 바뀌거나 파괴 되었을 때 다시 불러들일 수 있습니다. 

보통 Back 키를 누르게 되면 앱을 완전 종료하게 되며, Home 키를 누르면 현재 앱이 단순히 런처 뒤로 가기 때문에 계속 살아 있는 상태가 됩니다. 이 때 메모리가 충분한 기기라면 안드로이드 프레임워크에서 항상 데이터를 남겨두며 호출될 것입니다. 다만 메모리가 부족하면 이것은 보장되지 않습니다.  Home키를 사용하더라도 리소스가 부족하면 해당 앱이 완전 종료되어 상태가 없어지기 때문입니다.

1. 먼저 lazy를 위한 옵션인 NONE을 사용해 편하게 지연 초기를 사용할 수 있도록 다음 파일에 추가합니다. 

lazy의 옵션을 미리 지정하기 위한 래퍼 함수 utils/Extensions.kt
...
/**
 * LazyThreadSafetyMode는 세가지로 SYNCHRONIZED, PUBLICATION, NONE 이 있습니다.
 * SYNCHRONIZED: 락을 사용해 동기화함 (성능 저하 하지만 다중 스레드에 안전함)
 * PUBLICATION: 읽기를 주로 하는 경우 다중 접근 (성능 높임 및 안전)
 * NONE: 누구나 접근하므로 스레드에 안전하지 않음 (성능 높음)
 */
fun <T> androidLazy(initializer: () -> T) : Lazy<T> = lazy(LazyThreadSafetyMode.NONE, initializer)

안드로이드에서 MainThread에서 구동되 생성되는 객체가 스레드의 안전이 자체적으로 보장된다고 할 때 LazyThreadSafetyMode.NONE 옵션을 사용하면 성능 오버헤드 없이 사용할 수 있습니다. 

2. 이제 상태 저장을 위해 MovieFragment 클래스를 다음과 같이 추가 변경하여 저장할 수 있도록 합니다. 

상태저장을 위한 수정 변경 ui/MovieFragment.kt
...
class MovieFragment : RxBaseFragment() {

    private val movieManager by lazy { MovieManager() }
    private var theMovieList: MovieList? = null
    private val movieAdapter by androidLazy { MovieAdapter() } // (1)

    companion object { // (2) 상태 저장을 위한 변수
        private val KEY_THE_MOVIE = "theMoviePopular"
    }
...
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
...
        //  어댑터의 연결
        if (rv_movie_list.adapter == null) {
            rv_movie_list.adapter = movieAdapter // (3)
        }

        // (4) 상태 저장이 있을 때 데이터 구성 
        if (savedInstanceState != null && savedInstanceState.containsKey(KEY_THE_MOVIE)) {
            theMovieList = savedInstanceState.get(KEY_THE_MOVIE) as MovieList
            movieAdapter.clearAndAddMovieList(theMovieList!!.results)
        } else {
            requestMovie()
        }
    }

    override fun onSaveInstanceState(outState: Bundle) { // (5)
        super.onSaveInstanceState(outState)
        val movie = movieAdapter.getMovieList()
        if (theMovieList != null && movie.isNotEmpty()) {
            outState.putParcelable(KEY_THE_MOVIE, theMovieList?.copy(results = movie)) // (6)
        }
    }

    private fun requestMovie() {
        ...
    }
} 

(1)번의 movieAdapter는 래퍼 함수에 의해 NONE 옵션을 가지는 lazy로 선언되었습니다. (2)번의 컴페니언 객체인 KEY_THE_MOVIE는 상태 저장에 사용할 키 변수 입니다. 어댑터 설정을 (3)번과 같이 lazy로 정의된 변수로 초기화 했습니다. (4)번에서 상태 저장이 있을 때는 기존 상태를 가져와 기존 상태의 데이터로 어댑터에 연결합니다. (5)번에서는 Bundle에 현재 상태를 저장하기 위해 (6)번에서 putParcelable을 사용해 outState에 담고 있습니다.  이때 키와 results의 목록을 지정합니다.

이제 실행을 하고 화면을 가로나 세로로 돌려 봅니다. 화면의 Orientation이 변경되면 화면이 다시 그려지고 기존의 상태 정보가 있을 경우 보던 아이템 위치가 제대로 나타나는 것을 확인할 수 있습니다. 

클릭 이벤트의 처리

이제 아이템을 클릭했을 때 다른 화면을 보여 줄 수 있도록 클릭 이벤트에 대한 처리를 추가해 보겠습니다. 

클릭 이벤트를 추가하기 ui/adapter/MovieItemAdapter.kt
...
class MovieItemAdapter(val viewActions: ViewSelectedListener) : ItemAdapter { // (1)

    interface ViewSelectedListener { // (2)
        fun onItemSelected(url: String?)
    }
...
    inner class MovieViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
            parent.inflate(R.layout.item_movie)) {
...
        fun bind(item: MovieItem) {
            imgPoster.loadImg("https://image.tmdb.org/t/p/w500/${item.poster_path}")
            overview.text = item.overview
            releaseDate.text = item.release_date
            voteCount.text = "${item.vote_count} 투표"
            tvTitle.text = item.title
            tvAverage.rating = item.vote_average / 2

            // (3) 아이템의 클릭 이벤트 - 이너클래스 이므로 viewActions은 바깥 클래스의 멤버이지만 접근 가능
            super.itemView.setOnClickListener {
                viewActions.onItemSelected("https://image.tmdb.org/t/p/w500/${item.poster_path}")
            }

            // (4) 영화 예약에 관련된 클릭 이벤트 처리
            btnReserve.setOnClickListener {
                it.snackbar("스낵바입니다.")
            }
        }
    }
}

(1)번과 같이 MovieItemAdapter에 클릭 이벤트 처리를 위한 (2)번의 ViewSelectedListener의 매개변수를 추가합니다. (3)번에서 이너클래스 내부에서 super에 있는 itemView에 클릭 이벤트 리스너를 설정하고 있습니다. 여기서는 바깥 멤버였던 ViewSelectedListener의 변수인 viewActions에 접근해 onItemSelected()를 호출합니다. (4)번은 예약을 위한 버튼을 눌렀을 때 이벤트 입니다. 여기서는 단지 해당 그림의 URL을 보여주거나 스낵바를 부르는 단순한 처리를 하고 있는데 향후 상세 영화 정보나 실제 예약할 수 있는 화면을 만들면 좋습니다.

이 프로젝트에서 예약 처리에 대한 부분은 독자들에게 맡기겠습니다. 

클릭 이벤트 부분 추가하기 MovieFragment.kt
...
class MovieFragment : RxBaseFragment(), MovieItemAdapter.ViewSelectedListener { // (1) 인터페이스 구현

    private val movieManager by lazy { MovieManager() }
    private var theMovieList: MovieList? = null
    private val movieAdapter by androidLazy { MovieAdapter(this) } // (2) 이벤트 처리 대상
...
    override fun onItemSelected(url: String?) { // (3) 이벤트 핸들러 구현
        if (url.isNullOrEmpty()) {
            rv_movie_list.snackbar("No URL assigned to this results")
        } else {
            val intent = Intent(Intent.ACTION_VIEW)
            intent.data = Uri.parse(url) // URL을 인텐트에 넣어 ACTION_VIEW에 의해 외부 앱 호출
            startActivity(intent) // (4) 전달 받은 URL을 보여줄 수 있는 외부 액티비티 시작
        }
    }
...
}

이제 실행 후 아이템과 예약 버튼을 눌러보고 클릭 이벤트가 잘 발생하는지 확인 합니다

 

 

youngdeok's picture

Language

Get in touch with us

"If you would thoroughly know anything, teach it to other."
- Tryon Edwards -