근래에 개발을 하면서 데이터바인딩을 즐겨 쓰고있다. 안드로이드에선 2016년도부터 이를 안정버전으로 지원하기 시작했는데 개인적으론 DI 라이브러리가 따로 필요 없어도 편리하다고 느껴진다.
데이터바인딩은 MVVM이라는 아키텍쳐 패턴에서 없어서는 안되는 중요한 요소이다.
위의 다이어그램을 보듯이 View와 ViewModel이 데이터바인딩을 통해 느슨히 연결되고 Model은 View가 아닌 ViewModel과 통신하게 된다. 원칙적으로 View는 ViewModel에게 일방적으로 작업을 던지고 ViewModel은 직접적으로 View를 알수 없어 데이터바인딩을 통해서만 View가 갱신 된다.
그래서 대부분의 상황에서 ViewModel의 변화로 인해 ViewModel -> View 방향으로 전달되는 일방통행(1-way)의 데이터바인딩이 만들어진다.
하지만 다이어그램에 나와있듯이 반대의 경우도 가능하다. 2-way 데이터바인딩은 여기서 나오는 개념인데 말 그대로 양방향 데이터바인딩을 의미한다. 재밌는건 안드로이드 공식 사이트에선 2-way 데이터바인딩에 대해서 구체적인 설명이 되어있지 않고 국내 사이트에서도 안드로이드 기준의 설명은 없어 직접 글을 쓰고자 한다.
2-way databinding 사용하기
먼저 안드로이드에서 제공하는 @BindingAdapter 어노테이션을 살펴보자.
@BindingAdapter("android:paddingLeft") public static void setPaddingLeft(View view, int padding) { view.setPadding(padding, view.getPaddingTop(), view.getPaddingRight(), view.getPaddingBottom()); }
이런 bindingAdapter가 존재하는 경우 XML 내에서는 다음과 같이 사용할 수 있다.
@{} 표기의 의미에 대해선 데이터바인딩이 뭔지 공부를 하면 된다.
<View .... android:padding="@{12}" ... />
이렇게 하면 데이터바인딩을 통해 일방적으로 View에 결과를 던져줄 수 있다. 심지어는 Observable을 붙여 값이 변경될때 자동으로 View에 반영되도록 하는것도 가능하다.
하지만 반대로 View에서 생성된 값을 ViewModel로 전달할 수는 없을까?
아래 예제는 2-way 데이터바인딩의 기초 예제이다. EditText에 TextWatcher를 안붙이고 입력된 text를 잡아내서 다른 TextView에 값을 넣어보자.
<?xml version="1.0" encoding="utf-8"?> <layout> <data> <variable name="vm" type="net.yatopark.databindingexample.TextViewModel" /> </data> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" android:text="@={vm.contents}"/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@{vm.contents}" /> </LinearLayout> </layout>
public class MainActivity extends AppCompatActivity { private ActivityMainBinding binding; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_main); binding.setVm(new TextViewModel()); } } public class TextViewModel { public ObservableField<String> contents = new ObservableField<>(); }
이렇게 만든 앱을 구동한뒤 EditText에 값을 입력하면 TextView에 그대로 나온다. 따로 TextWatcher를 안붙이고 이런 구현이 가능한데 비결은 2-way 데이터바인딩이다. 잘 보면 EditText의 android:text에 @={}이란 표현을 사용하는데 이것이 View <-> ViewModel로 양방향 바인딩을 하는 문법이다. 여기서 추가된 반대방향의 바인딩은 편의상 inverse binding이라 부르자.
사실 이는 TextView에서 기본적으로 지원하는 것으로, 이 외에도 안드로이드 차원에서 기본적으로 지원하는 InverseBindingAdapter들이 존재한다. 모두 사용자 입력이 일어나는 View들로, 종류가 그렇게 많지는 않으니 참고만 하도록 한다. 다음은 inverse binding을 지원하는 속성 목록이다. [참고]
AbsListView android:selectedItemPosition CalendarView android:date CompoundButton android:checked DatePicker android:year, android:month, android:day NumberPicker android:value RadioGroup android:checkedButton RatingBar android:rating SeekBar android:progress TabHost android:currentTab TextView android:text TimePicker android:hour, android:minute
커스텀 2-way 데이터바인딩
우리가 만든 커스텀뷰에 편의를 위해 2-way 데이터바인딩 처리가 필요할 수 있다. 그럴때는 직접 구현하면 된다. 좀 예제가 작위적이긴 하지만 pressed 상태일 때 알파가 0.5가 되는 이미지뷰를 구현해보자.
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:app="http://schemas.android.com/apk/res-auto"> <data> <variable name="vm" type="net.yatopark.databindingexample.ImageViewModel" /> </data> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <net.yatopark.databindingexample.PressedAlphaImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@mipmap/ic_launcher" android:clickable="true" app:pressed="@={vm.pressed}" android:alpha="@{vm.getAlpha(vm.pressed)}"/> </LinearLayout> </layout>
public class PressedAlphaImageView extends AppCompatImageView { private OnPressChangedListener listener; public PressedAlphaImageView(Context context) { super(context); } public PressedAlphaImageView(Context context, AttributeSet attrs) { super(context, attrs); } public PressedAlphaImageView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void setPressed(boolean pressed) { super.setPressed(pressed); if (listener != null) { listener.onPressChanged(pressed); } } public void setOnPressChangedListener(OnPressChangedListener listener) { this.listener = listener; } public interface OnPressChangedListener { void onPressChanged(boolean pressed); } } public class PressChangeBindingAdapter { @BindingAdapter("pressed") public static void setPressChanged(PressedAlphaImageView view, boolean pressed) { view.setPressed(pressed); } @InverseBindingAdapter(attribute = "pressed", event = "pressChanged") public static boolean getPressChanged(PressedAlphaImageView view) { return view.isPressed(); } @BindingAdapter("pressChanged") public static void setPressChangedListener(PressedAlphaImageView view, final InverseBindingListener listener) { view.setOnPressChangedListener(new PressedAlphaImageView.OnPressChangedListener() { @Override public void onPressChanged(boolean pressed) { if (listener != null) { listener.onChange(); } } }); } } public class ImageViewModel { public ObservableBoolean pressed = new ObservableBoolean(); public float getAlpha(boolean pressed) { if (pressed) { return 0.5f; } else { return 1.0f; } } }
여기서 중요한 것은 InverseBindingAdapter와 pressChanged라는 BindingAdapter이다. InverseBindingAdapter는 BindingAdapter와 짝을 이루어 양방향 데이터바인딩을 구성한다.
pressChanged BindingAdapter는 pressed 값에 변화가 일어났을 때, InverseBindingAdapter에 onChanged 콜백을 통해 알려준다. pressChanged 이벤트는 앱 구동시에 자동으로 리스너가 등록되게 된다.
pressChange라는 이름을 주지 않으면 아래 예시처럼 기본으로 [attribute_name]AttrChanged 라는 이름을 사용하게 되는데, 처음 소스처럼 마음대로 이름을 정하면 되므로 굳이 이 규칙을 따를 필요는 없다.
public class PressChangeBindingAdapter { @BindingAdapter("pressed") public static void setPressChanged(PressedAlphaImageView view, boolean pressed) { view.setPressed(pressed); } @InverseBindingAdapter(attribute = "pressed") public static boolean getPressChanged(PressedAlphaImageView view) { return view.isPressed(); } @BindingAdapter("pressedAttrChanged") public static void setPressChangedListener(PressedAlphaImageView view, final InverseBindingListener listener) { view.setOnPressChangedListener(new PressedAlphaImageView.OnPressChangedListener() { @Override public void onPressChanged(boolean pressed) { if (listener != null) { listener.onChange(); } } }); } }
변화를 통지하는 리스너를 등록하는 부분은 잘못 구현하면 무한루프에 빠지기 쉽다. 이 부분을 조심해서 구현하도록 한다.