源码环境是android-API-28,计划在页面处于主页时左划进入负一屏,右滑退出负一屏。从三个方面推进,TouchEvent,Animation 和负一屏View。从Touch的ACTION_DOWN时拦截并处理touch事件,在ACTION_MOVE时拖拽View,在ACTION_UP/ACTION_CANCEL时根据拖拽距离完成剩余动画:返回主页或者前进到负一屏。
-
Touch event
-
通过源码研究发现touch事件的分发与传递是从第二层布局(DragLayer)的onInterceptTouchEvent(MotionEvent)开始的,
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction(); ...... return findActiveController(ev); } protected boolean findActiveController(MotionEvent ev) { mActiveController = null;...... for (TouchController controller : mControllers) { if (controller.onControllerInterceptTouchEvent(ev)) { mActiveController = controller; return true; } } return false; }
-
参照原有launcher3的touch事件处理思路,我们自定义一个TouchController去鉴别处理水平方向的手势,然后注册到UIFactory中。
public class QuickSearchController extends AbstractStateChangeTouchController{ QuickSearchController(Launcher l) { super(l, SwipeDetector.HORIZONTAL); //SwipeDetector.HORIZONTAL 筛选出水平方向的手势,作用原理在下方会说明 }...... public class UiFactory { public static TouchController[] createTouchControllers(Launcher launcher) { boolean swipeUpEnabled = OverviewInteractionState.getInstance(launcher) .isSwipeUpGestureEnabled(); if (!swipeUpEnabled) { return new TouchController[] { launcher.getDragController(), new QuickSearchController(launcher), new OverviewToAllAppsTouchController(launcher), new LauncherTaskViewController(launcher)}; } ......
-
研究
AbstractStateChangeTouchController
的onControllerInterceptTouchEvent
:@Override public final boolean onControllerInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { mNoIntercept = !canInterceptTouch(ev); if (mNoIntercept) { return false; } // Now figure out which direction scroll events the controller will start // calling the callbacks. final int directionsToDetectScroll; boolean ignoreSlopWhenSettling = false; if (mCurrentAnimation != null) { directionsToDetectScroll = SwipeDetector.DIRECTION_BOTH; ignoreSlopWhenSettling = true; } else { directionsToDetectScroll = getSwipeDirection(); if (directionsToDetectScroll == 0) { mNoIntercept = true; return false; } } mDetector.setDetectableScrollConditions(directionsToDetectScroll, ignoreSlopWhenSettling); } if (mNoIntercept) { return false; } onControllerTouchEvent(ev); return mDetector.isDraggingOrSettling(); }
可得出如下结论:
1.自定义的子类需要关注
canInterceptTouch()
的实现,并在正确的场景下返回true以拦截事件使其不会往后往下分发。
2.如果想拦截Touch事件,getSwipeDirection()
不能返回0。
3.如果actionDown时canInterceptTouch()
没有返回true,后续move与up更不会处理 -
那么在QuickSearchController实现如下两个方法
@Override protected boolean canInterceptTouch(MotionEvent ev) { if (mCurrentAnimation != null ) { // If we are already animating from a previous state, we can intercept. return true; }下面的判断是为了防止频繁快速滑动时currentScreen计算错误导致bug if(mLauncher.getWorkspace().isPageInTransition()){ return false; }当处于普通状态的第一页或者搜索页时,请求拦截此事件 return (mLauncher.getCurrentWorkspaceScreen() ==0 && mLauncher.isInState(NORMAL)) || mLauncher.isInState(STATE_SEARCH); } //以下方法由getSwipeDirection()调用,只要返回值和fromState不同即代表想拦截 @Override protected LauncherState getTargetState(LauncherState fromState, boolean isDragTowardPositive) { touch点的坐标先小后大是 Negative,先大后小是Positive,即isDragTowardPositive代表右去,反之代表左去 if(isDragTowardPositive && fromState == STATE_SEARCH){ return NORMAL; }else if(!isDragTowardPositive && fromState == NORMAL && mLauncher.getCurrentWorkspaceScreen() == 0){ return STATE_SEARCH; } return fromState; }
-
返回值是
mDetector.isDraggingOrSettling()
,因此在ACTION_DOWN时,最终return了false,而在ACTIONMOVE时,我们需要mDetector把state修改为dragging从而拦截此事件使其不再向子view分发。参考代码 SwipeDetector.java:public boolean onTouchEvent(MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_MOVE: int pointerIndex = ev.findPointerIndex(mActivePointerId); if (pointerIndex == INVALID_POINTER_ID) { break; } mDisplacement = mDir.getDisplacement(ev, pointerIndex, mDownPos); computeVelocity(mDir.getDisplacement(ev, pointerIndex, mLastPos), ev.getEventTime()); // handle state and listener calls. if (mState != ScrollState.DRAGGING && shouldScrollStart(ev, pointerIndex)) { setState(ScrollState.DRAGGING); } if (mState == ScrollState.DRAGGING) { reportDragging(); } mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex)); break; ...... } public static final int DIRECTION_POSITIVE = 1 << 0; public static final int DIRECTION_NEGATIVE = 1 << 1; private boolean shouldScrollStart(MotionEvent ev, int pointerIndex) { 如果move距离小于8dp,忽略 if (Math.max(mDir.getActiveTouchSlop(ev, pointerIndex, mDownPos), mTouchSlop) > Math.abs(mDisplacement)) { return false; } 关注mScrollConditions的位运算。对于一次水平滑动来说: 如果是从左到右的滑动,那么mScrollConditions必须包含DIRECTION_NEGATIVE才能开始drag 如果是从右到左的滑动,那么mScrollConditions必须包含DIRECTION_POSITIVE才能开始drag if (((mScrollConditions & DIRECTION_NEGATIVE) > 0 && mDisplacement > 0) || ((mScrollConditions & DIRECTION_POSITIVE) > 0 && mDisplacement < 0)) { return true; } return false; }
可以看到要想改变成dragging状态,
shouldScrollStart()
方法必须返回true才行,因此mScrollConditions的定义值就是关键钥匙。 -
mScrollConditions的赋值:
-
SwipeDetector#setDetectableScrollConditions(...)
方法定义了这个值,而这个方法在AbstractStateChangeTouchController#onControllerInterceptTouchEvent()
的ACTION_DOWN时就调用了。 - 根据
mScrollConditions = getSwipeDirection()
方法,实现getTargetState(),参阅第4步,在想要生效的状态时返回不同于原始参数的返回值即可。class AbstractStateChangeTouchController{... private int getSwipeDirection() { LauncherState fromState = mLauncher.getStateManager().getState(); int swipeDirection = 0; if (getTargetState(fromState, true /* isDragTowardPositive */) != fromState) { swipeDirection |= SwipeDetector.DIRECTION_POSITIVE; } if (getTargetState(fromState, false /* isDragTowardPositive */) != fromState) { swipeDirection |= SwipeDetector.DIRECTION_NEGATIVE; } return swipeDirection; }
-
手势抬起事件(ACTION_UP) :同ACTION_CANCEL,如果是dragging状态,则修改为SETTLING状态,最终调用
AbstractStateChangeTouchController#onDragEnd()
,根据当前拖动位置完成剩余动画,是回滚到主页,还是前进到负一屏。至此touchEvent分发部分结束。接下来是animation。
-
-
Animation while dragging and drag-end
需要说明的是,传统意义上理解的动画其实只在onDragEnd时触发。在手指拖动屏幕时, 其实是一直在调用setProgress(float)方法直接做translationX位移而已。
假设最大滑动距离W是屏幕的宽度,即从0往左滑动W像素才能完全显示负一屏,(0-100%),那么每一像素的百分比就是1/W,这个值定义在initCurrentAnimation()方法内, 作为返回值给出。因此当手指拖动屏幕横向滑动距离X时,负一屏需要移动的百分比就是X/W。
-
在AbstractStateChangeTouchController#onDrag(...)方法中调用updateProgress(fraction),然后AnimatorPlaybackControllerVL#setPlayFraction(fraction),通过传入的float值计算出当前应当处于的动画位置,然后更新动画进度。这里我仿照AllAppsTransitionController定义一个水平方向的动画控制器,修改动画的targetView和setProgress(progress)方法。
class QuickSearchAnimController implements StateHandler { copy from AllAppsTransitionController private float mShiftRange = Util.SCREEN_WIDTH; // changes depending on the orientation public void setProgress(float progress) { float shiftCurrent = progress * mShiftRange; 左滑负一屏进入时的translateX变化,由-screenWidth到0,退出时由0到-screenWidth mMinusOnePageView.setTranslationX(shiftCurrent - mShiftRange); 主页控件的translateX变化,由0到screenWidth,负一屏退出时由screenWidth变到0 mLauncher.getWorkspace().setTranslationX(shiftCurrent); mLauncher.getWorkspace().getPageIndicator().setTranslationX(shiftCurrent); mLauncher.getHotseat().setTranslationX(shiftCurrent); }
-
View
简单实现了一下,自定义一个frameLayout,包含一个输入框EditText即可。我这里使用了仿苹果高斯模糊透明度渐变的效果。有几个注意点记录如下- FrameLayout如果需要铺满至全屏幕(包含状态栏和导航栏),需要设置
launcher:layout_ignoreInsets="true"
,或者实现接口Insettable,具体原因参考源码LauncherRootView#fitSystemWindows(Rect)
。 - 使用的源图来自wallPaper,需要有READ_EXTERNAL_STORAGE权限,且需要注意性能。毕竟在拖拽时频繁生成bitmap并渲染是很容易导致卡顿。可采用原图缩小后作为blur的原图使用以减小内存消耗。
- 高斯模糊采用原生RenderScript,透明度渐变思路是:先将原图做blur处理,做完后根据拖拽进度,更改画笔的alpha,然后在自定义frameLayout上画此图作为背景。
- FrameLayout如果需要铺满至全屏幕(包含状态栏和导航栏),需要设置