[Android] DataBinding의 동작방식 - 4. include Tag 혹은 ViewStub 사용시의 Binding

이번에는 <include>와 ViewStub 사용시의 Binding에 대해서 알아보려고 한다. 사실 include와 ViewStub까지 사용해서 앱을 만드는 경우는 그렇게 많지는 않다고 생각한다. 하지만 앱이 복잡해 지는경우, View의 재사용을 include를 통해 적용하게 되는 경우등에는 쓰는 경우들이 있는데, Binding을 사용하는 경우에는 Bind되는 Model을 재사용하거나 분리하려고 할 때도 쓸 수 있다.

"[Android] DataBinding의 동작방식" 전체목록
   1. Setter Method와의 연결
   2. BindingAdapter의 기본 및 사용 시점
   3. BindingAdapter의 사용시 팁
   4. include Tag 혹은 ViewStub 사용시의 Binding
   5. Listener, Callback
   6. InverseBinding (InverseBindingAdapter) + Two way Binding

1. <include>에서의 Binding

 우선은 제일 간단한 <include> Tag 사용시의 Binding에 대해서 알아본다. 가장 외곽
 1) outer layout xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="user"
            type="com.example.User" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.firstName}" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.lastName}" />

        <include
            layout="@layout/activity_test_2"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:jobInfo="@{user.job}"/>
    </LinearLayout>
</layout>


 2) Inner layout xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="jobInfo"
            type="com.example.JobInfo" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{jobInfo.job}"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{jobInfo.years}" />


    </LinearLayout>
</layout>

이해를 돕기위해 아주 간단한 Layout XML 두개를 위와같이 준비했다. 사실 여기서 가장 중요하면서, 유일한 포인트 하나는 Outer Layout XML의 app:jobInfo="@{user.job}" 부분이다. 앞의 app은 Custom Namespace로써 원하는 어떤 이름도 상관없지만 중요한 부분은 바로 그 다음이다. jobInfo 이 이름은 include가 갖는 layout에 정의된 variable중 하나의 name이어야 한다. 물론 ="@{user.job}" 와 같은 형식으로 들어가는 값 역시 inner layout에 정의된 type과 일치되어야 한다.

위의 내용은 사실 Developer Page에 나온 이야기이긴 하지만, 애매하게 글이 적혀있다. 그 내용을 가져와 보면 아래와 같다.


필자가 부족한것일지도 모르나, 위의 내용을 처음 봤을 때는 Outer Layout의 variable name을 맞춰서 넘겨줘야 한다로 이해를 했었다. 하지만 inner layout XML에 정의된 variable name으로 넘겨줘야 함을 잊지 말자. 그리고 주의해야할 점이 있는데 Binding 사용시에는 Inner Layout XML의 최상위 Layout은 <merge>를 사용해서는 안된다는 점이다. 이러면 빌드가 안된다. 예시는 Google에 올라와있는 것을 참고하자. 즉! 아래와 같이 작성해서는 안된다는 말이다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:bind="http://schemas.android.com/apk/res-auto">
   <data>
       <variable name="user" type="com.example.User"/>
   </data>
   <merge>
       <include layout="@layout/name"
           bind:user="@{user}"/>
       <include layout="@layout/contact"
           bind:user="@{user}"/>
   </merge>
</layout>


 마지막으로 한가지 더. 필자가 DataBinding을 통해 유용하게 처리하고 있는 것들 중 하나는 View들의 Visibility 관리이다. Model의 상태에 따라서 각각의 View들의 Visibility가 바뀌도록 하는 로직을 쉽게 만들 수 있었다. include도 마찬가지다. <include> tag 안쪽에서 android:visibility를 사용해줄 수 있다.


2. ViewStub에서의 Binding (ViewStub의 중요사항)

  이 부분을 읽고 있는 사람들에게 ViewStub이 무엇인지 설명할 필요는 없지 않을까 싶다. 하지만 우선 알고 넘어갔으면 하는 부분이 몇가지 있다.

 1) ViewStub도 일단은 View
