Android 之路 (15) – 骨架状态布局(SkeletonLayout)的实现思路与封装
前言
废话不多说,先看看成果物,如果有兴趣了再往下看,不感兴趣就直接关闭就行。
骨架状态布局(SkeletonLayout),前几年开始流行至今,已经是大多数App的标配了。以往的做法是在页面开始请求的时候弹出一个LoadingDialog,失败的时候又弹出一个ConfirmDialog。而Dialog也需要和网络请求绑在一起,用户返回上一页,需要关闭LoadingDialog,并且要取消网络请求,一不注意就会造成内存泄露,整个过程也非常的繁琐。
骨架图就相对于方便一点,整个loading和error的提示都在页面上做的。对于用户来讲,用户需要取消,直接返回上一页就行,没有多余的操作。对于开发来说,更加方便封装和控制,减少内存泄露的风险。
正文
分析
一般骨架图都是继承并实现一个SkeletonLayout,先解析内容的布局放到第0个,后面再将自己的各种状态layout实例化出来,add到后面,遮盖住真正的内容,就像叠卡片一样。
而对于SkeletonLayout的使用有两种方式:xml引用和基类代码中封装,这两种方式均,没有太大的区别。xml应用是为了拓展、移植,而本系列是为了封装属于自己的基类库,所以选择直接将SkeletonLayout封装在 CadnyBase
,也是为了后续的封装打基础。
编码
确定状态
初步定义三个简单的状态:
– Loading:加载中,页面第一次加载数据的时候使用
– Empty:空状态,无数据的情况下使用
– Retry:请求发生错误,需要再次点击重试的情况下使用
我们先以这三种状态为基础,后续再进行扩充。
创建状态Layout
关于loading的动画使用的是 AVLoadingIndicatorView 。
另外为了阅读方便,xml部分的代码进行了折叠,三个layout也是直接从网络上找的样式,也没有什么难度, 具体根据实际项目需求来编写,唯一要注意的是根布局需要设置 clickable为true
, focusable为true
,直接把事件消费掉,否则会传递到真正的内容布局中。
skeleton_base_loading
skeleton_base_empty
skeleton_base_retry
创建SkeletonLayout
layout已经创建好,接下来就是创建SkeletonLayout,将三个状态布局解析出来增加到SkeletonLayout中,代码也不复杂,都是在构造方法中节选布局。
public class SkeletonLayout extends FrameLayout { /**加载中布局*/ private View mLoadingLayout = null; /**重试布局*/ private View mRetryLayout = null; /**空布局*/ private View mEmptyLayout = null; public SkeletonLayout(@NonNull Context context) { super(context); initSkeletonLayout(); // 不显示布局 switchSkeleton(null); } /** * 初始化骨架布局 */ private void initSkeletonLayout() { FrameLayout.LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); setLayoutParams(layoutParams); initRetryView(); initLoadingView(); initEmptyView(); } private void initLoadingView() { if (mLoadingLayout != null) { removeView(mLoadingLayout); } mLoadingLayout = View.inflate(getContext(), mLoadingLayoutId, null); addView(mLoadingLayout); } private void initEmptyView() { if (mEmptyLayout != null) { removeView(mEmptyLayout); } mEmptyLayout = View.inflate(getContext(), mEmptyLayoutId, null); addView(mEmptyLayout); } private void initRetryView() { if (mRetryLayout != null) { removeView(mRetryLayout); } mRetryLayout = View.inflate(getContext(), mRetryLayoutId, null); addView(mRetryLayout); } /** * 切换布局状态,进行显示和隐藏 * * @param skeletonView 需要显示的布局 */ private void switchSkeleton(View skeletonView) { if (mLoadingLayout != null) { mLoadingLayout.setVisibility(skeletonView == mLoadingLayout ? VISIBLE : GONE); } if (mEmptyLayout != null) { mEmptyLayout.setVisibility(skeletonView == mEmptyLayout ? VISIBLE : GONE); } if (mRetryLayout != null) { mRetryLayout.setVisibility(skeletonView == mRetryLayout ? VISIBLE : GONE); } } }
定义接口及回调
为了让外部能够控制状态的切换,及重试图标点击的回调,需要定义一个接口来约束,主要有以下四个方法:
public interface OnSkeletonListener { /**显示loading状态*/ void showSkeletonLoading(); /**显示重试状态,请求失败的时候使用*/ void showSkeletonRetry(); /**隐藏所有状态,现在主内容*/ void showSkeletonContent(); /**显示空状态,没有数据的时候使用*/ void showSkeletonEmpty(); /**重试状态下被点击,用来确认下一步操作*/ void onSkeletonRetry(); }
实现接口及操作
接口相关都定义好了,接下来就是进行实现,不同的方法下隐藏其它的布局,只显示当前布局,另外为了命名域方便,将接口定义在了SkeletonLayout中,全部代码如下:
public class SkeletonLayout extends FrameLayout { /**加载中布局*/ private View mLoadingLayout = null; /**重试布局*/ private View mRetryLayout = null; /**空布局*/ private View mEmptyLayout = null; /**状态监听 */ private OnSkeletonListener onSkeletonListener; public SkeletonLayout(@NonNull Context context) { super(context); initSkeletonLayout(); } /** * 初始化骨架布局 */ private void initSkeletonLayout() { FrameLayout.LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); setLayoutParams(layoutParams); initRetryView(); initLoadingView(); initEmptyView(); // 不显示布局 switchSkeleton(null); } private void initLoadingView() { if (mLoadingLayout != null) { removeView(mLoadingLayout); } mLoadingLayout = View.inflate(getContext(), mLoadingLayoutId, null); addView(mLoadingLayout); } private void initEmptyView() { if (mEmptyLayout != null) { removeView(mEmptyLayout); } mEmptyLayout = View.inflate(getContext(), mEmptyLayoutId, null); addView(mEmptyLayout); } private void initRetryView() { if (mRetryLayout != null) { removeView(mRetryLayout); } mRetryLayout = View.inflate(getContext(), mRetryLayoutId, null); addView(mRetryLayout); // 点击事件并回调 View mSkeletonRetry = mRetryLayout.findViewById(R.id.mSkeletonRetry); if (mSkeletonRetry != null) { mSkeletonRetry.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (onSkeletonListener != null) { onSkeletonListener.onSkeletonRetry(); } } }); } } public void showSkeletonLoading() { switchSkeleton(mLoadingLayout); } public void showSkeletonRetry() { switchSkeleton(mRetryLayout); } public void showSkeletonContent() { switchSkeleton(null); } public void showSkeletonEmpty() { switchSkeleton(mEmptyLayout); } /** * 切换布局状态,进行显示和隐藏 * * @param skeletonView 需要显示的布局 */ private void switchSkeleton(View skeletonView) { if (mLoadingLayout != null) { mLoadingLayout.setVisibility(skeletonView == mLoadingLayout ? VISIBLE : GONE); } if (mEmptyLayout != null) { mEmptyLayout.setVisibility(skeletonView == mEmptyLayout ? VISIBLE : GONE); } if (mRetryLayout != null) { mRetryLayout.setVisibility(skeletonView == mRetryLayout ? VISIBLE : GONE); } } /** * 回调接口 */ public interface OnSkeletonListener { /**显示loading状态*/ void showSkeletonLoading(); /**显示重试状态,请求失败的时候使用*/ void showSkeletonRetry(); /**隐藏所有状态,现在主内容*/ void showSkeletonContent(); /**显示空状态,没有数据的时候使用*/ void showSkeletonEmpty(); /**重试状态下被点击,用来确认下一步操作*/ void onSkeletonRetry(); } }
封装SkeletonLayout到CandyBase
让CandyBase实现 OnSkeletonListener
接口,并且开放一个 initSkeletonLayout(@LayoutRes int layoutId)
方法给子类,让需要骨架的之类调用该方法进行初始化, OnSkeletonListener
的实现方法也只是调用 SkeletonLayout
的实现方法,全部代码如下:
/** * 骨架图 */ private SkeletonLayout mSkeletonLayout; /** * 初始化骨架图,需要骨架的地方才会进行初始化,传入的是Activity的根布局 * @param layoutId 根布局 * @return 解析好的根布局,增加了骨架图在原本的ViewGroup上面 */ protected View initSkeletonLayout(@LayoutRes int layoutId) { ViewGroup contentView = (ViewGroup) LayoutInflater.from(mActivity).inflate(layoutId, null, false); mSkeletonLayout = new SkeletonLayout(mActivity); contentView.addView(mSkeletonLayout); mSkeletonLayout.setOnSkeletonListener(this); return contentView; } public void showSkeletonLoading() { if (mSkeletonLayout != null) { mSkeletonLayout.showSkeletonLoading(); } } public void showSkeletonRetry() { if (mSkeletonLayout != null) { mSkeletonLayout.showSkeletonRetry(); } } public void showSkeletonContent() { if (mSkeletonLayout != null) { mSkeletonLayout.showSkeletonContent(); } } public void showSkeletonEmpty() { if (mSkeletonLayout != null) { mSkeletonLayout.showSkeletonEmpty(); } } @Override public void onSkeletonRetry() { //重试点击 }
使用
使用方式也很简单,只需要将 setContentView
的值换成 initSkeletonLayout
方法初始化后的值即可,如下:
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(initSkeletonLayout(R.layout.activity_skeleton_layout)); }
根据不同的需求调用OnSkeletonListener接口不同的方法。
演示
优化
抽取ID & 适配不同项目
SkeletonLayout
是创建在 library-core
里面中,对其它项目提供的也是打包好的 aar
,基本上是不可改变的,遇到需要不同项目需要不同状态布局的情况,我们需要将布局里面的id抽取出来,依赖 library-core
的项目只需要在layout下 创建同名的状态布局layout
文件,复用定义好的id,这样在构建的时候就会以我们创建的为主,进行替换。
以下是抽取的ID:
适配单页面不同样式
前面针对单项目的样式进行适配,现在需要对单个页面需要不同样式进行适配,方法也简单,为每种布局提供 setLayout
的方法,设置完成后再解析add就行,然后在相应的子类Activity或者Fragment里面设置即可,代码如下:
public void setLoadingLayoutId(@LayoutRes int mLoadingLayoutId) { this.mLoadingLayoutId = mLoadingLayoutId; initLoadingView(); } public void setRetryLayoutId(@LayoutRes int mRetryLayoutId) { this.mRetryLayoutId = mRetryLayoutId; initRetryView(); } public void setEmptyLayoutId(@LayoutRes int mEmptyLayoutId) { this.mEmptyLayoutId = mEmptyLayoutId; initEmptyView(); }
演示,为了演示方便,在Activity中随机了一个布尔值,设置不同的layout。
结束
总结
骨架状态布局的实现和封装并不是特别复杂,只需要要一点思路,然后慢慢实现即可,这次的封装属于耦合性强的封装,主要是为了项目开发方便,也有一些需要注意的坑:
– 各个状态布局需要预留顶部ToolBar、状态栏的高度
– SkeletonLayout要手动设置高度和宽度
– 需要将id抽取出来,以便重写布局的时候使用
源码
参考
软广
来都来了,就给个关注吧,时不时会悄悄的推送一些小技巧的文章~~!