日々是好日

プログラミングについてのあれこれ、ムダ知識など

CoordinatorLayout+BottomNavigationView+NestedScrollView+その他諸々で画面構築してみた

次のぎじゅつを使って画面構築してみたのでメモ

  • 見た目はToolbar+コンテンツ表示Fragment+BottomNavigationViewの画面構成
  • コンテンツ表示Fragmentは、BottomNavigationViewの選択により入れ替える
  • Toolbarはコンテンツ表示Fragmentのスクロール動作により隠れるようにする
  • Toolbarのタイトルを、Navigationを使ってBottomNavigationViewに連動させる
  • BottomNavigationViewは固定する

BottomNavigationView & Toolbar & Navigation 間の連携に微妙なコツがあったり、 そもFrameLayoutであることを考慮したり、いろいろハマりまくった。

スクロールでBottomNavigationViewも隠したい場合は、カスタムBehavior定義して設定してやればおけ。 個人的には画面切り替えにスクロール動作が増えてしまうので、あえて固定する方法をとった。

動作プレビュー

動きはこんな感じです。 BottomNavigationViewのMenuをタップしてコンテンツ切り替え。

f:id:kcpoipoi:20210129005514g:plain

レイアウトXML

main_activity.xmlはこんな感じ。属性は適宜省略しています。

<layout>
  <data>
    <variable name="viewModel"
      type="MainActivityViewModel" />
  </data>

  <androidx.coordinatorlayout.widget.CoordinatorLayout>
    <com.google.android.material.appbar.AppBarLayout>

      <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:minHeight="?attr/actionBarSize"
        app:layout_scrollFlags="scroll|enterAlways" />

    </com.google.android.material.appbar.AppBarLayout>

    <!-- コンテンツ表示Fragment -->
    <androidx.fragment.app.FragmentContainerView
      android:id="@+id/nav_container"
      android:name="androidx.navigation.fragment.NavHostFragment"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:layout_gravity="start"
      android:layout_marginBottom="?attr/actionBarSize"
      app:defaultNavHost="true"
      app:layout_behavior="@string/appbar_scrolling_view_behavior"
      app:navGraph="@navigation/nav_graph" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
      android:id="@+id/bottom_navigation"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="bottom"
      app:labelVisibilityMode="unlabeled"
      app:menu="@menu/bottom_menu" />

  </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

コンテンツの一例はコレ。 <layout>の直下に<NestedScrollView>を入れておくと、Fragmentがinflateされたときにイイ感じにFragmentContainerViewに展開してくれる (<merge>タグと同じ働きをしてくれるようです。)。

<layout>
  <data>
    <variable name="viewModel"
      type="MainActivityViewModel" />
  </data>

  <androidx.core.widget.NestedScrollView>
    <LinearLayout>
      <androidx.recyclerview.widget.RecyclerView/>
      <!-- リソースもバインドできる -->
      <include
        android:id="@+id/tag"
        layout="@layout/chip_group_layout"
        app:groupName="@{@string/tag}" />
      <include
        android:id="@+id/bland"
        layout="@layout/chip_group_layout"
        app:groupName="@{@string/bland}" />
    </LinearLayout>
  </androidx.core.widget.NestedScrollView>
</layout>

実装編

コンテンツのレイアウトを作成する

home_fragment.xml等、必要なコンテンツのレイアウトを作る。

気を付けるところは、スクロールイベントをCoordinatorLayoutで拾うため、 必ず<layout>直下に<RecyclerView>または<NestedScrollView>を配置すること。

前節を参照してください。

BottomNavigationViewのMenuを作成する

BottomNavigationViewに表示するメニューを作る(res/menu/bottom_navigation.xml)。 ここで設定したidを使って、次のNavigation graphとの紐付けを行います。

<menu>
  <item
    android:id="@+id/home"
    android:icon="@drawable/ic_home_24px"
    android:title="@string/home"
    />
  <item
    android:id="@+id/create"
    android:icon="@drawable/ic_add_box_24px"
    android:title="@string/create"
    />
  ...
</menu>

res/navigation/nav_graph.xmlを作成する。 今回、特定のFragmentからの遷移は実装しないので、同じような<fragment>要素をコンテンツの数だけ作っていきます。

