araki tech

for developers including me

Android Kotlin日本語チュートリアル-⑦Fragmentに引数を渡す

Android Kotlin日本語チュートリアル

Android Kotlin日本語チュートリアル

本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。

コンセプトは

  • プログラミングをあまり知らない人でも完走できる
  • プログラミングにある程度詳しい人にも満足できる
  • 実用的な知識を提供する
  • とにかくわかりやすく

で、全9回と長めですが頑張っていきましょう。

このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。

作成するのは以下のようなメモアプリです。

Android Kotlin日本語チュートリアル

完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。

第7回 : Fragmentに引数を渡す

第7回は、少し話題を戻してFragmentの話です。

以前Fragmentを生成した際には、Activityから特に何もNewMemoFragmentへ渡すものもなかったのですが、もし何かしらのデータをFragmentに渡して生成したい場合はどうすれば良いのでしょうか?

コンストラクタに引数を用意しますか?

実はそれはあまり良い方法ではありません。

今回は第2回でも説明したライフサイクルと絡めて、適切なデータ受け渡しを学びましょう。

また、ついでみたくなりますが、DialogFragmentについても学んでいきます。

Drawableリソースを使ったMemoDetailFragmentのレイアウト作成

さて、今回作成するのはMainActivityであるメモを選択して、その詳細を表示するFragmentです。

そのFragmentでは編集もできるとなお良いですね。

まずは土台を作りましょう。

ところで、今までボタンのレイアウトはAndroidフレームワークにお任せしていましたが、せっかくなら自分でデザインしたいですよね?

そういうときは、Drawableリソースで定義することができます。

早速、res/drawable/に新しくdelete_button.xmlを作成してみてください。

Android Kotlin日本語チュートリアル
ルートは「shape」にする。(後で書き書きかえ可能なのでどちらでも良いが…)
res/drawable/delete_button.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <!--  角の設定。今回は5dpだけ丸みをつける。  -->
    <corners android:radius="5dp"/>
    <!--  塗りつぶす色。  -->
    <solid android:color="@color/delete"/>
</shape>
res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>

    <color name="delete">#ffc24f4f</color>
</resources>

あとは適当にFragmentのレイアウトを作成します。

fragment_memo_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="#F6F6E9"
    android:clickable="true">

    <TextView
        android:id="@+id/memo_detail_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Sample Memo"
        android:textSize="25sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/new_memo_guideline_1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <TextView
        android:id="@+id/memo_detail_date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="15sp"
        android:text="更新日: 2021/12/01 12:00"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/memo_detail_title"/>

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/new_memo_guideline_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.15"/>

    <EditText
        android:id="@+id/memo_detail_contents"
        android:layout_width="@dimen/memo_detail_contents_width"
        android:layout_height="@dimen/memo_detail_contents_height"
        android:inputType="textMultiLine"
        android:maxLines="10"
        android:background="#eeeeee"
        app:layout_constraintTop_toTopOf="@id/new_memo_guideline_1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="@id/new_memo_guideline_2"/>

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/new_memo_guideline_2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.85"/>

    <TextView
        android:id="@+id/memo_detail_expire"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="15sp"
        android:text="締め切り: 2021/12/30 17:30"
        app:layout_constraintTop_toTopOf="@id/new_memo_guideline_2"
        app:layout_constraintBottom_toTopOf="@id/memo_delete_button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/memo_delete_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/memo_detail_delete_button"
        android:textColor="@color/white"
        android:background="@drawable/delete_button"
        app:layout_constraintTop_toTopOf="@id/memo_detail_expire"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginEnd="20sp"/>

</androidx.constraintlayout.widget.ConstraintLayout>
res/values/layout_params.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="new_memo_edit_width">250dp</dimen>
    <dimen name="new_memo_label_width">80dp</dimen>
    <dimen name="new_memo_contents_height">200dp</dimen>

    <dimen name="memo_detail_contents_width">300dp</dimen>
    <dimen name="memo_detail_contents_height">350dp</dimen>
</resources>

Android Kotlin日本語チュートリアル

テキストはレイアウト確認用の仮のものなので、気にしないでください。(最終的にアプリ提供する場合は余計な処理が走るので仮テキストも消した方が良いですね)