@RemoteView
public final class ViewStub extends View {
    public ViewStub(Context context) {
        super((Context)null, (AttributeSet)null, 0, 0);
        throw new RuntimeException("Stub!");
    }
  위의 코드에서 보는 것 처럼, ViewStub도 View를 extends한... 일단은 View인 녀석이다. 그렇기 때문에 어느정도 View의 속성들을 갖고 있을 뿐 아니라 Binding에서도 View처럼 고려되는 부분이 있기도 하다. 물론 진짜 Binding된 후의 결과로는 View가 아니게 된다. 말이 엄청 혼란스러울텐데, 일단 그정도만 알아두고 간다. 일단은 View다!


 2) ViewStub의 inflate는 setVisibility()로도 된다
  무슨말인지 알기위해 ViewStub의 setVisibility() 코드를 보면 아래와 같다.
@Override
@android.view.RemotableViewMethod
public void setVisibility(int visibility) {
    if (mInflatedViewRef != null) {
        View view = mInflatedViewRef.get();
        if (view != null) {
            view.setVisibility(visibility);
        } else {
            throw new IllegalStateException("setVisibility called on un-referenced view");
        }
    } else {
        super.setVisibility(visibility);
        if (visibility == VISIBLE || visibility == INVISIBLE) {
            inflate();
        }
    }
}

  위의 코드의 Bold 처리된 부분을 보면, 넘어온 visibility가 VISIBLE, INVISIBLE인 경우라면 inflate를 호출한다. 조금 더 정확하게 보면, mInflatedViewRef가 null인 경우, 즉, inflate가 되지 않은 경우에 visibility가 GONE이 아닌것이 넘어오면 inflate를 한다. 그리고 재미있는 것은, mInflateViewRef가 null이 아닌 경우는 해당 View의 Reference를 통해 Visibility 처리를 한다. (WeakReference를 사용했기 때문에 mInflateViewRef.get()을 먼저 사용해주는 코드가 보이긴 한다.)
 그러니까 위의 내용을 정리를 하면, 각 View들의 Visibility관리를 할 때 ViewStub의 setVisibility()만 잘 호출해줘도 충분하다 = 굳이 Inflate된 View의 Reference를 갖고 있을 필요가 없어진다. 라는 것이다. 이 부분이 DataBinding에서 ViewStub을 사용할 때 굉장히 유용한 부분이 된다.


 3) ViewStub의 inflate는 Sync.
  제목 그대로 inflate의 처리는 비동기로 처리되지 않고, 동기로 처리된다. 그러므로, ViewStub의 inflate를 처리한 후, 바로 다음 Line에서 getView()를 통해 inflate된 결과 View를 가져올 수 있다.


3. ViewStub에서의 Binding 

 자 위에서 ViewStub의 중요한 부분들에 대해 알아봤다. 그렇다면 이제 ViewStub을 Binding과 함께 사용하기 위해 어떻게 해야할지 가장먼저 Developer Page의 내용부터 살펴보도록하자.



위의 내용이 Developer Page에 있는 DataBinding에서의 ViewStub에 대한 내용의 전부이다. 위의 내용이 참 말이 어렵게 써있긴 한데, 이해하기 쉽게 정리를 해보면 아래 두가지 이다.

  1. ViewStub은 ViewStubProxy로 래핑되어 Binding 된다.
  2. ViewStub이 갖는 layout에 Data를 넣기 위해선 OnInflateListener를 통해 inflate가 완료된 시점을 받아 Data를 Bind 시켜라.

라는 것인데... 1번은 중요한 내용이지만 2번은... 어쩌면 완전 헛소리를 하는 것이다. 물론 2번처럼 해도 되긴 하지만, 굳이? 라는 생각이 든다. 당연히 더 좋은 방법이 있다. 왜 이런 내용이 Developer Page에는 정리가 되어있지 않은지 모르겠다.
 지금부터는 ViewStub을 DataBinding에서 쓰기위한 방법들과 팁들을 살펴보자.

 1) ViewStub의 inflate
  우리는 위에서 ViewStub의 setVisibility()를 이용하면 inflate를 할 수 있다고 확인 했다. 이 성질을 이용하면 아래와 같은 XML을 작성해서 View visibility 컨트롤 및 inflate가 가능해진다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <import type="android.view.View"/>
        <variable
            name="user"
            type="com.example.User" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.firstName}" />

        <ViewStub
            android:id="@+id/controller"
            android:inflatedId="@+id/controller"
            android:layout="@layout/view_controller"
            android:layout_width="match_parent"
            android:layout_height="150dp"
            android:visibility="@{user.useController ? View.VISIBLE : View.GONE}"
            app:jobInfo="@{user.job}"/>
    </LinearLayout>
</layout>

우리는 단순히 위와같이, 일반 View처럼 Visibility 컨트롤을 해주면 되는 것이다! 그렇다.. 이론적으로는... 하지만 ViewStub의 혼란은 여기서부터 시작된다.

 Developer Page에 나와있는 것과 같이, ViewStub은 ViewStubProxy라는 객체로 래핑되어서 Binding Code 내에서 관리가 된다. 위의 XML을 통해 생성된 Binding Code의 일부를 살펴보면 아래와 같다.

public class ActivityTestBinding extends android.databinding.ViewDataBinding  {

    ...
    public final android.databinding.ViewStubProxy controller;
    private final android.widget.LinearLayout mboundView0;
    private final android.widget.TextView mboundView1;
    // variables
    private com.example.User mUser;
    // values
    // listeners

그리고 이 Binding Code에서 ViewStub의 setVisibility()가 호출되는 부분을 살펴보면 아래와 같다.

if (!this.controller.isInflated()) 
    this.controller.getViewStub().setVisibility(userUseControllerViewVISIBLEViewGONE);

그렇다. 문제는 여기서 생긴다. ViewStub의 setVisibility()는 해당 ViewStub이 inflate 되지 않았을 때만 호출된다. 사실 저 조건문만 없었다면, 우린 ViewStub이든 <include>이든 일반 View들이든 신경쓰지 않고 사용할 수 있었을텐데... 왜 Google은 이렇게 해둔것일까 하는 의문이 들었다. (그리고 아직도 모르겠다...)
 하지만 한가지 확실한 것은, ViewStub이 inflate 되지 않은 시점이라면 android:visibility="@{true}"를 통해 XML Binding으로 inflate시킬 수 있다는 것이다.

 2) ViewStub의 Visibility
 한 단계 더 발전해서, ViewStub의 Visibility를 관리하려면 어떻게 해야할까? Android 버그인지, 어떤 의도인지는 몰라도 단순히 ViewStub의 android:visibility만을 이용하는 것은 단순히 inflate만 할 수가 있는...

 우선 안타까운 이야기부터 한다면, 단순한 방법으로는 이 문제를 해결할 수 없다. 지금부터 이야기 하는 방법보다 더 좋은 방법을 알고 있다면 댓글로...

 아래와 같이 @BindingAdapter를 활용하는 방법을 먼저 생각해볼 수 있다.

@BindingAdapter("android:visibility")
public static void setVisibility(ViewStubProxy viewStub, int visibility) {
    viewStub.getViewStub().setVisibility(visibility);
}
 위 Method에서 중요한 부분은 첫번째 Parameter가 View가 아닌 ViewStubProxy라는 점이다.  하지만 이렇게 해두고 빌드를 하면 해당 Method를 사용하지 않고 이전과 같은 코드를 생성하게 된다. 즉... 해당 Method에 Binding 되지 않는다는 것...

 현재 이 문제를 해결하기 위해서는 ViewStub의 Binding되는 방식에 대해서 이해하고 넘어가야 한다. 이 부분을 보면 왜 구글이 setVisibility()처리를 inflate 되어있지 않았을 때만 할 수 있게 해두었는지 살짝의 실마기가 보일 수도...

 우선 기본적인 ViewStub의 XML코드를 살짝 보고, Binding되는 부분의 코드도 같이 보자. 집중해서 봐야할 부분만 Bold 처리했다.

  - XML
<ViewStub
    android:id="@+id/controller"
    android:inflatedId="@+id/controller"
    android:layout="@layout/view_controller"
    android:layout_width="match_parent"
    android:layout_height="150dp"
    android:visibility="@{user.useController ? View.VISIBLE : View.GONE}"
    app:jobInfo="@{user.job}"/>

  - Binding Code
if (!this.controller.isInflated())
    this.controller.getViewStub().setVisibility(userUseControllerViewVISIBLEViewGONE);
if (this.controller.isInflated())
    this.controller.getBinding().setVariable(BR.jobInfo, userJob);

 위 코드를 보면, 어떤건 inflate 되었을 때, 어떤건 inflate되지 않았을 때 수행되도록 구성이 된다. 참... 어렵다 ViewStub이녀석...
 결국 기본으로 제공되는 Binding 기능이 아니라, 직접 정의한 @BindingAdapter를 사용해야만 완벽한 ViewStub의 컨트롤이 될 것으로 예상해볼 수 있다. 그래서 개인적으로 ViewStub과 @BindingAdapter의 연결에 대해서 몇가지 테스트를 거쳐본 후 나온 결과를 간략하게 정리하면 아래와 같다.

  - ViewStubProxy를 대상으로하는 BindingAdapter의 Attribute가 1개일때 빌드는 되지만, Bind가 되지 않는다. 
  - ViewStubProxy를 대상으로하는 BindingAdapter의 Attribute가 2개 이상일 때 빌드도 되지 않는다. (View instance를 받을 수 있는 필드가 있어야 한다는 에러가 나온다)
  - 즉, ViewStubProxy를 직접 대상으로 하는 BindingAdapter를 정의할 수 없다.

  - View를 대상으로 하는 BindingAdapter의 Attribute가 2개 이상일 때, 해당 Attribute들을 ViewStub에 적용하면, 해당 BindingMethod를 이용해 Binding Code가 생성이 되지만, ViewStubProxy는 View로 Casting이 되지 않는다는 에러가 발생한다. 

 위의 말들을 다 이해할 수 있기를 바란다. 그래서 위의 내용들을 정리해서 ViewStub에 연결되는 BindingAdapter를 만드려면!!

 1) ViewStub에 필요한 최소 2개 이상의 attribute들을 View를 대상으로 하는 BindingAdapter로 정의한다.
 2) 위에서 만든 Method와 동일하게 한번 더 만든 다음 1번 Parameter를 ViewStubProxy로 한다.


 위의 2단계면 끝이다. 그럼 지루한 줄글 대신 실제 코드를 보자.

@BindingAdapter(value = {"android:visibility", "test"}, requireAll = false)
public static void setVisibility(View view, int visibility, int test) {
    view.setVisibility(visibility);
}
public static void setVisibility(ViewStubProxy viewStub, int visibility, int test) {
    viewStub.getViewStub().setVisibility(visibility);
}
 위에서 말한것처럼 View를 대상으로 하는 BindingAdapter Method를 정의한 후, Method Overloading으로 ViewStubProxy를 대상으로 하는 Method를 하나 더 정의하면 된다.
 이미 눈치챈 사람들도 있겠지만, @BindingAdapter에서 requireAll=false를 하는 약간의 트릭을 사용했다. 이렇게 되면 test라고 넣은 attribute를 전혀 사용하지 않아도 위의 Method를 이용할 수 있다. 즉, Attribute 1개짜리 BindingAdapter를 정의한것과 같다.
 위와같이 정의한 후 빌드를 하면 아래와 같읕 코드가 생성된다.

