SystemUI 拖拽事件分析

求你指教我们怎样数算自己的日子,好叫我们得着智慧的心。----诗篇90:12

之前写过两篇关于SystemUI的文章:
SystemUI之功能介绍和UI布局实现
SystemUI之呈现流程
本篇分析下SystemUI 拖拽事件处理的过程。

他山之石可以攻玉,通过本篇的分析力求能触摸到Android团队对复杂view的处理技巧,以便今后我们也能在自己的项目里运用上这些技巧。
着重分析下面几个知识点

  • 自定义View的高效布局方式,onMesure,onLayout—onDraw如何实现技巧
  • onTouchEvent—onIntecept—onDispach如何运用,手势监听处理逻辑
  • 代码的封装性

开胃小菜---点击事件

如果对SystemUI布局结构不了解,请先参考之前的文章SystemUI之功能介绍和UI布局实现 ,我们先挑个软柿子捏捏,看看下图示意的点击事件是如何处理的。
这里写图片描述
在放上SystemUI的布局图

SystemUI 拖拽事件分析_第1张图片
这里写图片描述

这里主要分析两块:

点击顶部,如何控制状态栏伸缩

根据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拖拽已经占据了大部分篇幅,如果在介绍框架跟设计,恐怕篇幅会又臭又长。自己能力跟精力有限,本篇只好草草收场。

写作的过程纠结无比,想推倒重新再来,却又不甘心放弃已经写成的前半部分。所谓"食之无味,弃之可惜"。恐怕读的人也感觉无趣。希望读的有心人能多提些好的写作建议,不甚感激。

你可能感兴趣的:(SystemUI 拖拽事件分析)