[ハンズオンあり]1からのAndroidTODOアプリ開発記録~設計とUI実装~

はじめに

AndroidのTODOアプリの設計とUI実装をしていきます。

完成品

画像35

UIの部分の実装をやります。

読みながら手を動かしたい方は、

git clone -b before-implementation-of-kotlin-todo-ui \
https://github.com/KazuyoshiHidaka/kotlin_todo \
implementation_of_kotlin_todo_ui

このコマンドからこちらのブランチをcloneしてください。

完成後のコードはこちらになります。


※謝罪
この記事を序盤で一度削除してしまいましたorz。なので序盤は実装の流れだけになります。


設計

・モデル
noteにちなんで、Pageモデル

Page {
    title: String
    content: String
    updatedAt: DateTime
}


・Activity

・一覧表示のMainActivity
・詳細表示のPageDetailActivity
・PageモデルのNew, Editを担う、PageEditActivity
の3つを実装していきます。


Activityの生成

まずは、PageDetailActivityPageEditActivityを生成します。
ここまでのコードはこちら


MainActivityのUI実装

MainActivityUIを実装していきます。

一度記事を削除してしまったので、途中まで活動報告ですが、
ここに書いてある流れで実装を進めました。


MainActivityにてきとうな配列データを用意して、

val pagesList: Array<String> = arrayOf(
   "私は時間すでにこうした増減学に対してものの後にありたあり。",
   "何しろ今を病気界はじっととんだ尊重ましたほどに行くているあるがは意味聞えるなたて、多少には死んなですでです。",
   "分子がしです訳はちゃんと今におもにべきたでしょ。"
)


RecyclerViewを使って、

画像1

ここまで実装をしました。ここまでのコードはこちら

また、この次recyclerview-selectionというライブラリを導入して、苦戦しながらキャッチアップしましたが、止めましたorz。苦戦の形跡はこちら。。
TODOアプリでは、UIはそこそこにして、コードの設計に注力して試行錯誤していきたいと思います。

ここまでで事後報告は終わりです。


次に、RecyclerViewには仮のArray<String>データを与えていたので、StringをPageモデルに置き換えます。

data class Page(
   val id: Long,
   val title: String,
   val content: String,
   val updatedAt: Date
)
private val pagesList: Array<Page> = arrayOf(
   Page(1, "あかさたな", "あいうえおかきくけこさしすせそたちつてとなにぬねの", Date()),
   Page(2, "はまやらわ", "はひふえほまみむめもやゆよらりるれろわをん", Date()),
   Page(3, "やゆよ", "よ", Date())
)

サンプルデータも置き換え、

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
   val page = data[position]
   holder.title.text = page.title
}

RecyclerViewも、StringをPageに置き換え、完了です。コードはこちら


後は、PageDetailActivityに遷移するためのリンクと、PageEditActivityに遷移するためのFloatingActionButtonを実装します。

まずは、リンクから。

RecyclerViewViewHolderをクリックイベントに対応させるために、RecyclerView.AdapteronBindViewHolderメソッド内で、Viewにクリックリスナーをつけます。

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
   val page = data[position]
   holder.itemView.setOnClickListener {
       val intent = Intent(context, PageDetailActivity::class.java).also {
           it.putExtra(PUT_EXTRA_KEY_PAGE_DETAIL, page)
       }
       startActivity(intent)
   }
   holder.title.text = page.title
}

intentの定義はバインド時じゃなくてもいいかなと思い、setOnClickListener内へ・・

companion object {
   const val PUT_EXTRA_KEY_PAGE_DETAIL = "PageDetail"
}

static的な扱いをしたいのでcompanion objectに・・

@Parcelize
data class Page(
   val id: Long,
   val title: String,
   val content: String,
   val updatedAt: Date
) : Parcelable

PageモデルをParcelize化すると、IntentputExtraで渡せるようになります。

正しくPageモデルを受け取れているか確認したいので、PageDetailActivityも少しだけ実装します。

画像2

UIをさくっと実装して~

class PageDetailActivity : AppCompatActivity() {

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

       val page: Page? = intent.getParcelableExtra<Page?>(PagesRecyclerViewComponent.PUT_EXTRA_KEY_PAGE_DETAIL)
       val pageTitle = findViewById<TextView>(R.id.page_detail_title).also {
           it.text = page?.title
       }
       val pageContent = findViewById<TextView>(R.id.page_detail_content).also {
           it.text = page?.content
       }
   }
}

IntentからPageを受け取って、タイトルとコンテンツを設定して~
intent.get~~ExtraだとNullableにorz)

<activity
   android:name=".ui.PageDetailActivity"
   android:parentActivityName=".MainActivity" />

マニフェストファイルから、さくっと戻るボタンを追加して~

画像3

おお。お?

ViewHolderをタップした時に、インタラクションがあればいいですね。

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:drawable="@android:color/darker_gray" android:state_pressed="true" />
</selector>

Resourcedrawableを追加して、ViewHolderbackgroundプロパティに適用して

画像4

若干雑ですが、良しとしましょう。

PageDetailActivityへのリンクの出来上がりです。


次は、PageEditActivityにつながるFloatingActionButtonを追加していきます。

とは言っても、FloatingActionButtonMainActivityに置いて、Idを適用して、onClickプロパティにstartActivity(Intent)を適用して、マニフェストファイルからPageEditActivityのparentActivityプロパティにMainActivityを指定して戻るボタンを追加してあげればできそうですね。


画像5

世の中そんなに甘くなかったorz

画像6

と思って手動でいじってたら普通にいけた(゜_゜?)

画像7

FABを置き

+アイコンがデフォルトではないようなので、Android StudioのVector Assets Studioから追加していきます。

画像8


floatingActionButton = findViewById<FloatingActionButton>(R.id.main_floating_action_button).also {
   it.setOnClickListener {
       val intent = Intent(this, PageEditActivity::class.java)
       startActivity(intent)
   }
}

startActivityを実装して

マニフェストファイルにparentActivityを追加します。

