前言
废话不多说,先看看,如果有兴趣了再往下看,不感兴趣就直接关闭就行。
骨架状态布局(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抽取出来,以便重写布局的时候使用
源码
- 源码v0.15
- SkeletonLayout
如果可以的话,请给我一个star 仓库地址
参考
- Loading设计图