Android標準のButtonは少し特殊で、デザイン変更を独自のものにする場合はAppCompatButtonを使用しましょう。

MemoDetailFragmentも作成して、レイアウトの設定をしておきます。

class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail) {
}

Adapterにアイテムクリックのリスナーを設定する

MemoDetailFragmentは、RecyclerViewの各アイテムをクリックしたら表示されるようにしたいです。

こういう場合は、RecyclerViewのAdapterでその設定を行います。

処理自体は、「アイテムクリック」→「MainActivityがMemoDetailを表示」といった感じです。

感の良い方はお気づきかもしれませんが、今回も下層クラス (Adapter) から上位クラス (Activity) へのイベント伝播ですね。

前回は、FragmentにListenerインターフェイスを定義しました。

今回もリスナーを使った実装をしますが、ちょっとだけ違う形で実現させてみます。

それは、MainAdapterのコンストラクタ引数にラムダを渡すように定義して、それをアイテムクリック時に発火させるようにします。

/**
 * MainActivityで管理するRecyclerView用のAdapter
 * @param onItemClick ViewHolderのItemViewをクリックした際の処理
 */
class MainAdapter(
    private val onItemClick: (Memo)-> Unit
) : RecyclerView.Adapter<MainAdapter.MainViewHolder>() {

    private var memoItems: List<Memo> = emptyList()

    // ...

    // ViewHolderが作られた時や更新された時に呼ばれるメソッド
    // ここでViewの中身を決めたりViewの設定を行う
    override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
        // positionからどのデータをViewHolderに保持させるViewを決める
        val memo = memoItems[position]

        holder.title.text = memo.title
        holder.createdDate.text = memo.createdTimeMillis.toDateString()
        holder.updatedData.text = memo.updateTimeMillis.toDateString()
        holder.expiredDate.text = memo.expireTimeMillis.toDateString()

        holder.itemView.setOnClickListener { onItemClick(memo) }
    }

    // ...
}

つまり、MainActivityでAdapterをインスタンス化する際にリスナーを直接ラムダ (関数型とも言う)で渡す、という形です。

class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {

    private val viewModel: MainViewModel by viewModels()

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

        val addButton: Button = findViewById(R.id.add_button)

        val recyclerView: RecyclerView = findViewById(R.id.main_list)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = MainAdapter(::onItemClick)
        recyclerView.addItemDecoration(
            DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        )

        // ...
    }

    override fun onDismiss(memoTitle: String) {
        // ...
    }

    // RecyclerViewのアイテムがクリックされたときの処理
    private fun onItemClick(memo: Memo) {
        supportFragmentManager.beginTransaction().run {
            add(R.id.main_container, MemoDetailFragment())
            addToBackStack(null)
            commit()
        }
    }
}

さてここで特殊な記法、::onItemClick が登場しましたが、これは関数参照を取得するもので、簡単に言えば関数定義から関数型への変換と言えます

省略せずに書けば、this::onItemClickでとなりクラス名::関数名という文法になります。

メリットは定義を分離できることが挙げられるので、複雑なラムダを渡す場合はこういったことも出来るということを頭に入れておきましょう。

さて、これでMemoDetailFragmentがアイテムクリックで生成されるようになりましたが、まだメモの情報を渡せていません




Fragmentに引数を渡す

今回のメインです。

Fragmentにメモ情報を生成時に渡したいのですが、

class MemoDetailFragment(private val memo: Memo)

のようにコンストラクタで渡すことは推奨されていません。

覚えているかわかりませんが、FragmentやActivityは再生成される可能性があるという話を第2回でお話ししました。

Fragmentは再生性される場合は、引数なしコンストラクタを使った再生性が行われるので、上記の方法だとアプリクラッシュにつながります。

公式リファレンスでも以下のような記述があります。

All subclasses of Fragment must include a public no-argument constructor. The framework will often re-instantiate a fragment class when needed, in particular during state restore, and needs to be able to find this constructor to instantiate it. If the no-argument constructor is not available, a runtime exception will occur in some cases during state restore.

Fragmentのいかなるサブクラスは引数なしコンストラクタを用意する必要があります。フレームワークは必要であれば、特に状態復元時に再インスタンス化を走らせます。その際に空のコンストラクタを必要とします。もし空引数コンストラクタが見つからない場合、実行時エラーが発生します。
(Fragment | Android developers)

