BottomNavigationViewをカスタムしてFiNCアプリ独自のUIを実現
こんにちは、技術開発本部のバイソン改め南里です。FiNCというヘルスケアの会社でアプリ開発(iOS/Android)に携わっています。
本エントリでは、FiNCのAndroidアプリ上で利用するBottomNavigationViewのカスタムライブラリを作った際の取り組みに関して話します。
FiNCで利用するカスタムUIの課題
FiNCでは、ホーム画面にBottomNavigationViewを利用しています。
BottomNavigationView自体は、Frameworkでも提供されているコンポーネントですが、FiNCでは以下の要件があります。(BottomNavigationViewの基本を知りたい方はこちら。)
- BottomNavigationViewには未読を示すバッジがつく
- ユーザータイプ(法人、一般ユーザー向けなど)によって異なるBottomNavigationItemを表示したい(個数、順番)
上記の条件を満たすUIはAndroidのFrameworkでは提供されていなかったので、FiNCでは独自のUIが実装されていました。
FiNC独自で作成していたBottomNavigationViewはActivityやModelと密に結合した状態であったので、保守、運用が大変でした。
当時、起動後に表示されるホーム画面は仮説検証の段階にあり、度重なる変更に耐えられるように依存性を解消していく必要がありました。
未読のバッジに対応する
Frameworkから逸脱しない
何かをカスタマイズする場合、基本的にはFrameworkレベルの設計に従うことが重要だと思っています。
AndroidのViewの場合、
- ライフサイクル(画面回転、生成タイミング)が複雑になりやすい
- 画面サイズの考慮
があるので、慎重に設計する必要があります。
Frameworkの設計
BottomNavigationViewの内部はMVPで構成されています。FlexibleBottomNavigationViewの設計の際には基本的な構造は同じまま、ItemViewでinflateしているxmlを新たに作成しました。
navigationView.setItemBadgeCount(R.id.item_notification, badgeCount)
上記によりmenuItemのリソースidとバッジの数を渡すだけでバッジをつけることが可能になります。(3桁以上の場合は、+99になります。)
以下ライブラリです。需要あればもうちょっと頑張ろうかなって感じです。(0.1.2..!!!)
custom bottom navigation. Contribute to neonankiti/FlexibleBottomNavigationView development by creating an account on…github.com
dependencies{
implementation 'com.neonankiti:flexiblebottomnavigationview:0.1.2'
}
gradleにも対応しています。
ユーザータイプでBottomNavigationItemの個数、順番を変更する
動的にmenu.xmlを指定する
FlexibleBottomNavigationViewにアイテムのデータ(個数、順番)は、ユーザータイプによって異なります。
BottomNavigationViewはxml上でmenuを指定するやり方が一般的ですが、その場合ユーザータイプによる出し分けが難しくなります。動的に設定するには以下のように指定します。
val menuItemResId = if(userType == UserType.COMPANY){
R.menu.menu_bottom_nav_def_company
} else {
R.menu.menu_bottom_nav_def_default
}
navigationView.inflateMenu(menuItemResId);
各Fragmentの依存性を解消する
FlexibleBottomNavigationViewはMainActivity上で利用されているのですが、BottomNavigationViewにおいてもページ切り替えの実装は以下のようになると思います。
navigationView.setOnNavigationItemSelectedListener(object : FlexibleBottomNavigationView.OnNavigationItemSelectedListener {
override fun onNavigationItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.item1 -> {
true
}
R.id.item2 -> {
true
}
R.id.item3 -> {
true
}
else -> false
}
})
上記の問題は、MainActivityがR.id.item1など特定のドメインに関する情報を知ってしまっていることです。
それにより、ユーザータイプごとの違いを吸収することが難しくなります。
対応方針としては以下です。
- ユーザータイプに対応するmenuItem群の配列の生成をMainActivityから隠蔽する。
- menuItemを元に、Fragmentをメモリ上にキャッシュ。
ユーザータイプごとの配列を作る
sealed class BottomNavItem(id: Int) {
class Item1(id: Int): BottomNavItem(id)
class Item2(id: Int): BottomNavItem(id)
class Item3(id: Int): BottomNavItem(id)
}
BottomNavItemというSealedクラスを定義し、識別子として利用しFragmentをメモリ上にキャッシュします。
実際には、Item1などがMainActivityから見えないように、Factoryクラスに委譲して作成します。
interface BottomNavItemFactory {
fun createItems(userType: UserType): List<BottomNavItem>
}
class MainBottomNavItemFactory : BottomNavItemFactory {
override fun createItems(userType: UserType): List<BottomNavItem> = when (userType) {
UserType.COMPANY -> listOf(Item1(R.id.item1),
Item2(R.id.item2),
Item3(R.id.item3))
// 他のUserTypeで生成するものをわける。
}
}
menuItemに対応するFragmentをメモリ上にキャッシュする
menuItemIdをそのままキャッシュすることはできません。リソースIDはビルド毎に値が変わってしまいます。
上記理由により、キャッシュする際には、BottomNavItemをKeyにMapで管理します。
private val mainFragments = hashMapOf<BottomNavItem, Fragment>()
利用時にキャッシュヒットしなければ、キャッシュするようにします。
省略しますが、対応するFragmentも同じように隠蔽化します。
結果的に最初のコードが以下のような形になると思います。
navigationView.setOnNavigationItemSelectedListener(object : FlexibleBottomNavigationView.OnNavigationItemSelectedListener {
override fun onNavigationItemSelected(item: MenuItem): Boolean = when (item.itemId) {
// itemIdをBottomNavItemに変換し、その識別子によりFragmentへのアクセスを実現する。
switchFragment(item.itemId)
}
})
これで項目の変更、オーダーの変更が入った場合も、以下を変更するだけで実装出来ます。
- MainBottomNavItemFactory
- MainFragmentFactory
実際のソースコードでは、他の箇所での利用、またサンプルコードは書きませんでしたが、Fragment自体を抽象化する必要が出たりします。そういった理由でFactoryパターンを利用しています。
まとめ
本エントリでは、かなり駆け足ではありましたが、FiNCにおいてCustomViewを開発、運用した際に起こった問題とその対処法に関して説明しました。
AndroidにおけるCustomViewは、意識しないとついつい肥大化しやすいことが多いので気をつけて実装したいものですね。
iOS/Androidエンジニア募集中!!
最後になりますが株式会社FiNCでは、iOS・Androidエンジニアを募集しています。ご興味がある方はぜひこちらからご応募下さい!!