SystemUI 拖拽事件分析
求你指教我们怎样数算自己的日子,好叫我们得着智慧的心。----诗篇90:12
之前写过两篇关于SystemUI的文章:
SystemUI之功能介绍和UI布局实现
SystemUI之呈现流程
本篇分析下SystemUI 拖拽事件处理的过程。
他山之石可以攻玉,通过本篇的分析力求能触摸到Android团队对复杂view的处理技巧,以便今后我们也能在自己的项目里运用上这些技巧。
着重分析下面几个知识点
自定义View的高效布局方式,onMesure,onLayout—onDraw如何实现技巧onTouchEvent—onIntecept—onDispach如何运用,手势监听处理逻辑代码的封装性
开胃小菜---点击事件
如果对SystemUI布局结构不了解,请先参考之前的文章SystemUI之功能介绍和UI布局实现 ,我们先挑个软柿子捏捏,看看下图示意的点击事件是如何处理的。
这里写图片描述
在放上SystemUI的布局图
这里主要分析两块:
点击顶部,如何控制状态栏伸缩
根据SystemUI的布局图,很容易找到点击事件入口是在NotificationPanelView的onClick里。
@Override
public void onClick(View v) {
if (v == mHeader) {
onQsExpansionStarted();
if (mQsExpanded) {
flingSettings(0 /* vel */, false /* expand */, null, true /* isClick */);
} else if (mQsExpansionEnabled) {
EventLogTags.writeSysuiLockscreenGesture(
EventLogConstants.SYSUI_TAP_TO_OPEN_QS,
0, 0);
flingSettings(0 /* vel */, true /* expand */, null, true /* isClick */);
}
}
}
主要的事件处理被封装在了flingSettings方法中,
private void flingSettings(float vel, boolean expand, final Runnable onFinishRunnable,
boolean isClick) {
float target = expand ? mQsMaxExpansionHeight : mQsMinExpansionHeight;
//忽略非主要代码
ValueAnimator animator = ValueAnimator.ofFloat(mQsExpansionHeight, target);
if (isClick) {
animator.setInterpolator(mTouchResponseInterpolator);
animator.setDuration(368);
} else {
mFlingAnimationUtils.apply(animator, mQsExpansionHeight, target, vel);
}
//忽略非主要代码
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
setQsExpansion((Float) animation.getAnimatedValue());
}
});
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mScrollView.setBlockFlinging(false);
mScrollYOverride = -1;
mQsExpansionAnimator = null;
if (onFinishRunnable != null) {
onFinishRunnable.run();
}
}
});
animator.start();
mQsExpansionAnimator = animator;
mQsAnimatorExpand = expand;
}
这里使用属性动画在onAnimationUpdate回调里控制状态栏收缩,设置了addUpdateListener监听器监听动画执行过程中值的变化,同时设置AnimatorListenerAdapter监听动画结束。
Tips:
如果只需要监听动画的某一个事件,比如结束事件,应该设置AnimatorListenerAdapter监听器,这样就只用实现需要的事件,如果设置的是AnimatorListener监听器,那么就不得不全部复写onAnimationStart/onAnimationRepeat/onAnimationEnd等回调事件,即使你只想要监听其中的一个回调事件。
在onAnimationUpdate回调里,可以拿到状态栏的当前高度,再来看看
setQsExpansion((Float) animation.getAnimatedValue())的执行情况,该方法又调用setQsTranslation(height)方法,在其中调用了mQsContainer.setY(height - mQsContainer.getDesiredHeight() + getHeaderTranslation())
语句,这个也就是状态栏的伸缩实现。
顶部view里的设置、时钟小图标如何跟随变化
顶部view里内容的变换同样也是在NotificationPanelView的setQsExpansion方法中实现。
frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java
private void setQsExpansion(float height) {
height = Math.min(Math.max(height, mQsMinExpansionHeight), mQsMaxExpansionHeight);
mQsFullyExpanded = height == mQsMaxExpansionHeight;
if (height > mQsMinExpansionHeight && !mQsExpanded && !mStackScrollerOverscrolling) {
setQsExpanded(true);
} else if (height <= mQsMinExpansionHeight && mQsExpanded) {
setQsExpanded(false);
if (mLastAnnouncementWasQuickSettings && !mTracking && !isCollapsing()) {
announceForAccessibility(getKeyguardOrLockScreenString());
mLastAnnouncementWasQuickSettings = false;
}
}
mQsExpansionHeight = height;
mHeader.setExpansion(getHeaderExpansionFraction());
setQsTranslation(height);
...
先调用setQsExpanded(boolean expanded)方法,最终通过动态更改布局参数,达到顶部view的整体收缩和拉伸。
调用方法链如下:
setQsExpanded---->
updateQsState---->
StatusBarHeaderView.setExpanded---->
StatusBarHeaderView.updateEverything---->
StatusBarHeaderView.updateHeights.
frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarHeaderView.java
private void updateHeights() {
int height = mExpanded ? mExpandedHeight : mCollapsedHeight;
ViewGroup.LayoutParams lp = getLayoutParams();
if (lp.height != height) {
lp.height = height;
setLayoutParams(lp);
}
}
顶部view整体的收缩看完了,在关注下顶部View的一个细节---MaterialDesign风格的立体效果是如何实现的。
StatusBarHeaderView.setExpansion-->StatusBarHeaderView.setExpansion-->StatusBarHeaderView.setClipping
frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarHeaderView.java
private void setClipping(float height) {
mClipBounds.set(getPaddingLeft(), 0, getWidth() - getPaddingRight(), (int) height);
setClipBounds(mClipBounds);
invalidateOutline();
}
接着在分析内部小控件是如何变换的。同样从setExpansion看起。
setExpansion-->updateLayoutValues-->StatusBarHeaderView$LayoutValues.interpoloate-->applyLayoutValues
上面这条调用关系链都在StatusBarHeaderView里实现。看下interpoloate和applyLayoutValues方法
private static final class LayoutValues {
float timeScale = 1f;
float clockY;
float dateY;
...
public void interpoloate(LayoutValues v1, LayoutValues v2, float t) {
timeScale = v1.timeScale * (1 - t) + v2.timeScale * t;
clockY = v1.clockY * (1 - t) + v2.clockY * t;
dateY = v1.dateY * (1 - t) + v2.dateY * t;
...
}
}
private void applyLayoutValues(LayoutValues values) {
mTime.setScaleX(values.timeScale);
mTime.setScaleY(values.timeScale);
mClock.setY(values.clockY - mClock.getHeight());
mDateGroup.setY(values.dateY);
interpoloate方法先计算出缩放比例和透明度比例,然后在applyLayoutValues对控件做缩放处理。
以上分析完了状态栏伸缩的实现。其分析时用的代码基于Android5.0。Android7.0上SystemUI状态栏又发生了变化。
Android7.0上SystemUI拖拽实现
我们先看看Android7.0上SystemUI拖拽时的样子。
这里写图片描述
可以看到Android7.0上向上拖拽时,快捷小图标非常炫酷移动效果,下面来看看其如何实现。
根据SystemUI的布局图快捷小图标的父类视图为QSContainer,因此小图标的变化很可能在其中实现,查看其中的方法,在onFinishInflate()方法中有一个QSAnimator对象,onFinishInflate()方法在视图全部加载完成后会调用,而QSAnimator在SystemUI中是QuickSettingAnimator的缩写,这样看来动画的实现多半是在QSAnimator中实现。
frameworks/base/packages/SystemUI/src/com/android/systemui/qs/QSAnimator.java
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
int oldTop, int oldRight, int oldBottom) {
mQsPanel.post(mUpdateAnimators);
}
继续跟踪mUpdateAnimators来到了updateAnimators(),
private void updateAnimators() {
//...
for (QSTile<?> tile : tiles) {
//...
if (count < mNumQuickTiles && mAllowFancy) {
//...
// Move the quick tile right from its location to the new one.
translationXBuilder.addFloat(quickTileView, "translationX", 0, xDiff);
translationYBuilder.addFloat(quickTileView, "translationY", 0, yDiff);
// Counteract the parent translation on the tile. So we have a static base to
// animate the label position off from.
firstPageBuilder.addFloat(tileView, "translationY", mQsPanel.getHeight(), 0);
// Move the real tile's label from the quick tile position to its final
// location.
translationXBuilder.addFloat(label, "translationX", -xDiff, 0);
translationYBuilder.addFloat(label, "translationY", -yDiff, 0);
//...
}
}
if (mAllowFancy) {
//...
PathInterpolatorBuilder interpolatorBuilder = new PathInterpolatorBuilder(0, 0, 0, 1);
translationXBuilder.setInterpolator(interpolatorBuilder.getXInterpolator());
translationYBuilder.setInterpolator(interpolatorBuilder.getYInterpolator());
mTranslationXAnimator = translationXBuilder.build();
mTranslationYAnimator = translationYBuilder.build();
}
}
以上代码通过mNumQuickTiles来确定动画结束后小图标的个数,默认为5,可以同过对settings数据库中的sysui_qqs_count字段来配置,而mAllowFancy决定是否开启动画效果。
来看看将mNumQuickTiles设置成7,关闭mAllowFancy后的效果
这里写图片描述
Tips:
更改settings数据库中某个字段的值,可以用类似如下的快捷方式:
adb shell settings put secure sysui_qqs_count 7
以上我们理清了Android7.0上拖拽动画的实现过程。细节方面还有一些疑惑。
动画是如何动起来的
translationXBuilder是TouchAnimator类中的一个静态类Builder,其build()方法返回的是一个TouchAnimator对象。
frameworks/base/packages/SystemUI/src/com/android/systemui/qs/TouchAnimator.java
public class TouchAnimator {
public static class Builder {
//...
public TouchAnimator build() {
return new TouchAnimator(mTargets.toArray(new Object[mTargets.size()]),
mValues.toArray(new KeyframeSet[mValues.size()]),
mStartDelay, mEndDelay, mInterpolator, mListener);
}
}
}
TouchAnimator是对动画类的封装,而其内建的Builder又是对动画参数的配置,那么问题来了,build方法直接返回了一个TouchAnimator对象,并没有看到其start动画,动画的所有参数已经配置好了,其已经处于就绪状态,它在何处被start呢?
为了弄清楚translationXBuilder到底如何工作的,在回到updateAnimators方法中,看看
translationXBuilder.addFloat(quickTileView, "translationX", 0, xDiff);
到底做了什么。
public Builder addFloat(Object target, String property, float... values) {
add(target, KeyframeSet.ofFloat(getProperty(target, property, float.class), values));
return this;
}
这里的getProperty是个什么鬼
private static Property getProperty(Object target, String property, Class<?> cls) {
if (target instanceof View) {
switch (property) {
case "translationX":
return View.TRANSLATION_X;
case "translationY":
return View.TRANSLATION_Y;
case "translationZ":
return View.TRANSLATION_Z;
case "alpha":
return View.ALPHA;
case "rotation":
return View.ROTATION;
case "x":
return View.X;
case "y":
return View.Y;
case "scaleX":
return View.SCALE_X;
case "scaleY":
return View.SCALE_Y;
}
}
if (target instanceof TouchAnimator && "position".equals(property)) {
return POSITION;
}
return Property.of(target.getClass(), cls, property);
}
这种用法还第一次见到,厉害了我的谷歌哥!
我们传入的是quickTileView,getProperty根据属性返回给了对应的View.TRANSLATION_X,接着KeyframeSet.ofFloat new出一个FloatKeyframeSet对象,最后传入的quickTileView对象被存放在mTargets list中,FloatKeyframeSet对象被存放在mValues list中。
view有了,动画属性也设置进来了,最后动画属性如何被设置到view上呢?原来动画设置被隐藏在FloatKeyframeSet中
@Override
protected void interpolate(int index, float amount, Object target) {
float firstFloat = mValues[index - 1];
float secondFloat = mValues[index];
mProperty.set((T) target, firstFloat + (secondFloat - firstFloat) * amount);
}
关键的mProperty.set语句实际上就相当于:
View.TRANSLATION_X.set(view, 100f);
它的主要调用过程如下:
NotificationPanelView.updateQsExpansion
---->QSContainer.setQsExpansion
---->QSAnimator.setPosition(expansion)
---->TouchAnimator.setPosition(position)
---->mKeyframeSets[i].setValue(t, mTargets[i])
---->mProperty.set((T) target, firstFloat + (secondFloat - firstFloat) * amount);
后记
本篇博文的前半部分实际上早几个月已经完成了,当时计划本篇重点要阐述SystemUI的主体框架以及其中精妙的代码设计。UI上的拖拽动画只是作为开胃小菜顺带入题用的。但计划总被各种事情打断,当前也早已经不负责SystemUI模块的问题了,UI拖拽已经占据了大部分篇幅,如果在介绍框架跟设计,恐怕篇幅会又臭又长。自己能力跟精力有限,本篇只好草草收场。
写作的过程纠结无比,想推倒重新再来,却又不甘心放弃已经写成的前半部分。所谓"食之无味,弃之可惜"。恐怕读的人也感觉无趣。希望读的有心人能多提些好的写作建议,不甚感激。