前置きが長くなりましたが、Fragmentに引数を渡す場合はコンストラクタではなくargumentsというBundleを使って渡します。

argumentsはFragmentが破棄されても中身は破棄されないので、Fragmentは再生成時にはこのargumentsを参照することができます。

とりあえず実装してみましょう。

MemoDetailFragment.kt
class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val memo = requireArguments()[KEY_MEMO] as Memo
        Log.d(this::class.simpleName, "memo = $memo")
    }

    companion object {
        private const val KEY_MEMO = "memo"
        
        /** [MemoDetailFragment]の引数ありインスタンスを生成する */
        fun newInstance(memo: Memo): MemoDetailFragment {
            return MemoDetailFragment().apply {
                arguments = bundleOf(
                    KEY_MEMO to memo
                )
            }
        }
    }
}

インスタンス生成関数はクラスメソッド(companion object内 = インスタンス化してなくてもクラスから直接呼べる関数) で定義する必要があります。

名前はなんでも良いですが、newInstanceという名前が一般的に使われます。

MainActivity.kt
class MainActivity : AppCompatActivity(), NewMemoFragment.Listener {

    // ...

    // RecyclerViewのアイテムがクリックされたときの処理
    private fun onItemClick(memo: Memo) {
        supportFragmentManager.beginTransaction().run {
            add(R.id.main_container, MemoDetailFragment.newInstance(memo))
            addToBackStack(null)
            commit()
        }
    }
}

Bundleに詰めることができるオブジェクトは、シリアル化できるものしか渡せないので、MemoにSerializableを実装させます。

data class Memo(
    @PrimaryKey(autoGenerate = true) val id: Int,
    @ColumnInfo(name = TITLE) val title: String,
    @ColumnInfo(name = CONTENTS) val contents: String,
    @ColumnInfo(name = CREATED_TIME) val createdTimeMillis: Long,
    @ColumnInfo(name = UPDATE_TIME) val updateTimeMillis: Long,
    @ColumnInfo(name = EXPIRE_TIME) val expireTimeMillis: Long
) : Serializable {
    // ...

ちなみにdata classをシリアル化させるにはそのプロパティもシリアル化可能である必要があります

今回はMemoのプロパティが全て基本型で全てシリアル化可能なのでこれでOKですが、もしそうでない場合はIDのみ渡して、FragmentでIDからデータベース取得のような工夫が必要です。

ログを見るとMemoDetailFragment生成時にしっかりとメモ情報が渡されていることが確認できるかと思います。

あとは、レイアウトにメモ情報を適用させましょう。

MemoDetailFragment.kt
class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val memo = requireArguments()[KEY_MEMO] as Memo
        Log.d(this::class.simpleName, "memo = $memo")

        val title = view.findViewById<TextView>(R.id.memo_detail_title)
        val date = view.findViewById<TextView>(R.id.memo_detail_date)
        val contents = view.findViewById<EditText>(R.id.memo_detail_contents)
        val expire = view.findViewById<TextView>(R.id.memo_detail_expire)
        val deleteButton = view.findViewById<Button>(R.id.memo_delete_button)

        title.text = memo.title
        date.text =
            getString(R.string.memo_detail_date_format, memo.updateTimeMillis.toDatetimeString())
        expire.text =
            getString(R.string.memo_detail_expire_format, memo.expireTimeMillis.toDatetimeString())
        contents.setText(memo.contents)
        deleteButton.setOnClickListener {
            // TODO また後で実装する
        }
    }

    companion object {
        // ...
    }
}
util/TimeUtil.kt
object TimeUtil {
    private const val DATE_FORMAT = "yyyy/MM/dd"
    private const val DATETIME_FORMAT = "yyyy/MM/dd HH:mm"
    private val DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT)
    private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern(DATETIME_FORMAT)
    // 何回も使うのでプロパティとして保持しておく
    private val ZONE_ID = ZoneId.systemDefault()

    /**
     * UNIX epoch time millis to String
     */
    fun Long.toDateString(): String =
        Instant.ofEpochMilli(this)
            .atZone(ZONE_ID)
            .format(DATE_FORMATTER)

    /**
     * UNIX epoch time millis to String (Datetime format)
     */
    fun Long.toDatetimeString(): String =
        Instant.ofEpochMilli(this)
            .atZone(ZONE_ID)
            .format(DATETIME_FORMATTER)
}