<string name="page_detail_activity_title">Page</string>
<string name="page_edit_activity_title">New Page</string>

resource.stringに追加し、マニフェストファイルからActivitylabelを振り、

画像9

いい感じだ!!
(ViewHolderにリップルを加えました)ここまでのコード


PageDetailActivityのUI実装

次は、PageDetailActivityの実装をしていきます。

本当はもっと早く導入するべきでしたが、レイアウトファイルでのウィジェットのは、MaterialComponentのstyleを活用してパパっと終わらせていきましょう!

画像10

Last Updatedの部分に、updatedAtカラムの値を当てていきます。

<string name="page_detail_activity_updated_at_label">Last Updated: %1$s</string>

resourcestringplaceholder付きのラベルを追加して、

pageUpdatedAt = findViewById<TextView>(R.id.page_detail_updated_at).also {
   it.text = getString(
       page_detail_activity_updated_at_label,
       DateFormat.getDateInstance(DateFormat.LONG).format(page?.updatedAt ?: Date())
   )
}

updatedAtラベルのtextをセットして、(Nullableですいません土下座)

画像11



次は、編集ボタンと削除ボタンを追加します。実際にデータを削除して画面を更新とかはしません。今はUIのみです。

PageDetailActivityのレイアウトのルートをConstraintLayoutからCoordinatorLayoutに切り替え、FloatingActionButtonを追加します。

画像12


画像13

Vector Assets から、editアイコンを追加して、FloatingActionButtonに適用し、


画像14

戻るボタンで、PageDetailActivityではなくMainActivityに戻ってしまうorz


マニフェストファイルからのparentActivityNameに値を2つセットして使い分けたり、とかはできないためです。

parentActivityの値を動的に変更
・Fragmentを使って(Fragmentまだ触れてないorz)
などいくつかのアプローチを検討しましたが、
新たにPageNewActivityを追加して、includeタグでFormのレイアウトを使いまわすようにしたいと思います。(思い出してみると、AppBarのタイトルの変更とか考えてませんでした)


Intent(this, PageNewActivity::class.java)

MainActivityFloatingActionButtonIntentPageNewActivityに変更して、

<activity
   android:name=".ui.PageNewActivity"
   android:label="@string/page_new_activity_title"
   android:parentActivityName=".ui.PageDetailActivity" />

マニフェストファイルを修正して、

<string name="page_new_activity_title">New Page</string>
<string name="page_edit_activity_title">Edit Page</string>

PageEditActivitylabel用のresource/stringを加えて、


画像15

薄々感じてたよ。parentActivityNameはページを戻してくれるわけじゃないことにorz


ということで、ページを戻す必要があるので、Fragmentに入っていきます!
この機に、ページ遷移でのPageモデルの受け渡しを型安全にするためにも、
Navigation Componentを導入していきます!
ここまでで一旦コミットしておきます。

こちらの記事で導入しました!
Navigation Componentは今はいいや、という方は、

git clone -b after-migration-to-navigation-component \
https://github.com/KazuyoshiHidaka/kotlin_todo \
kotlin_todo_KazuyoshiHidakkotlin

こちらのコマンドから、Navigation Component導入後のソースコードをcloneしてください。


画像16

無事、ページが戻ってくれるようになりました😃


ActionBarのカスタマイズ

PageDetailFragmentの続きからいきます。
AppBarの右端に削除アイコンを追加して、押したらMainFragmentに戻るようにしたいと思います。

まず、AppBarをカスタマイズするためには、デフォルトのActionBarを使わずに自分でToolbarをレイアウトファイルに定義する必要があります。
ドキュメントを参考に進めます。

マニフェストファイルからapplicationタグのthemeをNoActionBarに変え、

<application
    android:theme="@style/Theme.AppCompat.Light.NoActionBar"
/>


activity_main.xmlにToolbarを定義、Toolbarの下に来るfragmentにもmarginTopを当てます。

<FrameLayout ...

   <androidx.appcompat.widget.Toolbar
       android:id="@+id/toolbar"
       android:layout_width="match_parent"
       android:layout_height="?attr/actionBarSize"
       android:background="?attr/colorPrimary"
       android:elevation="4dp"
       android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
       app:popupTheme="@style/ThemeOverlay.AppCompat.Dark" />


   <fragment
    android:layout_marginTop="?attr/actionBarSize"
       ...


</FrameLayout>


MainActivityのonCreateメソッド内で、setSupportActionBarメソッドを呼び、今定義したToolbarを引数に渡してあげます。

class MainActivity : AppCompatActivity() {

   ...

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate...
       setContentView...
       setSupportActionBar(findViewById(R.id.toolbar))

       ...

       setupActionBarWithNavController()

この時、Navigation Componentで使う、setupActionBarWithNavControllerメソッドより先に呼ぶようにしましょう。

画像17

変化が分かりにくいですが、デフォルトのActionBarから自前のToolbarに置き換わりました!

少しおまけですが、ドキュメントにも書いてある通り、MainActivityのgetSupportActionBarメソッドから、先ほど定義したToolbarにアクセスしていろいろいじれます。😃

class MainActivity : AppCompatActivity() {

   ...

   override fun onCreate...

   override fun getSupportActionBar(): androidx.appcompat.app.ActionBar? {
       return super.getSupportActionBar().also {
           it?.hide()
       }
   }

getSupportActionBarメソッド内での、Toolbarの操作の仕方ですが、super.getSupportActionBarにapplyかalsoスコープ関数を当ててやるのが無難かと思います。

画像18

Toolbarにhide()メソッドを使うとこうなります🙄

次に、本題のToolbarに削除ボタンの配置を行っていきます。ドキュメントを参考にいきます。

アクションボタンを追加するためには、res/menuに新しいXMLファイルを作ります。

画像19

こんな感じの画面で定義していきます。

適当に定義してみます。

画像20

itemタグのapp:showAsActionプロパティによって、アイコンを見せるか、隠すか決めることができます。

app:showAsAction="ifRoom" を設定すると、アクションのためのスペースがアプリバーにある場合、アクションはボタンとして表示されます。十分なスペースがない場合、超過したアクションがオーバーフロー メニューに送信されます。app:showAsAction="never" を設定すると、アクションは常にオーバーフロー メニューにリストされ、アプリバーには表示されません。


それでは、削除アイコンを追加していきます。

まずは、Vector Assetからdeleteアイコンをres/drawableに追加します。

画像21


後は、先ほどのres/menuレイアウトファイルに、アイコンを追加します。

<menu xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto">

