Android:自定义布局多状态切换容器MultiStateView

26 篇文章 2 订阅

前言

写界面的时候,大概率总会考虑到以下几个界面?

  • 加载数据时的loading界面
  • 数据加载出来的content界面
  • 数据加载失败界面
  • 无数据界面

那么能不能统一写一个容器进行管理上述提到的布局呢?
在外部使用的时候只需要根据传入状态然后展示对应的布局即可。

思路

基于上面的使用要求,可以自定义一个多状态容器view,进行不同状态的容器添加、展示管理。

  1. 列举常用的状态:加载中网络错误错误空数据正常内容其他...
  2. 不同状态的view可以通过状态来获取、控制、显示
  3. 不同的状态的view可以通过xml添加、也可以在程序中调用函数添加
  4. 可以在状态切换之前/之后监听,并且拦截是否真正的进行状态切换
  5. 其他优化,是否包含切换动画等等

MultiStateView 核心代码实现思路

首先选择容器是什么,我这里选择FrameLayout,因为简单,减少onDraw的绘制过程。

接下来考虑,首先需要可以通过xml添加对应状态的布局,所以需要定义attrs.xml

	<declare-styleable name="MultiStateView">
        <!-- 多状态view -->
        <attr name="msv_contentView" format="reference" />
        <attr name="msv_loadingView" format="reference" />
        <attr name="msv_emptyView" format="reference" />
        <attr name="msv_netErrorView" format="reference" />
        <attr name="msv_otherErrorView" format="reference" />
        <attr name="msv_customView" format="reference" />
        <!-- 设置当前view状态 -->
        <attr name="msv_currentViewState" format="enum">
            <enum name="content" value="0" />
            <enum name="loading" value="1" />
            <enum name="empty" value="2" />
            <enum name="net_error" value="3" />
            <enum name="other_error" value="4" />
            <enum name="custom" value="5" />
        </attr>
        <!-- view切换时是否使用默认动画 -->
        <attr name="msv_enableAnimateChanges" format="boolean" />
    </declare-styleable>

对应的在程序中需要不同状态的枚举类,用来区分不同状态的布局view。

    enum class ViewState {
        CONTENT,
        LOADING,
        EMPTY,
        NET_ERROR,
        OTHER_ERROR,
        CUSTOM
    }

好,分类做好了,我们怎么管理这些状态呢?并且每个状态和设置进来的view都有对应关系。
利用枚举类的ordinal属性,它表示不同的枚举的序号,比如CONTENTordinal0CUSTOMordinal5
基于上述的概念,我们完全可以使用一个数组来管理,每个状态的ordinal作为下标,数组真正储存View,这样就优雅形成了映射关系。
另外我们还需要一个currentState来代表当前显示的状态,代码如下:

	    /**
     * 状态监听,改变前
     *
     * @return true 会继续更改state  false 不继续更改状态,状态还是oldState
     */
    var stateBeforeChangeListener: ((oldState: ViewState, newState: ViewState) -> Boolean)? = null

    /**
     * 状态监听,改变后  此时state已经更改为newState
     */
    var stateAfterChangeListener: ((oldState: ViewState, newState: ViewState) -> Unit)? = null
    /**
     * 储存不同状态的view.
     * 根据enum直接定位到内部数组的索引
     */
    val views = Array<View?>(ViewState.values().size) { null }
    
   	/**
     * 当前状态设置
     */
    var currentState = ViewState.CONTENT
    	set(value) {
            val previewState = field
            if (value != previewState) {
                if (stateBeforeChangeListener?.invoke(previewState, value) != false) {
                    field = value
                    //根据状态进行view显示
                    showViewByState(previewState)
                    stateAfterChangeListener?.invoke(previewState, value)
                }
            }
        }

好,接下来,按照思路来说,我们需要加载xml中设置的布局,思路即为,根据引用加载到相关的布局之后,首先设置到views中,之后调用addView添加到容器中。

	init{
        //初始化 配置的布局
        val typeArray = context.obtainStyledAttributes(attrs, R.styleable.MultiStateView)
        val layoutInflater = LayoutInflater.from(context)

        //CONTENT布局
        defaultLayoutInflater(
            ViewState.CONTENT,
            R.styleable.MultiStateView_msv_contentView,
            typeArray,
            layoutInflater
        )
        //其他布局填充
        ...
        //获取并设置默认的viewState 若无默认设置则为 ViewState.CONTENT
        currentState = when (typeArray.getInt(
            R.styleable.MultiStateView_msv_currentViewState,
            ViewState.CONTENT.ordinal
        )) {
            ViewState.CONTENT.ordinal -> ViewState.CONTENT
            ViewState.LOADING.ordinal -> ViewState.LOADING
            ViewState.EMPTY.ordinal -> ViewState.EMPTY
            ViewState.NET_ERROR.ordinal -> ViewState.NET_ERROR
            ViewState.OTHER_ERROR.ordinal -> ViewState.OTHER_ERROR
            ViewState.CUSTOM.ordinal -> ViewState.CUSTOM
            else -> ViewState.CONTENT
        }
        //切换动画开关
        enableAnimateLayoutChanges =
            typeArray.getBoolean(R.styleable.MultiStateView_msv_enableAnimateChanges, false)
	}
    /**
     * 封装一个方法,做默认布局填充动作
     */
    private fun defaultLayoutInflater(
        state: ViewState,
        @StyleableRes res: Int,
        typeArray: TypedArray,
        layoutInflater: LayoutInflater
    ) {
        //view被数组持有  可以利用索引查找
        typeArray.getResourceId(res, View.NO_ID)
            .takeIf { it != View.NO_ID }?.let {
            	//正确获取到布局之后
                val contentView = layoutInflater.inflate(it, this, false)
                //不为空,先赋值到数组,并且加入布局
                views[state.ordinal] = contentView
                addView(contentView)
            }
    }

