概要
Material Designが発表され、Shared Elementが登場してから早数年。
ストアに並ぶアプリを見ている限りだと、あまり導入しているのを見かけない。
僕が参加している開発でも何度か入れたいと思ったのだが、何だかんだ入れずにここまで来てしまっていた。
今回はちょうどアプリを開発していく中で自分が納得するルーティングライブラリを作りたい気持ちとAnnotationProcessorを使いたい気持ちが高まってきていたので、自分が良いと思えるルーティングとは何かを作ってみることにした。
Githubを追っていてもShared Elementをいい感じにしてくれるライブラリが中々見つからなかったので、今回はそこにもフォーカスして作り込んでいくことにした。
成果物
MoriRouter
Fragmentを使った画面遷移をアノテーションを使った自動生成を用いて開発をサポートしてくれるライブラリ
特長
- アノテーション経由で画面遷移用のコードを自動生成
- 画面内を構成するFragment用のBuilderも自動生成
- URLから特定の画面へ遷移させるコードも自動生成
- パラメータとして扱いたい部分も値をプレースホルダにするだけ
- SharedElement用のメソッドも自動生成
- リストからの遷移やViewPagerを使うような難しい実装も比較的楽にいける
- アニメーション系の処理をアノテーションと自動生成で吸収させているので、Viewのコードにアニメーションに関する記述が入りにくい
- 生成したコードはandroid support annotations付き
使い方
Download
- JitPackを
build.gradle
のrepositoriesに追記する
repositories {
maven { url "https://jitpack.io" }
}
dependencies {
implementation 'com.github.chuross.mori-router:annotation:x.x.x'
annotationProcessor 'com.github.chuross.mori-router:compiler:x.x.x' // or kpt
}
基本
@RouterPath
のアノテーションを画面遷移として使いたいFragmentに付与するだけ
@RouterPath
や@Argument
のname
はルーターのメソッド名になる。
@RouterPath(name = "main")
class MainScreenFragment : Fragment() {
@Argument
lateinit var param1: String
@Argument(name = "hoge", required = false)
var param3: ArrayList<String> = arrayListOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MoriBinder.bind(this) // @Argumentの各フィールドに値を入れることができる
}
....
}
これをビルドするとMoriRouter
というクラスが自動生成され、その中にこの画面を起動するためのメソッドが追加されている。
あとはベースとなるActivityあたりでRouterを生成して利用する。
val transitionFactory = DefaultTransitionFactory { Fade() } // android.support.transition または android.transitionのアニメーション
val options = MoriRouterOptions.Builder(R.id.container) // 画面遷移を描画するためのFrameLayoutのid
.setEnterTransitionFactory(transitionFactory) // 画面起動時の共通アニメーション
.setExitTransitionFactory(transitionFactory) // 画面終了時の共通アニメーション
.build()
val router = MoriRouter(supportFragmentManager, options)
// 画面遷移のためのメソッドが自動生成される
router.main("required1", 1000) // main(String param1, Integer param2)
.hoge(arrayListOf("fuga")) // optional value
.launch() // MainScreenFragmentを起動
router.pop() // 前の画面に戻す時はこれを呼ぶ
先ほどアノテーションに定義した内容がそのままメソッド名として生成される。
あとは必要に応じて画面遷移に必要なパラメータを渡して最後にlaunch
を呼べばR.id.containerのLayoutで画面遷移を実行される。便利
Fragment Builder
@RouterPath
を用いることで画面遷移の生成は便利になった。
しかし実際は画面内もFragmentで構成されることがあるのでこっちも楽をしたい。
そういう時は@RouterPath
の代わりに@WithArguments
を付与してあげることでBuilderクラスを自動生成できる。
@WithArguments
class HogeFragment : Fragment() {
@Argument
lateinit var hogeName: String
....
}
こうするとHogeFragmentBuilder
クラスが自動生成されるので以下のように扱えるようになる
val fragment: HogeFragment = HogeFragmentBuilder(hogeName).build()
便利
ここらへんは記述を統一できるメリットがあるくらいでFragmentArgs
やAutoBundle
使っても何の問題もない。
遷移アニメーションのオーバーライド
MoriRouterの初期化時に渡しているEnter / Exit Transitionは全画面共通で遷移時に利用される。
しかし特定の画面では専用のアニメーションを指定したいケースがある。
そんな時は@RouterPath
にoverrideEnterTransitionFactory
とoverrideExitTransitionFactory
を指定することで定義することができる。
@RouterPath(
name = "main",
overrideEnterTransitionFactory = MainScreenTransitionFactory::class,
overrideExitTransitionFactory = MainScreenTransitionFactory::class
)
class MainScreenFragment : Fragment() {
URLから特定の画面へ遷移
@RouterPath
にurlのフォーマットを指定することでURL経由で特定の画面を起動することができる。
対象のフォーマットは複数指定することができるのでカスタムスキーマ、またはhttp・httpsなどで使い分けができるようになっている。
@RouterPath(
name = "second",
uris = [
"example://hoge/{hoge_id}/{fuga}",
"https://example.com/hoge/{hoge_id}/{fuga}"
]
)
class SecondScreenFragment : Fragment() {
@UriArgument(name = "hoge_id")
var hogeId: Int
@UriArgument
var fuga: String
// @UriArgumentを使う場合は@Argument(required=true)は使えない
@Argument(required = false)
var piyo: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MoriBinder.bind(this)
}
}
フォーマット内に{}
を用いることでViewクラス内のフィールドと対応させて値を取得することができる。
あとはMoriRouter
にdispatch
というメソッドがあるので、そこにUriを渡してあげると画面遷移できるようになる。
router.dispatch(Uri.parse("example://hoge/123/test")) // launch SecondScreenFragment (hogeId = 123, fuga=test)
router.dispatch(Uri.parse("https://example.com/hoge/123/test")) // launch SecondScreenFragment (hogeId = 123, fuga=test)
便利
Shared Elementサポート
Shared Elementの実装は、遷移元と遷移先で同じtransitionNameを指定するといい感じにアニメーションしてくれる・・・そう思っていた時期が僕にもありました。
単純なパターンであればそれでも成り立つが、実際はそう簡単にいかないパターンの方が多い。
このライブラリでは極力それを簡略化して実装しやすい工夫を入れているので紹介したい。
基本
まずは遷移元にTransitionNameをXMLまたはコード上からセットする
必ずViewにIDを持たせること
- XML
<YourLayout
android:id="@+id/your_id" <!-- must have view id -->
android:transitionName="your_transition_name" />
- コード
ViewCompat.setTransitionName(yourView, "your_transition_name");
次に遷移先のクラスをSharedElementを受け取る形に定義していく
必ずsharedEnterTransitionFactory
とsharedExitTransitionFactory
にSharedElement時のアニメーションをセットすること
あとはMoriBinder
が持つbindElement
に遷移先のViewのIDを渡せば完了。
遷移元と遷移先のViewが同じIDを持つようにする。
@RouterPath(
name = "third",
sharedEnterTransitionFactory = ThirdScreenSharedTransitionFactory::class,
sharedExitTransitionFactory = ThirdScreenSharedTransitionFactory::class
)
class ThirdScreenFragment : Fragment() {
....
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 遷移元でShared ElementさせたいViewに指定していたIDと同じものを指定する
// 遷移元と遷移先のViewは同じIDを持つイメージ
MoriBinder.bindElement(this, R.id.your_id)
}
}
ここまで定義できたら、あとは画面遷移時にShared ElementしたいViewを追加していくだけ。
router.third()
.addSharedElement(yourView) // Shared ElementしたいViewをセット
.launch()
あとは遷移時にいい感じに指定されたTransitionFactory
を用いてアニメーションしてくれる。
RecyclerViewやViewPagerで使う場合は後述の注意点に細かく記載するが、
TransitionNameはユニークである必要があるので注意
動的にShared Elementが変わる場合
遷移先がViewPagerへのShared Elementで画面終了時に別のViewをShared Elementで戻す場合がある。
こういった場合はaddSharedElement
は使えず、手動でマッピングする必要がある。
しかしこのライブラリでは手動マッピングをサポートするクラスも自動生成してくれるので楽勝である。
まず遷移元と遷移先のViewに同じTransitionNameをセットする。
先ほどと違い、手動マッピングの場合は遷移先も遷移元のTransitionNameの生成方法を知る必要がある。
僕はこんな感じで遷移元からプレフィックスをもらうような感じにした。
ViewCompat.setTransitionName(yourView, "your_transition_name");
その後は遷移先のViewの@RouterPath
にmanualSharedViewNames
を定義する。
これはTransitionNameとは別に遷移元・遷移先を繋ぐ名前として利用する。
(TransitionNameとは別の名前が良い)
@RouterPath(
name = "third",
manualSharedViewNames = ["shared_view_image"],
sharedEnterTransitionFactory = ThirdScreenSharedTransitionFactory::class,
sharedExitTransitionFactory = ThirdScreenSharedTransitionFactory::class
)
class ThirdScreenFragment : Fragment() {
....
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val callback = ThirdSharedElementCallBack()
.sharedViewImage({ /* ViewPagerから現在のFragmentを取得してその中のSharedElementしたいViewを返す */ })
setEnterSharedElementCallback(callback)
}
}
あとはsetEnterSharedElementCallback
をセットすれば遷移先は完了。
ThirdSharedElementCallBack
は自動生成されたコードで、これ経由でCallbackを作れば手動マッピングを簡略化してくれる。
次に遷移元の定義を行っていく
@RouterPath(
name = "second"
)
class SecondScreenFragment : Fragment() {
....
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val callback = ThirdSharedElementCallBack()
.sharedViewImage({ /* RecyclerViewからViewを取得する処理 */ })
setExitSharedElementCallback(callback)
}
....
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
....
// call manualSharedMapping
router.third().manualSharedMapping(context).launch()
}
}
今度はsetExitSharedElementCallback
をセットする。
SharedElementCallbackは先ほどのものと同じ。
あとは遷移処理を呼ぶ時にaddSharedElement
の代わりにmanualSharedMapping
を呼べばOK
便利
Shared Elementの注意点
TransitionNameは原則としてユニークである必要がある(ハマった)
これはもちろんRecyclerViewやViewPagerにも当てはまり、
同じViewを使いまわすからといってTransitionNameも使いまわしてしまうとアニメーションしない。
例えばRecyclerViewの場合はtransition_view_image_0
・transition_view_image_1
みたいな感じでpositionごとに別のTransitionNameを指定する必要がある。
ViewPagerの中にRecyclerViewを持つ同じFragmentを使おうものなら、transition_view_image_1_1
みたいな感じでViewPagerのインデックス
+ RecyclerViewのインデックス
で分ける必要性がある。(めんど)
あとがき
まだまだ手を加える部分は多いと思うけれど、SharedElementやルーティングのコードはすっきりするんじゃないだろうか。
今後もメンテしていく予定なのでこのまま良くしていきたい。
まだ良い方法はあると思っているのでもしかしたら違うアプローチも試して見ようと思う。
「これってどうやって実装すればいいんだ?」みたいなのはサンプル読んでもらえば何となくわかるかもしれない。