SystemUI 拖拽事件分析

weixin_33866037發表於2017-10-27

求你指教我們怎樣數算自己的日子,好叫我們得著智慧的心。----詩篇90:12

之前寫過兩篇關於SystemUI的文章:
SystemUI之功能介紹和UI佈局實現
SystemUI之呈現流程
本篇分析下SystemUI 拖拽事件處理的過程。

他山之石可以攻玉,通過本篇的分析力求能觸控到Android團隊對複雜view的處理技巧,以便今後我們也能在自己的專案裡運用上這些技巧。
著重分析下面幾個知識點

  • 自定義View的高效佈局方式,onMesure,onLayout—onDraw如何實現技巧
  • onTouchEvent—onIntecept—onDispach如何運用,手勢監聽處理邏輯
  • 程式碼的封裝性

開胃小菜---點選事件

如果對SystemUI佈局結構不瞭解,請先參考之前的文章SystemUI之功能介紹和UI佈局實現 ,我們先挑個軟柿子捏捏,看看下圖示意的點選事件是如何處理的。
這裡寫圖片描述
在放上SystemUI的佈局圖

2912789-51323d4fd8f31e60
這裡寫圖片描述

這裡主要分析兩塊:

點選頂部,如何控制狀態列伸縮

根據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拖拽時的樣子。


2912789-965963c0bf03284d
這裡寫圖片描述

可以看到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後的效果


2912789-3e0546f90611870b
這裡寫圖片描述

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拖拽已經佔據了大部分篇幅,如果在介紹框架跟設計,恐怕篇幅會又臭又長。自己能力跟精力有限,本篇只好草草收場。

寫作的過程糾結無比,想推倒重新再來,卻又不甘心放棄已經寫成的前半部分。所謂"食之無味,棄之可惜"。恐怕讀的人也感覺無趣。希望讀的有心人能多提些好的寫作建議,不甚感激。

相關文章