骨架屏简介
骨架屏可以理解为是当数据还未加载进页面之前,页面的一个空白的样板。一个简单的关键渲染路径。在看到在页面完全渲染完成之前,用户会看到一个样式简单,描绘了当前页面的大致框架的骨架屏页面,然后骨架屏中各个占位部分被实际资源完全替换,这个过程中用户会觉得内容正在逐渐加载即将呈现,降低了用户的焦躁情绪,使得加载过程主观上变得流畅。
可以看上面的示例图,第一个为骨架屏,第二个为经典加载圈,第三个为无优化,可以看到相比于传统的加载圈,会在感官上觉得内容出现的流畅而不突兀,体验更加优良。
谁在使用Skeleton Screen?
国内 : 支付宝、饿了么、今日头条、、新浪微博、知乎、美团、领英、B站等
国外 :Facebook 、Google 、Medium、WordPress App、Slack 等
Android Skeleton Screen 框架 Skeleton
GitHub地址:https://github.com/ethanhua/Skeleton
Skeleton优点 :
增包体积小 ,加入后包体积仅增大14.296Kb。
API简单 常用 API : shimmer 、count 、color 、angle 、 duration、 frozen、restore、 replace
链式调用
入侵程度较小,不用修改基本控件,对业务逻辑不造成影响
使用简单,功能强大 支持列表,View,占位符状态修改
基本使用
build.gradle引入
dependencies {
implementation 'com.ethanhua:skeleton:1.1.2'
implementation 'io.supercharge:shimmerlayout:2.1.0'
}
在列表页使用
skeletonScreen = Skeleton.bind(recyclerView)
.adapter(adapter)
.load(R.layout.item_skeleton_news)
.show();
View使用
skeletonScreen = Skeleton.bind(rootView)
.load(R.layout.layout_img_skeleton)
.show();
常用API
shimmer 是否展示光晕动画
count 在数据加载完成前 列表显示的个数,默认值10
color 光晕动画颜色 ,可以在values -color 加入自己需要的颜色
angle 加载光晕动画斜角读书(最大值30)
duration 光晕动画执行间隔时间
frozen 是否关闭列表的刷新
load 用于加载骨架屏占位布局
hide 隐藏骨架屏 ,用于数据加载完成前占位
show 显示骨架屏,用于数据加载完成前
接口SkeletonScreen 实现类:
- RecyclerViewSkeletonScreen 用于列表
- ViewSkeletonScreen 用于View
项目实战
在列表中使用
在数据加载前,绑定列表(RecyclerView)
skeletonScreen =
//绑定当前列表
Skeleton.bind(dataRecyclerView)
//设置加载列表适配器 ,并且开启动画 设置光晕动画角度等 最后显示
.adapter(storyPlatformAdapter).shimmer(true).angle(20)
.frozen(false)
.duration(1000)
.count(10)
.load(R.layout.item_skeleton_news)
.show();
数据加载成功后
//隐藏骨架屏
skeletonScreen.hide();
效果:详情参考手机demo
在普通页面中使用:
绑定布局View,数据加载结束之前show方法
skeletonScreen = Skeleton.bind(rootView)
.load(R.layout.activity_view_skeleton)
.duration(1000)
.color(R.color.shimmer_color)
.angle(0)
.show();
在数据加载结束后调用hide方法
skeletonScreen.hide();
效果:详情参考手机demo
在View级别使用
绑定要占位的View,在图片加载开始前调用show方法
skeletonScreen = Skeleton.bind(imageView)
.load(R.layout.layout_img_skeleton)
.duration(1000)
.color(R.color.shimmer_color_for_image)
.show();
图片加载完后 调用hide方法
skeletonScreen.hide()
效果:详情参考手机demo
状态占位符 ViewReplacer
常用API: replace、 restore
代码:
初始化一个ViewReplacer,传入选择占位的View
//初始化
mViewReplacer = new ViewReplacer(findViewById(R.id.tv_content));
状态占位
//传入要显示的状态布局,比如加载,失败,设置等
mViewReplacer.replace(R.layout.layout_error);
显示/恢复原有View
mViewReplacer.restore();
源码分析
RecyclerViewSkeletonScreen
先从列表绑定使用开始。
SkeletonScreen skeletonScreen = Skeleton.bind(recyclerView)
.adapter(adapter)
.shimmer(true)
.angle(30)
.frozen(false)
.duration(1000)
.count(10).color(R.color.colorFontRed)
.load(R.layout.item_skeleton_news)
.show();
bind时候做了什么?
根据传入的类型 返回了两个类型RecyclerViewSkeletonScreen的Builder和ViewSkeletonScreen的Builder
public class Skeleton {
public static RecyclerViewSkeletonScreen.Builder bind(RecyclerView recyclerView) {
return new RecyclerViewSkeletonScreen.Builder(recyclerView);
}
public static ViewSkeletonScreen.Builder bind(View view) {
return new ViewSkeletonScreen.Builder(view);
}
}
先看下RecyclerViewSkeletonScreen中Builder的代码
public static class Builder {
//传入的列表适配器
private RecyclerView.Adapter mActualAdapter;
//传入的列表
private final RecyclerView mRecyclerView;
//开启动画 默认为true
private boolean mShimmer = true;
//列表item数量
private int mItemCount = 10;
//占位item布局
private int mItemResID = R.layout.layout_default_item_skeleton;
//光晕动画颜色
private int mShimmerColor;
//光晕动画默认时间
private int mShimmerDuration = 1000;
//光晕动画角度
private int mShimmerAngle = 20;
//列表默认开启刷新
private boolean mFrozen = true;
public Builder(RecyclerView recyclerView) {
this.mRecyclerView = recyclerView;
this.mShimmerColor = ContextCompat.getColor(recyclerView.getContext(), R.color.shimmer_color);
}
/**
* @param 传入适配器
*/
public Builder adapter(RecyclerView.Adapter adapter) {
this.mActualAdapter = adapter;
return this;
}
/**
* @param 占位图item条数 默认是10
*/
public Builder count(int itemCount) {
this.mItemCount = itemCount;
return this;
}
/**
* @param 是否展示动画 默认是true
*/
public Builder shimmer(boolean shimmer) {
this.mShimmer = shimmer;
return this;
}
/**
* @param 动画时长,单位毫秒
*/
public Builder duration(int shimmerDuration) {
this.mShimmerDuration = shimmerDuration;
return this;
}
/**
* @param 光晕动画的颜色
*/
public Builder color(@ColorRes int shimmerColor) {
this.mShimmerColor = ContextCompat.getColor(mRecyclerView.getContext(), shimmerColor);
return this;
}
/**
* @param 光晕动画的角度,最大30度 最小0度
*/
public Builder angle(@IntRange(from = 0, to = 30) int shimmerAngle) {
this.mShimmerAngle = shimmerAngle;
return this;
}
/**
* @param 占位图的布局id
*/
public Builder load(@LayoutRes int skeletonLayoutResID) {
this.mItemResID = skeletonLayoutResID;
return this;
}
/**
* @param 列表是否刷新 默认true
* @return
*/
public Builder frozen(boolean frozen) {
this.mFrozen = frozen;
return this;
}
//show 方法 调用show方法后 返回自己
public RecyclerViewSkeletonScreen show() {
RecyclerViewSkeletonScreen recyclerViewSkeleton = new RecyclerViewSkeletonScreen(this);
recyclerViewSkeleton.show();
return recyclerViewSkeleton;
}
}
通过Builder代码可以看到,动画,item数量,光晕动画时间,角度,颜色,列表默认刷新,是否开启,占位item布局,都有默认值。
调用bind,传入了recyclerView 并且获取到光晕动画的默认颜色。
public Builder(RecyclerView recyclerView) {
this.mRecyclerView = recyclerView;
this.mShimmerColor = ContextCompat.getColor(recyclerView.getContext(), R.color.shimmer_color);
}
接下来.adapter,传入了列表适配器,当然可以不传,但是最后结果就是页面空白。
public Builder adapter(RecyclerView.Adapter adapter) {
this.mActualAdapter = adapter;
return this;
}
接下来设置参数 就是替换默认值,直到调用show方法。将自身传入RecyclerViewSkeletonScreen生成一个RecyclerViewSkeletonScreen。
看下RecyclerViewSkeletonScreen代码,Builder将获取到参数传给RecyclerViewSkeletonScreen。
并且new出来一个SkeletonAdapter。
private RecyclerViewSkeletonScreen(Builder builder) {
mRecyclerView = builder.mRecyclerView;
mActualAdapter = builder.mActualAdapter;
mSkeletonAdapter = new SkeletonAdapter();
mSkeletonAdapter.setItemCount(builder.mItemCount);
mSkeletonAdapter.setLayoutReference(builder.mItemResID);
mSkeletonAdapter.shimmer(builder.mShimmer);
mSkeletonAdapter.setShimmerColor(builder.mShimmerColor);
mSkeletonAdapter.setShimmerAngle(builder.mShimmerAngle);
mSkeletonAdapter.setShimmerDuration(builder.mShimmerDuration);
mRecyclerViewFrozen = builder.mFrozen;
}
调用show方法,将SkeletonAdapter设置给了mRecyclerView。到此为止,一个展位列表就制作完成。
@Override
public void show() {
mRecyclerView.setAdapter(mSkeletonAdapter);
if (!mRecyclerView.isComputingLayout() && mRecyclerViewFrozen) {
mRecyclerView.setLayoutFrozen(true);
}
}
当我们数据加载完调用hide方法,又发生了什么?其实就是mRecyclerView又设置了我们自己原来列表的适配器。
@Override
public void hide() {
mRecyclerView.setAdapter(mActualAdapter);
}
ViewSkeletonScreen
在看下View中的使用
skeletonScreen = Skeleton.bind(rootView)
.load(R.layout.activity_view_skeleton)
.duration(1000)
.color(R.color.shimmer_color)
.angle(0)
.show();
继续从bind看起,bind后返回一个 ViewSkeletonScreen.Builder ,进入 ViewSkeletonScreen.Builde看看。
public static class Builder {
//传入的view
private final View mView;
//占位资源id
private int mSkeletonLayoutResID;
//是否开启动画 默认为true
private boolean mShimmer = true;
//光晕动画颜色
private int mShimmerColor;
//光晕动画时间 默认1s
private int mShimmerDuration = 1000;
//光晕动画角度 默认20
private int mShimmerAngle = 20;
public Builder(View view) {
this.mView = view;
this.mShimmerColor = ContextCompat.getColor(mView.getContext(), R.color.shimmer_color);
}
/**
* @param 加载传入的占位布局
*/
public Builder load(@LayoutRes int skeletonLayoutResID) {
this.mSkeletonLayoutResID = skeletonLayoutResID;
return this;
}
/**
* @param 获取光晕动画颜色
*/
public Builder color(@ColorRes int shimmerColor) {
this.mShimmerColor = ContextCompat.getColor(mView.getContext(), shimmerColor);
return this;
}
/**
* @param 获取是否开启动画
*/
public ViewSkeletonScreen.Builder shimmer(boolean shimmer) {
this.mShimmer = shimmer;
return this;
}
/**
*
* @param 光晕动画时间
*/
public ViewSkeletonScreen.Builder duration(int shimmerDuration) {
this.mShimmerDuration = shimmerDuration;
return this;
}
/**
* @param 光晕动画角度 0-30
*/
public ViewSkeletonScreen.Builder angle(@IntRange(from = 0, to = 30) int shimmerAngle) {
this.mShimmerAngle = shimmerAngle;
return this;
}
public ViewSkeletonScreen show() {
ViewSkeletonScreen skeletonScreen = new ViewSkeletonScreen(this);
skeletonScreen.show();
return skeletonScreen;
}
}
基本上看起来和列表的没什么区别,我们直接去看下ViewSkeletonScreen代码。
private ViewSkeletonScreen(Builder builder) {
mActualView = builder.mView;
mSkeletonResID = builder.mSkeletonLayoutResID;
mShimmer = builder.mShimmer;
mShimmerDuration = builder.mShimmerDuration;
mShimmerAngle = builder.mShimmerAngle;
mShimmerColor = builder.mShimmerColor;
mViewReplacer = new ViewReplacer(builder.mView);
}
将Builder获取到的数据传了过去,并在内部初始化了一个ViewReplacer,我们暂且不看ViewReplacer代码,去看下show方法
@Override
public void show() {
View skeletonLoadingView = generateSkeletonLoadingView();
if (skeletonLoadingView != null) {
mViewReplacer.replace(skeletonLoadingView);
}
}
我们看下generateSkeletonLoadingView方法
private View generateSkeletonLoadingView() {
ViewParent viewParent = mActualView.getParent();
if (viewParent == null) {
Log.e(TAG, "the source view have not attach to any view");
return null;
}
ViewGroup parentView = (ViewGroup) viewParent;
if (mShimmer) {
return generateShimmerContainerLayout(parentView);
}
return LayoutInflater.from(mActualView.getContext()).inflate(mSkeletonResID, parentView, false);
}
获取到父View,如果开启动画就去调用generateShimmerContainerLayout,最后封装了一个view返回。
看下generateShimmerContainerLayout方法
private ShimmerLayout generateShimmerContainerLayout(ViewGroup parentView) {
final ShimmerLayout shimmerLayout = (ShimmerLayout) LayoutInflater.from(mActualView.getContext()).inflate(R.layout.layout_shimmer, parentView, false);
shimmerLayout.setShimmerColor(mShimmerColor);
shimmerLayout.setShimmerAngle(mShimmerAngle);
shimmerLayout.setShimmerAnimationDuration(mShimmerDuration);
View innerView = LayoutInflater.from(mActualView.getContext()).inflate(mSkeletonResID, shimmerLayout, false);
ViewGroup.LayoutParams lp = innerView.getLayoutParams();
if (lp != null) {
shimmerLayout.setLayoutParams(lp);
}
shimmerLayout.addView(innerView);
shimmerLayout.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
shimmerLayout.startShimmerAnimation();
}
@Override
public void onViewDetachedFromWindow(View v) {
shimmerLayout.stopShimmerAnimation();
}
});
shimmerLayout.startShimmerAnimation();
return shimmerLayout;
}
我们看到首先动画开启的时候,创建了一个ShimmerLayout动画布局,将获取到的参数设置过去,然后根据传入的占位图布局封装了一个占位view。将占位view添加进了光晕动画布局,添加了OnAttach监听,加入的时候开启动画,注销的时候停止动画,开启动画,返回。这里有一个shimmerLayout.startShimmerAnimation()。因为是一个自定义View,我们最后再说。
回到show方法,我们可以看到接下来就是调用 mViewReplacer.replace方法,将占位view或者带着光晕动画的shimmerLayout替换过去。
看来
我们看下hide方法,判断如果是动画布局就停止动画,并且调用mViewReplacer.restore();
@Override
public void hide() {
if (mViewReplacer.getTargetView() instanceof ShimmerLayout) {
((ShimmerLayout) mViewReplacer.getTargetView()).stopShimmerAnimation();
}
mViewReplacer.restore();
}
ViewReplacer
到现在为止大体的逻辑我们已经看通了,接下来看来ViewReplacer,因为在上面代码也用到了这个类
代码量不是很大,直接粘贴出来。
public class ViewReplacer {
private static final String TAG = ViewReplacer.class.getName();
//传递进来的view
private final View mSourceView;
//当前在父布局里的view
private View mTargetView;
private int mTargetViewResID = -1;
//当前view
private View mCurrentView;
//传入的view的父布局
private ViewGroup mSourceParentView;
//传递进来的view的LayoutParams
private final ViewGroup.LayoutParams mSourceViewLayoutParams;
//在父布局的位置
private int mSourceViewIndexInParent = 0;
//传入的view的id
private final int mSourceViewId;
public ViewReplacer(View sourceView) {
mSourceView = sourceView;
mSourceViewLayoutParams = mSourceView.getLayoutParams();
mCurrentView = mSourceView;
mSourceViewId = mSourceView.getId();
}
public void replace(int targetViewResID) {
if (mTargetViewResID == targetViewResID) {
return;
}
if (init()) {
mTargetViewResID = targetViewResID;
replace(LayoutInflater.from(mSourceView.getContext()).inflate(mTargetViewResID, mSourceParentView, false));
}
}
public void replace(View targetView) {
if (mCurrentView == targetView) {
return;
}
if (targetView.getParent() != null) {
((ViewGroup) targetView.getParent()).removeView(targetView);
}
if (init()) {
mTargetView = targetView;
mSourceParentView.removeView(mCurrentView);
mTargetView.setId(mSourceViewId);
mSourceParentView.addView(mTargetView, mSourceViewIndexInParent, mSourceViewLayoutParams);
mCurrentView = mTargetView;
}
}
public void restore() {
if (mSourceParentView != null) {
mSourceParentView.removeView(mCurrentView);
mSourceParentView.addView(mSourceView, mSourceViewIndexInParent, mSourceViewLayoutParams);
mCurrentView = mSourceView;
mTargetView = null;
mTargetViewResID = -1;
}
}
public View getSourceView() {
return mSourceView;
}
public View getTargetView() {
return mTargetView;
}
public View getCurrentView() {
return mCurrentView;
}
private boolean init() {
if (mSourceParentView == null) {
mSourceParentView = (ViewGroup) mSourceView.getParent();
if (mSourceParentView == null) {
Log.e(TAG, "the source view have not attach to any view");
return false;
}
int count = mSourceParentView.getChildCount();
for (int index = 0; index < count; index++) {
if (mSourceView == mSourceParentView.getChildAt(index)) {
mSourceViewIndexInParent = index;
break;
}
}
}
return true;
}
}
先看构造 传入view赋值给当前变量mCurrentView,获取它的LayoutParams和id。
public ViewReplacer(View sourceView) {
mSourceView = sourceView;
mSourceViewLayoutParams = mSourceView.getLayoutParams();
mCurrentView = mSourceView;
mSourceViewId = mSourceView.getId();
}
我们看下replace 有两个同名不同参,一个传入布局id,一个传view
public void replace(int targetViewResID) {
if (mTargetViewResID == targetViewResID) {
return;
}
if (init()) {
mTargetViewResID = targetViewResID;
replace(LayoutInflater.from(mSourceView.getContext()).inflate(mTargetViewResID, mSourceParentView, false));
}
}
判断如果传入id一样,就返回。然后调用init方法,调用了replace(View)方法
我们看下replace(View)
public void replace(View targetView) {
if (mCurrentView == targetView) {
return;
}
if (targetView.getParent() != null) {
((ViewGroup) targetView.getParent()).removeView(targetView);
}
if (init()) {
mTargetView = targetView;
mSourceParentView.removeView(mCurrentView);
mTargetView.setId(mSourceViewId);
mSourceParentView.addView(mTargetView, mSourceViewIndexInParent, mSourceViewLayoutParams);
mCurrentView = mTargetView;
}
也是如果当前view和替换的view一样就返回,然后获取父View,又看到了init方法。我们看下
private boolean init() {
if (mSourceParentView == null) {
mSourceParentView = (ViewGroup) mSourceView.getParent();
if (mSourceParentView == null) {
Log.e(TAG, "the source view have not attach to any view");
return false;
}
int count = mSourceParentView.getChildCount();
for (int index = 0; index < count; index++) {
if (mSourceView == mSourceParentView.getChildAt(index)) {
mSourceViewIndexInParent = index;
break;
}
}
}
return true;
}
init方法其实就是获取父View,如果不是顶级view就获取在父布局中的index并且返回true。
继续看replace(View)方法,接下来就很好理解,用传入的占位布局封装的View替换原有的View。
在看下restore()方法
public void restore() {
if (mSourceParentView != null) {
mSourceParentView.removeView(mCurrentView);
mSourceParentView.addView(mSourceView, mSourceViewIndexInParent, mSourceViewLayoutParams);
mCurrentView = mSourceView;
mTargetView = null;
mTargetViewResID = -1;
}
}
可以看到,其实就是将占位View从原来传入View父布局删除,将原来View放回原处。
ShimmerLayout
前面一直搁浅着一个shimmerLayout.startShimmerAnimation()方法,现在看一下
我们将整个自定义控件的代码都复制下来,一步步看。
public class ShimmerLayout extends FrameLayout {
private static final int DEFAULT_ANIMATION_DURATION = 1500;
private static final byte DEFAULT_ANGLE = 20;
private static final byte MIN_ANGLE_VALUE = -45;
private static final byte MAX_ANGLE_VALUE = 45;
private static final byte MIN_MASK_WIDTH_VALUE = 0;
private static final byte MAX_MASK_WIDTH_VALUE = 1;
private static final byte MIN_GRADIENT_CENTER_COLOR_WIDTH_VALUE = 0;
private static final byte MAX_GRADIENT_CENTER_COLOR_WIDTH_VALUE = 1;
private int maskOffsetX;
private Rect maskRect;
private Paint gradientTexturePaint;
private ValueAnimator maskAnimator;
private Bitmap localMaskBitmap;
private Bitmap maskBitmap;
private Canvas canvasForShimmerMask;
private boolean isAnimationReversed;
private boolean isAnimationStarted;
private boolean autoStart;
private int shimmerAnimationDuration;
private int shimmerColor;
private int shimmerAngle;
private float maskWidth;
private float gradientCenterColorWidth;
private ViewTreeObserver.OnPreDrawListener startAnimationPreDrawListener;
public ShimmerLayout(Context context) {
this(context, null);
}
public ShimmerLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ShimmerLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
setWillNotDraw(false);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.ShimmerLayout,
0, 0);
try {
shimmerAngle = a.getInteger(R.styleable.ShimmerLayout_shimmer_angle, DEFAULT_ANGLE);
shimmerAnimationDuration = a.getInteger(R.styleable.ShimmerLayout_shimmer_animation_duration, DEFAULT_ANIMATION_DURATION);
shimmerColor = a.getColor(R.styleable.ShimmerLayout_shimmer_color, getColor(R.color.shimmer_color));
autoStart = a.getBoolean(R.styleable.ShimmerLayout_shimmer_auto_start, false);
maskWidth = a.getFloat(R.styleable.ShimmerLayout_shimmer_mask_width, 0.5F);
gradientCenterColorWidth = a.getFloat(R.styleable.ShimmerLayout_shimmer_gradient_center_color_width, 0.1F);
isAnimationReversed = a.getBoolean(R.styleable.ShimmerLayout_shimmer_reverse_animation, false);
} finally {
a.recycle();
}
setMaskWidth(maskWidth);
setGradientCenterColorWidth(gradientCenterColorWidth);
setShimmerAngle(shimmerAngle);
if (autoStart && getVisibility() == VISIBLE) {
startShimmerAnimation();
}
}
@Override
protected void onDetachedFromWindow() {
resetShimmering();
super.onDetachedFromWindow();
}
@Override
protected void dispatchDraw(Canvas canvas) {
if (!isAnimationStarted || getWidth() <= 0 || getHeight() <= 0) {
super.dispatchDraw(canvas);
} else {
dispatchDrawShimmer(canvas);
}
}
@Override
public void setVisibility(int visibility) {
super.setVisibility(visibility);
if (visibility == VISIBLE) {
if (autoStart) {
startShimmerAnimation();
}
} else {
stopShimmerAnimation();
}
}
public void startShimmerAnimation() {
if (isAnimationStarted) {
return;
}
if (getWidth() == 0) {
startAnimationPreDrawListener = new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(this);
startShimmerAnimation();
return true;
}
};
getViewTreeObserver().addOnPreDrawListener(startAnimationPreDrawListener);
return;
}
Animator animator = getShimmerAnimation();
animator.start();
isAnimationStarted = true;
}
public void stopShimmerAnimation() {
if (startAnimationPreDrawListener != null) {
getViewTreeObserver().removeOnPreDrawListener(startAnimationPreDrawListener);
}
resetShimmering();
}
public void setShimmerColor(int shimmerColor) {
this.shimmerColor = shimmerColor;
resetIfStarted();
}
public void setShimmerAnimationDuration(int durationMillis) {
this.shimmerAnimationDuration = durationMillis;
resetIfStarted();
}
public void setAnimationReversed(boolean animationReversed) {
this.isAnimationReversed = animationReversed;
resetIfStarted();
}
/**
* Set the angle of the shimmer effect in clockwise direction in degrees.
* The angle must be between {@value #MIN_ANGLE_VALUE} and {@value #MAX_ANGLE_VALUE}.
*
* @param angle The angle to be set
*/
public void setShimmerAngle(int angle) {
if (angle < MIN_ANGLE_VALUE || MAX_ANGLE_VALUE < angle) {
throw new IllegalArgumentException(String.format("shimmerAngle value must be between %d and %d",
MIN_ANGLE_VALUE,
MAX_ANGLE_VALUE));
}
this.shimmerAngle = angle;
resetIfStarted();
}
/**
* Sets the width of the shimmer line to a value higher than 0 to less or equal to 1.
* 1 means the width of the shimmer line is equal to half of the width of the ShimmerLayout.
* The default value is 0.5.
*
* @param maskWidth The width of the shimmer line.
*/
public void setMaskWidth(float maskWidth) {
if (maskWidth <= MIN_MASK_WIDTH_VALUE || MAX_MASK_WIDTH_VALUE < maskWidth) {
throw new IllegalArgumentException(String.format("maskWidth value must be higher than %d and less or equal to %d",
MIN_MASK_WIDTH_VALUE, MAX_MASK_WIDTH_VALUE));
}
this.maskWidth = maskWidth;
resetIfStarted();
}
/**
* Sets the width of the center gradient color to a value higher than 0 to less than 1.
* 0.99 means that the whole shimmer line will have this color with a little transparent edges.
* The default value is 0.1.
*
* @param gradientCenterColorWidth The width of the center gradient color.
*/
public void setGradientCenterColorWidth(float gradientCenterColorWidth) {
if (gradientCenterColorWidth <= MIN_GRADIENT_CENTER_COLOR_WIDTH_VALUE
|| MAX_GRADIENT_CENTER_COLOR_WIDTH_VALUE <= gradientCenterColorWidth) {
throw new IllegalArgumentException(String.format("gradientCenterColorWidth value must be higher than %d and less than %d",
MIN_GRADIENT_CENTER_COLOR_WIDTH_VALUE, MAX_GRADIENT_CENTER_COLOR_WIDTH_VALUE));
}
this.gradientCenterColorWidth = gradientCenterColorWidth;
resetIfStarted();
}
private void resetIfStarted() {
if (isAnimationStarted) {
resetShimmering();
startShimmerAnimation();
}
}
private void dispatchDrawShimmer(Canvas canvas) {
super.dispatchDraw(canvas);
localMaskBitmap = getMaskBitmap();
if (localMaskBitmap == null) {
return;
}
if (canvasForShimmerMask == null) {
canvasForShimmerMask = new Canvas(localMaskBitmap);
}
canvasForShimmerMask.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
canvasForShimmerMask.save();
canvasForShimmerMask.translate(-maskOffsetX, 0);
super.dispatchDraw(canvasForShimmerMask);
canvasForShimmerMask.restore();
drawShimmer(canvas);
localMaskBitmap = null;
}
private void drawShimmer(Canvas destinationCanvas) {
createShimmerPaint();
destinationCanvas.save();
destinationCanvas.translate(maskOffsetX, 0);
destinationCanvas.drawRect(maskRect.left, 0, maskRect.width(), maskRect.height(), gradientTexturePaint);
destinationCanvas.restore();
}
private void resetShimmering() {
if (maskAnimator != null) {
maskAnimator.end();
maskAnimator.removeAllUpdateListeners();
}
maskAnimator = null;
gradientTexturePaint = null;
isAnimationStarted = false;
releaseBitMaps();
}
private void releaseBitMaps() {
canvasForShimmerMask = null;
if (maskBitmap != null) {
maskBitmap.recycle();
maskBitmap = null;
}
}
private Bitmap getMaskBitmap() {
if (maskBitmap == null) {
maskBitmap = createBitmap(maskRect.width(), getHeight());
}
return maskBitmap;
}
private void createShimmerPaint() {
if (gradientTexturePaint != null) {
return;
}
final int edgeColor = reduceColorAlphaValueToZero(shimmerColor);
final float shimmerLineWidth = getWidth() / 2 * maskWidth;
final float yPosition = (0 <= shimmerAngle) ? getHeight() : 0;
LinearGradient gradient = new LinearGradient(
0, yPosition,
(float) Math.cos(Math.toRadians(shimmerAngle)) * shimmerLineWidth,
yPosition + (float) Math.sin(Math.toRadians(shimmerAngle)) * shimmerLineWidth,
new int[]{edgeColor, shimmerColor, shimmerColor, edgeColor},
getGradientColorDistribution(),
Shader.TileMode.CLAMP);
BitmapShader maskBitmapShader = new BitmapShader(localMaskBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
ComposeShader composeShader = new ComposeShader(gradient, maskBitmapShader, PorterDuff.Mode.DST_IN);
gradientTexturePaint = new Paint();
gradientTexturePaint.setAntiAlias(true);
gradientTexturePaint.setDither(true);
gradientTexturePaint.setFilterBitmap(true);
gradientTexturePaint.setShader(composeShader);
}
private Animator getShimmerAnimation() {
if (maskAnimator != null) {
return maskAnimator;
}
if (maskRect == null) {
maskRect = calculateBitmapMaskRect();
}
final int animationToX = getWidth();
final int animationFromX;
if (getWidth() > maskRect.width()) {
animationFromX = -animationToX;
} else {
animationFromX = -maskRect.width();
}
final int shimmerBitmapWidth = maskRect.width();
final int shimmerAnimationFullLength = animationToX - animationFromX;
maskAnimator = isAnimationReversed ? ValueAnimator.ofInt(shimmerAnimationFullLength, 0)
: ValueAnimator.ofInt(0, shimmerAnimationFullLength);
maskAnimator.setDuration(shimmerAnimationDuration);
maskAnimator.setRepeatCount(ObjectAnimator.INFINITE);
maskAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
maskOffsetX = animationFromX + (int) animation.getAnimatedValue();
if (maskOffsetX + shimmerBitmapWidth >= 0) {
invalidate();
}
}
});
return maskAnimator;
}
private Bitmap createBitmap(int width, int height) {
try {
return Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8);
} catch (OutOfMemoryError e) {
System.gc();
return null;
}
}
private int getColor(int id) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return getContext().getColor(id);
} else {
//noinspection deprecation
return getResources().getColor(id);
}
}
private int reduceColorAlphaValueToZero(int actualColor) {
return Color.argb(0, Color.red(actualColor), Color.green(actualColor), Color.blue(actualColor));
}
private Rect calculateBitmapMaskRect() {
return new Rect(0, 0, calculateMaskWidth(), getHeight());
}
private int calculateMaskWidth() {
final double shimmerLineBottomWidth = (getWidth() / 2 * maskWidth) / Math.cos(Math.toRadians(Math.abs(shimmerAngle)));
final double shimmerLineRemainingTopWidth = getHeight() * Math.tan(Math.toRadians(Math.abs(shimmerAngle)));
return (int) (shimmerLineBottomWidth + shimmerLineRemainingTopWidth);
}
private float[] getGradientColorDistribution() {
final float[] colorDistribution = new float[4];
colorDistribution[0] = 0;
colorDistribution[3] = 1;
colorDistribution[1] = 0.5F - gradientCenterColorWidth / 2F;
colorDistribution[2] = 0.5F + gradientCenterColorWidth / 2F;
return colorDistribution;
}
}
先看startShimmerAnimation(),可以看到,进入判断如果动画开启就返回,如果宽度是0,并且正在测绘,也返回并且递归,直到绘制完成,去获取动画,并且将动画标识更改为true,追下getShimmerAnimation()
public void startShimmerAnimation() {
if (isAnimationStarted) {
return;
}
if (getWidth() == 0) {
startAnimationPreDrawListener = new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(this);
startShimmerAnimation();
return true;
}
};
getViewTreeObserver().addOnPreDrawListener(startAnimationPreDrawListener);
return;
}
Animator animator = getShimmerAnimation();
animator.start();
isAnimationStarted = true;
}
getShimmerAnimation()方法其实就是创建了一个属性动画,我们看下上面源码,发现calculateBitmapMaskRect(),返回了一个根据角度生成的一个矩形,矩阵会根据属性动画不断的从左向右,根据属性动画传入的时长,不断的向右位移,直到位移大于0从新绘制。最后将该动画返回并且开启。
private Animator getShimmerAnimation() {
if (maskAnimator != null) {
return maskAnimator;
}
if (maskRect == null) {
maskRect = calculateBitmapMaskRect();
}
final int animationToX = getWidth();
final int animationFromX;
if (getWidth() > maskRect.width()) {
animationFromX = -animationToX;
} else {
animationFromX = -maskRect.width();
}
final int shimmerBitmapWidth = maskRect.width();
final int shimmerAnimationFullLength = animationToX - animationFromX;
maskAnimator = isAnimationReversed ? ValueAnimator.ofInt(shimmerAnimationFullLength, 0)
: ValueAnimator.ofInt(0, shimmerAnimationFullLength);
maskAnimator.setDuration(shimmerAnimationDuration);
maskAnimator.setRepeatCount(ObjectAnimator.INFINITE);
maskAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
maskOffsetX = animationFromX + (int) animation.getAnimatedValue();
if (maskOffsetX + shimmerBitmapWidth >= 0) {
invalidate();
}
}
});
return maskAnimator;
}
总结:
ViewReplacer 的replace原理其实就是利用传入的view获取到父View,用占位View替换掉原始View。restore就是将原来的View放回原处。
ShimmerLayout:可以说是光晕动画的自定义View,核心就是根据传入的动画时间生成属性动画不断位移根据传入颜色,角度生成的矩形。
RecyclerViewSkeletonScreen 内部封装了一个包含ShimmerLayout的item的适配器,然后根据show和hide不断替换自带适配器和传入适配器。
ViewSkeletonScreen 根据传入的View 获取父View和传入View的id,LayoutParams,在show的时候其实内部调用了ViewReplacer的replace,hide的时候又调用了ViewReplacer的restore。
Skeleton框架,源码简单,侵入性较低,对原有代码逻辑入侵极小,无需替换原有View,只需要在列表页面和在普通页面只需要数据加载前传入列表和适配器,以及需要的占位布局。在数据加载完成后只需要hide即可。对原有逻辑无影响。但是用户体验感会提升很多。