if (this.controller.isInflated()) this.controller.getBinding().setVariable(BR.jobInfo, userJob);
com.example.BindingAdapters.setVisibility(this.controller, userUseControllerViewVISIBLEViewGONE, (int)0);

 여기서 우리는 Binding Code가 생성되는 순서를 아래와 같이 예측해볼 수 있다.
 1) 우선 맨 위에서도 이야기 했던것 처럼 ViewStub은 원래가 View이다. 그렇기 때문에 View를 대상으로 하는 BindingAdapter에 우선 Bind가 된다. 
 2) 이후 ViewStub이 ViewStubProxy로 래핑이 되면서 instance를 대체하게 된다.
 3) 그러면서 이전에 매핑된 View 대상 Binding Method으로 Code가 생성이 되는데, View를 Parameter로 받아야 하는 자리에 ViewStubProxy가 들어가게 된다. 이때 Type이 맞지 않아 문제가 발생한다.
 4) 하지만, 이때 실제로는 Overloading으로 정의해둔 ViewStubProxy용 Method가 호출되면서 우회적으로 Binding이 된다. 

 꽤나 복잡하다. ViewStub을 위한 BindingAdapter를 사용하는 것...
 코드 작성은 복잡하지 않은데, 이것을 이해하기까지 걸린 과정이 너무 어려웠을 수 있다. 필자도 사용한지 꽤 오랜 시간이 지난 후에야 깨달은 방법이다.




 대안!
 사실 이 방법 말고 또 다른 방법이 있긴 하다. 대안을 하나 추천해본다. 저 위쪽에서 나왔던 Outer Layout / Inner Layout을 이용하는 부분인데 아래 코드부터 보자.

  - Outer Layout XML

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <import type="android.view.View" />
        <variable
            name="user"
            type="com.example.User" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{user.firstName}" />

        <ViewStub
            android:id="@+id/controller"
            android:layout_width="match_parent"
            android:layout_height="150dp"
            android:inflatedId="@+id/controller"
            android:layout="@layout/view_controller"
            android:visibility="@{user.job.useController ? View.VISIBLE : View.GONE}"
            app:jobInfo="@{user.job}" />
    </LinearLayout>
</layout>

  - Inner Layout XML
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="jobInfo"
            type="com.example.JobInfo" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="@{jobInfo.useController ? View.VISIBLE : View.GONE}"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{jobInfo.job}"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{jobInfo.years}" />

    </LinearLayout>
</layout>

위의 코드 역시 Bold 처리된 부분을 잘 보자.
사실 별 내용은 아니다.

 - Outer Layout의 ViewStub에 들어간 android:visibility는 결국 Visible만 
의미가 있다. GONE시에는 inflate가 이미 되어있기 때문에 명령이 들어가지 않는다. == 즉, 이시점의 android:visibility는 inflate용이다. 
 - Inner Layout에도 똑같이 android:visibility를 넣어줬는데, 얘가 진짜다. 얘로 Visibility를 관리하는 것이다.


개인적으로 ViewStub쪽에서 한참 고생을 했었다. 편법스럽긴 하지만 나름 답을 찾았고, 가장 큰 도전 포인트였던 ViewStub의 Visibility Animation도 BindingAdapter를 통해 잘 해결할 수 있었다.
(Adapter쪽은 아직 해결이 안되었다... 지금까지의 경험으로 다시 도전을 하면 답이 나올지도 모르지만, 아직 말씀한 답을 낸 사람은 없어보인다. Git에도...)

글이 조금 길었지만... 이번 포스트에서는 <include>와 ViewStub에 대해서 알아봤다. 관련된 답을 찾는 사람에게 도움이 되었으면 좋겠다.
다음에는 BindingAdapter를 이용한 Listener 처리를 알아보겠다. 물론 Developer Page에 나와있지 않은 내용들 기반으로!!

댓글

  1. https://android.jlelse.eu/creating-a-custom-view-with-data-binding-its-so-simple-520923d775cd

    Medium에 올라온 유사한 글 링크를 올린다.
    include tag를 이용해 DataBinding하는 법에 대해 나와있다.
    (사실 위의 내용을 장황하게 풀어놓은 정도이다.)

    답글삭제
  2. viewstub의 databinding 고생기 자세히 풀어주셔서 감사합니다~

    답글삭제

댓글 쓰기

이 블로그의 인기 게시물

[Android] Retrofit2, OkHttpClient Method 정리

[Android] Layout별 성능 비교[Measure 호출횟수 비교] (LinearLayout vs RelativeLayout vs ConstraintLayout)