Viewを動的に変化させるアプローチ

  • 49
    いいね
  • 0
    コメント

これはDroidKaigi 2017の発表資料を文字と動画に起こしたものです。

スライドはこちら

発表の内容

  • お題
    • ユーザーの操作に追従させたい
  • キーワード
    • DataBinding
    • ConstraintLayout
  • Material Designに特化した話ではなく、どう実装するかのお話

標準APIとSupport Libraryでできること

  • アニメーション
    • ViewPagerの切替時
    • FABが操作の完了時に出てくる

old_style_list_fab.mp4.gif

  • AppBarLayout
    • 一定以上スクロールすると、色が変わる
    • タイトルのみ動かせる
    • 補足:このサンプルはバグのため動かせなかった、、

old_style_detail.mp4.gif

やりたいことユーザーの操作に追従させたい

今回の話のゴール

new_style_list_fab_1.mp4.gif new_style_detail.mp4.gif

実装アプローチのイメージ

  • 意識するのは3つ
    • 操作イベント
    • 属性値の計算
    • Viewの更新
  • この3つを見ていきます image

何を操作イベントとする?

  • タッチイベントは辛い
    • タッチイベントは種類がいっぱい ^ 全部で何個種類があるか全部言える人いる?
    • マルチタッチになるとどうなる?
    • ネステッドスクロールはさらに難解
    • flingが入るともうカオス
  • CoordinatorLayoutのBehaviorも同じ

無難なイベント

  • スクロールイベント系
    • ScrollView
    • RecyclerView
    • AppBarLayout
  • レイアウト系イベント
    • OnLayoutChangeListener
    • OnGlobalLayoutListener
    • ただし注意しないとループに嵌まる

よくよく考えると

  • タッチ操作でViewを変化させるというより
  • 他のViewに合わせて動くと考えるほうが自然

image

Viewの属性値の計算はどこでやる?

コードでゴリゴリやる

  • 行数が増えがちになる
  • コードを読まないとどういう動きかわからない
  • なのであまりやりたくない

属性値の計算は分けて考える

  • スクロール量などの値を正規化した値にする
  • 具体的な属性値への計算は別途やる

image

ViewPagerでの計算

  • タブは0.0〜3.0の値から計算
  • FABは0.0〜1.0の値から計算

image

@Override
public void onPageScrolled(
        int position,             // 0,1,2などのページ番号
        float positionOffset, // 0.0〜1.0の範囲のオフセット
        int positionOffsetPixels) {
    float absolutePosition = position + positionOffset;
    float fabFactor = (position == 0) ? positionOffset : 1;
    /* 省略 */
}

ScrollViewでの計算

  • 0.0〜1.0の値から諸々の属性値を計算する
    • ActionBar
    • タイトル : 座標と色
    • アイコン
    • その他のテキスト : 座標と色
@Override
public void onScrollChange(NestedScrollView v,
        int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
    int h = mBinding.layoutContents.image.getHeight();
    float factor = (h > 0) ? (float) scrollY / h : 0f;
    if (factor < 0f) {
        factor = 0f;
    } else if (factor > 1f) {
        factor = 1f;
    }
    /* 省略 */
}

Viewに設定する属性値の計算

  • 対象となる属性
    • alpha
    • width / height
    • translationX / translationY
    • scaleX / scaleY
    • textSize
    • etc
  • この辺りの計算はDataBindingの計算が便利

Viewの更新のアプローチ

DataBindingが強い

  • 変数を渡せる
  • 計算式が書ける

DataBindingの変数

  • layout xmlでの定義
<layout>
    <data>
        <variable name="fabFactor" type="float" />
    </data>
    <RelativeLayout android:id="@+id/layout">
        <!-- 省略 -->
    </RelativeLayout>
</layout>
  • Java側から渡す
mBinding.setFabFactor(fabFactor);

DataBindingで計算

<android.support.design.widget.FloatingActionButton
  android:id="@+id/button_search"
  android:layout_alignParentBottom="true"
  android:layout_alignParentEnd="true"
  android:translationY="@{fabFactor * @dimen/fab_offset}" />

new_style_list_fab_2.mp4.gif

具体的な例

FABの現れ方

new_style_list_fab_2.mp4.gif

image

  • 完全に隠れるには
    • 親のheight - 自分のtop
  • が必要になる

タブの動き

  • 文字サイズを変える
  • 背景色を変える
  • elevationを変える
  • 幅を変える
<TextView
    android:id="@+id/tab_0"
    android:layout_width="0dp"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:background="@{DataBindingFunctions.evaluateColor(position, 0f, @color/bgTab, @color/bgTabSelected)}"
    android:elevation="@{DataBindingFunctions.evaluateFactor(position, 0f, 0f, @dimen/elevation_tab)}"
    android:gravity="center"
    android:text="List"
    android:textAppearance="?android:textAppearanceMedium"
    android:textSize="@{DataBindingFunctions.evaluateFactor(position, 0f, @dimen/text_tab_small, @dimen/text_tab_large)}"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toStartOf="@+id/tab_1"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:setLayoutWeight="@{DataBindingFunctions.evaluateFactor(position, 0f, 3f, 4f)}"
    />

※:DataBindingFunctionsは独自の補間用関数群

DataBindingの制限

  • LayoutParamに直接的に値を設定できない
    • xml上で android:layout_***** となっているもの
    • BindingAdapter用のメソッドを作ればできる