どうでしょうか?

これでメモ情報をFragmentにしっかり渡すことができ、UIも反映できたと思います。

Android Kotlin日本語チュートリアル

DialogFragmentで日付を設定する

さて、最後にDialogFragmentを使った日付設定を実装してみます。

対象となるのはメモの破棄日で、ユーザビリティ的にはこのMemoDetailFragmentから変更できるのが望ましいですよね。

これを実現するには、先述したDialogFragmentが適任です。

Android Kotlin日本語チュートリアル
Android developersのスクリーンショット

こんな感じのFragmentです。

まずはレイアウトを作りましょう。

今回はAndroid標準で提供されているDatePickerTimePickerを使用して日付と時刻両方を同時に編集できるようにします。

res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--  省略  -->

    <color name="delete">#ffc24f4f</color>
    <color name="datetime_picker_background">#55194769</color>
</resources>
res/values/strings.xml
<resources>
    <!--  省略  -->

    <!--  DatetimePicker  -->
    <string name="datetime_picker_title">締切日を編集</string>
    <string name="datetime_picker_cancel">キャンセル</string>
    <string name="datetime_picker_update">更新</string>
</resources>
dialog_datetime_picker.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="5dp"
    android:background="@color/datetime_picker_background"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/datetime_picker_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/datetime_picker_title"
        android:textSize="20sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/date_picker"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <DatePicker
        android:id="@+id/date_picker"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:datePickerMode="spinner"
        android:calendarViewShown="false"
        app:layout_constraintTop_toBottomOf="@id/datetime_picker_title"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <TimePicker
        android:id="@+id/time_picker"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:timePickerMode="spinner"
        app:layout_constraintTop_toBottomOf="@id/date_picker"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/picker_update_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/datetime_picker_update"
        app:layout_constraintTop_toBottomOf="@id/time_picker"
        app:layout_constraintStart_toEndOf="@id/picker_cancel_button"
        app:layout_constraintEnd_toEndOf="parent" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/picker_cancel_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/datetime_picker_cancel"
        app:layout_constraintTop_toBottomOf="@id/time_picker"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/picker_update_button"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Android Kotlin日本語チュートリアル

ひとまずこんなレイアウトで良いでしょう。

そうしたらDatetimePicker.ktを作ってDialogFragmentを構築しましょう。

class DatetimePicker : DialogFragment() {
    
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // Viewのレイアウトを読み込む
        val view = View.inflate(requireContext(), R.layout.dialog_datetime_picker, null)
        // AlertDialogをベースにDialog生成
        return AlertDialog.Builder(requireContext()).create().apply {
            setView(view)
        }
    }
}

ボタンの挙動などはまた後で実装します。

そしてこれを呼び出す側のMemoDetailFragmentにも加筆します。

class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ... 

        expire.setOnClickListener { showDatetimePicker() }
    }

    private fun showDatetimePicker() {
        DatetimePicker().show(parentFragmentManager, "datetime_picker")
    }

    companion object {
        // ...
    }
}

これで、締切日をタッチすると以下のようにDialogFragmentが生成されると思います。

Android Kotlin日本語チュートリアル
まだ日付は設定されていない

このようにDialogFragmentを使うと簡単なオーバレイUIをすぐに実装することができます。

Repositoryと繋げる

あとは、もうすでに学んだことを生かして、MemoDetailFragmentとデータベースを繋げるのみです。

やるべきことは、

  • 削除ボタンで該当データを消す
  • メモの内容が変更されて戻った時にデータを更新する

です。

ここは特に「こういう実装をしましょう」とは決めないので皆さんの知識を生かして実装にトライしてみましょう。

とは言え、初学者にとっては簡単な話ではないので、ここから私の実装例は載せます。

それをコピーしながら、理解を進めても良いです。

削除機能

res/values/strings.xml
<resources>
    <!--  省略  -->

    <!--  MemoDetailFragment  -->
    <string name="memo_detail_delete_button">削除</string>
    <string name="memo_detail_date_format">更新日: %1$s</string>
    <string name="memo_detail_expire_format">締め切り: %1$s</string>
    <string name="memo_delete_text">「%1$s」を削除しました。</string>

    <!--  省略  -->
