MotionLayoutのハマりどころ


この記事は第二のドワンゴ Advent Calendar 2019の16日目の記事です。
AndroidのレイアウトとアニメーションをMotionLayoutで作ったら色々ハマりました、という記事です。

前置き

MotionLayout(が含まれるConstraintLayoutパッケージ)はまだBetaバージョンのため、今後の更新によって記事の内容が適用できなくなる可能性があります。ご注意ください。

この記事は、 ConstraintLayout 2.0.0 beta 3 をベースにして書かれています。

また、「まだこれはできません」「こうするとバグります」などと書きますが、全てを複数の環境で確認したわけではないので、誤謬が含まれている可能性があります。
「知らないだけでできる」「こっちでは動く」「おま環」などがあれば教えて下さい。切実に。

MotionLayoutのかんたんな紹介

ConstraintLayout とは、UIの要素間に制約を設定することで画面レイアウトを作成するための仕組みです。こいつの詳しい説明は割愛いたします。

MotionLayoutは この ConstraintLayout から派生したものです。(実装上もMotionLayoutはConstraintLayoutの子クラス)
こちらは、一つの画面に対して複数のレイアウトを設定することができ、この複数のレイアウト間をアニメーションしながら切り替えることができる優れもの、なんですが、気をつけないといけない部分がとても多くなかなか苦労させられます。

実例を交えて説明します。

動かしてみる

Google Map(iOS)のこのUIのように

下からViewがせり上がってくるアニメーションを作ってみます。

layout ファイル

<androidx.constraintlayout.motion.widget.MotionLayout
    android:id="@+id/motion_layout"
    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:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/motion_activity_main"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/hello"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="hello"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:id="@+id/bottom_view"
        android:layout_width="0dp"
        android:layout_height="200dp"
        android:background="#eb5454" />

</androidx.constraintlayout.motion.widget.MotionLayout>

motion ファイル

<MotionScene
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:motion="http://schemas.android.com/apk/res-auto">

    <Transition
        motion:constraintSetEnd="@id/constraint_expand"
        motion:constraintSetStart="@id/constraint_init" >
        <OnSwipe
            motion:touchAnchorId="@id/bottom_view"
            motion:dragDirection="dragUp" />
    </Transition>

    <ConstraintSet android:id="@+id/constraint_init">
        <Constraint android:id="@id/bottom_view"
            android:layout_width="0dp"
            android:layout_height="400dp"
            motion:layout_constraintTop_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>

    <ConstraintSet android:id="@+id/constraint_expand">
        <Constraint android:id="@id/bottom_view"
            android:layout_width="0dp"
            android:layout_height="400dp"
            motion:layout_constraintBottom_toBottomOf="parent"
            motion:layout_constraintEnd_toEndOf="parent"
            motion:layout_constraintStart_toStartOf="parent" />
    </ConstraintSet>
</MotionScene>

で、画面を上下に擦るとこうなります

割と簡単にリッチなアニメーションができましたね。やばいですね。

軽く説明しておきます。
まずLayoutファイルですが、ルートにMotionLayoutがあり、MotionLayoutの子ビューとしてTextView( @id/hello )と背景色が赤のView( @id/bottom_view )が追加されています。
MotionLayoutの属性の内、ConstraintLayoutと唯一違う必須属性は、 app:layoutDescription="@xml/motion_activity_main" のみです。ここで指定したリソースにMotionLayoutを動かすために必要なMotionScene要素を記述します。
また、名前から察することができますが、MotionSceneは app/res/xml/motion_activity_main.xml に配置してあります。(わかりやすさのため layout ファイルにプレフィックスとして motion_ を付加していますが、どんな名前でも大丈夫です。)

@id/hello には制約( app:layout_constraintEnd_toEndOf 等のこと)が書いてありますが、 bottom_view には制約が書いてありません。MotionLayoutで動かしたい要素は、motionファイルの方で制約を記述するので何も書かなくて良いです。逆に、MotionLayoutで動かさない要素は、ConstraintLayoutの方で制約を書いてあれば、motionファイルでは考慮しなくて良くなります。

次に motion ファイルですが、MotionScene要素の中にTransition要素とConstraintSet要素が入っていることがわかります。

ConstraintSetには layout ファイルと同じように制約を記述します。基本的には、 layout ファイルから要素をコピペし、 TextView, View等をConstraintに変更 (これを忘れて何故か動かない、という経験を何度かしているのでご注意を)し、 app:motion: にすれば大丈夫です。 layout_ と付かないものは大半消してしまっても構いません。(今回の例では、 @id/bottom_viewandroid:background 要素はConstraintには必要ありません。アニメーションしながら色を徐々に変えたい、とかには使えません。)
今回は、 bottom_view を上下にアニメーションさせたいので上のときの制約 @id/constraint_expand と 初期状態の制約 @id/constraint_init を作りました。