   <item
       android:id="@+id/page_detail_toolbar_action_delete"
       android:icon="@drawable/ic_delete_black_24dp"
       android:title="@string/page_detail_toolbar_action_delete_label"
       app:showAsAction="ifRoom"/>
   
</menu>


string resourceに定義します。

<resources>

  ...

 <string name="page_detail_toolbar_action_delete_label">Delete</string>


後はドキュメントを参考に、MainActivityからonCreateOptionsMenuメソッドを呼んで、定義したmenuを表示させます。

class MainActivity : AppCompatActivity() {

   ...

   override fun onCreateOptionsMenu(menu: Menu): Boolean {
       val inflater: MenuInflater = menuInflater
       inflater.inflate(R.menu.toolbar, menu)
       return true
   }

ドキュメントを参考に、配置したdeleteアイコンに、アクションをあてていきます。

画像22

アイコンの色が白い方が好ましいので、変更します。

先ほど定義したdeleteアイコンのレイアウトファイル

<vector xmlns:android="http://schemas.android.com/apk/res/android"
       android:width="24dp"
       android:height="24dp"
       android:viewportWidth="24.0"
       android:viewportHeight="24.0">
   <path
       android:fillColor="#FF000000"
       android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

ここから、pathタグのfillColorプロパティを#FFFFFFFFに変更します。
レイアウトファイル名も、ic_delete_blackからic_delete_whiteに変えましょう。


次に、PageDetailの画面の時だけ、削除アイコンが表示されるようにします。

ここから少し前に出た、Navigation Componentの領域になるので、
まだ触られてない方、申し訳ありません。

ドキュメントを参考に、ナビゲーションの変更をリッスンします。

まずは公式の例から。

navController.addOnDestinationChangedListener { _, destination, _ ->
  if(destination.id == R.id.full_screen_destination) {
    toolbar.visibility = View.GONE
    bottomNavigationView.visibility = View.GONE
  } else {
    toolbar.visibility = View.VISIBLE
    bottomNavigationView.visibility = View.VISIBLE
  }
}

2行目、現在のディスティネーションのIDと、ナビゲーショングラフで登録したディスティネーションのIDを比較して、条件分岐しています。

この条件分岐を使えば、ある画面だけにUI要素を表示、などの要件が満たせます。

それでは、MainActivityに組み込んでいきます。

class MainActivity : AppCompatActivity() {

   ...

   override fun onCreate(savedInstanceState: Bundle?) {

       ...

       val navController = findNavController(R.id.main_fragment_host).also {
           addCustomDestinationChangedListener()
       }
   }

元々onCreateメソッド内で宣言してあった、navControllerにスコープ関数を使って修飾していきます。Listenerの中身がどれだけ増えてもいいように、カスタムメソッドに切り分けます。

class MainActivity : AppCompatActivity() {

   ...

   private fun addCustomDestinationChangedListener() {
       findNavController(R.id.main_fragment_host).also {
           it.addOnDestinationChangedListener { _, destination, _ ->
               showDeleteIconOnOnlyPageDetail(destination)
           }
       }
   }

ここで、addOnDestinationChangedListenerでイベントを登録していきます。(onCreateメソッド内の方の、navControllerには本来スコープ関数は不要なのですが、意味合い的にnavControllerとセットにしておきたかったので書いてます)

そしてまた、Listenerの中身について記述していくので、イベントの用途ごとにメソッドを切り分けていきます。showDeleteIconOnOnlyPageDetailメソッドを定義していきます。

class MainActivity : AppCompatActivity() {

   ...

