59
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Kotlin + Realm + RecyclerViewを使ってTodoアプリを作る

Last updated at Posted at 2019-11-28

Realm を使ったシンプルなサンプルアプリを作ってみました。
最初にダミーデータを作成しRecyclerViewを使ってリスト表示します。
今回はセルがタップされたときにデータを削除し、メッセージをToastで表示するようにしました。

全てのソースコードはこちらにあります。(GitHub)

環境

  • Kotlin 1.3.50
  • Realm 5.15.2
  • realm-android-adapter 3.1.0
  • recyclerview-v7:29.1.0
  • cardview-v7:29.1.0
  • targetSDK: 29

手順

  1. Realmの初期設定
  2. CustomAdapterの作成
  3. データ作成と一覧表示
  4. Itemをクリックしたときの処理を追加する

1. Realm の初期設定

Realm 公式ドキュメントに乗っ取りながらやると良いと思います。
https://realm.io/docs/java/5.15.2/

build.gradle の dependencies に以下を追加

classpath "io.realm:realm-gradle-plugin:5.15.2"

app/build.gradle の上部に以下を追加

apply plugin: 'kotlin-kapt'
apply plugin: "realm-android"

app/build.gradleのdependenciesに以下を追加

implementation 'com.android.support:recyclerview-v7:29.1.0'
implementation 'com.android.support:cardview-v7:29.1.0'
implementation 'io.realm:android-adapters:3.1.0'

[補足] realm-android-adapters について

Realm公式が出しているAdapterのライブラリです。表示しているデータの中身が変わったときにViewを更新するためnotifyDataSetChanged()等を呼ぶことが多いですが、Realmを使う場合DBの内容が書き変わったことを受け取りView を更新しなければならず、処理が複雑になることが考えられます。

このライブラリを使うと、RealmのDB内が書き換わったときに自動でViewを更新してくれて便利なので、今回はこれを使用します。
(ちなみに、ListViewとRecyclerViewの2つに対応しています)

参考: realm/realm-android-adapter

Applicationクラスの作成

Applicationクラスを作成します。アプリを起動したときに実行するようにし、Realmのinitializeの処理を行います。
今回はアプリ名がRealmTodoなのでRealmTodoApplication.ktという名前で作成しました。

RealmTodoApplication.kt
class RealmTodoApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        Realm.init(this)
        val realmConfig = RealmConfiguration.Builder()
            .deleteRealmIfMigrationNeeded()
            .build()
        Realm.setDefaultConfiguration(realmConfig)
    }
}

※ すでにDBにデータがある状態でモデルとなるクラスを変更(例えばプロパティを追加など)した場合、アプリを削除し再インストールする必要が出てきて正直手間になります。そこで、RealmConfigurationにて.deleteRealmIfMigrationNeeded()を設定すると、もともとあったデータを全て削除してくれるようになりこの手間を省くことができるようになります。

参考: Migrations | Realm Java 5.15.2

また、AndroidManifest.xmlのタグにname属性を追加し、先ほど作成したApplicationクラスを指定します。

AndroidManifest.xml
    <application
        android:name=".RealmTodoApplication"
        /* 省略 */
    ></application>

</manifest>

※ここでApplicationクラスを確認するとクラス名が灰色になっており参照できていないように見えますが、実際はちゃんと参照できているのでこのままでOKです。

これでRealm関連の初期設定は完了です。

2. CustomAdapterの作成

リストのItemとなるレイアウトの作成

CardViewを使っていい感じのリストにします。

list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    card_view:cardElevation="2dp">

    <LinearLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:background="?android:attr/selectableItemBackground"
        android:clickable="true"
        android:focusable="true">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_margin="8dp"
            card_view:srcCompat="@mipmap/ic_launcher" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginBottom="8dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/contentTextView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="2"
                android:gravity="center_vertical"
                android:text="やること"
                android:textSize="18sp" />

            <TextView
                android:id="@+id/dateTextView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                android:gravity="center_vertical|end"
                android:text="2019/11/20 12:00:00" />

        </LinearLayout>

    </LinearLayout>