Transitionは、どこからどこまで、どうアニメーションさせるかを記述します。

どうでもいい豆知識その1: MotionScene の部分は別になんでもいいらしく、<Hoge></Hoge> で囲ってもちゃんと動く。おかげで MotionShcene という Typo にチームメンバーが誰も長らく気づかなかった

どうでもいい豆知識その2:layoutファイルには制約を書かなくてもちゃんと動作するのだが、Android Studio の PreviewはMotionSceneを見てくれないので何も書いていないとPreviewが壊れる・・・と書こうとしたのだが、Android Studioの最新のStableバージョン(3.5.3)で試したら、 constraintSetStart で指定したConstraintのPreviewがちゃんと描画されていた。

どうでもいい豆知識その3:以前まで(少なくともAlpha3くらいまで)は一切動かさないものまで全て制約を書く必要があり、Previewのためlayoutファイルに一回、motion ファイルに二回(状態が増えればもっと)同じ記述をしなければならず地獄だった。今は動かさなくていいものは書かなくて良い上に、Alpha5からの新機能である Derived Constraints (後述)によりかなり楽になった

コードから動かしたい

最低限動くところから、実際のアプリケーションではもっと様々な要求が生まれると思います。
例えば、別のイベントによりアニメーションを実行させたいとします。
この場合は、

val motionLayout = findViewById<MotionLayout>(R.id.motion_layout)
motionLayout.transitionToState(id: Int)

を呼び出せばOKです。idには、今回の例では R.id.constraint_expand 等を指定すると良いでしょう

アニメーションなしで即座に動いてほしい

この場合、 MotionLayout::transitionToState の代わりに、 MotionLayout::setState を呼び出せば望みの挙動が得られる はずなのですが (Alpha3では動いていた)、Beta3時点で setStateは壊れており、何をしてもうんともすんとも言いません。

代替案として、以下の方法が挙げられます

  1. アニメーションの時間を0にする
motionLayout.run {
    setTransitionDuration(0)
    transitionToState(id: Int)
}
  1. setTransition()を使う
motionLayout.setTransition(id: Int, <任意のInt>)

setTransitionを使う方法を採用していたのですが、何度か動かしているとConstraintが張り付いて全く動かなくなる現象が起きるので、あまりおすすめの方法とは言えません。アニメーションの時間を0にするのが良いと思います。

余談1:setTranstionDuration を0にする方法は書いていて気づきました。この記事書いてよかった~

余談2:setTransition は setState の代わりに悪用するために存在するはずではないような気がするんですが、どうやって使うんですか?
動的にアニメーションを設定できそうな気配は感じられるのですが、肝心のアニメーションを呼び出すメソッドは見当たらず・・・
と思っていたんですが、progressに値をセットしたいときにどのアニメーションのprogressかを指定できるっぽいですね。この記事書いてよかった~

複数の状態の間をアニメーションしたい

実際のアニメーションでは、一つのViewが展開して閉じるだけではありません。展開の途中で止めてちいさなUIとして表示しておきたい、という要望があるかもしれません。さらに、他のViewが全く別のタイミングでアニメーションする場合は?

こういう場合、ConstraintSetは2つでは足りません。(他のViewが~のくだりでは、Fragmentを入れ子にして別のMotionLayoutを展開する、という解決策が使える場合もありますが)
幸い、ConstraintSet を複数 MotionScene に記述することはある程度考慮されているため、上の例にならって、どんどん増やしてしまって構いません。
ただ、Viewの要素が多くなってくると、全てのConstraintSetで同じような記述をすることに飽きてきて「ここの状態間では動くけど、他の状態では常に同じ位置にいるので何度も書きたくない」、という要望が発生すると思います。こんなときは、Derived Constraintsを利用しましょう。
書き方は、motion:deriveConstraintsFrom を追加するだけです。

<ConstraintSet
    android:id="@id/sonnani_ugokanai_constraint"
    motion:deriveConstraintsFrom="@id/constraints_base" >
    <Constraint
        android:id"@id/yuiitu_ugoku_view"
        ... ここに制約を書く
    />
</ConstraintSet>

これで、差分のConstraintだけ書けば後は derive したConstraintSetを参照してくれます。

また特定の条件でしか使えませんが、記述量を減らす記法として、XMLの属性ではなく子要素として書く記法が使えることがあります。
普通に書くと、Constraint 要素の属性として android:layout_widthmotion:layout_constraint~ 等を記述することになりますが、子要素の <Layout /><PropertySet /> として記述することができます。
この書き方で書くと、たとえば、遷移によりVisibilityがGoneになるだけならば、

<ConstraintSet
    android:id="@id/sonnani_ugokanai_constraint"
    motion:deriveConstraintsFrom="@id/constraints_base" >
    <Constraint android:id"@id/be_gone_view" >
        <PropertySet android:visibility="gone" />
    </Constraint>
