Android自定义Loading提示框

工作需要一个自定义的Loading提示框,那么比起从网络上找第三方库修改,从零做一个Loading更能学到更多的知识,那么我们开始吧。

制作一个旋转的Loading控件

  • 首先需要自定义View来承载动画
  • 创建LoadingView继承自View
  • 初始化画笔和图片资源
1
2
3
4
5
6
7
private void init() {   
paint = new Paint();
//画笔 抗锯齿
paint.setFlags(Paint.ANTI_ALIAS_FLAG);
//图片资源
loadingPic = decodeResource(getResources(), R.drawable.ic_loading);
}
  • 在onMeasure中,获取图片的宽度和高度

由于我们是要对控件进行旋转,所以需要取出旋转的最大直径作为控件的宽高,防止旋转中,图案被遮挡。

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//获取需要旋转的图片宽高
picWidth = loadingPic.getWidth();
picHeight = loadingPic.getHeight();
//选择直角三角形的第三边,作为直径,作为控件的宽高(因需要旋转)
//a^2 + b^2 = c^2
max = (int) Math.sqrt(picWidth * picWidth + picHeight * picHeight);
setMeasuredDimension(max, max);
}
  • 在onDraw中,对图案进行绘制
1
2
3
4
5
6
7
8
9
10
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//移动画布中心至图片中心点
canvas.translate(max / 2, max / 2);
//图片旋转
canvas.rotate(rate);
//画出图片
canvas.drawBitmap(loadingPic, -(picWidth / 2), -(picHeight / 2), paint);
}

至此,我们已经把对应的图片绘制到控件上了,一切都还比较常规,没有什么难度,接下来要处理的是动画部分。

  • 首先使用ValueAnimator来做一个将沙漏旋转180度的动画。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//开始动画
public void startAnimate() {
animator = ValueAnimator.ofFloat(0, 180);
animator.setDuration(650); //设置动画时长
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
rate = (float) animation.getAnimatedValue();
invalidate();
}
});
animator.start();
}

Loading动画是一个可循环的沙漏,也就是说,只要Loading界面没有关闭,需要一直循环下去。这部分在网络搜索了很久,但是一直没有找到合适的办法。

  1. 方法1 - TimerTask + Handler

第一次的尝试,使用TimerTask + Handler进行动画,间隔时间进行动画,这里面要注意一点,在TimerTask的run方法执行UI操作,会弹出异常,因为Android只允许在UI线程进行UI操作。所以需要Handler。

1
2
3
4
5
6
7
8
9
10
public void startAnimate() {
Timer timer = new Timer();
TimerTask timerTask = new TimerTask() {
@Override
public void run() {
rotate();
}
};
timer.schedule(timerTask, 100, 750);
}

这种方法虽然可以实现,但是十分的麻烦。所以换了一种思路。从ValueAnimator下手

  1. 方法2 - ValueAnimator.INFINITE

ValueAnimator中有一个方法setRepeatCount,可以将动画循环执行,经过测试,确实有效,但是,沙漏每旋转一次,中间都需要间隔停止一下,但是这个函数,并不可以设置每次动画循环的间隔。所以我们继续想办法。

1
animator.setRepeatCount(ValueAnimator.INFINITE);
  1. 方法3 - onAnimationEnd

经查阅资料,发现ValueAnimator可以添加监听,也就是addListener,在监听中,可以对动画的起止等操作进行监听,onAnimationEnd则是在动画执行结束时的监听。那么我们是不是可以,在动画结束之后,设置一个启示延迟,然后再执行动画,下一次动画结束时,仍然会触发这个监听,那么不就可以实现动画的循环了么。setStartDelay方法,可以设置ValueAnimator动画延迟多久开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//开始动画
public void startAnimate() {
animator = ValueAnimator.ofFloat(0, 180);
animator.setDuration(650); //设置动画时长
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
rate = (float) animation.getAnimatedValue();
invalidate();
}
});
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
animator.setStartDelay(300);
animator.start();
}
});
animator.start();
}

最后测试发现,动画可以循环执行,也达到了我所预期的效果

  1. 方法4 - AnticipateInterpolator