   private fun showDeleteIconOnOnlyPageDetail(destination: NavDestination) {
       val item = toolbar.menu.findItem(R.id.page_detail_toolbar_action_delete)

       if (destination.id == R.id.pageDetailFragment) {
           item.isVisible = true
       } else {
           item.isVisible = false
       }
   }

名前の通り、PageDetail画面の時だけ、削除アイコンを表示させるメソッドです。

if (destination.id == R.id.pageDetailFragment) {
// この部分で、PageDetailとそれ以外の処理を分けています

後は、先ほどres/menuに作ったレイアウトファイルで定義した、ToolbarのMenuのうちの削除アイコンを、

toolbar.menu.findItem(R.id.page_detail_toolbar_action_delete)

で特定し、isVisibleにtrue・falseを与えることで、アイコンの表示・非表示を切り替えています。

ここまでで画面を見てみましょう。

画像23

お気づきでしょうか・・

最初だけ、削除アイコン消せてない🙄

というわけで、最初だけ手動で非表示にしましょう。。

class MainActivity : AppCompatActivity() {

   ...

   override fun onCreateOptionsMenu(menu: Menu): Boolean {
       val inflater: MenuInflater = menuInflater
       inflater.inflate(R.menu.toolbar, menu)

       // NOTE:
       // 最初のロードでは、showDeleteIconOnOnlyPageDetailによるMenuItemの非表示ができないため、
       // 代わりにここで非表示にする
       val deleteIcon = menu.findItem(R.id.page_detail_toolbar_action_delete)
       deleteIcon.isVisible = false

       return true
   }

MainActivityのonCreateOptionsMenuメソッド、そもそもres/menuで定義したToolbarのMenuのレイアウトをinflateしているところです。

このメソッドをoverrideして、MenuをinflateしてあげないとMenuが表示できないのですが、
このメソッドをreturn trueする前に、Menuのプロパティを操作しときなさいということなのでしょうか・・
ちなみに、ここでreturn falseをすると、Menuは表示されないようです。

画像24

ここで、カスタムToolbarの実装は終わりです。PageDetailのUI実装も同時に終わりました。
次は、いよいよアプリケーション開発の定番であり重要な基礎となる、Form周りのUI実装に入っていきます。

ここで一旦コミットします。コードはこちらです


Formの実装

PageNew, PageEditのUI実装をするにあたり、Formについて触れていきたいと思います。

と意気込んでFormについて調べたところ、特にFormウィジェットのようなものはなく、以前にも私のnoteで触れたように、EditTextや送信ボタンを組み合わせて自分で作るようです。

ではまずは、さくっとレイアウトの実装をします。
fragment_page_new.xmlに実装していきます。

画像30

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <androidx.constraintlayout.widget.ConstraintLayout
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_marginStart="16dp"
       android:layout_marginTop="16dp"
       android:layout_marginEnd="16dp"
       android:layout_marginBottom="16dp">

       <EditText
           android:id="@+id/page_new_page_form_title"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:autofillHints=""
           android:ems="10"
           android:hint="@string/page_new_page_form_title_hint"
           android:inputType="textCapWords"
           android:singleLine="true"
           android:textAllCaps="false"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="parent" />

       <EditText
           android:id="@+id/page_new_page_form_content"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_marginTop="16dp"
           android:autofillHints=""
           android:ems="10"
           android:gravity="start|top"
           android:hint="@string/page_new_page_form_content_hint"
           android:inputType="textMultiLine|textCapSentences"
           android:minLines="@integer/page_new_page_form_content_min_lines"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toBottomOf="@+id/page_new_page_form_title" />
   </androidx.constraintlayout.widget.ConstraintLayout>

   <com.google.android.material.floatingactionbutton.FloatingActionButton
       android:id="@+id/page_new_floating_action_button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_gravity="bottom|end"
       android:layout_margin="16dp"
       android:src="@drawable/ic_check_black_24dp" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

Vector Assetから、checkアイコンを追加します。

FloatingActionButtonのstyleについてですが、

> res/values/styles.xml

<resources>

   <style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
       
       ...

       <item name="floatingActionButtonStyle">@style/MyFABStyle</item>
   </style>

   <style name="MyFABStyle" parent="Widget.MaterialComponents.FloatingActionButton" />

</resources>

このような感じで、MyFABStyleを追加しました。
FloatingActionButtonは頻出なので、ひとつひとつにstyleを当てるよりも、一か所で管理できた方がいいかと思います。

この変更に伴って、fragment_main.xmlfragment_page_detail.xml内のFloatingActionButtonのプロパティから、clickable, focusableを未指定にしても、ちゃんとクリックできるようになりました😃

ここからは、少しおまけなのですが、
EditTextについて、autofillHintsというプロパティがあります。
ざっくり、ユーザーの個人情報などを自動入力するように設定できるようです。詳しくはドキュメントを参照ください。

また、inputTypeというプロパティもあります。
こちらは、text入力の際に、EditTextに表示されるテキストの見た目や、キーボードを調整して、EditTextの用途ごとにいい感じのユーザー体験を設定できます。具体的には、

画像25

こちらは、inputType="text" という普通の値なのですが、


画像26

パスワード用に、inputType="textPassword" にしたり


画像27

Email用の、inputType="textEmailAddress"


画像28

英文などに使えそうな、文頭を自動で大文字にしてくれるinputType="textCapSentences"という気の利いたものや


画像29

日時入力用の inputType="date" という大胆なものまで(日時入力はDatePickerが無難かとは思いますが😅)

おまけおわり


というわけで、PageNew画面のレイアウトが実装できました。

次は、バリデーションを実装します。
具体的には、Titleが入力されていないときに、Errorを出したいと思います。

まず、PageNewFragmentの、onViewCreatedメソッドの中で、findViewByIdを使ってViewの取得をします。(他の2つのViewの取得もついでにやっています)

class PageNewFragment : Fragment() {
   private lateinit var titleForm: EditText
   private lateinit var contentForm: EditText
   private lateinit var floatingActionButton: FloatingActionButton

   ...

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

       titleForm = view.findViewById(R.id.page_new_page_form_title)

       contentForm = view.findViewById(R.id.page_new_page_form_content)

       floatingActionButton = view.findViewById(R.id.page_new_floating_action_button)
   }


次に、titleFormのテキストが変更されたときに発火するリスナーを登録して、そのリスナーの中でバリデーションを実行します。
この要件を満たしてくれる、便利なクラスがあります!

class PageNewFragment : Fragment() {
   
  ...

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

       titleForm = view.findViewById(R.id.page_new_page_form_title)
       titleForm.addTextChangedListener(titleWatcher)

       ...

   }

   private val titleWatcher = object : TextWatcher {
       override fun afterTextChanged(p0: Editable?) {
       }

       override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }

       override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }
   }

}

TextWatcher !

TextWatcherはinterfaceで、用意されているメソッドを見て分かる通り、EditTextのtextの変更をがっつり監視してくれます😃
こいつに頼って、バリデーションを実装していきます🙃

Titleが空文字の時はエラーにするので、

class PageNewFragment : Fragment() {
   
  ...

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

       titleForm = view.findViewById(R.id.page_new_page_form_title)
       titleForm.addTextChangedListener(titleWatcher)

       ...

   }

   private val titleWatcher = object : TextWatcher {
       override fun afterTextChanged(p0: Editable?) {
           validateTitle(p0.toString())
       }

       override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }
       override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }
   }
   
   private fun validateTitle(text: String) {
       titleForm.error = errorTextValidateTitle(text)
   }