</resources>
model/MemoDao.kt
@Dao
interface MemoDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertMemo(memo: Memo)

    @Query("SELECT * FROM memo")
    fun fetchAll(): List<Memo>

    @Delete
    fun deleteMemo(memo: Memo)

    @Query("DELETE FROM memo WHERE id=:id ")
    fun deleteMemo(id: Int)
}

もちろん最初に用意していた削除メソッドを利用してもOKです。

repository/MemoRepository.kt
class MemoRepository(context: Context) {
    // ...

    fun deleteMemo(id: Int) = dao.deleteMemo(id)

    // ...
}
viewmodel/MemoDetailViewModel.kt
class MemoDetailViewModel(app: Application) : MemoViewModel(app) {

    fun deleteMemo(id: Int) = viewModelScope.launch(Dispatchers.IO) {
        memoRepository.deleteMemo(id)
    }
}
MemoDetailFragment.kt
class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail) {
    interface Listener {
        fun onDelete(memoTitle: String)
    }

    private val viewModel: MemoDetailViewModel by viewModels()
    private lateinit var listener: Listener

    override fun onAttach(context: Context) {
        super.onAttach(context)
        listener = context as? Listener
            ?: throw ClassCastException("The parent activity needs to implement Listener")
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ... 
        deleteButton.setOnClickListener { onDeleteMemo(memo) }

        expire.setOnClickListener { showDatetimePicker() }
    }

    private fun onDeleteMemo(memo: Memo) {
        viewModel.deleteMemo(memo.id)
        listener.onDelete(memo.title)
        parentFragmentManager.popBackStack()
    }

    private fun showDatetimePicker() {
        DatetimePicker().show(parentFragmentManager, "datetime_picker")
    }

    companion object {
        // ...
}
MainActivity.kt
class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {

    // ...

    override fun onDelete(memoTitle: String) {
        hideSoftwareKeyboard(R.id.main_container)
        viewModel.loadMemoItems()
        makeToast(this, getString(R.string.memo_delete_text, memoTitle))
    }

    // ...
}

更新機能

model/MemoDao.kt
@Dao
interface MemoDao {
    // ...

    @Update(onConflict = OnConflictStrategy.REPLACE)
    fun updateMemo(memo: Memo)
}

なぜか@UpdateもデフォルトだとOnConflictStrategy.ABORTが指定されるようなので、変更します。

repository/MemoRepository.kt
class MemoRepository(context: Context) {
    // ...

    /**
     * メモを更新する。
     */
    fun updateMemo(memo: Memo) = dao.updateMemo(memo)

    companion object {
        const val DATABASE_NAME = "memo"
    }
}
DatetimePicker.kt
class DatetimePicker : DialogFragment() {
    interface Listener: Serializable {
        fun onDateTimeChanged(dateTimeWrapper: DateTimeWrapper)
    }

    private val onDateChanged =
        DatePicker.OnDateChangedListener { _, year, monthOfYear, dayOfMonth ->
            dateTimeWrapper.year = year
            dateTimeWrapper.month = monthOfYear + 1 // 1始まりにしておく
            dateTimeWrapper.dayOfMonth = dayOfMonth
        }

    private val onTimeChanged =
        TimePicker.OnTimeChangedListener { _, hourOfDay, minute ->
            dateTimeWrapper.hour = hourOfDay
            dateTimeWrapper.minute = minute
        }

    private val dateTimeWrapper = DateTimeWrapper()

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // Viewのレイアウトを読み込む
        val view = View.inflate(requireContext(), R.layout.dialog_datetime_picker, null)

        // 各種Pickerの初期化
        val initialDateTime = (requireArguments()[KEY_TIME_MILLIS] as Long).toZonedDateTime()
        view.findViewById<DatePicker>(R.id.date_picker).init(initialDateTime)
        view.findViewById<TimePicker>(R.id.time_picker).init(initialDateTime)

        val cancelButton = view.findViewById<Button>(R.id.picker_cancel_button)
        val updateButton = view.findViewById<Button>(R.id.picker_update_button)

        cancelButton.setOnClickListener { dismiss() }
        updateButton.setOnClickListener { update() }