其实方法3已经是一个很优秀的解法了,在方法3测试的时候,使用的是LinearInterpolator的动画效果。但是经过测试,发现AnticipateInterpolator的动画效果更加的有趣。

AnticipateInterpolator是先回拉一下再进行正常动画轨迹,类似于投掷的动画效果,在旋转中也比较酷炫。但是由于动画变成了一组,也就是说,需要先0-180度旋转,在进行180-360度旋转,那么如何实现一组动画的实现和循环播放呢。
这个时候,就用到了AnimatorSet,它可以对一组ValueAnimator动画进行顺序或特殊需求的播放。但是AnimatorSet,不支持动画的循环播放,那怎么办呢,有着上一个监听器的经验,在AnimatorSet中,我们也发现了监听器的功能,那么就很简单了,在监听动画组播放结束之后,重新执行动画播放即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//一个带动感的动画
public void startFastAnimate() {
ValueAnimator animatorA = ValueAnimator.ofFloat(0, 180);
animatorA.setDuration(750);
animatorA.setStartDelay(200);
animatorA.setInterpolator(new AnticipateInterpolator());
animatorA.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
rate = (float) animation.getAnimatedValue();
invalidate();
}
});

ValueAnimator animatorB = ValueAnimator.ofFloat(180, 360);
animatorB.setDuration(750);
animatorB.setStartDelay(200);
animatorB.setInterpolator(new AnticipateInterpolator());
animatorB.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
rate = (float) animation.getAnimatedValue();
invalidate();
}
});

//设置动画链
final AnimatorSet set = new AnimatorSet();
set.playSequentially(animatorA, animatorB);
set.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
set.start();
}
});
set.start();
}

制作Loading载体Dialog

现在,我们就可以播放这个带动感的动画了。但是工作就完成了么,并不是,这只是个View,它的载体,也就是Dialog,需要设置一下。

  • 首先创建一个布局文件,用于放置Loading界面的布局样式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/widget_loading_background">

<com.dwzq.market.widget.loading.KLoadingView
android:id="@+id/k_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="40dp"
android:layout_marginTop="10dp"
android:layout_marginRight="40dp"
android:layout_marginBottom="10dp"
android:padding="5dp" />
</FrameLayout>
  • 浅灰色背景样式
1
2
3
4
5
6
7
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!--圆角弧度-->
<corners android:radius="6dp" />
<!--背景填充色-->
<solid android:color="#66091018" />
</shape>
  • 设置Dialog的主题Style
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--加载dialog的style-->
<style name="loading_dialog" parent="android:style/Theme.Dialog">
<!-- 是否有边框 -->
<item name="android:windowFrame">@null</item>
<!-- 是否带有标题 -->
<item name="android:windowNoTitle">true</item>
<!-- 提示框背景颜色 -->
<item name="android:windowBackground">@android:color/transparent</item>
<!-- 是否为浮动 -->
<item name="android:windowIsFloating">true</item>
<!-- 是否允许对话框的背景变暗 -->
<item name="android:backgroundDimEnabled">true</item>
<!-- 重叠在window内容区域的前景,用于在title下放置阴影 -->
<item name="android:windowContentOverlay">@null</item>
</style>
  • 之后就可以通过Dialog来对该Loading界面进行显示了,期间可以进行一些设置,比如,不可点击空白区域取消,设置背景不变暗,设置Loading界面半透明等操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void showLoading(Context context) {
Dialog dialog = new Dialog(context, R.style.loading_dialog);
View view = LayoutInflater.from(context).inflate(R.layout.widget_k_loading, null);
FrameLayout layout = view.findViewById(R.id.root_view);
dialog.setContentView(layout);
//设置外边空白区域不可点击
dialog.setCanceledOnTouchOutside(false);
Window window = dialog.getWindow();
if (window != null){
//设置背景透明
window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
//设置Loading外界面不变暗
WindowManager.LayoutParams lp = window.getAttributes();
lp.dimAmount =0f;
window.setAttributes(lp);
}
KLoadingView view = dialog.findViewById(R.id.k_loading);
//开始动画旋转
view.startAnimate();
dialog.show();
}