</androidx.cardview.widget.CardView>

モデルの作成

RealmObjectを継承したモデルを作成します。@PrimaryKeyというアノテーションをつけると一意なプロパティとして定義することができ、その値をUUID.randomUUID().toString()とすると一意なIDを生成してくれます。(他にもAutoIncrementする方法もあります。)

参考: Realmで一意なPrimaryKeyを設定する方法二つ

Task.kt
open class Task(
    @PrimaryKey open var id: String = UUID.randomUUID().toString(),
    open var imageId: Int = 0,
    open var content: String = "",
    open var createdAt: Date = Date(System.currentTimeMillis())
) : RealmObject()

CustomAdapterの作成

ここでRealmRecyclerViewAdapterを継承したCustomAdapterを作成します。
タスクのリストを表示するのでTaskAdapter.ktとしました。

TaskAdapter.kt
class TaskAdapter(
    private val context: Context,
    private var taskList: OrderedRealmCollection<Task>?,
    private val autoUpdate: Boolean
) :
    RealmRecyclerViewAdapter<Task, TaskAdapter.TaskViewHolder>(taskList, autoUpdate) {

    override fun getItemCount(): Int = taskList?.size ?: 0

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task: Task = taskList?.get(position) ?: return

        holder.imageView.setImageResource(task.imageId)
        holder.contentTextView.text = task.content
        holder.dateTextView.text =
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPANESE).format(task.createdAt)

    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): TaskViewHolder {
        val v = LayoutInflater.from(context).inflate(R.layout.list_item, viewGroup, false)
        return TaskViewHolder(v)
    }

    class TaskViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val imageView: ImageView = view.imageView
        val contentTextView: TextView = view.contentTextView
        val dateTextView: TextView = view.dateTextView
    }

}

autoUpdateというパラメータをtrueにして渡すと、DBを更新した際にRecyclerViewを自動で更新してくれるようになります。
またSimpleDateFormatを使うと、Date型を指定フォーマットでStringに変換できます。
参考: SimpleDateFormat | Android Developers

これでCustomAdapter周りも完了です。

3. データ作成と一覧表示

次に、Realmを用いたDBへの書き込みと読み込み、リストへの一覧表示を実装します。今回は一旦ダミーデータを10件作成し、そのリストを表示するような処理にしました。

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>

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val realm: Realm by lazy {
        Realm.getDefaultInstance()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val taskList = readAll()

        // タスクリストが空だったときにダミーデータを生成する
        if (taskList.isEmpty()) {
            createDummyData()
        }

        val adapter = TaskAdapter(this, taskList, true)

        recyclerView.setHasFixedSize(true)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter

    }

    override fun onDestroy() {
        super.onDestroy()
        realm.close()
    }

    fun createDummyData() {
        for (i in 0..10) {
            create(R.drawable.ic_launcher_background, "やること $i")
        }
    }

    fun create(imageId: Int, content: String) {
        realm.executeTransaction {
            val task = it.createObject(Task::class.java, UUID.randomUUID().toString())
            task.imageId = imageId
            task.content = content
        }
    }

    fun readAll(): RealmResults<Task> {
        return realm.where(Task::class.java).findAll().sort("createdAt", Sort.ASCENDING)
    }

}

realmの変数ですが不変なオブジェクトとして扱いたかった(valで定義したかった)ので、lazyという委譲プロパティを使って遅延初期化をしています。これはこの変数が最初に参照されたときに{}内の値をsetして初期化します(2回目以降の参照では最初にsetした値を参照します)。
参考: 委譲プロパティ - Kotlin Programming Language

readAll()の中で.sort("createdAt", Sort.ASCENDING)をして作成したときの時刻を基準に昇順で取得しています。これは要素の削除をしたときに、リストの順番がバラバラになってしまう仕様を回避するためにこの実装にしました。
参考: Realm: Order of records was changed - Stack Overflow

