Android/Error

[Android] Snackbar에서 발생하는 ScrollView can host only one direct child

점냥 2023. 3. 2. 21:15
반응형

 

 

 

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의 생명 주기

 

왼쪽(A) Window에 붙어있을 때, 오른쪽 (B) Window 분리되어 있는 상태

 

 

sampleViewModel.function(data, successAction = {
    ...
    Snackbar.make(viewBinding.rootLayout, "SanckBar Test", 10000).show()
})

 

결국 문제가 발생한 코드는 View의 생명주기 문제였습니다. ViewModel에서 Corotuine을 사용해서 비동기로 Api를 요청하고, 그 결과를 successAction 람다로 View에서 바로 처리하려고 했는데 그게 실수였습니다..

 

이 문제를 해결하는 방법에 대해서 간단히 설명하고 이만 마치려고 합니다.

 

  1. ViewModel의 함수 호출 성공에 대한 액션을 람다가 아닌 LiveData, Flow 등의 observe field를 통해 뷰의 생명주기에 맞게 처리를 해줘야 합니다.
  2. 람다를 꼭 써야 하는 경우, Snackbar를 처리하는 로직을 LifecycleScope 내에서 실행되도록 합니다.

 

안드로이드 기본 소양이 될 수도 있는 View Lifecycle 문제라서 씁쓸하지만.. Snackbar의 내부 로직에 대해 좀 더 알게 되었다는 점을 위안으로 삼으려고 합니다

 

반응형