工作需要一个自定义的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); }
|
由于我们是要对控件进行旋转,所以需要取出旋转的最大直径作为控件的宽高,防止旋转中,图案被遮挡。
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(); max = (int) Math.sqrt(picWidth * picWidth + picHeight * picHeight); setMeasuredDimension(max, max); }
|
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 - 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下手
- 方法2 - ValueAnimator.INFINITE
ValueAnimator中有一个方法setRepeatCount,可以将动画循环执行,经过测试,确实有效,但是,沙漏每旋转一次,中间都需要间隔停止一下,但是这个函数,并不可以设置每次动画循环的间隔。所以我们继续想办法。
1
| animator.setRepeatCount(ValueAnimator.INFINITE);
|
- 方法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(); }
|
最后测试发现,动画可以循环执行,也达到了我所预期的效果
- 方法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>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <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> <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)); 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
| @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