ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

プルリクのレビュー件数をグラフにしてみた

こんにちは、tkyです。

社内でプルリクをいろいろなメンバーにレビューしてもらっているのですが、

「最近特定の人ばかりにレビュー依頼してる気がする・・・負荷溜まってないかな・・・」

という一抹の不安をいだき誰にレビュー依頼するか判断する材料にしようと考え(完全に個人用で)作った感じです。

何作ったの

社内のメンバーのプルリク溜まってる件数をグラフにしてみました。

token, organization, teamを入力することでこんな感じのグラフが出るようなものです。

こうすることでレビュー溜まってない人にレビューアサインしたほうが早いかな?など検討の余地が生まれて良いですね。

f:id:ticktakclock:20200414221150p:plain:w500
screenshot

github.com

技術的なところ

せっかくだしTypescriptとVueも一緒に触ってみながら成果物残してみるかー!というモチベーションです。 私はAndroidエンジニアですが、Reactとかのフロントの経験はあるのでそんなに抵抗はないです。

chart.jsのおかげでグラフ化はかんたんでした。感謝・・・

使ったAPI

大まかな流れは /orgs/:org_id/team/:team_id/members で特定のチームに属するメンバーを抽出して

そのメンバーが持っているPRレビューの数を /search/issues で取得します。

Search | GitHub Developer Guide

Team Members | GitHub Developer Guide

/search/issues の課題

search/issues?q=is:open+is:pr+review-requested:ticktakclock こんな感じで私がレビューすべきプルリクIssueが取得できるのですが、

review-requestedが1人しか指定できないのか、例えば

Aさん・・・2件、 Bさん・・・1件、 Cさん・・・4件

あったとして

  • search/issues?q=is:open+is:pr+review-requested:a-san+review-requested:b-san ・・・3件取得
  • search/issues?q=is:open+is:pr+review-requested:a-san+review-requested:c-san ・・・0件取得

みたいなことがあってなぜか複数指定して検索できるパターンとできないパターンがあり

指定したメンバー全員を一気に検索することができなかったことです。

そのため本リポジトリでは1人1人API叩いて検索してます。

配列を一気にasync - await  するのにはPromise.all()でくくってあげる必要がある、というのは本件に関係ありませんがそこそこ重要なポイントかなと思います。

      const res = await api(
        "/orgs/" + this.organization + "/teams/" + this.team + "/members"
      );
      const results: Array<RequestedReviewer> = await Promise.all(
        res.data.flatMap(async (user: User) => {
          const res = await api(
            "search/issues?q=is:open+is:pr+org:" +
              this.organization +
              "+archived:false" +
              "+review-requested:" +
              user.login +
              "&sort=created&order=asc&page=1&per_page=10"
          );
          return { user: user, issues: res.data.items };
        })
      );

これで何が問題かというとGitHubAPIのレート制限にすぐに引っかかるということです。

普通にtoken使ってやる場合は1時間あたり最大60件なので、比較的すぐに制限に引っかかります。

GitHub API v3 | GitHub Developer Guide

まとめ

  • レビュー件数がわかって少し幸せ
  • search apiもう少し拡張してほしい
  • typescriptとVueの勉強しようと思ったのにGitHubAPIの勉強になってしまった・・・

こうすれば複数人のIssue検索できるよ、のような知見があるかたはぜひ教えていただきたいです!!

WebView android.view.InflateException on Lollipop

こんにちは、tkyです。

久々にWebViewで落ちた案件です。Lollipop(5.0)です。悲しみです。

手元にLollipopがないので実機で発生するかわからないのですが、Emulatorで発生するものです。

もしかしたらPlay Storeからandroid system webveiwのバージョンを上げるだけでも回避できる可能性は十分にあります。

E/AndroidRuntime: FATAL EXCEPTION: main

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.your.packagename.UsingWebViewActivity}: android.view.InflateException: Binary XML file line #8: Error inflating class android.webkit.WebView
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2298)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2360)
        at android.app.ActivityThread.access$800(ActivityThread.java:144)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1278)

ぐぐると大体以下のような対策的なのが出てくるのですが、こんな事やっていなくても動いておりまして、はい。

override fun applyOverrideConfiguration(overrideConfiguration: Configuration?) {
    if (Build.VERSION.SDK_INT in 21..25) {
        return
    }
    super.applyOverrideConfiguration(overrideConfiguration)
}

この辺のstack overflowとissue見ると 1.0.2 では動いてるが、 1.1.0DayNight 系の機能実装のときにデグレったみたいでした。

