Shared Elementにも対応したAndroid用の多機能ルーティングライブラリを作った - MoriRouter

# 概要
Material Designが発表され、Shared Elementが登場してから早数年。
ストアに並ぶアプリを見ている限りだと、あまり導入しているのを見かけない。

僕が参加している開発でも何度か入れたいと思ったのだが、何だかんだ入れずにここまで来てしまっていた。

今回はちょうどアプリを開発していく中で自分が納得するルーティングライブラリを作りたい気持ちとAnnotationProcessorを使いたい気持ちが高まってきていたので、自分が良いと思えるルーティングとは何かを作ってみることにした。

Githubを追っていてもShared Elementをいい感じにしてくれるライブラリが中々見つからなかったので、今回はそこにもフォーカスして作り込んでいくことにした。

# 成果物
## MoriRouter
![ezgif-3-5ae226e28e.gif](https://qiita-image-store.s3.amazonaws.com/0/20629/f0f8c95b-b2ba-fdb2-26c5-878bc7f363c9.gif)

https://github.com/chuross/mori-router

**Fragment**を使った画面遷移をアノテーションを使った自動生成を用いて開発をサポートしてくれるライブラリ

## 特長
- アノテーション経由で画面遷移用のコードを自動生成
- 画面内を構成するFragment用のBuilderも自動生成
- URLから特定の画面へ遷移させるコードも自動生成
  - パラメータとして扱いたい部分も値をプレースホルダにするだけ
- SharedElement用のメソッドも自動生成
  - リストからの遷移やViewPagerを使うような難しい実装も比較的楽にいける
- アニメーション系の処理をアノテーションと自動生成で吸収させているので、Viewのコードにアニメーションに関する記述が入りにくい
- 生成したコードはandroid support annotations付き

# 使い方
## Download
- JitPackを`build.gradle`のrepositoriesに追記する

```gradle
repositories {
    maven { url "https://jitpack.io" }
}
```

- dependenciesに追記する [![](https://jitpack.io/v/chuross/mori-router.svg)](https://jitpack.io/#chuross/mori-router)


```gradle
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`はルーターのメソッド名になる。

```kotlin
@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を生成して利用する。

```kotlin
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で画面遷移を実行される。便利 :smiley::v:

## Fragment Builder
`@RouterPath`を用いることで画面遷移の生成は便利になった。
しかし実際は画面内もFragmentで構成されることがあるのでこっちも楽をしたい。

そういう時は`@RouterPath`の代わりに`@WithArguments`を付与してあげることでBuilderクラスを自動生成できる。

```kotlin
@WithArguments
class HogeFragment : Fragment() {

    @Argument
    lateinit var hogeName: String
    ....
}
```

こうすると`HogeFragmentBuilder`クラスが自動生成されるので以下のように扱えるようになる

```kotlin
val fragment: HogeFragment = HogeFragmentBuilder(hogeName).build()
```

便利:smiley::v:

ここらへんは記述を統一できるメリットがあるくらいで`FragmentArgs`や`AutoBundle`使っても何の問題もない。

## 遷移アニメーションのオーバーライド

MoriRouterの初期化時に渡しているEnter / Exit Transitionは全画面共通で遷移時に利用される。

しかし特定の画面では専用のアニメーションを指定したいケースがある。

そんな時は`@RouterPath`に`overrideEnterTransitionFactory`と`overrideExitTransitionFactory`を指定することで定義することができる。

```kotlin
@RouterPath(
    name = "main",
    overrideEnterTransitionFactory = MainScreenTransitionFactory::class,
    overrideExitTransitionFactory = MainScreenTransitionFactory::class
)
class MainScreenFragment : Fragment() {
```

## URLから特定の画面へ遷移
`@RouterPath`にurlのフォーマットを指定することでURL経由で特定の画面を起動することができる。

対象のフォーマットは複数指定することができるのでカスタムスキーマ、またはhttp・httpsなどで使い分けができるようになっている。

```kotlin
@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を渡してあげると画面遷移できるようになる。

```kotlin
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)
```

便利:smiley::v:

## Shared Elementサポート
Shared Elementの実装は、遷移元と遷移先で同じtransitionNameを指定するといい感じにアニメーションしてくれる・・・そう思っていた時期が僕にもありました。

単純なパターンであればそれでも成り立つが、実際はそう簡単にいかないパターンの方が多い。

このライブラリでは極力それを簡略化して実装しやすい工夫を入れているので紹介したい。

### 基本
まずは遷移元にTransitionNameをXMLまたはコード上からセットする
**必ずViewにIDを持たせること**

- XML

```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を持つようにする。

```kotlin
@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を追加していくだけ。

```kotlin
router.third()
       .addSharedElement(yourView) // Shared ElementしたいViewをセット
       .launch()
```

あとは遷移時にいい感じに指定された`TransitionFactory`を用いてアニメーションしてくれる。

RecyclerViewやViewPagerで使う場合は後述の注意点に細かく記載するが、
**TransitionNameはユニークである必要がある**ので注意

### 動的にShared Elementが変わる場合
<img src="https://qiita-image-store.s3.amazonaws.com/0/20629/bb077949-6fef-33d3-bac7-2ea83128cfa9.gif" width="170"/>


遷移先がViewPagerへのShared Elementで画面終了時に別のViewをShared Elementで戻す場合がある。

こういった場合は`addSharedElement`は使えず、手動でマッピングする必要がある。
しかしこのライブラリでは手動マッピングをサポートするクラスも自動生成してくれるので楽勝である。

まず遷移元と遷移先のViewに同じTransitionNameをセットする。
先ほどと違い、手動マッピングの場合は遷移先も遷移元のTransitionNameの生成方法を知る必要がある。

僕は[こんな感じ](https://github.com/chuross/mori-router/blob/master/app/src/main/kotlin/com/github/chuross/morirouter/screen/ViewPagerDetailScreenFragment.kt#L34)で遷移元からプレフィックスをもらうような感じにした。

```kotlin
ViewCompat.setTransitionName(yourView, "your_transition_name");
```

その後は遷移先のViewの`@RouterPath`に`manualSharedViewNames`を定義する。
これはTransitionNameとは別に遷移元・遷移先を繋ぐ名前として利用する。
(TransitionNameとは別の名前が良い)

```kotlin
@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を作れば手動マッピングを簡略化してくれる。

次に遷移元の定義を行っていく

```kotlin
@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

便利:smiley::v:

### 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やルーティングのコードはすっきりするんじゃないだろうか。

今後もメンテしていく予定なのでこのまま良くしていきたい。
まだ良い方法はあると思っているのでもしかしたら違うアプローチも試して見ようと思う。

「これってどうやって実装すればいいんだ？:thinking:」みたいなのはサンプル読んでもらえば何となくわかるかもしれない。

https://github.com/chuross/mori-router/tree/master/app