</ConstraintSet>

このようにLayout関連の記述をすっ飛ばすことができます。
残念ながら、制約のうち一部だけ変更したいので全部は書きたくない、といった要望には適しませんが、Layoutは変わらないがMotionLayoutで制御できる一部の属性のみ変更したい、といった状況はあり得ると思うので、覚えておいて損はないと思います。

詳しく知りたい方は、ConstraintLayout 2.0.0 Alpha3 のリリースノート を見てください。

複数状態をMotionLayoutで制御しようとすると、一つの(現状どうしようもない)問題が発生します。
それは、MotionSceneにはTransitionは一つしか設定できない、ということです。
つまり各状態間のアニメーションごとに、OnClickやOnSwipeを設定する事はできず、手軽にリッチなアニメーションが実装できる、という利点が少々薄れてしまいます。
となると、どうせコード上から 遷移を制御しなければならず、Transition要素は特に存在意義はないはずなので消したくなりますが、 motion:constraintSetStart が設定されていない場合、UI要素の描画位置が崩壊するバグ がある(詳しい条件は不明だが、少なくとも motion:constraintSetStart さえ設定すれば起きない)ので、意味はなくともつけておいたほうが無難です

アニメーションの終了等のイベントが取りたい

Listenerの登録ができます。

motionLayout.setTransitionListener(
    object : MotionLayout.TransitionListener {
        override fun onTransitionChange(p0: MotionLayout?, startId: Int, endId: Int, p3: Float) {}

        override fun onTransitionCompleted(p0: MotionLayout?, currentId: Int) {}

        override fun onTransitionStarted(p0: MotionLayout?, startId: Int, endId: Int) {}

        override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {}
   }
)

これでOK、なんですが、ここで流れてくるIdでハマることがあります。

StateSet、多分必要ない

さて、今まで意図的に存在に言及してきませんでしたが、MotionScene には、 StateSet という要素を追加できます。
これは何かというと、ConstraintSetとStateを対応付けて管理したり、defaultStateを設定したりできるもの、のハズなんですが、今の所あんまり機能していません。
まず、defaultStateですが、これを設定することで最初に画面に表示されるConstraintSetを変更できる、ように思えます。
しかし、最初に画面に表示されるConstraintSetの優先順位は、以下のようになっています。
1. Transition 要素の motion:constraintSetStart 属性に設定されたConstraintSet
2. 一番若いidをもつConstraintSet
defaultStateは特に関係ありません。なんのために存在する属性なんでしょうか。これにはミュウツーもニッコリです。

次に、コード上からMotionLayoutの現在の状態を取得する MotionLayout.getCurrentState() があるんですが、これを使うと、Stateのid ではなく ConstraintSet の id が帰ってきます。これと同様に、上述したEventListenerから流れてくる id も ConstraintSet の id です。Alpha3時点では State を設定すると StateのIdで帰ってきていたのですが、Stateを積極的に採用する理由も特にないですし、 ConstraintSet の id だけで状態を管理したほうが良いと思います。

ただ、StateにVariantというものが設定できるので、存在意義はそこにあるのかもしれません(が、よくわかっていない)

あとがき

ここまで書いて力尽きました。他のバグ等のハマりどころは下に羅列しておきます。
良いMotionLayout生活を

その他

  • KeyFrameは完全に壊れており、機能していない。複雑な軌跡を描くアニメーションは不可能になっている
  • MotionLayoutにMotionLayoutをネストさせ、同時にアニメーションしようとすると、描画がガタガタする。特に画像。beta2くらいで治ったがbeta3で再発。
  • MotionLayout 直下に RecyclerView(androidxの1.0.0) を入れると描画が盛大に乱れるバグがあったが、RecyclerViewのバージョンを1.1.0に上げると解決。ただ、一番下にスクロールした後にdatasetが変わるとスクロール位置が一定の高さズレたり、GridLayoutだと縦線がちらついたりあんまり安定はしていない様子。RecyclerViewをアニメーションで動かす必要がないのなら、FrameLayoutで分けてRecyclerViewだけMotionLayoutから出してあげたほうがいいかもしれない。
  • うちのアプリではbeta1 から今まで durationが設定できないバグが起きているが、試しに手元で試すと再現しなかった。どうして
  • 一部の要素だけ一瞬でアニメーションを完了したかったが、多分無理
  • 普段意識することはないが、 visibility=gone にすると width, height ともに 0dp となる。MotionLayoutでGONEにすると一点に収束するようなアニメーションになるため、要素を消そうと思ったらなんか思っていたのと違う、となりがち
  • match_parent じゃなくて "0dp" かつ Start と End を parent に制約付けるとバグが治ったことがあるのでそれ以来そっちにしている
  • wrap_content な要素とViewPagerをMotionLayout下に入れるとスクロールが正常に動かないことがあった。謎