Android Kotlin日本語チュートリアル-⑦Fragmentに引数を渡す
Android Kotlin日本語チュートリアル
本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。
コンセプトは
- プログラミングをあまり知らない人でも完走できる
- プログラミングにある程度詳しい人にも満足できる
- 実用的な知識を提供する
- とにかくわかりやすく
で、全9回と長めですが頑張っていきましょう。
このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。
作成するのは以下のようなメモアプリです。
完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。
第7回 : Fragmentに引数を渡す
第7回は、少し話題を戻してFragmentの話です。
以前Fragmentを生成した際には、Activityから特に何もNewMemoFragmentへ渡すものもなかったのですが、もし何かしらのデータをFragmentに渡して生成したい場合はどうすれば良いのでしょうか?
コンストラクタに引数を用意しますか?
実はそれはあまり良い方法ではありません。
今回は第2回でも説明したライフサイクルと絡めて、適切なデータ受け渡しを学びましょう。
また、ついでみたくなりますが、DialogFragmentについても学んでいきます。
Drawableリソースを使ったMemoDetailFragmentのレイアウト作成
さて、今回作成するのはMainActivityであるメモを選択して、その詳細を表示するFragmentです。
そのFragmentでは編集もできるとなお良いですね。
まずは土台を作りましょう。
ところで、今までボタンのレイアウトはAndroidフレームワークにお任せしていましたが、せっかくなら自分でデザインしたいですよね?
そういうときは、Drawableリソースで定義することができます。
早速、res/drawable/
に新しくdelete_button.xmlを作成してみてください。
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標準の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も反映できたと思います。
DialogFragmentで日付を設定する
さて、最後にDialogFragmentを使った日付設定を実装してみます。
対象となるのはメモの破棄日で、ユーザビリティ的にはこのMemoDetailFragmentから変更できるのが望ましいですよね。
これを実現するには、先述したDialogFragment
が適任です。
こんな感じのFragmentです。
まずはレイアウトを作りましょう。
今回はAndroid標準で提供されているDatePicker
とTimePicker
を使用して日付と時刻両方を同時に編集できるようにします。
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>
ひとまずこんなレイアウトで良いでしょう。
そうしたら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が生成されると思います。
このように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の機能がありますので、もう少し頑張りましょう。