        // AlertDialogをベースにDialog生成
        return AlertDialog.Builder(requireContext()).create().apply {
            setView(view)
        }
    }

    // Listenerをargumentsから読み込んで、メソッドを発火
    private fun update() {
        (requireArguments()[KEY_LISTENER] as Listener).onDateTimeChanged(dateTimeWrapper)
        dismiss()
    }

    // DatePickerを初期化する
    private fun DatePicker.init(z: ZonedDateTime) {
        // DataPickerのmonthは0始まりなので -1 しておく
        updateDate(z.year, z.monthValue - 1, z.dayOfMonth)
        setOnDateChangedListener(onDateChanged)
    }

    // TimePickerを初期化する
    private fun TimePicker.init(z: ZonedDateTime) {
        hour = z.hour
        minute = z.minute
        setOnTimeChangedListener(onTimeChanged)
        setIs24HourView(true)
    }

    companion object {
        private const val KEY_TIME_MILLIS = "time_millis"
        private const val KEY_LISTENER = "listener"

        fun newInstance(timeMillis: Long, listener: DatetimePicker.Listener): DatetimePicker {
            return DatetimePicker().apply {
                arguments = bundleOf(
                    KEY_TIME_MILLIS to timeMillis,
                    KEY_LISTENER to listener
                )
            }
        }
    }

    /** 一時的な日付のラッパークラス */
    data class DateTimeWrapper(
        var year: Int? = null,
        var month: Int? = null,
        var dayOfMonth: Int? = null,
        var hour: Int? = null,
        var minute: Int? = null
    ) {
        /**
         * UNIX時刻に変換する。
         * @param baseDateTime ベースとなる[ZonedDateTime]。もしプロパティがnullであればここから補完される。
         */
        fun toTimeMillis(baseDateTime: ZonedDateTime): Long {
            return ZonedDateTime.of(
                year ?: baseDateTime.year,
                month ?: baseDateTime.monthValue,
                dayOfMonth ?: baseDateTime.dayOfMonth,
                hour ?: baseDateTime.hour,
                minute ?: baseDateTime.minute,
                0,
                0,
                baseDateTime.zone
            ).toEpochSecond() * TimeUnit.SECONDS.toMillis(1)  // [sec] to [ms]
        }
    }
}

このDatetimePickerとMemoDetailFragmentの連携部分は少し複雑ですが、上記の実装では一時的なデータホルダーとしてDateTimeWrapperを用意してみました。

MemoDetailFragment.kt
class MemoDetailFragment : Fragment(R.layout.fragment_memo_detail), DatetimePicker.Listener {
    interface Listener {
        fun onDelete(memoTitle: String)
        fun onUpdate(memo: Memo)
    }

    private val viewModel: MemoDetailViewModel by viewModels()
    private lateinit var listener: Listener

    private lateinit var memo: Memo
    private lateinit var expire: TextView
    private lateinit var contents: EditText

    override fun onAttach(context: Context) {
        super.onAttach(context)
        listener = context as? Listener
            ?: throw ClassCastException("The parent activity needs to implement Listener")
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        memo = requireArguments()[KEY_MEMO] as Memo
        Log.d(this::class.simpleName, "memo = $memo")

        val title = view.findViewById<TextView>(R.id.memo_detail_title)
        val date = view.findViewById<TextView>(R.id.memo_detail_date)
        val deleteButton = view.findViewById<Button>(R.id.memo_delete_button)
        contents = view.findViewById(R.id.memo_detail_contents)
        expire = view.findViewById(R.id.memo_detail_expire)

        title.text = memo.title
        date.text =
            getString(R.string.memo_detail_date_format, memo.updateTimeMillis.toDatetimeString())
        expire.text =
            getString(R.string.memo_detail_expire_format, memo.expireTimeMillis.toDatetimeString())
        contents.setText(memo.contents)
        deleteButton.setOnClickListener { onDeleteMemo(memo) }

        expire.setOnClickListener { showDatetimePicker(memo.expireTimeMillis) }

        viewModel.updateExpireFailEvent.observe(this) {
            makeToast(requireContext(), getString(R.string.datetime_fail))
        }
    }