   private fun errorTextValidateTitle(text: String): String? {
       return if (text.isEmpty()) {
           getString(R.string.page_new_page_form_title_validator_label)
       } else {
           null
       }
   }

}

EditTextのsetErrorメソッドでバリデーションエラーを発生させることができます。
処理の内容は、まず、editableをtoStringして、入力したテキストを取得します。Editable?がNonNull化します😃。
validateTitleにテキストを渡すことで、メソッド名通り、Titleのバリデーションを発生させます。
validateTitleは、Titleフォームのバリデーション役を担います。
引数のStringをそのまま、Titleフォームのバリデーションのエラーテキストを作る役errorTextValidateTitleメソッドに渡します。
このように、Stringという用意しやすいインスタンスの依存のみで、エラーテキストが返ってくるので、エラーテキストの単体テストが書きやすくなると思います。(今はprivate状態ですが🙄)

画像31

ここまでで、レイアウトの実装とFormのバリデーションを実装しました。

後は、FloatingActionButtonを押したときのイベント登録です。
・改めてTitleFormのバリデーションを行う
・入力された情報を基にPageオブジェクトを作成する
・ひとつ前のページに戻る
これらの処理を実装していきます。

まずは、onViewCreateメソッド内でfindViewByIdから取得しているFloatingActionButtonにclickListenerを与えましょう。
また、onViewCreateメソッドを増大させないために、clickListener内では、clickListener用のカスタムメソッドを呼ぶだけにします。

class PageNewFragment : Fragment() {
   
  ...

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

       floatingActionButton =
           view.findViewById<FloatingActionButton>(R.id.page_new_floating_action_button).also {
               it.setOnClickListener { handleClickFAB() }
           }
   }
   
   ...

   private fun handleClickFAB() {

   }

次に、改めてTitleFormのバリデーションを行います。もちろん、バリデーションに引っかかったら処理を中断させます。

class PageNewFragment : Fragment() {
   
   ...

   private fun handleClickFAB() {
       val text = titleForm.editableText.toString()
       validateTitle(text)
       
       val titleError = titleForm.error
       if (titleError == null) {

       }
   }

titleError == null の所は、titleForm.error ?: let {}の方が簡潔に書けるのですが、今後バリデーションが必要なカラムが増えたときに、if文の条件分岐に少し加えるだけですむので、if文にしています。

次に、入力された情報を基にPageオブジェクトを作成します。

class PageNewFragment : Fragment() {
   
   ...

   private fun handleClickFAB() {
       val text = titleForm.editableText.toString()
       validateTitle(text)
       
       val titleError = titleForm.error
       if (titleError == null) {
           createPage()
       }
   }
   // TODO: Pageオブジェクトをデータに追加する
   private fun createPage() {
       val text = titleForm.editableText.toString()
       val content = contentForm.editableText.toString()
       val date = Date()
       // HACK: 一意なIDの生成について
       val id = (text + content + date.toString()).hashCode().toLong()

       val page = Page(id, text, content, date)
   }

一意なIDの生成について、ベターなやり方を知りたいです。
今は、こんな感じでPageオブジェクトを生成します。
また、Pageデータの追加は、今回はUIの実装だけなので触れません。


FABクリックリスナーの最後は、ひとつ前のページに戻る処理です。

class PageNewFragment : Fragment() {
   
   ...

   private fun handleClickFAB() {
       val text = titleForm.editableText.toString()
       validateTitle(text)
       
       val titleError = titleForm.error
       if (titleError == null) {
           createPage()
           findNavController().navigateUp()
       }
   }

NavControllernavigateUpメソッドで、完了です。が・・

画像32

キーボードが表示されたまま😺!

キーボードの非表示について、InputMethodManagerクラスhideSoftInputFromWindowメソッドというものがあるらしいのですが、

final Activity activity = getActivity();
final InputMethodManager inputManager = (InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE);
inputManager.hideSoftInputFromWindow(activity.getCurrentFocus().getWindowToken(), showKeyboard ? InputMethodManager.SHOW_FORCED : InputMethodManager.HIDE_NOT_ALWAYS);

ざっくり、こんな感じで書かないといけないらしく、Activityが必要だったりと、かなり面倒だなと思っていたら、

画像33

class PageNewFragment : Fragment() {
   
   ...

   private fun handleClickFAB() {
       val text = titleForm.editableText.toString()
       validateTitle(text)
       
       val titleError = titleForm.error
       if (titleError == null) {
           createPage()
           clearFocus()
           findNavController().navigateUp()
       }
   }

   ...

   private fun clearFocus() {
       titleForm.clearFocus()
       contentForm.clearFocus()
   }

EditTextのFocusをclearにしてあげたら行けました😃!
かなり楽な実装で済んで良かった。

これで、PageNewFragmentのUI実装も完了です!

[※ここから余談なので、興味ない方少し飛ばしてください]

後は、同じようにPageEditFragmentにもFormを実装してあげればUI実装が完全終了します!UI実装長すぎた!

苦節半月!最初の方は
・layoutの組み方
・どんなウィジェットあるの?
・Resource面倒くさくない?、でもこのResource一括管理の方がアプリのスケールに対応できるから仕方ないか
・What is the drawable resource?
・レイアウトの実装面倒くさすぎやろ!Flutterより大変だな・・お?Jetpack Compose? Flutterと同じ宣言型? よしやろう!→まだプレα版orz。
・ウィジェットのstyle、デフォルトでいい感じのスタイル適用してくれないんかorz

などのレイアウトのつらみや、

・ViewとかActivityとかのリファレンスサイト重すぎやろ!
・Activityのライフサイクル多すぎやろ・・orzどれ使えばええんや
・Fragmentってなんや・・Activityで十分やろ・・
→ただページをBackさせたいだけのとき、ActivityのparentActivityNameでは無理でしたorz

というところまで、いろいろつらみでしたが、
今では、かなり手に馴染んできて、ドキュメント片手に持って、たまにStackOverflowやQiitaも見とけば、難なく進められるようになってきたので、
一安心です😃。最初の山場を越えました。
今では、Androidで食っていきてえという好感度まで上がってきて、キャッチアップ自体も楽しく、AACの導入などの楽しみもあるので、このまま続けていきたいと思います。

[※余談終わり]

最後に、PageEditFragmentのUI実装をしていきますが、その前に、Formのレイアウトを使いまわすために、fragment_page_new.xmlから、Formの部分のレイアウトだけを別ファイルに切り出したいと思います。

まず、fragment_page_new.xmlで、Formをincludeタグに置き換えます。
(ConstraintLayoutの部分からincludeタグです)

> fragment_page_new.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <include
       layout="@layout/page_form"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_marginStart="16dp"
       android:layout_marginTop="16dp"
       android:layout_marginEnd="16dp"
       android:layout_marginBottom="16dp"/>

