이 글은 기존 운영했던 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 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
}
}
이제 해당 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)
}
}
위에서 만든 클래스에 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)
}
마지막으로 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);
}
}
하나하나 구현하기 에는 다소 어려움이 있지만, 하나를 구현해두면 다른 곳이나 다른 프로젝트에도 사용할 수 있기 때문에 활용도가 높다고 생각된다.