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 の make メソッドの第一引数は The view to find a parent from と定義されていて、ここから Snackbar を addView() する parent を探すことになります。
この addView() する parent を探すロジックが 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
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 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 が表示されます。
☆ 怒られるコードの実行
ボタンを押すと、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
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 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
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 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 を意識した設計をしていればまず起こらないはずなので、このようなバグが発生したら、発生個所やその周辺のコードが問題なのではなく、設計レベルで問題があると認識したほうが良さそうです。
気を付けましょう、、、。
おしまい。