[ハンズオンあり]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のレイアウトの中身を移します。

画像1

> 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" />


MainActivityUIロジックをMainFragmentに移行していきます。

FragmentonCreateViewで、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

MainActivitylateinitしてある変数を移行し、


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だけを実装していきましょう。

まずはMainActivityonCreate内で初期化している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化が済んだので、ささっと動作確認などができるようになります。

同じ要領で、PageDetailActivityPageNewActivityPageEditActivityを移行していきます。

PageDetailActivityでは、Intentから引数を受け取っていますが、ドキュメントを参考に

fragment.arguments = intent.extras

PageDetailActivity内では、fragment.argumentsにそのまま受け渡し、

val page: Page? =
    arguments?.getParcelable(PagesRecyclerViewComponent.PUT_EXTRA_KEY_PAGE_DETAIL)

PageDetailFragment内では、argumentsから受け取ります。


PageNewActivityPageEditActivityは空なので移行もすぐです😅

移行が完了してエラーが特に無ければ、Navigation Componentの導入に入っていきましょう。(UIテスト用意してなくてすいません)

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


Navigation Componentを統合する

次は、本題のNavigation Componentの導入です。

公式ドキュメントを参考に、アプリ内のナビゲーションを視覚的に編集することができるナビゲーショングラフを作成します。

画像2

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 がシステムの [戻る] ボタンを確実にインターセプトするようになります。


ナビゲーショングラフを編集していきます。

画像3


左上のNew Destinationアイコンから、定義したFragmentをわちゃわちゃ並べてみます。

画像4


矢印をドラッグして、actionを定義できます。

画像6

Navigation コンポーネントでは、ユーザーがデスティネーション間を移動する仕組みのことを「アクション」と呼びます。アクションでは、遷移アニメーションやポップ動作を表現することもできます。


フラグメントに渡す引数も定義できます。

画像6

今回の例では、pageDetailFragmentpageEditFragmentにNotNullなLongキーを渡します。

navigationの定義もしたので、後はFragment用のページ遷移に置き換えていきます。ページ遷移時、Fragmentに型安全な引数を渡すために、SafeArgsプラグインを導入します。

SafeArgsプラグインを導入すると、ナビゲーションのディスティネーション内に定義したactionタグに基づいてDirectionクラスを、 argumentタグに基づいてArgumentsクラスを生成してくれます。
Navigationのページ遷移では、NavControllerにDirectionを与えることでディスティネーション間の遷移ができます。


それではSafeArgsプラグインを導入していきましょう。
まずは、ドキュメントを参考にプロジェクトに追加しましょう。

少し前の手順で、既にfragmentタグ内にactionとargumentを定義したので、SafeArgsプラグインを導入したら、Rebuild Projectをしましょう!(Rebuild Projectにたどり着くまで30分以上はまった)

画像7

ちゃんとSafeArgsが導入できていると、この予測変換のようにArgs, Directionクラスが生成されます。

このDirectionNavControllerを使って、早速置き換えていきます。
MainActivityfabClickCallback()メソッドから行きましょう。

元々こんな感じで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)
}

ディスティネーション間遷移に置き換えます。

すると、

画像8

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に渡すためです。

MainFragmentonViewCreated内のこの部分。

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にエラーが出た場合、

画像9

ドキュメントなどを参考に、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 }
  
  ...


後は、PageDetailFragmentfabClickCallbackメソッドも書き換えて、PageEditFragmentへの遷移も実現しましょう。(メソッド名も変えます

class PageDetailFragment: Fragment() {
    ...

    private fun handleClickFab() {
        val pageId = args.pageId
        val directions = PageDetailFragmentDirections.actionPageDetailFragmentToPageEditFragment(pageId)
    
        findNavController().navigate(directions)
    }
}


そしてついに・・・

画像10

単一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.xmlToolbarを追加して、

<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>


MainActivityonCreateメソッド内で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です。

すると・・

画像11

違う、そうじゃない。

というわけでデフォルトの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以外のメソッドでも使用するので、外側で宣言します)


画像12

(どうでもいいけどここから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の戻る動作に置き換えています。

画像13

上手く動作していますね。

後は、ナビゲーショングラフからAppBarに表示されているラベルを変更します。

画像14

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。

そしてついに・・・

画像15

OK😄

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


おわりに

今回は、ActivityベースからFragmentベースへの移行を含めて、基本的なNavigation Componentの使い方を学べました。

DrawerやBottomNavigationBarなどのUI要素には触れられていませんが、Navigation Componentの導入が出来れば、これらのUI要素の制御も、ドキュメントを参考に簡単にできるかと思います。

これらのUI要素の制御に関する資料はこちらです。


Github
https://github.com/KazuyoshiHidaka

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