不过针对addView这里需要注意一点,因为我们的容器是ViewGroup,也可以直接利用xml包含的形式设置子布局,这么就不会经过我们设置的状态进而被views持有,所以需要在addView的时候做一次校验,如果CONTENT为null,且不是其他状态View,则默认分配给CONTENT类型。

    /**
     * 判断如果不是ViewState中除了contentView以外定义的View,将此view默认设置为contentView
     */
    private fun checkContentView(view: View?) {
        // 如果contentView为null,且将要add的view不等于其他view,则将其默认赋值给contentview
        ViewState.values()
            .superReduce<Pair<Boolean, Boolean>, ViewState> { lastValue, currentViewState ->
                var isNeedSetContent = lastValue ?: false to true
                if (currentViewState == ViewState.CONTENT && obtainView(currentViewState) == null) {
                    isNeedSetContent = true to isNeedSetContent.second
                }
                if (obtainView(currentViewState) === view) {
                    isNeedSetContent = isNeedSetContent.first to false
                }
                isNeedSetContent
            }.takeIf { it != null && it.first && it.first == it.second }?.let {
                views[ViewState.CONTENT.ordinal] = view
                if (currentState != ViewState.CONTENT) {
                    obtainView(ViewState.CONTENT)?.visibility = View.GONE
                }
            }
    }

superReduce这里自己简单封装了一下,主要在循环执行的时候会好用一点。

/**
 * 仿照_Arrays库,允许返回非Array本身的类型
 */
inline fun <S, T> Array<out T>.superReduce(operation: (lastValue: S?, T) -> S?): S? {
    if (isEmpty())
        throw UnsupportedOperationException("Empty array can't be reduced.")
    var accumulator: S? = null
    for (index in 1..lastIndex) {
        accumulator = operation(accumulator, this[index])
    }
    return accumulator
}

接下来就是设置currentState的时候,对于View的显示控制了,在上面的代码中已经出现了一个showViewByState函数,下面实现一下即可。

    /**
     * 根据state显示相关的view
     * @param state View状态
     */
    private fun showViewByState(previousState: ViewState) {
        if (enableAnimateLayoutChanges) {
            //其他View全部隐藏
            for (i in views.indices) {
                if (i != currentState.ordinal && i != previousState.ordinal) {
                    views[i]?.visibility = View.GONE
                }
            }
            //使用animate执行previousView与currentView的切换
            animateView(obtainView(previousState))
            return
        }
        //不执行动画
        for (i in views.indices) {
            if (i == currentState.ordinal) {
                views[i]?.visibility = View.VISIBLE
            } else {
                views[i]?.visibility = View.GONE
            }
        }
    }

关于动画的处理,如下所示:

    /**
     * 动画执行隐藏显示
     * @param view view
     */
    private fun animateView(previousView: View?) {
        if (previousView == null) {
            obtainView(currentState)?.let { it.visibility = View.VISIBLE }
                ?: throw IllegalStateException("当前状态的view不能为null")
            return
        }
        val animateDuration = 200L
        ObjectAnimator.ofFloat(previousView, "alpha", 1.0F, 0.0F).apply {
            duration = animateDuration
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    previousView.visibility = View.VISIBLE
                }

                override fun onAnimationEnd(animation: Animator?) {
                    previousView.visibility = View.GONE
                    obtainView(currentState)?.let {
                        //当前View显示
                        ObjectAnimator.ofFloat(it, "alpha", 0.0F, 1.0F)
                            .apply { duration = animateDuration }.start()
                    } ?: throw IllegalStateException("当前状态的view不能为null")
                }
            })
        }.start()
    }

简单使用测试如下,至于测试的逻辑代码这里就不写了,下面提供git代码地址,感兴趣的可以找找呀。

<?xml version="1.0" encoding="utf-8"?>
<com.pumpkin.mvvm.widget.MultiStateView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:msv_emptyView="@layout/layout_test_empty"
    app:msv_loadingView="@layout/layout_test_loading"
    app:msv_netErrorView="@layout/layout_test_neterror"
    app:msv_enableAnimateChanges="true"
    app:msv_currentViewState="empty">

    <include
        android:id="@+id/i_normal"
        layout="@layout/layout_test_normal" />

</com.pumpkin.mvvm.widget.MultiStateView>

以上即为核心实现逻辑代码。

附上完整GIT代码地址➡️➡️➡️➡️➡️➡️➡️➡️➡️➡️

最后

创作不易,如有帮助一键三连咯🙆‍♀️。欢迎技术探讨噢!

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

pumpkin的玄学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值