[ハンズオンあり]Android Navigation Component~Activityベースからの移行~
今回は、AndroidでUIの実装をActivityベースからFragmentベースに変更し、Navigation Componentを導入していきます。
読みながら手も動かして、ActivityからNavigation Componentへの移行を体に叩きつけたい方は、
git clone -b before-migration-to-navigation-component \
https://github.com/KazuyoshiHidaka/kotlin_todo \
migration_to_navigation_component
このコマンドからこちらのブランチをcloneしてください。
それでは始めていきます。公式ドキュメントを参考に進めていきます。
依存関係の宣言
公式ドキュメントを参考に、入れてください。
フラグメントの導入
まずは、フラグメントの導入からです。
MainActivityから導入していきます。
まずは、MainFragmentをレイアウトファイルと一緒に生成し、MainActivityのレイアウトの中身を移します。
> fragment_main.xml
<?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.recyclerview.widget.RecyclerView
android:id="@+id/main_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/main_floating_action_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_add_black_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
MainActivityのレイアウトの移行が済んだので、MainFragmentをホストするためだけのシンプルなレイアウトに変更します。
> activity_main.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_fragment_host"
android:layout_height="match_parent"
android:layout_width="match_parent" />
MainActivityのUIロジックをMainFragmentに移行していきます。
FragmentのonCreateViewで、LayoutInflatorを使用しRoot Viewを用意し、残りのRoot Viewの子供にあたるView達は、onViewCreate内で操作していきます。
class MainFragment : Fragment() {
private lateinit var pagesRecyclerView: RecyclerView
private lateinit var pagesRecyclerViewComponent: PagesRecyclerViewComponent
private lateinit var floatingActionButton: FloatingActionButton
MainActivityでlateinitしてある変数を移行し、
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_main, container, false)
}
onCreateViewはAndroid Studioがいい感じに生成してくれたので、onViewCreatedだけを実装していきましょう。
まずはMainActivityのonCreate内で初期化しているView達を移行します。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesRecyclerView = findViewById<RecyclerView>(R.id.main_recycler_view).also {
pagesRecyclerViewComponent =
PagesRecyclerViewComponent(pagesList, it.context, ::startActivity)
it.setHasFixedSize(true)
it.layoutManager = pagesRecyclerViewComponent.viewManager
it.adapter = pagesRecyclerViewComponent.viewAdapter
}
floatingActionButton =
findViewById<FloatingActionButton>(R.id.main_floating_action_button).also {
it.setOnClickListener {
val intent = Intent(this, PageNewActivity::class.java)
startActivity(intent)
}
}
}
エラーの対処をしていきます。
・findViewByIdがFragmentには定義されていない
onViewCreatedの引数にViewが渡されているので、View.findViewByIdが使えます。
pagesRecyclerView = view.findViewById<>()
・Intentの定義とstartActivity
公式ドキュメントのやり方を参考に対処していきます。
公式ドキュメントでは、ページ遷移のためのIntentの定義、startActivityを一つの関数としてActivity内で定義し、
fun show(product: Product) {
val intent = Intent(this, ProductActivity::class.java)
intent.putExtra(ProductActivity.KEY_PRODUCT_ID, product.id)
startActivity(intent)
}
このように、Fragment内で呼び出しています。
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
(requireActivity() as ProductListActivity).show(product)
}
これを参考に修正していきます。
fun navigateToNewDetail() {
val intent = Intent(this, PageNewActivity::class.java)
startActivity(intent)
}
MainActivityに定義し、
private fun fabClickCallback() {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
(requireActivity() as MainActivity).navigateToPageNew()
}
}
MainFragment内で受け取ります。
最後に、MainActivity内でMainFragmentを初期化します。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_fragment_host)
if (savedInstanceState == null) {
val fragment = MainFragment()
supportFragmentManager
.beginTransaction()
.add(R.id.main_content, fragment)
.commit()
}
}
ここまででMainActivityのFragment化が済んだので、ささっと動作確認などができるようになります。
同じ要領で、PageDetailActivity、PageNewActivity、PageEditActivityを移行していきます。
PageDetailActivityでは、Intentから引数を受け取っていますが、ドキュメントを参考に、
fragment.arguments = intent.extras
PageDetailActivity内では、fragment.argumentsにそのまま受け渡し、
val page: Page? =
arguments?.getParcelable(PagesRecyclerViewComponent.PUT_EXTRA_KEY_PAGE_DETAIL)
PageDetailFragment内では、argumentsから受け取ります。
PageNewActivity、PageEditActivityは空なので移行もすぐです😅
移行が完了してエラーが特に無ければ、Navigation Componentの導入に入っていきましょう。(UIテスト用意してなくてすいません)
ここまでのコードはこちらです。
Navigation Componentを統合する
次は、本題のNavigation Componentの導入です。
公式ドキュメントを参考に、アプリ内のナビゲーションを視覚的に編集することができるナビゲーショングラフを作成します。
navigation resource file を作成します。
MainActivityのレイアウトに、今作ったナビゲーショングラフを導入します。
> activity_main.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="match_parent"
android:layout_width="match_parent">
<fragment
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/main_graph"
app:defaultNavHost="true"
android:id="@+id/main_fragment_host"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
Navigation コンポーネントは、アクティビティをナビゲーションのホストとして使用し、ユーザーがアプリ内を移動する際、各フラグメントをそのホストにスワップします。アプリのナビゲーションを視覚的にレイアウトするには、このグラフをホストするアクティビティ内に NavHost を設定する必要があります。フラグメントを使用しているため、Navigation コンポーネントのデフォルト NavHost 実装である NavHostFragment を使用できます。
app:NavGraph 属性は、このナビゲーション ホストに関連付けられているナビゲーション グラフをポイントします。このプロパティを設定すると、ナビゲーション グラフがインフレートし、NavHostFragment に対してグラフ プロパティが設定されます。app:defaultNavHost 属性の設定により、NavHostFragment がシステムの [戻る] ボタンを確実にインターセプトするようになります。
ナビゲーショングラフを編集していきます。
左上のNew Destinationアイコンから、定義したFragmentをわちゃわちゃ並べてみます。
矢印をドラッグして、actionを定義できます。
Navigation コンポーネントでは、ユーザーがデスティネーション間を移動する仕組みのことを「アクション」と呼びます。アクションでは、遷移アニメーションやポップ動作を表現することもできます。
フラグメントに渡す引数も定義できます。
今回の例では、pageDetailFragmentとpageEditFragmentにNotNullなLongキーを渡します。
navigationの定義もしたので、後はFragment用のページ遷移に置き換えていきます。ページ遷移時、Fragmentに型安全な引数を渡すために、SafeArgsプラグインを導入します。
SafeArgsプラグインを導入すると、ナビゲーションのディスティネーション内に定義したactionタグに基づいてDirectionクラスを、 argumentタグに基づいてArgumentsクラスを生成してくれます。
Navigationのページ遷移では、NavControllerにDirectionを与えることでディスティネーション間の遷移ができます。
それではSafeArgsプラグインを導入していきましょう。
まずは、ドキュメントを参考にプロジェクトに追加しましょう。
少し前の手順で、既にfragmentタグ内にactionとargumentを定義したので、SafeArgsプラグインを導入したら、Rebuild Projectをしましょう!(Rebuild Projectにたどり着くまで30分以上はまった)
ちゃんとSafeArgsが導入できていると、この予測変換のようにArgs, Directionクラスが生成されます。
このDirectionとNavControllerを使って、早速置き換えていきます。
MainActivityのfabClickCallback()メソッドから行きましょう。
元々こんな感じでActivity間遷移を実装していましたが、
private fun fabClickCallback() {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
(requireActivity() as MainActivity).navigateToPageNew()
}
}
private fun handleClickFab() {
val directions = MainFragmentDirections.actionMainFragmentToPageNewFragment()
findNavController().navigate(directions)
}
ディスティネーション間遷移に置き換えます。
すると、
PageNewFragmentのレイアウトが空なので分かりにくいですが、ページのコンテンツ部分は遷移できていると思います。
MainActivityのレイアウトがこんな感じになっていますが、
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="match_parent"
android:layout_width="match_parent">
<fragment
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/main_graph"
app:defaultNavHost="true"
android:id="@+id/main_fragment_host"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
このfragmentタグの部分がnavigationによって遷移しているため、MainActivityのActionBarや、FrameLayoutはそのままになっています。
引き続き、他のFragment間の遷移も導入していきます。
次は、MainFragmentから、PageDetailFragmentへの遷移の導入です。
MainFragment内にページ遷移用の関数を定義します。
private fun handleClickViewHolder(pageId: Long) {
val directions = MainFragmentDirections.actionMainFragmentToPageDetailFragment(pageId)
findNavController().navigate(directions)
}
今回は、引数がついています。この関数をViewHolderのClickListenerに渡します。
※ここから申し訳ないのですが、修正にお付き合いください
ここで、PagesRecyclerViewComponentというオレオレコンポーネントをいじってもらいます。(土下座)
修正の目的は、MainFragment内のhandleClickViewHolderメソッドをPagesRecyclerView内のViewHolderのClickListenerに渡すためです。
MainFragmentのonViewCreated内のこの部分。
pagesRecyclerViewComponent =
PagesRecyclerViewComponent(pagesList, it.context, ::handleClickViewHolder)
第三引数の ::startActivity を ::handleClickViewHolder に変更します。
次に、pagesRecyclerViewComponentの定義ファイルに移動し、
(hidaka.kotlinstudy.todo.ui_component.pages_recycler_view.kt)
PagesRecyclerViewComponentクラスの3つめのhandleClickVHコンストラクタの型を変更し
class PagesRecyclerViewComponent(
data: Array<Page>,
context: Context,
handleClickVH: (Long) -> Unit
) {
...
}
PagesRecyclerViewComponentの子クラス、MyAdapterクラスのhandleClickVHコンストラクタも (Long) -> Unit 型に変更します。
最後に、MyAdapterクラス内の onBindViewHolderメソッド内を
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val page = data[position]
holder.card.setOnClickListener {
handleClickVH(page.id)
}
holder.title.text = page.title
}
セットします。
修正ありがとうございました。
引き続き、MainFragmentから、PageDetailFragmentへのディスティネーション間遷移の導入をしていきます。
この地点で、MainFragmentからPageDetailFragmentへの引数付き遷移が完了したので、後は、PageDetailFragment内で、引数を受け取ってViewに反映させます。公式ドキュメントの書き方を参考にします。
class PageDetailFragment : Fragment() {
private val args by navArgs<PageDetailFragmentArgs>()
...
}
※この時、navArgsにエラーが出た場合、
ドキュメントなどを参考に、Java 8の言語機能をサポートするように設定しましょう。
kotlinの場合、kotlinOptionsの記述だけで適用されます。
> app/build.gradle
android {
...
kotlinOptions {
jvmTarget = "1.8"
}
...
}
続けていきます。
navArgsに、SafeArgsプラグインで生成した、Argumentsクラスを与えると引数を受け取ることができます。
後は、受け取ったpageIdを使って実装をしていきます。
PageDetailFragmentのonViewCreatedメソッド内で、Pageインスタンスを初期化します。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val page: Page? = pagesList.find { page -> page.id == args.pageId }
...
後は、PageDetailFragmentのfabClickCallbackメソッドも書き換えて、PageEditFragmentへの遷移も実現しましょう。(メソッド名も変えます)
class PageDetailFragment: Fragment() {
...
private fun handleClickFab() {
val pageId = args.pageId
val directions = PageDetailFragmentDirections.actionPageDetailFragmentToPageEditFragment(pageId)
findNavController().navigate(directions)
}
}
そしてついに・・・
単一Activityへの移行ができました!
後は、少し整理します。
PageDetailFragmentのhandleClickFabメソッドと、
MainFragmentのhandleClickFabメソッド、handleClickViewHolderメソッドの名前を変更します。
> PageDetailFragment
private fun handleClickFab() {
↓
private fun navigateToPageEdit() {
> MainFragment
private fun handleClickFab() {
↓
private fun navigateToPageNew() {
private fun handleClickViewHolder(pageId: Long) {
↓
private fun navigateToPageDetail() {
後は、PageEditFragmentのViewはまだ未実装なので意味はないですが、
引数の受け取りだけしておきます。
class PageEditFragment : Fragment() {
val args by navArgs<PageEditFragmentArgs>()
...
}
これで整理は完了です。一応Activityはまだ消さないでおきます。
今はまだ、ActionBarがMainActivityのままなので、次は、FragmentごとにActionBarなどのUIを変更していきます。
後、コミットするときにAndroid Studioに怒られて、activity_main.xml内のfragmentタグをFragmentContainerViewに変更しました。
※追記
FragmentContainerViewに変更したのですが、このあとの作業でnavigationが変更後のFragmentContainerViewをNavHostとして認識せず、はまったので、fragmentタグに戻しました🙄
> res/layout/activity_main.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="match_parent"
android:layout_width="match_parent">
<androidx.fragment.app.FragmentContainerView
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/main_graph"
app:defaultNavHost="true"
android:id="@+id/main_fragment_host"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
NavigationUI を使用して UI コンポーネントを更新する
次は、Navigation Componentに含まれているNavigationUIというクラスを使って、ActionBarやDrawer, BottomNavigationなどの、通常、Navigationで管理しているFragmentレイアウトの、外側にあるUI要素を制御していきます。
具体的にどうやってUI要素を制御していくかというと、
NavControllerの onDistinationChangedListenerインターフェースを使って制御できます。
onDistinationChangedListenerとは、NavControllerの現在のディスティネーション、または、与えられている引数が変更されたとき、発火されるリスナーイベントです。
それでは、ドキュメントを参考にActionBar(AppBar)の制御をしていきます。
activity_main.xmlにToolbarを追加して、
<FrameLayout 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.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<fragment
android:id="@+id/main_fragment_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/main_graph" />
</FrameLayout>
MainActivityのonCreateメソッド内でToolbarを初期化します。
class MainActivity : ... {
override fun onCreate(...) {
...
val navController = findNavController(R.id.main_fragment_host)
val appBarConfiguration = AppBarConfiguration(navController.graph)
findViewById<Toolbar>(R.id.toolbar)
.setupWithNavController(navController, appBarConfiguration)
}
}
AppBarConfigurationというクラスはドキュメントにも載っているのですが、
AppBarの左上に出てくる戻るボタンを自動で追加してくれるやつです。
Navigationのトップレベルのディスティネーション以外のディスティネーションにいるとき、戻るボタンを表示してくれます。
val appBarConfiguration = AppBarConfiguration(setOf(R.id.main, R.id.android))
このように、トップレベルのディスティネーションをカスタムして複数セットすることもできます。IDの部分は、ディスティネーションのIDです。
すると・・
違う、そうじゃない。
というわけでデフォルトのAppBarにNavigationを組み込んでいきます。
ドキュメント片手にいきます。🙃
MainActivityのonCreateメソッド内で、setupActionBarWithNavControllerを呼んであげればいいようです。
class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
override fun onCreate(savedInstanceState: Bundle?) {
...
val navController = findNavController(R.id.main_fragment_host)
appBarConfiguration = AppBarConfiguration(navController.graph)
setupActionBarWithNavController(navController, appBarConfiguration)
}
}
(appBarConfigurationは、onCreate以外のメソッドでも使用するので、外側で宣言します)
(どうでもいいけどここからGIFが綺麗になります😃)
このままでは戻るボタンが上手く聞いていないので、MainActivityでonSupportNavigateUp()メソッドも定義しましょう。
class MainActivity {
...
override fun onSupportNavigateUp(): Boolean {
val navController = findNavController(R.id.main_fragment_host)
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
}
Activity本来の戻る動作をNavigationの戻る動作に置き換えています。
上手く動作していますね。
後は、ナビゲーショングラフからAppBarに表示されているラベルを変更します。
mainFragment -> "@string/main_title"
pageNewFragment -> "@string/page_new_title"
pageDetailFragment -> "@string/page_detail_title"
pageEditFragment -> "@string/page_edit_title"
それぞれ string resourceへの参照に置き換えて、
元々あったリソースから、
<resources>
<string name="app_name">Note</string>
<string name="page_new_activity_title">New Page</string>
<string name="page_edit_activity_title">Edit Page</string>
<string name="page_detail_activity_title">Page</string>
<string name="page_detail_activity_updated_at_label">Last Updated: %1$s</string>
</resources>
<resources>
<string name="app_name">Note</string>
<string name="main_title">My Note</string>
<string name="page_new_title">New</string>
<string name="page_edit_title">Edit</string>
<string name="page_detail_title">Detail</string>
<string name="page_detail_updated_at_label">Last Updated: %1$s</string>
</resources>
下の方のリソースに置き換えましょう。
後は、このまま実行しようとすると参照エラーが出ると思うので、修正していきます。
> AndroidManifest.xml
...
<application>
<activity
android:name=".ui.PageNewActivity"
android:label="@string/page_new_title"
android:parentActivityName=".ui.PageDetailActivity" />
<activity
android:name=".ui.PageEditActivity"
android:label="@string/page_edit_title"
android:parentActivityName=".ui.PageDetailActivity" />
<activity
android:name=".ui.PageDetailActivity"
android:label="@string/page_detail_title"
android:parentActivityName=".MainActivity" />
applicationタグ内の上3つのactivityタグのlabelと、(一応まだ残しておきます)
> res/layout/fragment_page_detail.xml
...
<ConstraintLayout>
<TextView>
<TextView
style="@style/TextAppearance.MaterialComponents.Caption"
android:id="@+id/page_detail_updated_at"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:text="@string/page_detail_activity_updated_at_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/page_detail_title" />
<TextView>
fragment_page_detailレイアウトリソース内の、2つめのTextViewのlabel、
> fragment/PageDetailFragment.kt
class PageDetailFragment {
...
override fun onViewCreated {
...
pageUpdatedAt = view.findViewById<TextView>(R.id.page_detail_updated_at).also {
it.text = getString(
R.string.page_detail_updated_at_label,
DateFormat.getDateInstance(DateFormat.LONG).format(page?.updatedAt ?: Date())
)
}
pageUpdatedAt内の、string resource ID。
そしてついに・・・
OK😄
おわりに
今回は、ActivityベースからFragmentベースへの移行を含めて、基本的なNavigation Componentの使い方を学べました。
DrawerやBottomNavigationBarなどのUI要素には触れられていませんが、Navigation Componentの導入が出来れば、これらのUI要素の制御も、ドキュメントを参考に簡単にできるかと思います。
記事はいかがでしたか?サポートをして頂けると、もりもり成長していきます!