言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

AppBarLayoutを使う時にlayout_behaviorをFragmentに持たせるとviewが重ねて表示される

困った問題に出会ったのでメモ。

何が起こったか

Activityが CoordinatorLayout , その中に AppBarLayout を持っていて、(諸々省略するとこんな感じ)

<androidx.coordinatorlayout.widget.CoordinatorLayout>

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

        <TextView
            app:layout_scrollFlags="scroll|enterAlways" />

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

    <RelativeLayout /> <!-- Fragmentをhostingするcontainerのview group -->
</androidx.coordinatorlayout.widget.CoordinatorLayout>

上記のcontainerに app:layout_behavior をセットしたRecyclerViewを持ったFragmentをaddします。

<androidx.constraintlayout.widget.ConstraintLayout>
    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.recyclerview.widget.RecyclerView />

    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

するとこんな感じでRecyclerViewとAppBarLayoutが重ねて表示されてしまう。

f:id:muumuumuumuu:20190224180029p:plain:w200

Fragmentにわけない、もしくはActivityのcontainerに app:layout_behavior を設定する場合は問題なく表示されます。

f:id:muumuumuumuu:20190224180320p:plain:w200

なんでこんなことになるのか?

せっかくなのでAppBarLayoutCoordinatorLayoutの中身を見ていく。

そもそも前提として、app:layout_behaviorをつけると何が起きるのか?
CoordinatorLayout.LayoutParamsのコンストラクタでattribute setからapp:layout_behaviorで取得したbehaviorを取得しメンバに保持している。

android.googlesource.com

    public static class LayoutParams extends MarginLayoutParams {

        LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {

                    R.styleable.CoordinatorLayout_Layout_layout_behavior);
            if (mBehaviorResolved) {
                mBehavior = parseBehavior(context, attrs, a.getString(
                        R.styleable.CoordinatorLayout_Layout_layout_behavior));
            }

app:layout_behaviorに指定するのは@string/appbar_scrolling_view_behaviorを使うように公式referenceにも書いているが、この参照値の実態はcom.google.android.material.appbar.AppBarLayout$ScrollingViewBehaviorというクラスパスである。上記のparseBehavior()でreflectionを使ってBehaviorのインスタンスを生成し、メンバのmBehaviorに格納する。

CoordinatorLayoutのlayoutがonPreDraw()で走る時に、onChildViewsChanged()が呼ばれるが、このメソッドの中で先ほど格納したmBehaviorをgetしてonDependentViewChanged()をコールする。

    final void onChildViewsChanged(@DispatchChangeEvent final int type) {

            // Update any behavior-dependent views for the change
            for (int j = i + 1; j < childCount; j++) {
                final View checkChild = mDependencySortedChildren.get(j);
                final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
                final Behavior b = checkLp.getBehavior();

                    switch (type) {
                        case EVENT_VIEW_REMOVED:

                            break;
                        default: // typeはEVENT_PRE_DRAWなのでdefaultに入る
                            // Otherwise we dispatch onDependentViewChanged()
                            handled = b.onDependentViewChanged(this, checkChild, child);
                            break;
                    }

onDependentViewChanged() が呼ばれた後はいくつかのメソッドを経由してAppBarLayout.ScrollingViewBehavior#offsetChildAsNeeded()に辿り着く。この中でdependencyであるAppBarLayoutのbottom位置を取得して重ならないように良い感じに調整してくれる。

github.com

  public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {

    private void offsetChildAsNeeded(View child, View dependency) {
      final CoordinatorLayout.Behavior behavior =
          ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
      if (behavior instanceof BaseBehavior) {
        // Offset the child, pinning it to the bottom the header-dependency, maintaining
        // any vertical gap and overlap
        final BaseBehavior ablBehavior = (BaseBehavior) behavior;
        ViewCompat.offsetTopAndBottom(
            child,
            (dependency.getBottom() - child.getTop())
                + ablBehavior.offsetDelta
                + getVerticalLayoutGap()
                - getOverlapPixelsForOffset(dependency));
      }
    }

というわけで一連の処理の起点にはmBehaviorが必要だ。しかし、最初に見た通りmBehaviorCoordinatorLayout.LayoutParamsのコンストラクタで取得される。fragmentで独立したxmlを持ってそこからinflateしたfragmentをコードから動的にattachする場合、fragmentのLayoutParamsはCoordinatorLayout.LayoutParamsにならないので1mBehaviorが取得できない。 そのためapp:layout_behaviorを記述したにも関わらずoffsetが正しく設定されずviewが重なって表示されていた。

で、どうすれば良い?

CoordinatorLayout.LayoutParamsからbehaviorが取得できれば良いので、CoordinatorLayoutのchildにapp:layout_behaviorをつけるしかないと思う。以下のようなStack Overflowの記事を見つけたけれどそもそもLayoutParamsがCoordinatorLayout.LayoutParamsにcastできないのでハイハイ解散という感じだ。

stackoverflow.com

そもそもなんでこんなことやりたかったかというと、containerにattachするfragmentによってAppBarLayoutがscrollしたりしなかったりという挙動を実装したくて、fragmentの中にapp:layout_behaviorが書けるのであればそっちでscroll制御できるので楽そうという理由だった。

楽はできなさそうなのでattachするfragmentによってscrollFragを変更してやるしかなさそう。

// fragmentをattach/replaceするタイミング
val text = findViewById<TextView>(R.id.text) // `app:layout_scrollFlags` を設定していたview
val layoutParam = text.layoutParams as? AppBarLayout.LayoutParams ?: return
val scrollFrag = layoutParam.scrollFlags
if ((scrollFrag and AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL) == 1) {
    // もしもscrollのオプションが付いていたらとる(scrollしないようにする)
    layoutParam.scrollFlags = scrollFrag - AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
}

  1. 最初に示したxmlの例の場合はViewGroup#LayoutParamsになる。ここを見ると、LayoutParamsはroot viewのgetNameに$LayoutParamsをつけてreflectionしたclazzからinstance生成したもののようだ