   <com.google.android.material.floatingactionbutton.FloatingActionButton
       android:id="@+id/page_new_floating_action_button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_gravity="bottom|end"
       android:layout_margin="16dp"
       android:src="@drawable/ic_check_black_24dp" />


</androidx.coordinatorlayout.widget.CoordinatorLayout>

次に、component_page_form.xmlという新しいレイアウトファイルを作成し、Formのレイアウトを定義します。
EditTextのIDも変更します。

> component_page_form.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"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <EditText
       android:id="@+id/component_page_form_title"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:autofillHints=""
       android:ems="10"
       android:hint="@string/page_new_page_form_title_hint"
       android:inputType="textCapWords"
       android:singleLine="true"
       android:textAllCaps="false"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <EditText
       android:id="@+id/component_page_form_content"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_marginTop="16dp"
       android:autofillHints=""
       android:ems="10"
       android:gravity="start|top"
       android:hint="@string/page_new_page_form_content_hint"
       android:inputType="textMultiLine|textCapSentences"
       android:minLines="@integer/page_new_page_form_content_min_lines"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/component_page_form_title" />
</androidx.constraintlayout.widget.ConstraintLayout>


それでは、PageEditFragmentのUI実装に入っていきましょう。
まずは、レイアウトの実装からです。(PageNewFragmentのレイアウトの、FABのIDを変更しただけです。)

> fragment_page_edit.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <include
       layout="@layout/component_page_form"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_marginStart="16dp"
       android:layout_marginTop="16dp"
       android:layout_marginEnd="16dp"
       android:layout_marginBottom="16dp"/>

   <com.google.android.material.floatingactionbutton.FloatingActionButton
       android:id="@+id/page_edit_floating_action_button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_gravity="bottom|end"
       android:layout_margin="16dp"
       android:src="@drawable/ic_check_black_24dp" />


</androidx.coordinatorlayout.widget.CoordinatorLayout>

次に、PageEditFragment内で、Formまわりのロジックの実装です。

まずは、ざっくりと中身をそのままコピペします。

class PageEditFragment : Fragment() {

   val args by navArgs<PageEditFragmentArgs>()

   private lateinit var titleForm: EditText
   private lateinit var contentForm: EditText
   private lateinit var floatingActionButton: FloatingActionButton

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_page_edit, container, false)
   }

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

       titleForm = view.findViewById(R.id.component_page_form_title)
       titleForm.addTextChangedListener(titleWatcher)

       contentForm = view.findViewById(R.id.component_page_form_content)

       floatingActionButton =
           view.findViewById<FloatingActionButton>(R.id.page_edit_floating_action_button).also {
               it.setOnClickListener { handleClickFAB() }
           }
   }

   private val titleWatcher = object : TextWatcher {
       override fun afterTextChanged(p0: Editable?) {
           validateTitle(p0.toString())
       }

       override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }

       override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }
   }

   private fun validateTitle(text: String) {
       titleForm.error = errorTextValidateTitle(text)
   }

   private fun errorTextValidateTitle(text: String): String? {
       return if (text.isEmpty()) {
           getString(R.string.page_new_page_form_title_validator_label)
       } else {
           null
       }
   }

   private fun handleClickFAB() {
       val text = titleForm.editableText.toString()
       validateTitle(text)

       val titleError = titleForm.error
       if (titleError == null) {
           createPage()
           clearFocus()
           findNavController().navigateUp()
       }
   }

   // TODO: Pageオブジェクトをデータに追加する
   private fun createPage() {
       val text = titleForm.editableText.toString()
       val content = contentForm.editableText.toString()
       val date = Date()
       // HACK: 一意なIDの生成について
       val id = (text + content + date.toString()).hashCode().toLong()

       val page = Page(id, text, content, date)
   }

   private fun clearFocus() {
       titleForm.clearFocus()
       contentForm.clearFocus()
   }


}

次に、細かいIDの部分など、PageEdit用に修正していきましょう。

画像34

Editの検索・置換機能で _new を _edit に置換します。

すると、R.string.page_edit_page_form_title_validator_labelという長い名前のStringリソース参照で、エラーがでます。

このままこれを実装するのではなく、リソース元のID名を変更することで対応します。

<string name="page_new_page_form_title_validator_label">Please enter the title.</string>
 ↓
<string name="component_page_form_title_validator_label">Please enter the title.</string>


次は、PageEditなので、Fragmentの引数に受け取っているPageEditFragmentArgsから、Pageオブジェクトを取得し、このPageオブジェクトを編集する形に変えていきます。

まず、引数のPageオブジェクトのtitle, contentパラメータの値をViewの初期化時に、それぞれのEditTextに挿入します。

class PageEditFragment : Fragment() {

   private val args by navArgs<PageEditFragmentArgs>()

   ...

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

       val page = pagesList.find { page -> page.id == args.pageId }

       titleForm = view.findViewById<EditText>(R.id.component_page_form_title).also {
           it.addTextChangedListener(titleWatcher)
       }
       contentForm = view.findViewById(R.id.component_page_form_content)

       page?.let {
           titleForm.setText(it.title)
           contentForm.setText(it.content)
       }

pageがまだNullableなので、page?.letでnullを弾きます。


・次
PageオブジェクトをCreateではなく、Updateするために、既存のcreatePageメソッドをupdatePageメソッドに変更します。

と言っても、createPageメソッドと同じく、実際にupdateはしないので、形だけで済ませます。

class PageEditFragment : Fragment() {

   ...

   private fun handleClickFAB() {
       
       ...

       if (titleError == null) {
           updatePage()

           ...

       }
   }

   // TODO: PageオブジェクトをUpdateする
   private fun updatePage() {
       val text = titleForm.editableText.toString()
       val content = contentForm.editableText.toString()
       val date = Date()
   }


これでTODOアプリのUI実装編は終了です!お疲れ様でした!

と言いたいところなのですが、もうちょっと続きますorz。(はよ終われ

・次
Formのユーティリティクラスを作って、PageオブジェクトのFormまわりのロジックを一か所で管理したいと思います。

ちょっと経験ないのですが、頑張ります。

・次
まず考えやすくするために、ざっとPageEditPageNewで使っている共通のロジックを洗い出してみます。
> fun onViewCreated

titleForm = view.findViewById<EditText>(R.id.component_page_form_title).also {
   it.addTextChangedListener(titleWatcher)
}
contentForm = view.findViewById(R.id.component_page_form_content)
private val titleWatcher = object : TextWatcher {
   override fun afterTextChanged(p0: Editable?) {
       validateTitle(p0.toString())
   }

   override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
   }

   override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
   }
}
private fun validateTitle(text: String) {
   titleForm.error = errorTextValidateTitle(text)
}
private fun errorTextValidateTitle(text: String): String? {
   return if (text.isEmpty()) {
       getString(R.string.component_page_form_title_validator_label)
   } else {
       null
   }
}
> fun handleClickFAB() 

val text = titleForm.editableText.toString()
validateTitle(text)

val titleError = titleForm.error
if (titleError == null) {

   ...

   clearFocus()
   findNavController().navigateUp()
}
private fun clearFocus() {
   titleForm.clearFocus()
   contentForm.clearFocus()
}
・次
ViewとFormの関心に分けてみる

上から分けていきましょう。

View

> fun onViewCreated

titleForm = view.findViewById<EditText>(R.id.component_page_form_title).also {
   it.addTextChangedListener(titleWatcher)
}
contentForm = view.findViewById(R.id.component_page_form_content)


View

private val titleWatcher = object : TextWatcher {
   override fun afterTextChanged(p0: Editable?) {
       validateTitle(p0.toString())
   }

   override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
   }

   override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
   }
}


View

private fun validateTitle(text: String) {
   titleForm.error = errorTextValidateTitle(text)
}


Form

private fun errorTextValidateTitle(text: String): String? {
   return if (text.isEmpty()) {
       getString(R.string.component_page_form_title_validator_label)
   } else {
       null
   }
}


View

> fun handleClickFAB() 

val text = titleForm.editableText.toString()
validateTitle(text)

val titleError = titleForm.error
if (titleError == null) {

   ...

   clearFocus()
   findNavController().navigateUp()
}


View

private fun clearFocus() {
   titleForm.clearFocus()
   contentForm.clearFocus()
}


バリデーション以外View🙄!大体EditTextに依存した処理ですね。

この地点でViewとFormに分かれたので、Formの方だけユーティリティクラスに実装していけばいい、と思ったのですが、

Formはレイアウトが共通なので、共通レイアウトのViewに依存したロジックも共通にできないか調べたところ、Custom View に辿り着きました!

特に、ドキュメントに載っているやり方で、レイアウト(LinearLayoutなど)を拡張することで、簡単にレイアウトとロジックを持つコンポーネントが作れるようなので、早速やっていきます。

・これからやること
ConstraintLayoutを拡張して、PageFormComponentというカスタムコンポーネントを作り、その中にFormのEditTextなどに依存したロジックを移す
その後、PageNewFragment, PageEditFragmentからPageFormComponentを呼ぶ。

ボス戦前なのでセーブしておきます。セーブした内容はこちら。(commit)


カスタムコンポーネントを作りリファクタリングする

それではいきましょう。

・次
まずは、page_form.ktというカスタムコンポーネント用のファイルを作成します。
・次
ConstraintLayout
を拡張したPageFormComponentクラスを定義する。

ドキュメントを参考に、作成してみます。

class PageFormComponent(
   context: Context, attrs: AttributeSet
) : ConstraintLayout(context, attrs)
・次
元々定義していた、component_page_formレイアウトをinflateして、PageNew, PageEditのレイアウトに組み込み、表示させる。

ここまでたどり着くのに結構はまりました🙄。

class PageFormComponent(
   context: Context, attrs: AttributeSet
) : ConstraintLayout(context, attrs) {

   init {
       LayoutInflater.from(context).inflate(R.layout.component_page_form, this, true)
   }
}
> fragment_page_new.xml, fragment_page_edit.xml

<include
   layout="@layout/component_page_form"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:layout_marginStart="16dp"
   android:layout_marginTop="16dp"
   android:layout_marginEnd="16dp"
   android:layout_marginBottom="16dp" />

 ↓

<view
   class="hidaka.kotlinstudy.todo.ui_component.PageFormComponent"
   android:id="@id+/page_form_component_host"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:layout_marginStart="16dp"
   android:layout_marginTop="16dp"
   android:layout_marginEnd="16dp"
   android:layout_marginBottom="16dp" />

既存のレイアウトファイルをカスタムビューに適用する方法は、init関数内でLayoutInflator.from.inflateメソッドでいいようです。

AttributeSetという引数には、レイアウトファイルのviewタグ内に定義したattributeを取ってくれます。viewタグ内に定義したattributeをそのままConstraintLayoutに渡しています。

また、ConstraintLayoutではdefStyleAttr: Int という3つめの引数が取れるようで、

