2
0

More than 3 years have passed since last update.

SnackbarでScrollView can host only one direct childと怒られる件について

Last updated at Posted at 2020-10-05

SnackbarScrollView can host only one direct child と怒られる件についてのメモです。

概要

ScrollView/NestedScrollView は子を1つしか持てないため、2つ目を addView() しようとするとエラーが出ます。

しかし、そのようなコードを書いたつもりが無くても Snackbar が java.lang.IllegalStateException: ScrollView can host only one direct child と例外をスローすることがあります。

以下に、このような問題が発生するケースの説明とサンプル2点を示しました。

怒られるケース

厳密じゃないけどざっくり言うと、ScrollView や NestedScrollView の親が View 以外の場合に怒られます。

Snackbar の make メソッドの第一引数は The view to find a parent from と定義されていて、ここから Snackbar を addView() する parent を探すことになります。

この addView() する parent を探すロジックが Snackbar の findSuitableParent(View) メソッドです。

SnackBar#findSuitableParent(View)
@Nullable
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;
}

このメソッドは、適切な view (CoordinatorLayout or a suitable content view) を探し当てるまで親を辿ります。

しかし、適切な view が見つからない状態で親 view が見つからなかった場合(parent が View でない場合)、最後にたどり着いた view が返されます。

つまり、親を辿っていく過程で ScrollView や NestedScrollView の親が null の場合、このメソッドは ScrollView や NestedScrollView を返すことになります。

その後、ScrollView や NestedScrollView に対して Snackbar を addView() しようとして例外が発生します。

サンプルコード1

ボタンを押すと2秒後に Snackbar が表示されるサンプルです。

『このコードが入っていると、例外で落ちる』と書かれたコメント部分がアンコメントされていると ScrollView が親から remove された状態で Snackbar の表示を試み、ScrollView can host only one direct child と怒られて落ちるようになります。

◆ コード

☆ MainActivity

MainActivity
import android.os.Bundle
import android.os.Handler
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 画面レイアウトを設定する。
        setContentView(R.layout.activity_main)

        // ボタンを押すと invokeCatGod() メソッドが呼び出されるようにする。
        findViewById<Button>(R.id.button).setOnClickListener(::invokeCatGod)
    }

    private fun invokeCatGod(view: View) {
        Handler().run {
            // 2秒後に Snackbar を表示する。
            postDelayed(
                { showSnackBar(view) },
                2000
            )

            // このコードが入っていると、例外で落ちる:ここから -----------------------------------------------------------
            // 1秒後に、レイアウトファイルの最外殻の NestedScrollView を親から remove する。
            val scrollView = findViewById<View>(R.id.scrollView)
            postDelayed(
                { (scrollView.parent as ViewGroup).removeView(scrollView) },
                1000
            )
            // このコードが入っていると、例外で落ちる:ここまで -----------------------------------------------------------
        }
    }

    // Snackbar を表示する。
    private fun showSnackBar(view: View) {
        Snackbar.make(view, "体温ですにゃ。", Snackbar.LENGTH_SHORT).show()
    }
}

☆ activity_main.xml

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/scrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ねこ神様"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</ScrollView>

◆ 実行例

☆ 怒られないコードの実行

ボタンを押すと2秒後に Snackbar が表示されます。

開始直後 / ボタンを押して2秒後
 

☆ 怒られるコードの実行

ボタンを押すと、1秒後に ScrollView が親から remove され、そのさらに1秒後に Snackbar の表示を試みます。

開始直後 / ボタンを押して1秒後(viewをremove) / ボタンを押して2秒後(クラッシュ)
  

以下、例外出力:

java.lang.IllegalStateException: ScrollView can host only one direct child
    at android.widget.ScrollView.addView(ScrollView.java:261)
    at com.google.android.material.snackbar.BaseTransientBottomBar.showView(BaseTransientBottomBar.java:741)
    at com.google.android.material.snackbar.BaseTransientBottomBar$1.handleMessage(BaseTransientBottomBar.java:238)
    at android.os.Handler.dispatchMessage(Handler.java:101)
    at android.os.Looper.loop(Looper.java:169)
    at android.app.ActivityThread.main(ActivityThread.java:6595)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:767)

サンプルコード2

サンプルコード1と同じ振る舞いをしますが、View の操作は行わず Fragment のみを操作しています。

『このコードが入っていると、例外で落ちる』と書かれたコメント部分がアンコメントされていると Fragment が remove された状態で Snackbar の表示を試み、ScrollView can host only one direct child と怒られて落ちるようになります。

サンプルコード1では view を直接親から remove したのに対して、サンプルコード2では view を直接ではなく Fragment を remove しているという違いがあります。

◆ コード

☆ MainActivity

MainActivity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
        if (savedInstanceState == null) {
            supportFragmentManager.beginTransaction()
                    .replace(R.id.container, MainFragment.newInstance())
                    .commitNow()
        }
    }
}

☆ main_activity.xml

main_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" />

☆ MainFragment.kt

MainFragment.kt
class MainFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
            inflater.inflate(R.layout.main_fragment, container, false)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ボタンを押すと invokeCatGod() メソッドが呼び出されるようにする。
        view.findViewById<Button>(R.id.button).setOnClickListener(::invokeCatGod)
    }

    private fun invokeCatGod(view: View) {
        Handler().run {
            // 2秒後に Snackbar を表示するように設定する。
            postDelayed(
                    { showSnackBar(view) },
                    2000
            )

            // このコードが入っていると、例外で落ちる:ここから -----------------------------------------------------------
            // 1秒後に Fragment を remove する。
            postDelayed(
                    { parentFragmentManager.beginTransaction().remove(this@MainFragment).commit() },
                    1000
            )
            // このコードが入っていると、例外で落ちる:ここから -----------------------------------------------------------
        }
    }

    // Snackbar を表示する。
    private fun showSnackBar(view: View) {
        Snackbar.make(view, "体温ですにゃ。", Snackbar.LENGTH_SHORT).show()
    }

    companion object {
        fun newInstance() = MainFragment()
    }
}

☆ main_fragment.xml

main_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/scrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ねこ神様"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</ScrollView>

◆ 実行例

サンプルコード2の実行結果および例外の stacktrace は、サンプルコード1と完全に同一となります。

考察

今回のサンプルのような例外は、非同期呼び出しの結果表示後に Snackbar を呼び出すような場合に View や Fragment の remove を考慮し忘れて起こりがちな気がします。

Lifecycle を意識した設計をしていればまず起こらないはずなので、このようなバグが発生したら、発生個所やその周辺のコードが問題なのではなく、設計レベルで問題があると認識したほうが良さそうです。

気を付けましょう、、、。

おしまい。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0