DataBinding-自定义属性双向数据绑定

Android_Banner.jpg

简介

在使用DataBinding的时候我们知道数据驱动UI的显示,这种单向的数据绑定也是我们使用它最多的地方,既然有单向的数据绑定应该会存在双向绑定?

不错Android官方确实为我们提供了相应的双向绑定的属性。

比如EditText和CheckBox中

<EditText
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          // 重点在于这个 =
          android:text="@={viewModel.etText}"
          app:layout_constraintLeft_toLeftOf="parent"
          app:layout_constraintTop_toTopOf="parent" />

<CheckBox
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:checked="@={viewModel.checkBoxStatus}"
          app:layout_constraintLeft_toLeftOf="parent"
          app:layout_constraintTop_toTopOf="parent" />

所谓的双向绑定就是数据能驱动UI的显示,UI的状态变换也能改变绑定的属性值;

针对上述的EditText和CheckBox,它的双向绑定是系统帮助我们处理好了,双向绑定的写法也很简单 “@={属性值}”

接着就我们就自定义一个双向绑定的属性,在实现前我们看下最终的效果图

inverse.gif

看了效果,你想用RecylerView来实现?可以,不过要是使用双向数据绑定可以很简单的。

实现步骤

DataBinding为我们实现了双向数据绑定提供了@InverseBindingAdapter注解;

步骤一

首先我们需要一个数据源用来设置控件上布局的数据,我们使用@BindingAdapter自定义一个属性 data用来接受数据源

 /**
 * 设置数据源
 */
 @JvmStatic
 @BindingAdapter(value = ["data"], requireAll = true)
 fun setData(inverseGroupView: InverseGroupView, data: List<String>?) {
   data?.let {
   inverseGroupView.setData(it)
   }
 }
步骤二

然后我们需要定义双向绑定的属性 index 同样使用 @BindingAdapter

/**
* 设置角标
* 当数据发生边改的时候,会调用该方法设置数据 更新UI
*/
@JvmStatic
@BindingAdapter(value = ["index"], requireAll = true)
fun setIndex(inverseGroupView: InverseGroupView, index: Int) {
    inverseGroupView.selectIndex = index
    inverseGroupView.refreshSelectedIndex(index)
}
步骤三

接着我们使用@InverseBindingAdapter注解,来将我们因UI状态改变而导致属性值改变同步给自定义属性index(也就是从View中读取到值)

在使用@InverseBindingAdapter注解使,内部有两个属性,attribute:对应着自定义属性,event:是一个事件名称改属性在下面我会详细说明一下

@JvmStatic
@InverseBindingAdapter(attribute = "index", event = "indexChange")
fun getIndex(inverseGroupView: InverseGroupView) = inverseGroupView.selectIndex

到这里总结一下哈:当我们的index值发生改变的情况会调用步骤二中方法,通知UI布局发生变换

当我们的UI布局状态发生改变的情况下我们可以调用步骤三方法通知给绑定的属性值,让它重新设置值。

但是有一个问题就是步骤三的方法不知道UI的状态发生变化的时机此时就需要我们步骤4的操作了

步骤四

我们使用@BindingAdapter注解实现了一个View的状态值发生变化的事件通知,

注解中的value值要和@InverseBindingAdapter中的event中的值要保持一致,这样当View的状态值发生变化后会通知步骤三种的方法拿到值设置给绑定的属性值。

/**
* 双向数据绑定的
* InverseBindingListener 是一个监听器,用来处理属性改变时的通知
* 在这里我们给View设置了点击事件,当属性发生改变它会回调 onChange方法告诉DataBinding 去 @InverseBindingAdapter修饰的方法中取到值 然后设置给绑定的变量
*/
@JvmStatic
@BindingAdapter("indexChange")
fun setIndexChangeListener(
  inverseGroupView: InverseGroupView,
  changeListener: InverseBindingListener?
) {
      if (changeListener != null) {
        inverseGroupView.onSelectChangeListener = {
          changeListener.onChange()
        }
      } else {
        inverseGroupView.onSelectChangeListener = null
      }
}

到这里一个简单的自定义属性的双向绑定就完成了,这里我贴一下当时写的源码

/**
 * @author : zhangqi
 * @time : 12/7/20
 * desc : 使用DataBinding来自定义属性的双向绑定
 */
class InverseGroupView : LinearLayout {

