[Android] Snackbar에서 발생하는 ScrollView can host only one direct child
Fatal Exception: java.lang.IllegalStateException
ScrollView can host only one direct child
Snackbar란 안드로이드에서 사용자에게 메시지를 보여주는 기능이에요. Toast라는 비슷한 기능도 있긴 한데 ㅎ 제가 다니고 있는 회사에서는 Snackbar를 자주 사용하고 있어요. 그런데 위에 적힌 Exception이 가끔 제보가 들어왔었는데요. 지금까지 정확한 오류 원인을 파악하지 못하고 있었지만...
오늘 그 실마리를 잡은 것 같아서 여러분에게 공유해드리려고 합니다.
개요
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="42dp">
...
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
Snackbar.make(viewBinding.rootLayout, "SanckBar Test", 10000).show()
오류가 발생한 화면의 레이아웃과 Sncakbar를 show 하는 코드입니다. 여기서 주의 깊게 볼 부분은 NestedScrollView를 루트 레이아웃으로 사용하고 있는 화면에서 오늘 설명할 Exception이 발생할 수 있습니다.
Exception의 메시지에도 오류를 잘 설명해주고 있어요. ScrollView에 2개 이상의 child View를 추가하면서 발생하는 오류이기 때문에 Snackbar를 make 함수로 생성할 때 NestedScrollView가 아닌 그 안에 있는 뷰 그룹인 ConstraintLayout을 넘기고 있었습니다.
컨벤션, 효율적인 UI 구조처럼 이번 주제와 다른 부분은 제쳐두고 오류가 발생한 원인에 대해서만 봐주시면 감사하겠습니다 :) 여러분은 해당 코드를 문제가 있어 보이나요?
Snack 화면에 표시되는 방법 cc.ViewGroup
Snackbar도 View이기 때문에 화면에 표시되기 위해서 ViewGroup에 추가되어야 해요. 하지만 Snackbar를 생성하는 make 함수를 보게 되면 첫 번째 매개변수의 자료형이 ViewGroup이 아닌 View인 것을 확인할 수 있어요.
public static Snackbar make(
@NonNull View view, @NonNull CharSequence text, @Duration int duration
) {
...
}
여기서 우리가 놓쳤던 부분이 발견됩니다. Snackbar를 생성할 때 우리가 넘겨준 ViewGorup에서 Snackbar가 바로 추가가 되는 것이 아니에요. 내부 코드를 살펴보겠습니다.
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);
...
}
Snackbar는 넘겨받은 View에 대해서 한번 더 자신에게 알맞은 ViewGroup을 찾는 과정을 진행합니다.
함수에서 실행되는 코드를 단계별로 나눈다면 다음과 같아요.
- Step 1. 현재 View가 CoordinatorLayout이면 return
- Step 2. 현재 View가 DecorView의 Content FrameLayout이라면 return
- Step 3. 현재 View가 FrameLayout이지만 직접 추가한 FrameLayout 일 경우, 일단 해당 레이아웃을 저장한 뒤 parent를 재 탐색
- Step 4. parent가 null 일 때까지 Step 1~3 과정을 반복합니다.
Snackbar.make(viewBinding.rootLayout, "SanckBar Test", 10000).show()
그렇다면 다시 돌아와서 위 코드에서 Snackbar는 어디에 추가될까요? 정답은 두둥 Content FrameLayout입니다.
ConsraintLayout을 넘겨주었지만 위에서 설명한 알맞은 ViewGroup을 찾는 코드를 통해 NestedScrollView를 넘어 Window의 DecorView인 Content FrameLayout에 추가됩니다.
그렇다면 NestedScrollView를 첫 번째 매개변수에 넘겨도 문제는 없네요?
맞아요. 사실 NestedScrollView를 직접 넘겨주어도 정상적으로 작동되는 것을 확인할 수 있어요. 결과적으로 혼자서 쉐도우 복싱을 하고 있었습니다. ㅜㅜ
그렇다면 Exception은 왜 발생하는 걸까요?
View의 생명 주기
sampleViewModel.function(data, successAction = {
...
Snackbar.make(viewBinding.rootLayout, "SanckBar Test", 10000).show()
})
결국 문제가 발생한 코드는 View의 생명주기 문제였습니다. ViewModel에서 Corotuine을 사용해서 비동기로 Api를 요청하고, 그 결과를 successAction 람다로 View에서 바로 처리하려고 했는데 그게 실수였습니다..
이 문제를 해결하는 방법에 대해서 간단히 설명하고 이만 마치려고 합니다.
- ViewModel의 함수 호출 성공에 대한 액션을 람다가 아닌 LiveData, Flow 등의 observe field를 통해 뷰의 생명주기에 맞게 처리를 해줘야 합니다.
- 람다를 꼭 써야 하는 경우, Snackbar를 처리하는 로직을 LifecycleScope 내에서 실행되도록 합니다.
안드로이드 기본 소양이 될 수도 있는 View Lifecycle 문제라서 씁쓸하지만.. Snackbar의 내부 로직에 대해 좀 더 알게 되었다는 점을 위안으로 삼으려고 합니다