至此,我们就完成了简单的自定义Loading的实现。可以用在项目中了,收工!


一些Debug的补充

第一版设置Dialog的代码是这样的:

1
2
3
Dialog dialog = new Dialog(context);
dialog.setContentView(R.layout.widget_k_loading);
//省略设置逻辑

当时的测试机是Android 6和Android 9,并没有任何问题。但是在兼容性检查中,发现,在Android5.0上,Dialog的位置并非居中,而是靠左或靠右。初步判断是兼容性的问题,但是问题出现在哪里了呢?
百思不得解之后,我看了一下其他开源Loading库,对于Dialog部分的处理,发现了不同。

1
2
3
4
Dialog dialog = new Dialog(context, R.style.loading_dialog);
View view = LayoutInflater.from(context).inflate(R.layout.widget_k_loading, null);
FrameLayout layout = view.findViewById(R.id.root_view);
dialog.setContentView(layout);
  • 首先是在设置Dialog时,使用了带有Style的重载方法。
1
public Dialog(@NonNull Context context, @StyleRes int themeResId)

themeResId的注释描述为"a style resource describing the theme to use for the window",也就是描述窗口的主题资源。因为Dialog也是一个window,具体主题参数和解释见正文。

  • 第二个是,setContentView,不同于直接给定一个layoutRes,这里传递了一个View和LayoutParams
1
public void setContentView(@NonNull View view, @Nullable ViewGroup.LayoutParams params)

然而为什么会导致这种情况?在5.0的机器上,当注释掉setBackgroundDrawable时,发现了问题所在,实际上包裹LoadingView外层的布局出现了问题,变成了一个长条形的布局。

为了解释这个问题,需要依次查看不同部分的源码,看一下,到底有什么不同。

首先看一下setContentView,这个方法提供了三种重载。

1
2
3
public void setContentView(@LayoutRes int layoutResID)
public void setContentView(@NonNull View view)
public void setContentView(@NonNull View view, @Nullable ViewGroup.LayoutParams params)

方法1与23区别比较大,2就是带new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)默认值的3。

1
2
3
4
5
//来自PhoneWindow.java
@Override
public void setContentView(View view) {
setContentView(view, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
}

这也回答了一个问题,使用setContentView(View)的方式时,无论layout中根容器的宽高是什么,都按照WRAP_CONTENT的方式。而在setContentView(int)中,用户设置的布局,是会被添加到mContentParent中,而mContentParent是由generateLayout()生成的。在这种方式下可以直接通过在layout的根容器中指定宽、高来设置布局的尺寸。但是使用MATCH_PARENT,并不会产生效果。

但是这并不是导致Dialog显示偏移的原因。那来看一下new Dialog时添加的theme,是不是由于这个原因导致的。
查看Dialog构造器部分的代码,发现在没有给定themeId的时候,会使用默认的R.attr.dialogTheme

Android Dialog 部分源码

1
2
3
4
5
6
7
8
9
10
Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
if (createContextThemeWrapper) {
if (themeResId == ResourceId.ID_NULL) {
final TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
mContext = new ContextThemeWrapper(context, themeResId);
}
}

那么来看一下默认的theme实现是什么样的,或许就可以解释为什么在不同sdk版本下显示存在差异了。文件位置在android-sdk\platforms\android-<版本号>\data\res\values\themes.xml中,但是观察并没有发现差异。

在只继承系统Theme的情况下,5.0和9.0的表现是不同的,5.0是一个长条形的框,而9.0的尺寸是和LoadingView的尺寸相同的。

1
2
<style name="loading_dialog" parent="android:style/Theme.Dialog">
</style>

暂时没有更好的办法研究问题出现在哪里了,不过问题目前原因是清晰了,就是因为没有设置Dialog的Theme,与setContentView使用的参数无关。至于不同版本真正产生这个区别的原因,先记录下,等待一个答案的机会。

参考

https://www.cnblogs.com/chorm590/p/6854531.html

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×