    /** Fragmentが破棄される際にMemoを更新しておく */
    override fun onDestroy() {
        super.onDestroy()
        // Memoコンテンツは直接EditTextから取得する
        memo = memo.copy(
            contents = contents.text.toString(),
            updateTimeMillis = System.currentTimeMillis()
        )
        // [重要!]
        // MemoDetailViewModelのスコープでDBアクセス (Memo更新) をしてしまうと、
        // Fragment破棄と同時に処理がKillされてしまうので、上位のMainActivityにDBアクセスは任せる。
        listener.onUpdate(memo)
    }

    override fun onDateTimeChanged(dateTimeWrapper: DatetimePicker.DateTimeWrapper) {
        Log.d(this::class.simpleName, "datetime is changed as $dateTimeWrapper")

        memo = memo.copy(expireTimeMillis = viewModel.getNewExpireDateTime(memo, dateTimeWrapper))
        expire.text =
            getString(R.string.memo_detail_expire_format, memo.expireTimeMillis.toDatetimeString())
    }

    private fun onDeleteMemo(memo: Memo) {
        viewModel.deleteMemo(memo.id)
        listener.onDelete(memo.title)
        parentFragmentManager.popBackStack()
    }

    private fun showDatetimePicker(expiredTime: Long) {
        DatetimePicker.newInstance(expiredTime, this)
            .show(parentFragmentManager, "datetime_picker")
    }

    companion object {
        private const val KEY_MEMO = "memo"

        /** [MemoDetailFragment]の引数ありインスタンスを生成する */
        fun newInstance(memo: Memo): MemoDetailFragment {
            return MemoDetailFragment().apply {
                arguments = bundleOf(
                    KEY_MEMO to memo
                )
            }
        }
    }
}

ここで大事なのは、MemoDetailFragmentからMainnActivityに戻る際に、データの更新を行いますが、MemoDetailViewModelで更新処理を行わず、MainActivityとMainViewModelに処理を委譲している点です。

なぜかというと、MemoDetailViewModelにupdateMemo()のようなメソッドを用意して非同期で実行してしまうと、Fragmentの破棄と同時に処理も中断してしまい更新がうまくいきません。

viewModelScopeではなく、GlobalScopeを使えばうまくいきますがそれは避けるべきという話でしたので、リスナーにメソッドを追加してMainActivity側で処理を続行します。

また、memoなどいくつか可読性向上のためにプロパティ化しています。

MainActivity.kt
class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {

    // ...

    override fun onUpdate(memo: Memo) {
        hideSoftwareKeyboard(R.id.main_container)
        viewModel.updateMemo(memo)
    }

    // ...
}
MainViewModel.kt
class MainViewModel(app: Application) : MemoViewModel(app) {

    // ...

    /**
     * Memoを更新する。
     */
    fun updateMemo(memo: Memo) = viewModelScope.launch(Dispatchers.IO) {
        memoRepository.updateMemo(memo)
        _memoItems.postValue(memoRepository.fetchAllMemo())
    }
}
res/values/strings.xml
<resources>

    <!--  省略 -->
    <string name="datetime_fail">破棄日は現在より前の日付は設定できません</string>
</resources>

 

 

第7回のまとめ

  • Fragmentに引数を渡すときはコンストラクタではなく、クラスメソッドで渡す
    • そのメソッドでもらった引数はargumentsに詰める
    • argumentsに詰めることができるデータはSerializable を実装した(シリアル化可能な) データのみ
    • プロパティが基本型のみで構成されていればdata classに直接Serializableを実装すればOK
    • そうでなければ、IDのみ渡したり、必要なデータのみを渡す
  • 日付選択や二択の簡易なUIはDialogFragmentを利用できる

おわりに

第7回はここで終わりになります。

コードをたくさん掲載したのでかなりボリューミーな回になってしまいましたが、理解は追いついていますか?

もしまだ理解が追いついていないかも、という方もいるかもしれませんが大丈夫です。

一発で理解できるというのはなかなかレアだと思いますので、一つ一つ処理を追って理解していきましょう。

もし細かなメソッドで「これなんだろう?」と思ったら、積極的にWeb検索してみてください。
(もし解決しなければ私に直接連絡くれても構いません。TwitterでもメールでもOK。)

さて、本チュートリアルも後半です。

まだみなさんに覚えておいてもらいたいAndroidの機能がありますので、もう少し頑張りましょう。

Android Kotlin日本語チュートリアル

参考