ここでポイント。前節のMenuのid<fragment>の各idと一致させることで、Menuのクリックイベントの分岐処理が不要になります。 また、Toolbarに表示されるタイトルには、自動的にlabel属性の値が適用されます。

<navigation 
  android:id="@+id/nav_container"
  app:startDestination="@id/home">
  <fragment
    android:id="@+id/home"
    android:name="package.HomeFragment"
    android:label="Home"
    tools:layout="@layout/home_fragment" />

  <fragment
    android:id="@+id/create"
    android:name="package.CreateArticleFragment"
    android:label="Create Article"
    tools:layout="@layout/create_article_fragment" />
  ...
</navigation>

FragmentContainerViewにnav_graphをセット

次にMainActivityを実装する。

Toolbar及びBottomNavigationViewとNavigationとの紐付けは、setupWithNavControllerにより行います。 ここで2点ほどポイント。

  1. onCreate内でfindNavControllerを直接呼ぶと落ちる
    • IllegalStateExceptionの発生
  2. setSupportActionBar(it.toolbar)は最後の方で呼ぶ

1はsupportFragmentManagerを経由することで回避できます。 onCreateの中で出来る限りViewへの操作は完結させたいので、下記のように記述しました。

class MainActivity : AppCompatActivity() {
  private val viewModel: MainActivityViewModel by viewModels {
    MainActivityViewModelFactory(application)
  }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    DataBindingUtil.setContentView<MainActivityBinding>(this, R.layout.main_activity).let {
      it.viewModel = viewModel
      it.lifecycleOwner = this

      // ここでfindNavController(R.id.nav_container)を直接呼ぶと落ちる
      // リファレンスに記載あり
      supportFragmentManager.findFragmentById(R.id.nav_container)?.findNavController()?.let { nc ->
        it.toolbar.setupWithNavController(nc)
        it.bottomNavigation.setupWithNavController(nc)
      }

      setSupportActionBar(it.toolbar)
    }
  }
}

これでBottomNavigationViewの選択によりコンテンツが切り替わり、さらにToolbarのタイトルも変化するようになります。

スクロール時にToolbarを隠す動作の実装

わりとおなじみな実装にはなりますが、一応書いておきます。

基本となる画面構造

テンプレにしてもいいんじゃないかと思う基本構造。

CoordinatorLayoutはFrameLayoutベースだから、BottomNavigationView分の高さを削るためにはmarginを設定すればいい. BottomNavigationViewも隠すなら、layout_marginBottomを消してBottomNavigationViewにBehaviorを追加する。

これだけで、あとはCoordinatorLayoutがよしなにやってくれるので助かる。

<CoordinatorLayout>
  <AppBarLayout>
    <Toolbar
      android:minHeight="?attr/actionBarSize"
      app:layout_scrollFlags="scroll|enterAlways"/>
  </AppBarLayout>

  <FragmentContainerView
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    android:layout_marginBottom="?attr/actionBarSize"/>

  <BottomNavigationView />
</CoordinatorLayout>

NestedScrollView内にRecyclerViewを配置する場合の注意

少し逸れますが、いろいろ調べてる中で地雷があったのでメモ。

NestedScrollViewは子要素をラップするようにレイアウトのサイズが変わるので、 RecyclerViewをネストすると、アイテムが全て展開されてViewがリサイクルされなくなるとのこと。

そんなわけで、コンテンツFragment内で子Viewのサイズを固定値に再設定する必要があります。

override fun onCreateView(...) {
  binding.also {
    ...
    it.recyclerView.setOnGlobalLayout {
      // WindowManagerからアプリの画面高さを取得し、ActionBarの高さを差し引く
      val rvHeight = screenHeight - actionbarHeight
      it.recyclerView.layoutParams =
        LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, rvHeight)
    }
  }
  ...
}

rvHeightはアプリの画面高さからActionBarの高さ(=BottomNavigationViewの高さ)を差し引いたもので、 RecyclerViewの高さを固定値に変更してやります。

また、nestedScrollFlagをスクロール量に応じて動的に設定しないと、スクロールの挙動がおかしくなるのですが、 ちょっと紙面が足りないのでここまで。。。

参考

developer.android.com

qiita.com

qiita.com

android.benigumo.com

star-zero.medium.com

qiita.com

qiita.com

qiita.com

www.skcript.com