0%

网易新闻-日夜间切换动画

前言

最近在学习Android动画相关知识,正好公司视觉设计师设计了一个日夜间切换动画,个人感觉挺有趣,便着手实现。

分析

可以看到动画其实是像水波纹一样向外扩展,因此可以通过自定义View,不断向外画半径即可。整个实现过程主要分为四步(以日间切换到夜间为例):

  1. 先获取到日间的View的截图(通过API-View.getDrawingCache()实现)
  2. 将该截图盖到整个屏幕上
  3. 将主题改成夜间(此时整个主题就是夜间,但是因为盖了一层日间图,所以看到的表象是还处于日间)
  4. 不断用画笔画透明圆即可(PorterDuff.Mode.CLEAR)。

代码

代码实现非常简单

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
public class RippleView extends View {
public static final int ANIMATION_TYPE_SPREAD = 1;
public static final int ANIMATION_TYPE_SHRINK = 2;
public static final int ANIMATION_TYPE_INVALID = 3;
private Interpolator mInterpolator = new AccelerateDecelerateInterpolator();
private int mAnimationType = ANIMATION_TYPE_SPREAD;
private float mMaxRadius = 0.0f;
private float mClickX = 0.0f;
private float mClickY = 0.0f;
private int mDuration = 500;
private boolean mDrawParentAlready;
private float mClearRadius = 0;
private boolean[] mCacheState;
ValueAnimator mValueAnimator;
private Paint mClearPaint;
private View mParentView;
private Bitmap mBitmap;
ArrayList<RippleAnimatorListener> mListeners = null;
public RippleView(Context context, View parent) {
super(context);
this.mParentView = parent;
mClearPaint = new Paint();
mClearPaint.setColor(Color.TRANSPARENT);
mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
public void go() {
mClearRadius = 0;
mMaxRadius = getDefaultMaxRadius();
doAnimation(mAnimationType == ANIMATION_TYPE_SPREAD ? 0 : mMaxRadius,
mAnimationType == ANIMATION_TYPE_SPREAD ? mMaxRadius : 0);
}
public void setAnimationType(@AnimationType int animationType) {
this.mAnimationType = animationType;
}
public void addRippleAnimationListener(RippleAnimatorListener listener) {
if (mListeners == null) {
mListeners = new ArrayList<>();
}
mListeners.add(listener);
}
public void removeAllRippleListeners() {
if (mListeners != null) {
mListeners.clear();
mListeners = null;
}
}
public void setInterpolator(Interpolator interpolator) {
this.mInterpolator = interpolator;
}
public void setClickX(float clickX) {
this.mClickX = clickX;
}
public void setClickY(float clickY) {
this.mClickY = clickY;
}
public void setMaxRadius(float maxRadius) {
this.mMaxRadius = maxRadius;
}
public void setDuration(int duration) {
this.mDuration = duration;
}
@Override
protected void onDraw(Canvas canvas) {
/**
* step 1: capture parent view background.
* step 2: toggle theme.
* step 3: draw current view over whole window.
*/
drawParentIfNeed();
canvas.drawCircle(mClickX, mClickY, mClearRadius, mClearPaint);
}
/**
* Capture the snapshot of parent view and set current view background.
*/
private void drawParentIfNeed() {
if (!mDrawParentAlready) {
mCacheState = new boolean[2];
mBitmap = ImgUtil.getBitmapFromView(mParentView, mCacheState);
if (mBitmap != null && !mBitmap.isRecycled()) {
setBackgroundDrawable(new BitmapDrawable(mBitmap));
}
ThemeSettingsHelper.getInstance().toggleTheme();
mDrawParentAlready = true;
}
}
private void doAnimation(float start, float end) {
if (mValueAnimator != null) {
mValueAnimator.removeAllListeners();
mValueAnimator.removeAllUpdateListeners();
mValueAnimator = null;
}
mValueAnimator = ValueAnimator.ofFloat(start, end);
mValueAnimator.setDuration(mDuration);
mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mClearRadius = (float) animation.getAnimatedValue();
invalidate();
}
});
mValueAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
if (mListeners != null) {
for (RippleAnimatorListener listener : mListeners) {
if (listener != null) listener.onAnimationStart(animation);
}
}
}
@Override
public void onAnimationEnd(Animator animation) {
if (mListeners != null) {
for (RippleAnimatorListener listener : mListeners) {
if (listener != null) listener.onAnimationEnd(animation);
}
}
// Remember to recycle bitmap when animation end.
recycleBitmap();
}
@Override
public void onAnimationCancel(Animator animation) {
if (mListeners != null) {
for (RippleAnimatorListener listener : mListeners) {
if (listener != null) listener.onAnimationCancel(animation);
}
}
// Remember to recycle bitmap when animation cancel.
recycleBitmap();
}
@Override
public void onAnimationRepeat(Animator animation) {
if (mListeners != null) {
for (RippleAnimatorListener listener : mListeners) {
if (listener != null) listener.onAnimationRepeat(animation);
}
}
}
});
mValueAnimator.setInterpolator(mInterpolator);
mValueAnimator.start();
}
/**
* Recycle bitmap if need.
*/
private void recycleBitmap() {
if (mBitmap != null && !mBitmap.isRecycled()) {
mBitmap.recycle();
}
ImgUtil.restoreViewCacheState(mParentView, mCacheState);
}
@Override
protected void onDetachedFromWindow() {
removeAllRippleListeners();
if (mValueAnimator != null) {
mValueAnimator.removeAllListeners();
mValueAnimator.removeAllUpdateListeners();
mValueAnimator = null;
}
// Remember to recycle bitmap when view destroy.
recycleBitmap();
super.onDetachedFromWindow();
}
/**
* Get the max radius through four vertex (0,0), (0, Width),(height, 0), (width, height)
* in windows.
*
* @return max radius.
*/
protected float getDefaultMaxRadius() {
float screenHeight = SystemUtils.getScreenHeight();
float screenWidth = SystemUtils.getScreenWidth();
float line1 = (float) Math.sqrt((mClickX - 0) * (mClickX - 0) + (mClickY - 0) * (mClickY
- 0));
float line2 = (float) Math.sqrt((mClickX - screenWidth) * (mClickX - screenWidth) +
(mClickY - 0) * (mClickY - 0));
float line3 = (float) Math.sqrt((mClickX - 0) * (mClickX - 0) + (mClickY - screenHeight)
* (mClickY - screenHeight));
float line4 = (float) Math.sqrt((mClickX - screenWidth) * (mClickX - screenWidth) +
(mClickY - screenHeight) * (mClickY - screenHeight));
line1 = Math.max(line1, line2);
line1 = Math.max(line1, line3);
line1 = Math.max(line1, line4);
return line1;
}
@IntDef({ANIMATION_TYPE_SPREAD, ANIMATION_TYPE_SHRINK, ANIMATION_TYPE_INVALID})
@Retention(RetentionPolicy.SOURCE)
@interface AnimationType {
}
public interface RippleAnimatorListener {
void onAnimationStart(Animator animation);
void onAnimationEnd(Animator animation);
void onAnimationCancel(Animator animation);
void onAnimationRepeat(Animator animation);
}
}
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/**
*
* Usage:
<p>
RippleAnimationController controller = new RippleAnimationController.Builder(getActivity()).build();
controller.startAnimation();
</p>
*/
public class RippleAnimationController {
private @RippleView.AnimationType int mAnimationType = RippleView.ANIMATION_TYPE_SPREAD;
private Interpolator mInterpolator;
private RippleView mRippleView;
private Activity mActivity;
private float mMaxRadius;
private float mClickX;
private float mClickY;
private int mDuration;
public void startAnimation() {
if (mActivity == null) {
return;
}
final ViewGroup rootView = (ViewGroup) mActivity.getWindow()
.getDecorView().findViewById(android.R.id.content);
if (rootView == null) {
return;
}
if (mRippleView != null) {
mRippleView.clearAnimation();
mRippleView.removeAllRippleListeners();
mRippleView = null;
}
mRippleView = new RippleView(mActivity, rootView);
mRippleView.setAnimationType(mAnimationType);
if (mInterpolator != null) {
mRippleView.setInterpolator(mInterpolator);
}
if (Float.compare(mMaxRadius, 0.0f) > 0) {
mRippleView.setMaxRadius(mMaxRadius);
}
if (Float.compare(mClickX, 0.0f) >= 0) {
mRippleView.setClickX(mClickX);
}
if (Float.compare(mClickY, 0.0f) >= 0) {
mRippleView.setClickY(mClickY);
}
if (mDuration > 0) {
mRippleView.setDuration(mDuration);
}
rootView.addView(mRippleView);
mRippleView.addRippleAnimationListener(new RippleView.RippleAnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (rootView != null) {
rootView.removeView(mRippleView);
}
}
@Override
public void onAnimationCancel(Animator animation) {
if (rootView != null) {
rootView.removeView(mRippleView);
}
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
mRippleView.go();
}
private void setActivity(Activity activity) {
this.mActivity = activity;
}
private void setMaxRadius(float maxRadius) {
mMaxRadius = maxRadius;
}
private void setClickX(float clickX) {
mClickX = clickX;
}
private void setClickY(float clickY) {
mClickY = clickY;
}
private void setDuration(int duration) {
mDuration = duration;
}
private void setInterpolator(Interpolator interpolator) {
mInterpolator = interpolator;
}
private void setAnimationType(int animationType) {
mAnimationType = animationType;
}
public static class Builder {
@RippleView.AnimationType int mAnimationType = RippleView.ANIMATION_TYPE_SPREAD;
RippleAnimationController mController;
Interpolator mInterpolator;
Activity mActivity;
float mMaxRadius;
float mClickX;
float mClickY;
int mDuration;
public Builder(Activity activity) {
mController = new RippleAnimationController();
mActivity = activity;
}
public Builder setMaxRadius(float maxRadius) {
mMaxRadius = maxRadius;
return this;
}
public Builder setClickX(float clickX) {
mClickX = clickX;
return this;
}
public Builder setClickY(float clickY) {
mClickY = clickY;
return this;
}
public Builder setDuration(int duration) {
mDuration = duration;
return this;
}
public Builder setInterpolator(Interpolator interpolator) {
mInterpolator = interpolator;
return this;
}
public Builder setAnimationType(@RippleView.AnimationType int animationType) {
mAnimationType = animationType;
return this;
}
public RippleAnimationController build() {
mController.setAnimationType(mAnimationType);
mController.setInterpolator(mInterpolator);
mController.setMaxRadius(mMaxRadius);
mController.setActivity(mActivity);
mController.setDuration(mDuration);
mController.setClickX(mClickX);
mController.setClickY(mClickY);
return mController;
}
}
}

日夜间切换逻辑由RippleVIew控制,对外提供了RippleAnimationController来设置一些属性(动画时间、加速器等),并控制整个View显示和删除。当动画启动时,拿到Activity的根布局rootView

1
2
final ViewGroup rootView = (ViewGroup) mActivity.getWindow()
.getDecorView().findViewById(android.R.id.content);

然后生成一个RippleView,启动动画即可。

待优化

由于直接拿根布局View的bitmap,因此每次执行动画都消耗非常大的内存,如果是1080p(1080 X 1920)的屏幕则View-Bitmap大小为8b X 1080 X 1920 约为 8Mb,而中间又因为该bitmap有可能在还原根布局View状态时有可能被回收,为了保证不crash,RippleView又copy了一份bitmap,约为16Mb。

在运行过程中明显会有内存抖动,通过Android Monitors可以看到有一个尖波峰,动画结束后,又立马下降。后面会尽量考虑把中间copy那份bitmap去掉,但每次执行动画仍会分配8Mb,仍会出现内存抖动,暂时没有想到比较好的方案。

不过从真机来看,效果还是挺流畅的。