AppBarLayoutを使う時にlayout_behaviorをFragmentに持たせるとviewが重ねて表示される
困った問題に出会ったのでメモ。
AppBarLayoutがActivityにいて、layout_behaviorつけたscrollableなviewがFragmentにいる場合、AppBarLayoutはちゃんと動くんだけど、scrollするviewの方がAppBarLayoutと同じ高さからlayoutされるので隠れてしまう…
— むーむー/Atsuko FUKUI (@muumuumuumuu) February 21, 2019
みんな同じActivityにいる場合はちゃんとAppBarの下に表示される…
なんでーーー
何が起こったか
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が重ねて表示されてしまう。
Fragmentにわけない、もしくはActivityのcontainerに app:layout_behavior
を設定する場合は問題なく表示されます。
なんでこんなことになるのか?
せっかくなのでAppBarLayout
とCoordinatorLayout
の中身を見ていく。
そもそも前提として、app:layout_behavior
をつけると何が起きるのか?
CoordinatorLayout.LayoutParams
のコンストラクタでattribute setからapp:layout_behavior
で取得したbehaviorを取得しメンバに保持している。
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位置を取得して重ならないように良い感じに調整してくれる。
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
が必要だ。しかし、最初に見た通りmBehavior
はCoordinatorLayout.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できないのでハイハイ解散という感じだ。
そもそもなんでこんなことやりたかったかというと、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 }