Help us understand the problem. What is going on with this article?

Snackbar使用時に ScrollView can host only one direct child とエラーが起きる場合の対処

問題概要

この記事にたどり着いた人は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にしたい場合は別途対応が必要です。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした