DataBinding - Two-way Databinding with Custom View

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2018-06-07

도입

안드로이드 데이터 바인딩 라이브러리에는 양방향 바인딩 이란 기능이 있다.

가령 EditText 가 있다고 치면,  ViewModel 에 있는 ObservableField 의 내용이 바뀌면 자동으로 EditText 에 바인딩 되게 한다고 해보자. 그러면, 해당 기능을 사용하기 위해 필요한 표현식은 android:text="@{viewModel.mContent}일 것이다.

이 것을 단방향 바인딩이라 부른다. ViewModel -> XML, 즉 DataSource -> XML 로 일차적 통신을 하기 때문이다.

그러면, 반대로 XML -> ViewModel 로 오게 할 수 없을까? 란 생각에서 나오는 것이 양방향 바인딩 이다. 이 때 표현식은 android:text="@={viewModel.mContent}이다.

단순히 @ 뒤에 = 가 추가된 것으로 ObservableField 의 값이 변경되면 XML의 EditText 가 변경되고, EditText 가 변경되면 ViewModel 의 ObservableField  가 변경된다.

일반적인 안드로이드 위젯 클래스인 EditText, CheckBox 등에는 이러한 양방향 바인딩을 하기 위한 코드가 구현이 되어있지만, 이 위젯 클래스를 감싸서 만드는 커스텀 뷰에 양방향 바인딩을 사용하려면 직접 구현해야 한다.

이 글에서는 EditText 및 ImageView 를 적절히 구성한 레이아웃을 가진 InputView 란 커스텀 뷰에 EditText 의 텍스트 내용에 대한 양방향 바인딩을 구현해보려고 한다.

XML & Code

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="view"
            type="com.github.windsekirun.demoapp.widget.InputView" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/solid_fff_stroke_999"
        android:gravity="center"
        android:orientation="horizontal">

        <EditText
            android:layout_width="0dp"
            android:layout_height="30dp"
            android:id="@+id/editText"
            android:layout_weight="1"
            android:background="@null" />

        <ImageView
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginRight="10dp"
            tools:src="@drawable/ic_password_off" />

    </LinearLayout>
</layout>
class InputView constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) {
    private var mBinding: InputViewBinding

    val editText: EditText
        get() = mBinding.editText

    init {
        val inflater = LayoutInflater.from(context)
        mBinding = DataBindingUtil.inflate(inflater, R.layout.input_view, this, true,
                BindingComponent.create(context))
        mBinding.view = this
    }
}

EditText 에 String 를 설정하는 Component 코드 작성하기

이제 해당 InputView 를 가지고 DataBinding 의 property 를 구성하는 Component 코드를 작성해보자.

클래스를 하나 만들어보자. 여기서는  InputViewReverseBinding 란 이름을 붙여줄 것이다.

그리고, InputView 와 String 를 파라미터로 받는 메서드를 만들어보자.

object InputViewReverseBindingKt {

    @JvmStatic
    @BindingAdapter("content")
    fun setInputViewContent(view: InputView, content: String?) {
        
    }
}

여기서 중요한 부분은 @BindingAdapter 부분이다. Component  코드에서는 클래스 이름, 메서드 이름을 가지고 판단하는 것이 아닌 이 @BindingAdapter 어노테이션으로 판단한다.

그리고, 단순히 inputView 에 있는 content 에 설정만 해주면 되는데, 이렇게 되면 문제가 발생한다. setText 를 부르는 순간 EditText 에 곧 붙일 TextWatcher 가 발동하게 되고 > 조건에 의해 차후 설명할 InverseBindingListener 가 발동되고 > ViewModel 에 있는 ObservableField 에 값이 설정되고 > 값이 설정된 순간 다시 setInputViewContent 메서드가 실행되고 > ... 등의 무한 루프가 발생하게 된다.

이 문제를 해결하기 위해서는 반드시 설정할 content 가 기존 content 와 같지 않을 경우에만 설정하게 하면 된다.

@JvmStatic
@BindingAdapter("content")
fun setInputViewContent(view: InputView, content: String?) {
    val old = view.editText.text.toString()
    if (old != content) {
        view.editText.setText(content)
    }
}

EditText 에 TextWatcher 를 설정하는 Component 코드 작성하기

위에서 만든 클래스에 InputView 와 InverseBindingListener 를 파라미터로 받는 메서드를 만들어보자.  InverseBindingListener 는 onChange() 란 메서드를 가지고 있는데, 이 메서드에는 XML 에서 ViewModel 로 데이터를 설정하는 부분이 생성된다. 이 부분은 전부 작성하고 나면 설명할 것이다.

단, 여기서 @BindingAdapter 에 들어갈 이름은 위에서 만든 메서드에 사용되었던 @BindingAdapter 의 이름에 AttrChanged 를 붙인 이름이 들어가야 한다. 위에서는 content 라고 적었으니, 여기서는 contentAttrChanged 라고 들어가야 한다.

@JvmStatic
@BindingAdapter("contentAttrChanged")
fun setInputViewInverseBindingListener(view: InputView, listener: InverseBindingListener?) {
    
}

그리고 내부에서는 TextWatcher 를 새로 생성하고, EditText 에 설정하는 작업을 한다. 그리고 TextWatcher 의 afterTextChanged 옵션에서 listener.onChange 를 호출해준다. 이렇게 되면, EditText 의 afterTextChanged, 즉 텍스트가 변경 완료되었을 때 자동으로 ViewModel 에 설정될 것이다.

@JvmStatic
@BindingAdapter("contentAttrChanged")
fun setInputViewInverseBindingListener(view: InputView, listener: InverseBindingListener?) {
    val watcher = object : TextWatcher {
        override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {

        }

        override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {

        }

        override fun afterTextChanged(editable: Editable) {
            listener?.onChange()
        }
    }

    view.editText.addTextChangedListener(watcher)
}

위 두 메서드를 조합하는 Component 코드 작성하기

마지막으로 InputView 만 파라미터로 받는 메서드를 만들어보자. 여기서는 기존에 사용하던 @BindingAdapter 어노테이션이 아닌 @InverseBindingAdapter 어노테이션을 사용하게 되는데, 이 어노테이션은 수집된 값을 설정할 때에 값을 검색하는 데 사용되는 메서드에 부착되어, InverseBindingListener.onChange() 가 호출되었을 때 ViewModel 에 설정할 값을 적어놓는 곳이다.

간단히 말하자면, InputView 의 EditText 값을 적으면 된다.

@JvmStatic
@InverseBindingAdapter(attribute = "content", event = "contentAttrChanged")
fun getContent(view: InputView): String {
    return view.editText.text.toString()
}

여기서 @InverseBindingAdapter 의 파라미터 중 attribute 에는 첫번째로 만든 BindingAdapter 의 이름을, event 에는 두번째로 만든 BindingAdapter 의 이름을 적어준다.

전체 결과물 & 사용 방법

object InputViewReverseBinding {

    @JvmStatic
    @BindingAdapter("content")
    fun setInputViewContent(view: InputView, content: String?) {
        val old = view.editText.text.toString()
        if (old != content) {
            view.editText.setText(content)
        }
    }

    @JvmStatic
    @BindingAdapter("contentAttrChanged")
    fun setInputViewInverseBindingListener(view: InputView, listener: InverseBindingListener?) {
        val watcher = object : TextWatcher {
            override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {

            }

            override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {

            }

            override fun afterTextChanged(editable: Editable) {
                listener?.onChange()
            }
        }

        view.editText.addTextChangedListener(watcher)
    }

    @JvmStatic
    @InverseBindingAdapter(attribute = "content", event = "contentAttrChanged")
    fun getContent(view: InputView): String {
        return view.editText.text.toString()
    }
}
<com.github.windsekirun.demoapp.widget.InputView
            android:layout_width="match_parent"
            android:layout_marginTop="10dp"
            app:content="@={viewModel.mContent}"
            android:layout_height="wrap_content" />

번외 : 생성된 코드 살펴보기

위 코드까지 구현하고 빌드를 할 경우, 사용한 화면의 Binding 객체에 양방향 바인딩에 관련된 코드가 자동으로 생성될 것이다. 해당 파일을 살펴보면 제일 먼저 보이는 것이 InverseBindingListener 필드일 것이다.

// Inverse Binding Event Handlers
private android.databinding.InverseBindingListener mboundView5contentAttrChanged = new android.databinding.InverseBindingListener() {
    @Override
    public void onChange() {
        // Inverse of viewModel.mContent.get()
        //         is viewModel.mContent.set((java.lang.String) callbackArg_0)
        java.lang.String callbackArg_0 = com.github.windsekirun.demoapp.binding.InputViewReverseBinding.getContent(mboundView5);
        // localize variables for thread safety
        // viewModel.mContent != null
        boolean viewModelMContentJavaLangObjectNull = false;
        // viewModel.mContent
        android.databinding.ObservableField<java.lang.String> viewModelMContent = null;
        // viewModel
        com.github.windsekirun.demoapp.main.MainViewModel viewModel = mViewModel;
        // viewModel != null
        boolean viewModelJavaLangObjectNull = false;
        // viewModel.mContent.get()
        java.lang.String viewModelMContentGet = null;

        viewModelJavaLangObjectNull = (viewModel) != (null);
        if (viewModelJavaLangObjectNull) {

            viewModelMContent = viewModel.mContent;

            viewModelMContentJavaLangObjectNull = (viewModelMContent) != (null);
            if (viewModelMContentJavaLangObjectNull) {

                viewModelMContent.set(((java.lang.String) (callbackArg_0)));
            }
        }
    }
};

간단히 살펴보자면, onChange 가 불렸을 때 viewModel 의 객체를 가져오고,  viewModel 의 객체가 null 가 아니면 viewModel 의 mContent, 즉 ObservableField 를 가져온 다음에 이 mContent 도 null 이 아니면 callbackArg_0, 즉 getContent 의 결과물을 설정한다.

그다음, 이 InverseBindingListener 를 설정하는 부분은 executeBinding() 메서드에 있다.

@Override
protected void executeBindings() {
    long dirtyFlags = 0;
    synchronized(this) {
        dirtyFlags = mDirtyFlags;
        mDirtyFlags = 0;
    }
   
    android.databinding.ObservableField<java.lang.String> viewModelMContent = null;
   
    java.lang.String viewModelMContentGet = null;
    com.github.windsekirun.demoapp.main.MainViewModel viewModel = mViewModel;

    if ((dirtyFlags & 0xfL) != 0) {
        if ((dirtyFlags & 0xeL) != 0) {

                if (viewModel != null) {
                    // read viewModel.mContent
                    viewModelMContent = viewModel.mContent;
                }
                updateRegistration(1, viewModelMContent);

                if (viewModelMContent != null) {
                    // read viewModel.mContent.get()
                    viewModelMContentGet = viewModelMContent.get();
                }
        }
    }
    if ((dirtyFlags & 0xeL) != 0) {
        // api target 1

        com.github.windsekirun.demoapp.binding.InputViewReverseBinding.setInputViewContent(this.mboundView5, viewModelMContentGet);
    }
    if ((dirtyFlags & 0x8L) != 0) {
        // api target 1

        com.github.windsekirun.demoapp.binding.InputViewReverseBinding.setInputViewInverseBindingListener(this.mboundView5, mboundView5contentAttrChanged);
    }
}

마무리

하나하나 구현하기 에는 다소 어려움이 있지만, 하나를 구현해두면 다른 곳이나 다른 프로젝트에도 사용할 수 있기 때문에 활용도가 높다고 생각된다.

profile
Android Developer @kakaobank

0개의 댓글