問題概要
この記事にたどり着いた人はScrollViewにaddViewした覚えがないのに
ScrollView can host only one direct child
のエラーに悩まされている人かと思います。
その場合、
Snackbarのmakeの第一引数に渡しているviewが、ScrollViewになっていないか確認してください。
ScrollViewになっていれば、原因はそれの可能性が高いです。
原因
MateralComponentの1.1.0-alpha-06までのSnackbarの実装を見てみると、
@NonNull
public static Snackbar make(
@NonNull View view, @NonNull CharSequence text, @Duration int duration) {
final ViewGroup parent = findSuitableParent(view);
if (parent == null) {
throw new IllegalArgumentException(
"No suitable parent found from the given view. Please provide a valid view.");
}
final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
final SnackbarContentLayout content =
(SnackbarContentLayout)
inflater.inflate(
hasSnackbarButtonStyleAttr(parent.getContext())
? R.layout.mtrl_layout_snackbar_include
: R.layout.design_layout_snackbar_include,
parent,
false);
final Snackbar snackbar = new Snackbar(parent, content, content);
snackbar.setText(text);
snackbar.setDuration(duration);
return snackbar;
}
makeの中で、ViewGroupを取得する際に、findSuitableParentを使用していることがわかります。
final ViewGroup parent = findSuitableParent(view);
では、さらに見ていくと、
private static ViewGroup findSuitableParent(View view) {
ViewGroup fallback = null;
do {
if (view instanceof CoordinatorLayout) {
// We've found a CoordinatorLayout, use it
return (ViewGroup) view;
} else if (view instanceof FrameLayout) {
if (view.getId() == android.R.id.content) {
// If we've hit the decor content view, then we didn't find a CoL in the
// hierarchy, so use it.
return (ViewGroup) view;
} else {
// It's not the content view but we'll use it as our fallback
fallback = (ViewGroup) view;
}
}
if (view != null) {
// Else, we will loop and crawl up the view hierarchy and try to find a parent
final ViewParent parent = view.getParent();
view = parent instanceof View ? (View) parent : null;
}
} while (view != null);
// If we reach here then we didn't find a CoL or a suitable content view so we'll fallback
return fallback;
}
まずCoordinatorLayoutであるか見て、その次にFrameLayoutであるかどうかを見ています。
その場合の細かい話は今回置いておいて、違った場合を見てみると、parent viewが見つかるまでview.getParent()しています。
最終的に、parent viewがScrollViewだった場合、ScrollViewがparent viewとして返ります。
そして、
final Snackbar snackbar = new Snackbar(parent, content, content);
そのparentを自身のコンストラクタに渡しています。
private Snackbar(
ViewGroup parent,
View content,
com.google.android.material.snackbar.ContentViewCallback contentViewCallback) {
super(parent, content, contentViewCallback);
accessibilityManager =
(AccessibilityManager) parent.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
}
次に、Snackbarの親であるBaseTransientBottomBarを見てみます。
最終的に実行されるshowView()を見てみると、
final void showView() {
if (this.view.getParent() == null) {
final ViewGroup.LayoutParams lp = this.view.getLayoutParams();
if (lp instanceof CoordinatorLayout.LayoutParams) {
// If our LayoutParams are from a CoordinatorLayout, we'll setup our Behavior
final CoordinatorLayout.LayoutParams clp = (CoordinatorLayout.LayoutParams) lp;
final SwipeDismissBehavior<? extends View> behavior =
this.behavior == null ? getNewBehavior() : this.behavior;
if (behavior instanceof BaseTransientBottomBar.Behavior) {
((BaseTransientBottomBar.Behavior) behavior).setBaseTransientBottomBar(this);
}
behavior.setListener(
new SwipeDismissBehavior.OnDismissListener() {
@Override
public void onDismiss(View view) {
view.setVisibility(View.GONE);
dispatchDismiss(BaseCallback.DISMISS_EVENT_SWIPE);
}
@Override
public void onDragStateChanged(int state) {
switch (state) {
case SwipeDismissBehavior.STATE_DRAGGING:
case SwipeDismissBehavior.STATE_SETTLING:
// If the view is being dragged or settling, pause the timeout
SnackbarManager.getInstance().pauseTimeout(managerCallback);
break;
case SwipeDismissBehavior.STATE_IDLE:
// If the view has been released and is idle, restore the timeout
SnackbarManager.getInstance().restoreTimeoutIfPaused(managerCallback);
break;
default:
// Any other state is ignored
}
}
});
clp.setBehavior(behavior);
// Also set the inset edge so that views can dodge the bar correctly, but only if there is
// no anchor view.
if (anchorView == null) {
clp.insetEdge = Gravity.BOTTOM;
}
}
extraBottomMarginAnchorView = calculateBottomMarginForAnchorView();
updateBottomMargin();
targetParent.addView(this.view);
}
最後の行の
targetParent.addView(this.view);
でaddViewしており、このtargetParentがSnakbarのコンストラクタで親に渡していたparentです。
ここがScrollViewだった場合、エラーが発生してランタイムで落ちてしまいます。
DataBindingを使っている場合の落とし穴
ちなみに、rootがScrollViewになってしまう原因として、
findViewByIdで
android.R.id.content
を指定するとxml上で見えるrootのさらに上の階層のviewを取得できますが、(viewの階層構造に関しては割愛。DecorViewとかContentFrameLayoutとかだいぶ深い階層になっています)
DataBindingを使っている場合、binding.rootで取得したrootはxml上のrootです。
/**
* Returns the outermost View in the layout file associated with the Binding. If this
* binding is for a merge layout file, this will return the first root in the merge tag.
*
* @return the outermost View in the layout file associated with the Binding.
*/
@NonNull
public View getRoot() {
return mRoot;
}
the outermost View in the layout file associated with the Binding.
ということなので。
bindingしているViewのみを扱うことを考えたら、当然といえば当然かもしれません。
なので、 rootがScrollViewなLayoutにて、binding.root
を渡すとクラッシュします。
対処法
対処法としては、
https://stackoverflow.com/questions/4486034/get-root-view-from-current-activity
にあるように、 android.R.id.content
を指定して、ScrollViewではないrootが渡るように保証してあげるなどが考えられます。
例えば、
fun View.showError(
@StringRes errorMessageResId: Int = R.string.error
) {
Snackbar.make(findViewById(android.R.id.content), errorMessageResId, Snackbar.LENGTH_LONG)
.show()
}
のように、Viewのextensionにして共通で使うなど。
ただし、rootをCoordinatorLayoutにしたい場合は別途対応が必要です。