また、create() 内のcreateObject()ですが、1つ目の引数にモデル、2つ目の引数に@PrimaryKeyで設定したプロパティの値を代入して生成します。(これがないと落ちます)
createObject()以外にもcopyToRealm()insert()などがあります。
参考: Creating objects | Realm Java 5.15.2

実行すると以下のようになります。(DB内に保存しているので、アプリを終了し再起動しても見えるようになっていると思います。)

4. Itemをクリックしたときの処理を追加する

セルをタップしたときの処理を書いていきます。簡単な処理であればCustomAdapter側に直接記述すれば良いと思いますが、今回はRealmの処理がActivityに依存しているため、CustomAdapterからActivity内の処理(編集、削除など)を呼び出したいです。

そこで、今回はClickListenerとなるinterfaceを定義し、そのインスタンスをActivityからAdapterに渡すことで実現します。

TaskAdapter.kt
class TaskAdapter(
    private val context: Context,
    private var taskList: OrderedRealmCollection<Task>?,
    private var listener: OnItemClickListener, // ---------追加----------
    private val autoUpdate: Boolean
) :
    RealmRecyclerViewAdapter<Task, TaskAdapter.TaskViewHolder>(taskList, autoUpdate) {

    override fun getItemCount(): Int = taskList?.size ?: 0

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val task: Task = taskList?.get(position) ?: return

        // ----------------追加---------------
        holder.container.setOnClickListener{
            listener.onItemClick(task)
        }
        // -----------------------------------
        holder.imageView.setImageResource(task.imageId)
        holder.contentTextView.text = task.content
        holder.dateTextView.text =
            SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.JAPANESE).format(task.createdAt)

    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): TaskViewHolder {
        val v = LayoutInflater.from(context).inflate(R.layout.list_item, viewGroup, false)
        return TaskViewHolder(v)
    }

    class TaskViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val container : LinearLayout = view.container  // ---------追加----------
        val imageView: ImageView = view.imageView
        val contentTextView: TextView = view.contentTextView
        val dateTextView: TextView = view.dateTextView
    }

    // ----------------追加---------------
    interface OnItemClickListener {
        fun onItemClick(item: Task)
    }
    // -----------------------------------
}

MainActivity.kt
class MainActivity : AppCompatActivity() {

    /* 省略 */

    override fun onCreate(savedInstanceState: Bundle?) {
        /* 省略 */

        val adapter =
            TaskAdapter(this, taskList, object : TaskAdapter.OnItemClickListener {
                override fun onItemClick(item: Task) {
                    // クリック時の処理
                    Toast.makeText(applicationContext, item.content + "を削除しました", Toast.LENGTH_SHORT).show()
                    delete(item.id)
                }
            }, true)

        /* 省略 */

    }
    /* 省略 */

    fun update(id: String, content: String) {
        realm.executeTransaction {
            val task = realm.where(Task::class.java).equalTo("id", id).findFirst()
                ?: return@executeTransaction
            task.content = content
        }
    }

    fun update(task: Task, content: String) {
        realm.executeTransaction {
            task.content = content
        }
    }

    fun delete(id: String) {
        realm.executeTransaction {
            val task = realm.where(Task::class.java).equalTo("id", id).findFirst()
                ?: return@executeTransaction
            task.deleteFromRealm()
        }
    }

    fun delete(task: Task) {
        realm.executeTransaction {
            task.deleteFromRealm()
        }
    }

    fun deleteAll() {
        realm.executeTransaction {
            realm.deleteAll()
        }
    }

}

今回はセルがタップされたときにそれを削除し、それをToastで表示するようにしました。

これで一旦完成です。

終わりに

Realm周り結構ハマりどころがあるので、適宜調べながらやっていくと良いと思います。
また、SQLに慣れているのであればRoomを使うのもありなので、Realmが大変であれば別のものを採用するのもありだと思います。

参考: Room | Android Developers

59
45
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
45

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?