とっとこメモ

困った時のエンジニアメモ。Unity, Kotlin, Flutter などなど

【Kotlin】RecyclerViewで無限スクロールを実装する (APIからデータ取得編)

Koltin でRecyclerViewを使って無限スクールを実装する方法をメモります。

今回はAPIからデータを取得して表示する方法編です。

実際にはこちらの方がよく使うパターンだと思います。

gradleファイルの修正

以下のライブラリを追加します。

  • recyclerview:Recycler Viewを利用するため
  • retrofit: APIからHTTP経由でデータを取得するため
  • gson: JSONデータをパースするため
dependencies {
    ・・・
    implementation 'com.squareup.retrofit2:retrofit:2.0.2'
    implementation 'com.squareup.retrofit2:converter-gson:2.0.2'
    implementation 'androidx.recyclerview:recyclerview:1.1.0'
}

レイアウトファイルの追加

activity_main.xmlの修正

<?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/recyclerview"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

レコード用レイアウトファイル

row.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:background="@color/colorPrimaryDark"
    android:layout_height="44dp">

    <TextView
        android:id="@+id/textTitle"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="8dp"
        android:textStyle="bold"
        android:textSize="17sp"
        android:textColor="@android:color/white"
        android:gravity="center_vertical"
        android:text="Lorem Ipsum" />

    <TextView
        android:id="@+id/subtitle"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="8dp"
        android:textStyle="bold"
        android:textSize="17sp"
        android:textColor="@android:color/white"
        android:gravity="center_vertical"
        android:text="Lorem Ipsum" />
</LinearLayout>

ローディング表示用レコードのレイアウトファイル

データ読み込み中に一覧の一番下にローディング表示したレコードを表示させます。 そのレコードのレイアウトファイルです。

progressbar.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="horizontal">

        <ProgressBar
            android:id="@+id/progressbar"
            android:layout_width="24dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal" />
    </LinearLayout>

</LinearLayout>

Adapterファイルの作成

Adapterとは?

Viewとデータの橋渡しをするもの

RecyclerViewAdapter.kt

package com.example.android.sample.retrofitinfinite

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.row.view.*
import java.lang.IllegalArgumentException

class RecyclerViewAdapter(var list: ArrayList<Data>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>(){
    companion object{
        private const val VIEW_TYPE_DATA = 0
        private const val VIEW_TYPE_PROGRESS = 1
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when(viewType){
            VIEW_TYPE_DATA -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.row, parent, false)
                DataViewHolder(view)
            }
            VIEW_TYPE_PROGRESS -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.progressbar, parent, false)
                ProgressViewHolder(view)
            }
            else -> throw IllegalArgumentException("Different View type")
        }
    }

    override fun getItemCount(): Int {
        return list.size
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if(holder is DataViewHolder)
        {
            holder.textTitle.text = list.get(position).title
            holder.textSubtitle.text = list.get(position).subtitle
        }
    }

    inner class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
        var textTitle = itemView.textTitle
        var textSubtitle = itemView.subtitle
        init {
            itemView.setOnClickListener{
                Toast.makeText(itemView.context, list.get(adapterPosition).title, Toast.LENGTH_LONG).show()
            }
        }
    }
    inner class ProgressViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
    }

    override fun getItemViewType(position: Int): Int {
        var viewtype = list.get(position).category
        return when(viewtype){
            "data" -> VIEW_TYPE_DATA
            else -> VIEW_TYPE_PROGRESS
        }
    }
}

Dataクラスの作成

データクラスとは?

何もしない、データを保持するためだけのクラス。 Kotlinでは、これは データクラス と呼ばれ、 data としてマークされています。

1レコードごとのデータをここで定義する

Data.kt

package com.example.android.sample.retrofitinfinite

class Data(var category: String) {
    var title: String? = null //タイトル
    var subtitle: String? = null //サブタイトル
    init {
        this.category = category
    }
}

インターフェースの実装

Retrofit では API のリクエスト先のエンドポイントを Interface で定義します。

DataApi.kt

package com.example.android.sample.retrofitinfinite

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface DataApi {
    @GET("data.php")
    fun getData(@Query("index") index: Int): Call<List<Data>>
}

APIリクエストを生成するクラス

定義したインターフェースからAPIのリクエストを生成するクラスを作成する

RetrofitInstance.kt

package com.example.android.sample.retrofitinfinite

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class RetrofitInstance {
    companion object{
        fun getRetrofitInstance(): Retrofit{
            val retrofit = Retrofit.Builder().baseUrl("https://www.androidride.com/").addConverterFactory(
                GsonConverterFactory.create()
            ).build()
            return retrofit
        }
    }
}

MainActivity.tk の修正

必要なファイルは揃ったのでMainActivity.ktを修正します。

package com.example.android.sample.retrofitinfinite

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Adapter
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.activity_main.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit

class MainActivity : AppCompatActivity() {

    lateinit var list: ArrayList<Data>
    lateinit var adapter: RecyclerViewAdapter

    var notLoading = true

    lateinit var layoutManager: LinearLayoutManager
    lateinit var api: DataApi
    lateinit var retrofit: Retrofit

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        list = ArrayList()
        adapter = RecyclerViewAdapter(list)
        recyclerview.setHasFixedSize(true)

        layoutManager = LinearLayoutManager(this)
        recyclerview.layoutManager = layoutManager
        recyclerview.adapter = adapter

        retrofit = RetrofitInstance.getRetrofitInstance()
        api = retrofit.create(DataApi::class.java)

        load(0)

        addScrolllistener()
        


    }

    private fun addScrolllistener() {
        recyclerview.addOnScrollListener(object: RecyclerView.OnScrollListener(){
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if(notLoading && layoutManager.findLastCompletelyVisibleItemPosition() == list.size - 1){
                    list.add(Data("progress"))
                    adapter.notifyItemInserted(list.size - 1)
                    notLoading = false
                    val call: Call<List<Data>> = api.getData(list.size -1)
                    call.enqueue(object : Callback<List<Data>>{
                        override fun onResponse(
                            call: Call<List<Data>>?,
                            response: Response<List<Data>>?
                        ) {
                            list.removeAt(list.size - 1)
                            adapter.notifyItemRemoved(list.size)
                            if(response!!.body().isNotEmpty()){
                                list.addAll(response.body())
                                adapter.notifyDataSetChanged()
                                notLoading = true
                            } else {
                                Toast.makeText(applicationContext, "End of data reached", Toast.LENGTH_LONG).show()
                            }
                        }
                        override fun onFailure(call: Call<List<Data>>?, t: Throwable?) {
                        }
                    })
                }
            }
        })
    }

    private fun load(i: Int) {
        val call: Call<List<Data>> = api.getData(0)
        call.enqueue(object: Callback<List<Data>>{
            override fun onFailure(call: Call<List<Data>>?, t: Throwable?) {
            }

            override fun onResponse(call: Call<List<Data>>?, response: Response<List<Data>>?) {
                if(response!!.isSuccessful){
                    list.addAll(response!!.body())
                    adapter.notifyDataSetChanged()
                }
            }
        })
    }
}