stackoverflow.com

issuetracker.google.com

一応この問題は 1.2.0-alpha02 で対応されているようで、Gradleを更新したらたしかにLollipop WebViewで落ちなくなりました。

implementation 'androidx.appcompat:appcompat:1.2.0-alpha02'

mvnrepository.com

※この時Android Studioのリリースノートには 1.2.0-alpha01 までしか載っていなくてちょっとハマりました 🐥

そのAppCompat、バージョン上がってないですか?

動いていた時、appcompat1.0.2使っていたのですが(古)、問題が起こってたときもGradleは変えていませんでした。

+--- androidx.appcompat:appcompat:1.0.2

とある時期からなぜか1.1.0に依存更新されていたのです。

+--- androidx.appcompat:appcompat:1.0.2 -> 1.1.0

materialの更新で依存バージョンが上がっていた

+--- com.google.android.material:material:1.2.0-alpha04

| +--- androidx.appcompat:appcompat:1.1.0 (*)

僕の場合はMaterialの更新でたまたまappcompatのバージョンが上がって、デグレっていました。

どう回避するかはアプリ次第かと思われます。1.2.0-alpha02 のバージョンアップも多少リスクはあると思うので、プログラムで回避するのも一つの正解かと思われます。

implementation 'androidx.appcompat:appcompat:1.2.0-alpha02'

WebView使っているアプリを開発されている皆様は今一度AppCompatのバージョンとLollipopでの動作確認してみるとよいかもしれません!

以上です!

(追記)プログラムで回避する場合

ActivityにapplyOverrideConfigurationを実装してUI_MODE_NIGHT_MASKを除外するか

override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
    if (Build.VERSION.SDK_INT in Build.VERSION_CODES.LOLLIPOP..Build.VERSION_CODES.N_MR1) {
        overrideConfiguration.uiMode = overrideConfiguration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()
    }
    super.applyOverrideConfiguration(overrideConfiguration)
}

AndroidManufest.xmluiMode のconfigurationChangedを付与するか

        <activity
            android:name=".UsingWebViewActivity"
            android:configChanges="uiMode" />

どちらかかなと思いました。バージョン上げるのにリスクある場合や、影響範囲を限りなく少なくしたい場合はプログラムで回避することも視野に入れて良いと思っています。

Kotlinのif elseとletとalso

こんにちは、tkyです。

Kotlinにおいてnullチェックしてそのインスタンスにアクセスする場合、?.let{} を使うことがわりと多いです。

しかしnullだったときの処理も記述する場合?.let{} だとどうしてもわかりづらくなる場合があり、素直にif elseを使ったほうが読みやすいのではないかな〜という思いから記事書いてみました。

いろいろなNullチェック手法

いくつか書いてみました。わかりやすくするために関数で書いています。

このケースならパターン2の早期リターンが一番効率的だと思いますが、 Androidでいうところの context? などフレームワークで定義されている変数はnullableなことがままあり、 そういったものであればパターン3( context?.let{} )で書くことが多いです。

private fun printMessageLength1(message: String?) {
    // パターン1 if文
    if (message != null) {
        println(message.length) // smart castにより安全にmessageにアクセス
    }
}

private fun printMessageLength2(message: String?) {
    // パターン2 エルビス演算子による早期リターン
    message ?: return
    println(message.length) // smart castにより安全にmessageにアクセス
}

private fun printMessageLength3(message: String?) {
    // パターン3 ?.letを使う
    message?.let {
         println(it.length)
    }
}

private fun printMessageLength4(message: String?) {
    // パターン4 ?.alsoを使う
    message?.also {
        println(it.length)
    }
}

nullだったときに別の処理をする場合

nullだったときに"message is null" と表示してみましょう。

private fun printMessageLength1(message: String?) {
    // パターン1 if文
    if (message != null) {
        println(message.length) // smart castにより安全にmessageにアクセス
    } else {
        println("message is null")
    }
}

private fun printMessageLength2(message: String?) {
    // パターン2 エルビス演算子による早期リターン 複数行あるのでrun{}で書く
    message ?: run {
        println("message is null")
        return
    }
    println(message.length) // smart castにより安全にmessageにアクセス
}

private fun printMessageLength3(message: String?) {
    // パターン3 ?.letを使う
    message?.let {
        println(it.length)
    } ?: println("message is null")
}

private fun printMessageLength4(message: String?) {
    // パターン4 ?.alsoを使う
    message?.also {
        println(it.length)
    } ?: println("message is null")
}

