AndroidアプリへのBottom Navigationの導入
Fragmentの切り替え処理について
はじめに
こんにちは! nextbeat 開発グループ所属の Kawai です。
nextbeatでは、子育て情報メディアのKIDSNAというプロダクトの開発を担当しています。
KIDSNAはWeb(PC/SP)、アプリ(iOS/Android)とマルチプラットフォームに展開されており、子育てに役立つ情報や、ママさんライターが描く、「子育ての日常マンガ」が読めるとても便利で素敵なメディアです。
最近はアプリの開発に力を入れており、コメントやフォローなどのSNS要素も追加され、記事の内容だけでなくUI/UXもどんどん進化していってます! 子育て中のママさん、パパさんはもちろん、子育て情報に興味がある人は(ない人も)ぜひ使ってみてください!
トップページ改修
さて、そんなKIDSNAですが、1月の大型アップデートで、アプリのトップページのデザインが大きく変更されました。
アップデート前と比べると、記事のプレビューが大きくなり、ひとつひとつのアイテムが見やすくなりました。また、大きな変更点として、ナビゲーションが変更されました。
従来のKIDSNAは、ヘッダー部分にハンバーガーメニュー (+ドロワーメニュー)を設置する形式を採用していましたが、今回の改修でフッターにBottom Navigationを置くことにしました。今回はこのBottom Navigationについて紹介しようかと思います。
Bottom Navigation
Bottom Navigationはマテリアルデザインのひとつで、名前のとおり画面の下部に設置するナビゲーションアイテムです。
各アイテムにはアイコンとラベルを表示することができ、ワンタップで主要な画面を切り替えることができます。ただし、表示できるアイテムの数は3つから5つとされており、2つもしくは6個以上の数のアイテムを切り替えたい時には通常のヘッダータブかドロワーを使うことがGoogleによって推奨されています。
見た目的にもうるさいし、ひとつひとつのアイコンの表示領域が小さくなるため、視認性や操作性が悪くなるからですかね。
YouTubeやGoogle Mapsなどのアプリでも使われており、モダンなアプリではよく見かけるUIではないでしょうか。個人的には結構好きなUIで、Bottom Navigation導入後のアプリのほうが使いやすく感じています。
導入
AndroidにはBottomNavigationViewというコンポーネントが用意されています。今回はドキュメント情報も多く、デフォルトで用意されているこちらのViewを使うことにしました。
導入方法ですが、Android Studioのサポートを使うととても簡単です。
layoutファイルにコンポーネントを追加する場合は
Pallete -> Containers -> Bottom Navigation View
Activityとして新規に追加する場合は
New -> Activity -> Bottom Navigation Activity
で設置することができます。もちろん自分でコードを書くのもOK。
Activityとして追加した場合のデフォルトコードは以下のとおり。
<android.support.design.widget.BottomNavigationView
android:id="@+id/navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/navigation"
app:layout_constraintHorizontal_bias="0.0"/>
menuファイルでは、表示したい各itemのid、icon、titleを設定。
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_home"
android:icon="@drawable/ic_home_black_24dp"
android:title="@string/title_home"/>
<item
android:id="@+id/navigation_dashboard"
android:icon="@drawable/ic_dashboard_black_24dp"
android:title="@string/title_dashboard"/>
<item
android:id="@+id/navigation_notifications"
android:icon="@drawable/ic_notifications_black_24dp"
android:title="@string/title_notifications"/>
</menu>
Activityはこんな感じ。
class MainActivity : AppCompatActivity() {
private val mOnNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.navigation_home -> {
message.setText(R.string.title_home)
return@OnNavigationItemSelectedListener true
}
R.id.navigation_dashboard -> {
message.setText(R.string.title_dashboard)
return@OnNavigationItemSelectedListener true
}
R.id.navigation_notifications -> {
message.setText(R.string.title_notifications)
return@OnNavigationItemSelectedListener true
}
}
false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
navigation.setOnNavigationItemSelectedListener(mOnNavigationItemSelectedListener)
}
}
OnNavigationItemSelectedListener{}
で、各itemが選択された際の処理を記述していきます。サンプルではmessage.setText()
でシンプルに表示メッセージを変更していますが、実際にはここにFragmentを切り替える処理を記述することが多いかと思います。
ここで注意したいのが、Fragmentの切り替え方です。Web上で見つかるコードは、以下のようなreplaceで書かれていることが多いですが、replaceで切り替えると、表示ごとにFragmentの状態が初期化されてしまうため、各アイテムの状態を保つことができません。replaceは内部的にremove、addを行っているためです。
private fun replaceFragment(fragment: Fragment) {
fragmentManager
.beginTransaction().replace(containerId, fragment).commit()
}
ハンバーガーメニュー やドロワーメニューを使っていた時にはreplaceで問題なかった処理も、Bottom Navigation Viewでは、各Fragmentの状態を保持するために、他の処理に書き換える必要がでてきました。
解決策①
解決策の1つとして、replaceの代わりに、hide、showを組み合わせて表に出すフラグメントを切り替える方法があります。
考え方は非常にシンプルですが、力技感が否めません。。。
fun changeFragment(fragment: Fragment) {
fragmentManager.beginTransaction().hide(activeFragment).commit()
fragmentManager.beginTransaction().show(fragment).commit()
activeFragment = fragment
}
解決策②
もう1つの解決策として、FragmentPagerAdapterを継承したViewPagerクラスを作成し、onPageSelected()
の処理をオーバーライドする方法が考えられます。この方法では、FragmentをListで登録し、itemが選択された際にViewPagerのcurrentItem
を変更することで、Fragmentを切り替えます。
class ViewPagerAdapter(
private val fragment: List<Fragment>,
fragmentManager: FragmentManager
) : FragmentPagerAdapter(fragmentManager) {
override fun getCount() = fragment.size
override fun getItem(position: Int): Fragment {
return fragment[position]
}
}
ただし、ViewPagerをそのまま利用するとスワイプで画面切り替えができてしまうので、こちらもスワイプ無効化処理を実装する必要があります。
class BottomNavigationViewPager : ViewPager {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
override fun onInterceptHoverEvent(event: MotionEvent?): Boolean {
return false
}
override fun onTouchEvent(ev: MotionEvent?): Boolean {
return false
}
}
あとは初期化とリスナーの登録だけです。
Activityの全体はこんな感じ。
class MainActivity : AppCompatActivity() {
object LogicValues {
const val Fragment1 = 0
const val Fragment2 = 1
const val Fragment3 = 2
}
private var currentItem: MenuItem? = null
private val mOnNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.navigation_home -> {
viewPager.setCurrentItem(LogicValues.Fragment1, false)
return@OnNavigationItemSelectedListener true
}
R.id.navigation_dashboard -> {
viewPager.setCurrentItem(LogicValues.Fragment2, false)
return@OnNavigationItemSelectedListener true
}
R.id.navigation_notifications -> {
viewPager.setCurrentItem(LogicValues.Fragment3, false)
return@OnNavigationItemSelectedListener true
}
}
false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupViewPager()
navigation.setOnNavigationItemSelectedListener(mOnNavigationItemSelectedListener)
}
private fun setupViewPager(){
val fragment = mutableListOf(Fragment1(), Fragment2(), Fragment3())
val adapter = ViewPagerAdapter(fragment,supportFragmentManager)
viewPager.adapter = adapter
viewPager.offscreenPageLimit = 2
viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageSelected(position: Int) {
if (currentItem != null) {
(currentItem as MenuItem).isChecked = false
} else {
navigation.menu.getItem(0).isChecked = false
}
navigation.menu.getItem(position).isChecked = true
currentItem = navigation.menu.getItem(position)
}
})
}
}
Bottom Navigation Viewのitemが選択されたタイミングでViewPagerの切り替えを行なっています。はまりポイントは以下の2つです。
ひとつ目は、viewPager.offscreenPageLimit = 2
としているところ。これにより、ViewPagerは2つまで隣のFragmentの状態を保持することができます。デフォルトは1に設定されているらしく、2つ以上離れたFragmentの状態が、ページを開くたびにリセットされてしまいます。
ふたつ目ですが、setCurrentItem()
の第二引数をfalseにすることでViewPagerの切り替え時のアニメーションを無効にすることができます。currentItem = LogicValues.Fragment1
のように、直接プロパティを書き換えることもできますが、アニメーションが有効なままになってしまいます。この辺の処理は用途によって書き分けることが重要ですね。
以上の実装で、Fragmentの状態を保持しつつ、itemを切り替えることができるようになりました!
感想
Bottom Navigation Viewは、itemの選択/再選択時の処理、バッジの描画などを簡単に実装でき、非常に便利でしたが、単体で使うのではなく、ViewPagerなど、他のAndroidのネイティブコンポーネントと組み合わせることで、より効果的に使うことができると感じました。
(Fragmentの管理をもう少しうまく行えば、ViewPagerを使う必要もないかもしれませんが。。。)
Androidアプリ開発を始めて数ヶ月、まだまだわからないことがいっぱいですが、今後も継続してよりよいサービスを提供できるように精進していきたいです。
おそらく、より良い実装方法やベストプラクティスが存在するのではないかと思うので、知っている方がいればぜひ教えていただけるとありがたいです。
告知
隔週木曜日に恵比寿のオフィスで夜活(交流会)を実施しているので、転職希望のエンジニアの方はもちろん、他職種の方々もぜひ遊びに来てください! 綺麗なオフィスで軽食とお酒を楽しみましょう!
以上!