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

[Snackbar](https://developer.android.com/reference/com/google/android/material/snackbar/Snackbar) で *ScrollView 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](https://developer.android.com/reference/com/google/android/material/snackbar/Snackbar) の make メソッドの第一引数は *The view to find a parent from* と定義されていて、ここから Snackbar を addView() する parent を探すことになります。

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

```java: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() しようとして例外が発生します。

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

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

## ◆ コード
### ☆ MainActivity
```kotlin: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
```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秒後
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/ffea3480-0942-7384-efad-ffe7c53e28ce.png"/>　<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/fcd40b87-92cf-669f-fc84-dfc08752deea.png"/>

### ☆ 怒られるコードの実行
ボタンを押すと、1秒後に ScrollView が親から remove され、そのさらに1秒後に Snackbar の表示を試みます。

開始直後 / ボタンを押して1秒後(viewをremove) / ボタンを押して2秒後(クラッシュ)
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/ffea3480-0942-7384-efad-ffe7c53e28ce.png"/>　<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/8070fd46-4814-2c18-7409-2a3cf814c9da.png"/>　<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219474/76149e36-e33d-df0f-37eb-8775e46ee75a.png"/>

以下、例外出力：

```
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)
```

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

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

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

## ◆ コード

### ☆ MainActivity
```kotlin: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
```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
```kotlin: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
```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>
```

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

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

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

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

おしまい。