@BindingAdapter("setLayoutWeight")
public static void setLayoutWeight(View view, float value) {
    ViewGroup.LayoutParams params = view.getLayoutParams();
    if (params instanceof LinearLayout.LayoutParams) {
        ((LinearLayout.LayoutParams) params).weight = value;
        view.setLayoutParams(params);
    }
}

CollapsingToolbarLayoutみたいなことをやる

new_style_detail.mp4.gif

  • アイコンを動かす
<ImageView
    android:id="@+id/image_icon"
    android:layout_width="?android:actionBarSize"
    android:layout_height="?android:actionBarSize"
    android:background="@drawable/oval_fill_white"
    android:padding="4dp"
    android:scaleType="centerCrop"
    android:scaleX="@{2f-scrollFactor}"
    android:scaleY="@{2f-scrollFactor}"
    app:layout_constraintBottom_toBottomOf="@+id/dummy_action_bar"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@+id/dummy_action_bar"
    app:loadImage="@{item.file}"
    app:loadImageTransformation="@{CircleTransform.instance}"
    app:setLayoutMarginStart="@{(int)((1f-scrollFactor) * @dimen/icon_offset_x)}"
    app:setLayoutMarginTop="@{(int)((1f-scrollFactor) * @dimen/icon_offset_y)}"
    app:useFit="@{false}"
    tools:layout_marginStart="@dimen/icon_offset_x"
    tools:layout_marginTop="@dimen/icon_offset_y"
    tools:scaleX="2"
    tools:scaleY="2"
    tools:src="@drawable/dummy_icon" />
  • テキストを動かす
<TextView
    android:id="@+id/text_name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@{item.name}"
    android:textAppearance="?android:textAppearanceLarge"
    android:textColor="@{DataBindingFunctions.evaluateColor(scrollFactor, @android:color/white, @android:color/black)}"
    android:textSize="@{(1f-scrollFactor) * @dimen/text_title_large + scrollFactor * @dimen/text_title_small}"
    app:layout_constraintBottom_toBottomOf="@+id/image_icon"
    app:layout_constraintStart_toEndOf="@id/image_icon"
    app:layout_constraintTop_toTopOf="@+id/image_icon"
    app:setLayoutMarginStart="@{(int)((1f-scrollFactor) * @dimen/spacing_x4)}"
    tools:layout_marginStart="@dimen/spacing_x4"
    tools:text="Name"
    tools:textColor="@android:color/white"
    tools:textSize="@dimen/text_title_large" />

ステータスバーの色を変える

  • LOLLIPOP以降なら普通に変えられる FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
    • ※:これはDataBinding使わない

tip & trap

toolsNSを活用しよう

  • layout xmlの最終形がイメージしづらい
  • レイアウトエディタ上でイメージできるようにしよう

image

表示が切れる問題

  • 親のViewGroupを飛び出すと...
    • 表示が切れる
    • elevationの影が切れる
  • android:clipToPadding="false" で回避できる

OnLayoutChangeListenerのtrap

  • 気をつけないとループに嵌まることがある
    • 変更通知→レイアウト変更→変更通知→...
    • フリーズはしないので知らないうちにCPUを食い潰す...
  • 直前の値を保持して、不要ならメソッドを叩かないようにする

OnGlobalLayoutListenerのtrap

  • removeを忘れるとメモリリークする

ConstraintLayoutが強力

new_style_detail.mp4.gif

  • LayoutParamを弄ると、相対的な配置を維持したまま動く
    • layout_width
    • layout_height
    • layout_margin
    • layout_constraintHorizontal_weight
    • layout_constraintVertical_weight
    • etc
  • 3つのテキストはアイコンに対して相対的に動いている

ConstraintLayoutのtrap

  • layout_width=0でmatch_parentと同じ挙動
  • 100dp→0dpのように動かすと、0dpのときに突然広がる挙動をして困る
    • BindingAdapterのメソッドの中で0.01などにして、誤魔化用にすることで回避できる
  • ネガティブマージンが使えない問題
    • ダミーのSpaceを挟むことで回避できる

ConstraintLayoutのtrap

  • Android Studio 2.3現在
  • レイアウトエディタでxmlを触るとたまにxmlが壊れる(´;ω;`)

DataBindingは暗黙的型変換をしない

  • 計算でfloat, double, intの場合は明示的にキャストが必要
  • 型が違っているとビルドエラーになる
  • Java言語の仕様に従って、floatなら1.0f、doubleなら1.0とか書かないとダメ

考察

DataBindingを使う理由

  • 色や大きさはXMLに入れるべきか、Javaに入れるべきか
  • Javaに書くと
    • 行数が増える
    • xmlと距離が遠くて把握しづらい
    • コードが読める人じゃないとメンテナンスできない
  • DataBindingに書くと
    • xmlの属性の@{...}の部分を見ればだいたいわかる
    • ただ、横に長くなる...

パフォーマンスに関する考察

  • LayoutParamsをいじるのは正直重い
    • LayoutParamsを変えると、measure系が走る
    • ただ複数のViewが相対的に変化するならどのみち必要
  • 必要が無いならtranslateやscaleを使うほうが良い

工数に関すること

  • 極論で言えばタッチイベントからやるのがパフォーマンスが出る
    • CoordinatorLayoutと同じアプローチ ^ すなわち、CoordinatorLayoutのBehaviorを作る
    • でも難解

-イベント+DataBindingが楽(と考える)

まとめ

image

サンプルアプリ