Launcher添加左划负一屏

源码环境是android-API-28,计划在页面处于主页时左划进入负一屏,右滑退出负一屏。从三个方面推进,TouchEvent,Animation 和负一屏View。从Touch的ACTION_DOWN时拦截并处理touch事件,在ACTION_MOVE时拖拽View,在ACTION_UP/ACTION_CANCEL时根据拖拽距离完成剩余动画:返回主页或者前进到负一屏。

  • Touch event

    1. 通过源码研究发现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;
       }
      
    2. 参照原有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)};
          }
      ......
      
    3. 研究AbstractStateChangeTouchControlleronControllerInterceptTouchEvent

      @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更不会处理

    4. 那么在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;
        }
      
    5. 返回值是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的定义值就是关键钥匙。

    6. mScrollConditions的赋值:

      1. SwipeDetector#setDetectableScrollConditions(...)方法定义了这个值,而这个方法在AbstractStateChangeTouchController#onControllerInterceptTouchEvent()的ACTION_DOWN时就调用了。
      2. 根据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;
         }
        
    7. 手势抬起事件(ACTION_UP) :同ACTION_CANCEL,如果是dragging状态,则修改为SETTLING状态,最终调用AbstractStateChangeTouchController#onDragEnd(),根据当前拖动位置完成剩余动画,是回滚到主页,还是前进到负一屏。

    8. 至此touchEvent分发部分结束。接下来是animation。

  • Animation while dragging and drag-end

    1. 需要说明的是,传统意义上理解的动画其实只在onDragEnd时触发。在手指拖动屏幕时, 其实是一直在调用setProgress(float)方法直接做translationX位移而已。

    2. 假设最大滑动距离W是屏幕的宽度,即从0往左滑动W像素才能完全显示负一屏,(0-100%),那么每一像素的百分比就是1/W,这个值定义在initCurrentAnimation()方法内, 作为返回值给出。因此当手指拖动屏幕横向滑动距离X时,负一屏需要移动的百分比就是X/W。

    3. 在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上画此图作为背景。

你可能感兴趣的:(Launcher添加左划负一屏)