なお、エルビス演算子で複数行扱いたいとき run{} を使うことが多いかなと思います

    // 複数行処理したい場合はrun{}
    message?.let {
        println(it.length)
    } ?: run {
        println("message is null")
    }

let と also の違い

一見、パターン3とパターン4に違いが殆どないように感じます。が、違いは戻り値にあります。

  • public inline fun <T, R> T.let(block: (T) -> R): R
  • public inline fun <T> T.also(block: (T) -> Unit): T

引数として渡る値はどちらもTオブジェクト(今回の場合String?です,nullチェックされているのでStringです)

戻り値はletの場合任意に指定でき、alsoの場合Tオブジェクト(今回の場合String?です)

この前提を踏まえるとletの場合ちょっと問題が起こります。

printMessageLength3("hoge")

private fun printMessageLength3(message: String?) {
    message?.let {
        println(it.length)
        null
    } ?: run {
        println("message is null")
    }
}

出力

I/System.out: 4
I/System.out: message is null

messageがnon-nullなのに、let{}句内の記述内容によって動作が変化してしまうことです。

実際こんなコードを書くことはほぼないのですが、 fun doSomething(): String? のような関数がもし存在してlet内に記述されてしまったら 意図せぬところで不具合が起こることになります。

letとエルビス演算子の組み合わせ(?.let{} ?: run{} )はパット見問題なさそうに見えるが故にすぐに不具合に気づけない可能性も秘めているので 使うならalsoとエルビス演算子の組み合わせ(?.also{} ?: run{})が良いかなと思います。

まとめ

also 「また」は中学で習う英単語です。「AのほかにBもまた○○である」という表現で使われます。