    constructor(context: Context) : super(context)

    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)

    constructor(context: Context, attributeSet: AttributeSet, defStyle: Int) : super(
        context,
        attributeSet,
        defStyle
    )
    /**
     * 当前选中的index
     */
    var selectIndex: Int = 0
    /**
     * tag点击事件的回调事件
     */
    var onSelectChangeListener: ((Int) -> Unit)? = null
    /**
     * 用于收集回收可复用的View
     */
    var recyclerView = ArrayList<View>()
    /**
     * 用于存储拿到的数据
     */
    var mData: List<Any>? = null
    /**
     * 设置数据
     */
    fun <T : Any> setData(data: List<T>) {
        updateViewData(data)
    }
    /**
     * 更新布局的数据
     * 创建布局,将数据设置到布局上
     * data:新的数据源
     */
    private fun <T : Any> updateViewData(data: List<T>) {
        mData = data
        // 每次执行到这个方法时,需要回收一下,移除一下,因为接下来是要重新绑定数据的,
        recyclerViewMethod()
        /**
         * 遍历循环数据源,将数据绑定帮控件上
         */
        data.forEachIndexed { index, any ->
            val tagView = getReuseView()
            val tvTagView = tagView.findViewById<TextView>(R.id.tag)
            tagView.isSelected = index == selectIndex
            tvTagView.text = any as String
            //设置一下点击事件
            tagView.setOnClickListener {
                //要更新下布局上按钮的状态
                refreshSelectedIndex(index)
            }
            // 将View添加到父布局中
            addView(tagView)
        }
    }
    /**
     * 刷新下选中的子View
     */
    private fun refreshSelectedIndex(clickIndex: Int) {
        selectIndex = clickIndex
        for (i in 0 until childCount) {
            getChildAt(i).isSelected = i == clickIndex
        }
        onSelectChangeListener?.invoke(clickIndex)
    }
    /**
     * 获取到布局View对象
     */
    private fun newView(): View {
        return LayoutInflater.from(context).inflate(R.layout.item_tag, null, false)
    }
    /**
     * 获取到布局文件
     * 回收池中有 就拿第一个,
     * 没有的话就重新常见一个View对象
     */
    private fun getReuseView(): View {
        return if (recyclerView.isNotEmpty() && recyclerView.size > 0) {
            recyclerView.removeAt(0)
        } else {
            newView()
        }
    }
    /**
     * 首先将目前布局上已经有的子View存储到复用池中
     * 然后将这些子View从布局上移除
     */
    private fun recyclerViewMethod() {
        for (i in 0 until childCount) {
            recyclerView.add(getChildAt(i))
        }
        removeAllViews()
    }
    companion object {
        /**
         * 设置数据源
         */
        @JvmStatic
        @BindingAdapter(value = ["data"], requireAll = true)
        fun setData(inverseGroupView: InverseGroupView, data: List<String>?) {
            data?.let {
                inverseGroupView.setData(it)
            }
        }
        /**
         * 设置角标
         * 当数据发生边改的时候,会调用该方法设置数据 更新UI
         */
        @JvmStatic
        @BindingAdapter(value = ["index"], requireAll = true)
        fun setIndex(inverseGroupView: InverseGroupView, index: Int) {
            if (inverseGroupView.selectIndex == index) return
            inverseGroupView.selectIndex = index
            inverseGroupView.refreshSelectedIndex(index)
        }
        /**
         * 获取到当前选中的角标
         * event:数据改变的事件
         *
         * 当View的状态发生改变的时候(包括数据的填充,bg的改变),会调用该方法来获取到值
         */
        @JvmStatic
        @InverseBindingAdapter(attribute = "index", event = "indexChange")
        fun getIndex(inverseGroupView: InverseGroupView) = inverseGroupView.selectIndex
        /**
         * 双向数据绑定的
         * InverseBindingListener 是一个监听器,用来处理属性改变时的通知
         * 在这里我们给View设置了点击事件,当属性发生改变它会回调 onChange方法告诉DataBinding 去 @InverseBindingAdapter修饰的方法中取到值 然后设置给绑定的变量
         */
        @JvmStatic
        @BindingAdapter("indexChange")
        fun setIndexChangeListener(
            inverseGroupView: InverseGroupView,
            changeListener: InverseBindingListener?
        ) {
            if (changeListener != null) {
                inverseGroupView.onSelectChangeListener = {
                    changeListener.onChange()
                }
            } else {
                inverseGroupView.onSelectChangeListener = null
            }
        }
    }

}

注意点

由于当时我绑定的属性值使用的是LiveData,当我改变了View的状态值是会通知到@InverseBindingAdapter注解修饰的方法让它拿到值设置给绑定的属性值。

由于LiveData天生就有可观察性,当观察到数据源发生变化又会驱动UI状态值发生变化,这样UI发生变化又会被监听到 又去通知@InverseBindingAdapter注解修饰的方法让它拿到值设置给绑定的属性值。

这样就会陷入到无限的循环中,所以我当时的做法就是在绑定的属性值的 setter方法中做了新旧值的判断,如果值一致就不触发UI状态值的更新了

@JvmStatic
@BindingAdapter(value = ["index"], requireAll = true)
fun setIndex(inverseGroupView: InverseGroupView, index: Int) {
    if (inverseGroupView.selectIndex == index) return
    inverseGroupView.selectIndex = index
    inverseGroupView.refreshSelectedIndex(index)
}

其实这个地方可以从绑定的属性值入手解决这个问题,比如我们在设置值之前检查当前的值和将要的值一致的话就不进行 set或者post,比如我这个 DiffLiveData

/**
 * @author : zhangqi
 * @time : 12/6/20
 * desc : 如果当前LiveData中携带的值和将要设置的值是一致的,就不进行设置值的操作了
 */
class DiffLiveData<T>(value:T) : MutableLiveData<T>(value) {

    override fun setValue(value: T?) {
        if (Objects.equals(value, getValue())) return
        super.setValue(value)
    }

    override fun postValue(value: T?) {
        if (Objects.equals(value, getValue())) return
        super.postValue(value)
    }
}

本文中完整的源码

虽然DataBinding在报错的时候,错误查找起来不是很友好,但是作为AAC架构的基础,给我们带来很多方便之处,比如利用这种思想的开源库ItemBinding

就给我在日常开发中有很大的效率提高;这些好用的地方完全胜过它的一些小缺点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容