class PageFormComponent(
   context: Context, attrs: AttributeSet, defStyleAttr: Int
) : ConstraintLayout(context, attrs, defStyleAttr) {

前2つと同じように扱っていて、ずっとErrorが出ていました🙃。

途中、自分で好きにstyleを入れれるのかなと思い、

class PageFormComponent(
   context: Context, attrs: AttributeSet, defStyleAttr: Int
) : ConstraintLayout(context, attrs, R.style.AppTheme) {

適当にstyleを入れましたが、はまりました🙃。
Context, AttributeSetと違って、レイアウトファイルにただ書くだけじゃ値を取ってくれないようです。前2つと同じ扱いだと思っててはまりました🙄。

・次
Viewは用意できたので、PageFormComponentロジックを移し、
PageNewFragment内
から、PageFormComponentを参照する。

titleForm, contentFormに依存したロジックを取り出しましょう。

class PageFormComponent(
   context: Context, attrs: AttributeSet
) : ConstraintLayout(context, attrs, R.style.AppTheme) {

   val titleForm: EditText
   val contentForm: EditText

   init {
       LayoutInflater.from(context).inflate(R.layout.component_page_form, this, true)

       titleForm = findViewById<EditText>(R.id.component_page_form_title).also {
           it.addTextChangedListener(titleWatcher)
       }
       contentForm = findViewById(R.id.component_page_form_content)
   }

   private val titleWatcher = object : TextWatcher {
       override fun afterTextChanged(p0: Editable?) {
           validateTitle(p0.toString())
       }

       override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }

       override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
       }
   }

   fun validateTitle(text: String) {
       titleForm.error = errorTextValidateTitle(text)
   }

   private fun errorTextValidateTitle(text: String): String? {
       return if (text.isEmpty()) {
           resources.getString(R.string.component_page_form_title_validator_label)
       } else {
           null
       }
   }
}


class PageNewFragment : Fragment() {
   private lateinit var pageFormComponent: PageFormComponent
   private lateinit var floatingActionButton: FloatingActionButton

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_page_new, container, false)
   }

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

       pageFormComponent = view.findViewById(R.id.page_form_component_host)

       floatingActionButton =
           view.findViewById<FloatingActionButton>(R.id.page_new_floating_action_button).also {
               it.setOnClickListener { handleClickFAB() }
           }
   }

   private fun handleClickFAB() {
       val text = pageFormComponent.titleForm.editableText.toString()
       pageFormComponent.validateTitle(text)

       val titleError = pageFormComponent.titleForm.error
       if (titleError == null) {
           createPage()
           pageFormComponent.clearFocus()
           findNavController().navigateUp()
       }
   }

   // TODO: Pageオブジェクトをデータに追加する
   private fun createPage() {
       val text = pageFormComponent.titleForm.editableText.toString()
       val content = pageFormComponent.contentForm.editableText.toString()
       val date = Date()
       // HACK: 一意なIDの生成について
       val id = (text + content + date.toString()).hashCode().toLong()

       val page = Page(id, text, content, date)
   }

}
・次
PageFormComponenttitleとcontentを返す、getter/setterを定義し、冗長な参照を取り除きます。
class PageFormComponent(
   context: Context, attrs: AttributeSet
) : ConstraintLayout(context, attrs) {

   ...

   val titleForm: EditText = findViewById<EditText>(R.id.component_page_form_title).also {
       it.addTextChangedListener(titleWatcher)
   }
   val contentForm: EditText = findViewById(R.id.component_page_form_content)

   var title: String
       get() = titleForm.text.toString()
       set(value) {
           titleForm.setText(value)
       }
   var content: String
       get() = contentForm.text.toString()
       set(value) {
           contentForm.setText(value)
       }
}

setterはPageEditFragmentのとき使います。

一部変数名を変更します。

> PageNewFragment

・text -> title
・pageFormComponent -> pageForm
class PageNewFragment : Fragment() {

   ...

   private fun handleClickFAB() {
       val title = pageForm.title
       pageForm.validateTitle(title)

       val titleError = pageForm.titleForm.error
       if (titleError == null) {
           createPage()
           pageForm.clearFocus()
           findNavController().navigateUp()
       }
   }

   // TODO: Pageオブジェクトをデータに追加する
   private fun createPage() {
       val title = pageForm.title
       val content = pageForm.content
       val date = Date()
       // HACK: 一意なIDの生成について
       val id = (title + content + date.toString()).hashCode().toLong()

       val page = Page(id, title, content, date)
   }

}
・次
同じように、PageEditFragmentもリファクタリングします。

setterを使って、Formに初期値を与えることも忘れずにいきましょう。

class PageEditFragment : Fragment() {

   private val args by navArgs<PageEditFragmentArgs>()

   private lateinit var pageForm: PageFormComponent
   private lateinit var floatingActionButton: FloatingActionButton

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_page_new, container, false)
   }

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

       val page = pagesList.find { page -> page.id == args.pageId }

       pageForm = view.findViewById(R.id.page_form_component_host)
       page?.let {
           pageForm.title = it.title
           pageForm.content = it.content
       }

       floatingActionButton =
           view.findViewById<FloatingActionButton>(R.id.page_new_floating_action_button).also {
               it.setOnClickListener { handleClickFAB() }
           }
   }

   private fun handleClickFAB() {
       val title = pageForm.title
       pageForm.validateTitle(title)

       val titleError = pageForm.titleForm.error
       if (titleError == null) {
           updatePage()
           pageForm.clearFocus()
           findNavController().navigateUp()
       }
   }

   // TODO: PageオブジェクトをUpdateする
   private fun updatePage() {
       val title = pageForm.title
       val content = pageForm.content
       val date = Date()
   }

}


うん。なんか結構きれいに分けれた気がしています😃。
NullableなPageオブジェクトに依存することなく、Formコンポーネントができました!

気になったので、もう一か所変更を加えます。
PageNew, PageEdit両者の、handleClickFAB内で、条件分岐ロジックを書いてしまっていたので、これもPageFormComponentに移します。

> PageEditFragment, PageNewFragment

...

private fun handleClickFAB() {
   val isTitleValidate = pageForm.isTitleValidate()
   if (isTitleValidate) {
       updatePage()
       pageForm.clearFocus()
       findNavController().navigateUp()
   }
}
> PageFormComponent

fun isFormValidate(): Boolean {
   validateTitle(title)
   return titleForm.error == null
}

これで個人的には思い残すことはありません。

ここまでのコードはこちら。


おわりに

ということで、UI実装はできました!
ここから状態管理を投入して、Pageオブジェクトの登録・削除・編集が即時反映されるようにしていきます。

最後まで見て頂き、ありがとうございました!


Github
https://github.com/KazuyoshiHidaka

記事はいかがでしたか?サポートをして頂けると、もりもり成長していきます!