message?.also{ // A}: run{ // B }

「メッセージがnullでなけばAもまた行う。」

ある物と別の物に対して言及するときに使うイメージがあるので若干違和感がありますね。。僕だけかな。。。

なので純粋に if(message != null) { //A } else { //B } と書いたほうが他の実装者に意図も伝えやすく不具合が起こりにくくなるのではないかな、ということを考えながらプログラミングしています。

logcatが出力されなくなった時の対処

こんにちは、tkyです。

既出小ネタ集です。

AndroidStudioで開発中たまにAS上のLogcatが出てこないときがあります。

    read: unexpected EOF!

    --------- beginning of crash

昔から対策自体はありますが、全体的に記載記事が古くなっている方に感じるので今でも使えるよっていう意味も込めて書いてます。

adbサーバを再起動する

$adb kill-server

$adb start-server

これでOKです。adbコマンドが使えない場合パスを通してください。(パスの通し方は割愛で!)

何が起こっているのか?

adbのプロセスから吐かれているエラーらしい。デバイスからよきせぬEOF(End Of File)が飛んできたためのクラッシュと読めます。

ワークアラウンド感が否めませんがadbサーバを再起動することで対処する感じです。

バイスのバッファサイズが小さいと起こりやすい?バッファサイズを多めに設定しておくと発生頻度は少なくなるかもです。

f:id:ticktakclock:20200222153829p:plain:w200

Navigation SafeArgsを使って画面遷移でパラメータを渡す

こんにちは、tkyです。

前回はBottomNavigationとNavigationをあわせた画面遷移を作りましたが、パラメータを渡せないと実務では利用しづらいケースが多いと思います。

↓前回記事↓ ticktakclock.hatenablog.com

今回はSafe Argsの機能を使って次の画面にパラメータを渡すような実装を試してみたいと思います。

Home画面、Dashboard画面という単語が出てきますが、前回作ったアプリの画面をそのまま利用しているので前回記事を一読しておくと理解しやすいと思います。

今回の実装は前回のGitHubに追記しています。

github.com

Safe Argsできて何が嬉しいのか

ここで解決できることは、今までFragmentにnewInstance()でFragmentのインスタンスを作っていた関数が不要になり

companion object{
    fun newInstance(editName: String): DashboardFragment {
        val bundle = Bundle()
        bundle.putString("DETAIL_NAME", editName)
        return DashboardFragment().apply {
            arguments = bundle
        }
    }
}

このように記述できることにあると思います。

引数を含めたDestinationが型になるためActivityとFragmentを意識する必要がなくなる、contextも不要になることが大きなメリットです。

// 引数を含めたDestination
val destination =
    HomeFragmentDirections.actionNavigationHomeToNavigationDashboard(textValue)
findNavController().navigate(destination)

githubは前回と同じリポジトリに追記しています。

gradleへ追記

Safe Argsを利用するためにはNavigationの依存追加の他に、Project.gradleに以下を追加する必要があります。

    buildscript {
        repositories {
            google()
        }
        dependencies {
            def nav_version = "2.1.0"
            classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
        }
    }
    

また、app.gradleに以下のプラグインを追加します。Kotlinだけのプロジェクトの場合の例です。

apply plugin: "androidx.navigation.safeargs.kotlin"

Java/Kotin混合プロジェクトの場合 apply plugin: "androidx.navigation.safeargs" となります。

Argumentの定義

Navigation Design上で確認していきましょう。HOME画面→Dashboard画面の遷移でDashboard画面でパラメータを受け取りたい場合、

DashboardDestinationをクリックしてAttributesの中にあるArgumentsの+ボタンをクリックします。

f:id:ticktakclock:20200202152649p:plain:w200

するとどのような引数を追加するか入力するダイアログが出てきますので、NameとTypeだけ入力してあとは何もせずでAddします。

  • Name: editName
  • Type: String
  • Array: non checked
  • Nullable: non checked
  • Default Value: empty

これで完了です。xml上でも引数が追加されていることが確認できます。

<fragment
    android:id="@+id/navigation_dashboard"
    android:name="com.github.ticktakclock.bottomnavigationsample.ui.dashboard.DashboardFragment"
    android:label="@string/title_dashboard"
    tools:layout="@layout/fragment_dashboard">
    <!-- 中略 -->
    <argument
        android:name="editName"
        app:argType="string" />
</fragment>

argTypeについてはSerializableやParcelableなどの型も対応しているので独自データクラスなども対応できそうですね。良かったです。

詳細はこのページを見るとより深く理解できそうです。

遷移元処理

home画面にテキスト入力できるようにレイアウトを変更して、画面遷移時テキストの内容を遷移先に渡してみましょう。

HomeFragmentDirections.actionNavigationHomeToNavigationDashboard(editName: String) という遷移情報が自動で作られていることがわかります。

ここで作られたDestinationで画面遷移することになります。

val button: Button = root.findViewById(R.id.button)
button.setOnClickListener {
    val editText: EditText = root.findViewById(R.id.editText)
    val textValue = editText.text.toString()
    // 以前はリソースIDを直接指定していたが今回は引数を含めたDestinationを指定
    val destination =
        HomeFragmentDirections.actionNavigationHomeToNavigationDashboard(textValue)
    findNavController().navigate(destination)
//            findNavController().navigate(R.id.action_navigation_home_to_navigation_dashboard)
}

遷移先処理

Home画面から送った引数をDashboard画面で受け取ってみましょう。2種類方法があります(やってることは同じです)。

1つめは DashboardFragmentArgs.fromBundle()を使ってargumentsからDashboardFragmentArgsインスタンスを取得するやり方です。

arguments?.let {
    val args = DashboardFragmentArgs.fromBundle(it)
    val argsText: TextView = root.findViewById(R.id.text_args)
    argsText.text = args.editName
}

2つめは by navArgs() プロパティデリゲートを使ってDashboardFragmentArgsインスタンスを取得するやり方です。

private val args: DashboardFragmentArgs by navArgs()

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {

val argsText: TextView = root.findViewById(R.id.text_args)
argsText.text = args.editName
    return root
}

1つ目はargumentsがBundle?型なので一度Nullチェックをする必要があります。nullだったときのことを考える必要があります。 この場合引数は必ず存在することが前提なのでBundleの状態不正としてIllegalStateExceptionをThrowして実装者に気づかせるのが最善かと思われます。

2つ目の方が公式ドキュメントでも解説されていたやり方となります。nullチェックする必要がなく、こちらのほうが記述量も少なくなって楽ですね。

navArgs()は何をしているのか

実は navArgs() デリゲートの中を見ると1つ目とやっていることは何も変わりませんでした。ライブラリ側にチェック処理を任せられるので実装ミスもなくなる観点からnavArgs()を使ったほうがメリットが大きそうです。

@MainThread
inline fun <reified Args : NavArgs> Fragment.navArgs() = NavArgsLazy(Args::class) {
    arguments ?: throw IllegalStateException("Fragment $this has null arguments")
}

この状態でHomeFragmentで findNavController().navigate(R.id.action_navigation_home_to_navigation_dashboard) で引数を与えないリソースIDのみで画面遷移しようとすると想定通り IllegalStateException が吐かれました

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.github.ticktakclock.bottomnavigationsample, PID: 9503
    java.lang.IllegalStateException: Fragment DashboardFragment{d5e17c6 #1 id=0x7f08006c} has null arguments
        at com.github.ticktakclock.bottomnavigationsample.ui.dashboard.DashboardFragment$$special$$inlined$navArgs$1.invoke(FragmentNavArgsLazy.kt:42)
        at com.github.ticktakclock.bottomnavigationsample.ui.dashboard.DashboardFragment$$special$$inlined$navArgs$1.invoke(Unknown Source:0)
        at androidx.navigation.NavArgsLazy.getValue(NavArgsLazy.kt:44)
        at androidx.navigation.NavArgsLazy.getValue(NavArgsLazy.kt:34)

まとめ

  • Safe Argsでを使うと Activity.createIntent(), Fragment.newInstance() といった関数が不要になる
  • arg typsはSerializable, Parcelableも渡せるので独自クラスでも対応できる
  • navArgs() を使ってパラメータ取得すべし

Navigationでスマートな画面遷移を実現していきましょう〜!

あとは公式Doc見るのがよいです

developer.android.com

Android Jetpack Navigationで画面遷移する

こんにちは、tkyです。

下タブ(BottomNavigation)とNavigationをあわせて画面遷移とアニメーションを実現するところまで実装してみようと思います。

Navigationがわからない人が

  • AtivityとFragmentの画面遷移ができる
  • アニメーションに必要なパラメータがわかる

なんとなくNavigationわかってきたくらいを目標に記事を書いています。

作ったプロジェクトはこちら

github.com

Navigation

公式ドキュメントも合わせて確認しながら理解していこうと思います。

Androidアプリ内の「目的地」間を移動するためのフレームワーク、とのことです。目的地はActivityでもFragmentでもどちらでも問題なく、一貫した呼び方ができるのが特徴のようです。

今まで画面遷移は activity.startActivity(Intent(this, FooDetailActivity::class.java)) だったり fragmentTransaction.replace(R.id.container, BarDetailFragment.newInstance() のように呼び方がActivity/Fragmentで異なりましたが、

Navigationを使用するとActivity/Fragment遷移関係なくfindNavController().navigate(R.id.navigation_home) のように統一されたIFなるみたいです。

developer.android.com

AndroidStudioのプロジェクトテンプレート

実はプロジェクトテンプレートでBottomNavigationActivityを選択すれば手早くサンプルアプリが構築できるので使わない手はありません。早速作ってみます

f:id:ticktakclock:20200126164121p:plain:w200
プロジェクトテンプレートからBottomNavigation

デフォルトでふわっとしたフェードアニメーションが付与されているのが確認できます。

f:id:ticktakclock:20200126185324g:plain:w200
アプリ動作

更に嬉しいのがViewModelまで作っておいてくれている親切設計。ありがたいですね

class NotificationsFragment : Fragment() {

    private lateinit var notificationsViewModel: NotificationsViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        notificationsViewModel =
            ViewModelProviders.of(this).get(NotificationsViewModel::class.java)
        val root = inflater.inflate(R.layout.fragment_notifications, container, false)
        val textView: TextView = root.findViewById(R.id.text_notifications)
        notificationsViewModel.text.observe(this, Observer {
            textView.text = it
        })
        return root
    }
}
class NotificationsViewModel : ViewModel() {

    private val _text = MutableLiveData<String>().apply {
        value = "This is notifications Fragment"
    }
    val text: LiveData<String> = _text
}

activity_main.xml

まずはActivityから理解を深めます。

BottomNavigationが配置されています。メニューは@menu/bottom_nav_menuで定義しています。Home,Dashboard,Notificationsが定義されているだけです。

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="0dp"
        android:layout_marginEnd="0dp"
        android:background="?android:attr/windowBackground"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:menu="@menu/bottom_nav_menu" />

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/mobile_navigation" />

次に nav_host_fragment というFragment領域が定義されています。 androidx.navigation.fragment.NavHostFragment が実体のようです。

ドキュメントによると「NavHostはナビゲーション グラフからの宛先を表示する空のコンテナ。」ということらしいです。NavHostFragmentはNavHostインターフェースの実装クラスということですね。

一番下には app:navGraph="@navigation/mobile_navigation" の記述があります。ナビゲーショングラフと呼ばれ、ユーザが遷移可能なすべてのデスティネーション(遷移先/宛先)を指定します。

これでNavHostとナビゲーションを紐付けているわけですね。

MainActivity

        val navView: BottomNavigationView = findViewById(R.id.nav_view)

        val navController = findNavController(R.id.nav_host_fragment)
        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
        val appBarConfiguration = AppBarConfiguration(
            setOf(
                R.id.navigation_home, R.id.navigation_dashboard, R.id.navigation_notifications
            )
        )
        setupActionBarWithNavController(navController, appBarConfiguration)
        navView.setupWithNavController(navController)

findNavController() は拡張関数です、ですのでJavaから呼ぶ場合は Navigation.findNavController(Activity, @IdRes int viewId) で呼ぶ必要があります。

続いてAppBarConfigurationですが表示している画面に合わせてAppBarの表示タイトルを変更するのに使っているぽいです。

コメントによると「メニューIDとナビゲーションのIDを一致させる必要がある」というような記述があります。 確認するとたしかにxml上でIDが一致していました。ここは知らないとハマりそうなポイントです。

MenuIdとNavigationIdが一致していれば、画面と画面名をAppBarと連携する必要がないならAppBarConfigurationの記述は省いても問題なさそうです。

後はメニューとNavitgationを関連付ける処理が入っている感じです。 メニューとNavigationを紐付けているのでドロワーメニューなどでも同様のやり方で画面遷移実現できそうです。

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/mobile_navigation"
    app:startDestination="@+id/navigation_home">

    <fragment
        android:id="@+id/navigation_home"   ★★★ここと★★★
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" />

    <fragment
        android:id="@+id/navigation_dashboard"
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.dashboard.DashboardFragment"
        android:label="@string/title_dashboard"
        tools:layout="@layout/fragment_dashboard" />

    <fragment
        android:id="@+id/navigation_notifications"
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.notifications.NotificationsFragment"
        android:label="@string/title_notifications"
        tools:layout="@layout/fragment_notifications" />
</navigation>
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/navigation_home"   ★★★ここのIDを一致させる必要がある★★★
        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>

mobile_navigation.xml

先程登場したナビゲーションのxmlですがデザインモードというのがあって、グラフィカルに画面が表示されるモードがあります。

ここでnavigation_homeをクリックしたまま→navigation_dashboardにドラッグして線を引いてみます。 するとxml側にアクションが追加されます。アクションIDから「HomeはDashboardへの遷移アクションを持っている」というような文脈が想定できます

    <fragment
        android:id="@+id/navigation_home"
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" >
        <action
            android:id="@+id/action_navigation_home_to_navigation_dashboard"
            app:destination="@+id/navigation_dashboard" />
    </fragment>

せっかくなのでHomeFragmentにButtonを追加して遷移してみましょう。

findNavController().navigate()はActionかDestinationのリソースIDを受けて画面遷移します。

class HomeFragment : Fragment() {

    private lateinit var homeViewModel: HomeViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // 〜中略〜
        val button: Button = root.findViewById(R.id.button)
        button.setOnClickListener {
            findNavController().navigate(R.id.action_navigation_home_to_navigation_dashboard)
        }
        return root
    }

ボタンを押すとぱっと画面が切り替わることが確認できました。ここにはデフォルトのアニメーションは適用されないようです。

独自のアニメーションを実現するにはAnimationsを修正します。

f:id:ticktakclock:20200126190753p:plain:w200

Home->Dashboardへの画面遷移の場合の各アニメーションの役割です

  • Enter
  • Exit
    • Dashboardへ遷移する時のHome側のアニメーション
  • Pop Enter
    • Dashboardから離れる時のHome側のアニメーション
  • Pop Exit

それぞれ

nav_side_enter.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_mediumAnimTime"
        android:fromXDelta="100%p"
        android:toXDelta="0" />
</set>

nav_side_exit.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_mediumAnimTime"
        android:fromXDelta="0"
        android:toXDelta="-100%p" />
</set>

nav_side_pop_enter.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_mediumAnimTime"
        android:fromXDelta="-100%p"
        android:toXDelta="0" />
</set>

nav_side_pop_exit.xml

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_mediumAnimTime"
        android:fromXDelta="0"
        android:toXDelta="100%p" />
</set>

またナビゲーションは先程定義したアニメーションのIDを指定していきます。

    <fragment
        android:id="@+id/navigation_home"
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" >
        <action
            android:id="@+id/action_navigation_home_to_navigation_dashboard"
            app:destination="@+id/navigation_dashboard"
            app:enterAnim="@anim/nav_side_enter"
            app:exitAnim="@anim/nav_side_exit"
            app:popEnterAnim="@anim/nav_side_pop_enter"
            app:popExitAnim="@anim/nav_side_pop_exit"/>
    </fragment>

こんな感じに動きました

f:id:ticktakclock:20200126211447g:plain:w200

Activityに遷移する

DashboardFragmentにボタンを追加してSubActivityに遷移するように作ってみます。

f:id:ticktakclock:20200126222800p:plain:w300

先ほどと同様にmobile_navigation.xmlにはタグで定義してみます。同じようにdashboard->sub_activityに遷移するようなActionを定義します。

    <fragment
        android:id="@+id/navigation_dashboard"
        android:name="com.github.ticktakclock.bottomnavigationsample.ui.dashboard.DashboardFragment"
        android:label="@string/title_dashboard"
        tools:layout="@layout/fragment_dashboard" >
        <action
            android:id="@+id/action_navigation_dashboard_to_navigation_sub"
            app:destination="@id/navigation_sub"
            app:enterAnim="@anim/nav_default_enter_anim"
            app:exitAnim="@anim/nav_default_exit_anim"
            app:popEnterAnim="@anim/nav_default_pop_enter_anim"
            app:popExitAnim="@anim/nav_default_pop_exit_anim" />
    </fragment>

    <activity
        android:id="@+id/navigation_sub"
        android:name="com.github.ticktakclock.bottomnavigationsample.SubActivity"
        android:label="@string/title_sub"
        tools:layout="@layout/activity_sub" />

FragmentでもActivityでも Action として画面遷移を定義しているのでActivitiyは遷移先がActivity/Fragmentどちらなのか意識することなく画面遷移できていることがわかります。

class DashboardFragment : Fragment() {

    private lateinit var dashboardViewModel: DashboardViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // 〜中略〜
        val button: Button = root.findViewById(R.id.button)
        button.setOnClickListener {
            findNavController().navigate(R.id.action_navigation_dashboard_to_navigation_sub)
        }
        return root
    }
}

まとめ

  • Navigationを使うと画面遷移を定義できる
  • 画面遷移はActivity/Fragment意識することなく同じインターフェースで実現できる
  • 遷移はActionを使い、遷移時のアニメーションも定義できる

次はSafeArgsを試してみようと思います。

→Next article ticktakclock.hatenablog.com

CoordinatorLayoutでMotionLayoutアニメーションを実現する(Android)

こんにちは、tkyです。

前回はMotionLayoutを使って実践で使えそうなアニメーションを実装しました。

今回も実践で使えそうなアニメーションを実装してみようと思います。

こちら

f:id:ticktakclock:20191231132129g:plain:w200
デモ

画面スクロール時にタイトルのテキストがアニメーションするようなMotionLayoutです。

前回同様のGitHubリポジトリとなります。 Sample10 をご確認ください。

github.com

レイアウト

CoordinatorLayoutなのでここは特に変わらずです。AppBarのレイアウト(layout/motion_10_header)が今回MotionLayoutに対応させることになります。

body側はただのTextViewなので特に触れません。GitHub参照ということで。。。

layout/motion_10.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="false">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="180dp"
        android:theme="@style/AppTheme.AppBarOverlay">

        <include layout="@layout/motion_10_header" />

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

    <include layout="@layout/motion_10_body" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

AppBar

これがMotionLayoutのレイアウトになりますが、ポイントは Sample10MotionLayout というMotionLayoutを継承したカスタムMotionLayoutクラスを使っているというところです。

Sample10MotionLayout については後述します。

あとは表示したいUIを配置していきます。今回の場合、

  • 背景に使うImageView(写真は適当に準備しています)
  • タイトルTextView
  • サブタイトルTextView
<?xml version="1.0" encoding="utf-8"?>
<com.github.ticktakclock.motionlayoutsample.Sample10MotionLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:showPaths="true"
    android:id="@+id/motionLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:minHeight="50dp"
    app:layoutDescription="@xml/scene_10"
    app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed">


    <ImageView
        android:id="@+id/background"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:scaleType="centerCrop"
        app:srcCompat="@drawable/public_domain_photo_2"
        tools:layout_editor_absoluteX="0dp"
        tools:layout_editor_absoluteY="0dp" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/sample_10_title"
        android:textColor="@android:color/white"
        android:textSize="24sp"
        tools:layout_editor_absoluteX="173dp"
        tools:layout_editor_absoluteY="263dp" />

    <TextView
        android:id="@+id/title_sub"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/sample_10_sub_title"
        android:textColor="@android:color/white"
        tools:layout_editor_absoluteX="184dp"
        tools:layout_editor_absoluteY="348dp" />
</com.github.ticktakclock.motionlayoutsample.Sample10MotionLayout>

トリガー

やりたいことは「スクロールしたときにTextViewの位置を変更する」です。

スクロールは”縦のスワイプ”になるので motion:dragDirection="dragUp" でスクロールを実装します。ターゲットは背景画像にしておきます。

    <Transition
        motion:constraintSetEnd="@id/end"
        motion:constraintSetStart="@id/start"
        motion:duration="200"
        motion:motionInterpolator="linear">
        <OnSwipe
            motion:dragDirection="dragUp"
            motion:targetId="@id/background"
            motion:touchAnchorSide="bottom" />
    </Transition>

開始

@startにはAppBarが開いている状態の成約を指定していきます。

背景画像は見せておきたいので android:alpha="1" で表示状態にしておきます。

あとは通常通りタイトルTextViewは中央に来るように、サブタイトルTextViewはタイトルの下に来るように成約を設定します。

f:id:ticktakclock:20191231150318p:plain:w200
開始レイアウト

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/background"
            android:alpha="1">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                motion:layout_constraintBottom_toBottomOf="parent"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/background"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toTopOf="@id/background" />
        </Constraint>
        <Constraint android:id="@id/title_sub">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="8dp"
                motion:layout_constraintEnd_toEndOf="parent"
                motion:layout_constraintStart_toStartOf="parent"
                motion:layout_constraintTop_toBottomOf="@id/title" />
        </Constraint>
    </ConstraintSet>

終了

@endにはAppBarが閉じている状態の成約を指定していきます。

背景画像は見せなくて良いので android:alpha="0" で表示状態にしておきます。

スクロールした時にタイトルTextViewは左下、サブタイトルTextViewはタイトルの右横に配置します。

f:id:ticktakclock:20191231151114p:plain:w200
終了レイアウト

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/background"
            android:alpha="0">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                motion:layout_constraintBottom_toBottomOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:layout_marginBottom="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/background"
                motion:layout_constraintStart_toStartOf="parent" />
        </Constraint>
        <Constraint android:id="@id/title_sub">
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                motion:layout_constraintBottom_toBottomOf="@id/title"
                motion:layout_constraintStart_toEndOf="@id/title"
                motion:layout_constraintTop_toTopOf="@id/title" />
        </Constraint>
    </ConstraintSet>

カスタムMotionLayoutについて

ここまでやれば普通であればスクロールでアニメーションできるのですが、残念ながらMotionLayoutクラスではアニメーションできませんでした。

f:id:ticktakclock:20191231165627g:plain:w200
MotionLayoutのみデモ

アニメーションする際、MotionLayoutの progress 値が変化することでTransition発生することになりますが、AppBarのレイアウトの縦移動だけではMotionLayoutのこの値が変化することはないようです。

ということでMotionLayoutに AppBarLayout.OnOffsetChangedListener を実装してAppbarLayout で発生するoffsetをMotionLayoutのprogressに適用させることでTransitionの発生を促すようにチャレンジします。

class Sample10MotionLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), AppBarLayout.OnOffsetChangedListener {

    override fun onOffsetChanged(appBarLayout: AppBarLayout?, offset: Int) {
        val scrolled = appBarLayout?.totalScrollRange?.toFloat()
        progress = -offset / (appBarLayout?.totalScrollRange?.toFloat() ?: 0f)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        (parent as? AppBarLayout)?.addOnOffsetChangedListener(this)
    }
}

これでスクロールでMotionLayoutのTransitionを効かせる事ができたのですが、もっと良い方法がないか模索したいところです。

まとめ

CoordinatorLayoutとMotionLayoutを組み合わせたアニメーションを実装してみました。

MotionLayoutを拡張してAppBarのoffsetをMotionLayoutのprogressに適用させることでアニメーションを実現しました。

どうしてもカスタムMotionLayoutが必要なのかはわかりませんが、もっと良い方法があれば是非教えていただきたいです。

発展

カスタムMotionLayoutのprogressをうまく使えば様々なケースでMotionLayoutを使えそうなことがわかったのでDrawerLayoutでも適用可能かと思われます。

詳しくはGitHubSample11 をご確認ください。

f:id:ticktakclock:20191231172336g:plain:w200
Sample11

class Sample11MotionLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), DrawerLayout.DrawerListener {

    override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
        progress = slideOffset
    }

    override fun onDrawerClosed(drawerView: View) {

    }

    override fun onDrawerOpened(drawerView: View) {

    }

    override fun onDrawerStateChanged(newState: Int) {

    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        (parent as? DrawerLayout)?.addDrawerListener(this)
    }
}