求你指教我們怎樣數算自己的日子,好叫我們得著智慧的心。----詩篇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拖拽已經占據了大部分篇幅,如果在介紹框架跟設計,恐怕篇幅會又臭又長。自己能力跟精力有限,本篇只好草草收場。
寫作的過程糾結無比,想推倒重新再來,卻又不甘心放棄已經寫成的前半部分。所謂"食之無味,棄之可惜"。恐怕讀的人也感覺無趣。希望讀的有心人能多提些好的寫作建議,不甚感激。