Flutter TabBar 与原生ViewPager的滑动冲突

2,293 阅读3分钟

背景

最近有个比较麻烦的需求,是Android页面是ViewPager,包含两个fragment,第一个是Native的Fragment,第二个是FlutterFragment,FlutterFragment中包含TabBar和TabView。 默认情况下会存在事件冲突,当滑到FlutterFragment的时候,会发现FlutterFragment里的TabView无法横向滑动了,因为事件都被Native的ViewPager拦截了。

修改记录

1. 1.0方案是在down事件的时候,通过channnel+requestParentDisallowInterceptTouchEvent(true);来阻止ViewPager拦截事件,但这个方法很容易导致拦截失败,因为channel通信是需要时间的,如果在这个时间内move事件先满足ViewPager的滑动距离,那ViewPager就先触发拦截了
2. 优化后可以参考Native对Viewpager嵌套ViewPager的处理方式

原因分析

首先看ViewPager是怎么拦截事件的?通过阅读ViewPager源码会发现,ViewPager实现了onInterceptTouchEvent和onTouchEvent,Android Native的事件拦截这里就不赘述了,这里简单说下(以下源码只挑重点)

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    final int action = ev.getAction() & MotionEvent.ACTION_MASK;
    switch (action) {
        case MotionEvent.ACTION_MOVE: {
        if (dx != 0 && !isGutterDrag(mLastMotionX, dx)
                        && canScroll(this, false, (int) dx, (int) x, (int) y)) {
                    // Nested view has scrollable area under this point. Let it be handled there.
                    mLastMotionX = x;
                    mLastMotionY = y;
                    mIsUnableToDrag = true;
                    return false;
                }
            // 横向拖动距离大于最小拖动距离,且横向拖动距离*0.5>纵向
            if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {
                if (DEBUG) Log.v(TAG, "Starting drag!");
                mIsBeingDragged = true;
                // 拦截事件后,通知父亲不要拦截我的事件
                requestParentDisallowInterceptTouchEvent(true);
                setScrollState(SCROLL_STATE_DRAGGING);
                mLastMotionX = dx > 0
                        ? mInitialMotionX + mTouchSlop : mInitialMotionX - mTouchSlop;
                mLastMotionY = y;
                setScrollingCacheEnabled(true);
            } else if (yDiff > mTouchSlop) {
                // The finger has moved enough in the vertical
                // direction to be counted as a drag...  abort
                // any attempt to drag horizontally, to work correctly
                // with children that have scrolling containers.
                if (DEBUG) Log.v(TAG, "Starting unable to drag!");
                mIsUnableToDrag = true;
            }
            if (mIsBeingDragged) {
                // Scroll to follow the motion event
                if (performDrag(x)) {
                    ViewCompat.postInvalidateOnAnimation(this);
                }
            }
            break;
        }
        // 当mIsBeingDragged = true时,viewpager就拦截事件了
      return mIsBeingDragged;
}

注意看到在ViewPager的onInterceptTouchEvent里,调用了canScroll方法

// 这个方法的作用就是递归遍历所有子View,如果子view可以横向滚动(canScrollHorizontally),那就返回true,viewPager就不会在拦截了
 protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {
        if (v instanceof ViewGroup) {
            final ViewGroup group = (ViewGroup) v;
            final int scrollX = v.getScrollX();
            final int scrollY = v.getScrollY();
            final int count = group.getChildCount();
            // Count backwards - let topmost views consume scroll distance first.
            for (int i = count - 1; i >= 0; i--) {
                // TODO: Add versioned support here for transformed views.
                // This will not work for transformed views in Honeycomb+
                final View child = group.getChildAt(i);
                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight()
                        && y + scrollY >= child.getTop() && y + scrollY < child.getBottom()
                        && canScroll(child, true, dx, x + scrollX - child.getLeft(),
                                y + scrollY - child.getTop())) {
                    return true;
                }
            }
        }

        return checkV && v.canScrollHorizontally(-dx);
    }

接着看v.canScrollHorizontally(-dx),ViewPager复写了canScrollHorizontally

mFirstOffset = 0,mLastOffset viewpager的length
假设viewpager的length=4,vieapager充满屏幕=1080
向右滑动(direction < 0)
向左滑动 (direction > 0)
当viewpager处在第一个时,scrollX=0,向右滑动,direction < 0,(scrollX > (int) (width mFirstOffset));返回false,向左滑动,direction > 0,(scrollX < (int) (width mLastOffset)); 返回true
viewpager处在最后一个时,scrollX = width * mLastOffset,向右滑动,返回false,向左滑动,返回ture

 @Override
    public boolean canScrollHorizontally(int direction) {
        if (mAdapter == null) {
            return false;
        }

        final int width = getClientWidth();
        final int scrollX = getScrollX();
        if (direction < 0) {
            return (scrollX > (int) (width * mFirstOffset));
        } else if (direction > 0) {
            return (scrollX < (int) (width * mLastOffset));
        } else {
            return false;
        }
    }

再看看Flutter Native侧的处理,Flutter - Android端转发事件到flutter的代码在FlutterView里,可以看到FlutterView实现了onTouchEvent方法

@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
    // 没有attach到引擎就往上抛
  if (!isAttachedToFlutterEngine()) {
    return super.onTouchEvent(event);
  }
    //事件实际的处理在androidTouchProcessor.onTouchEven里
  return androidTouchProcessor.onTouchEvent(event);
}


// AndroidTouchProcessor onTouchEvent方法,核心逻辑是对事件的封装,然后发送给flutter
public boolean onTouchEvent(@NonNull MotionEvent event) {
  // 省略逻辑,不是本文重点,可以看到最后返回了true
  return true;
}

可以看到flutterView只是实现了onTouchEvent方法并返回了true, 那对于Viewpager来说,在move事件的时候肯定就把事件拦截了,所以从横向move事件开始flutter就收不到事件了(注意flutter还是能收到down事件的)

至此,问题已经定位到了,那怎么解决呢?解决可以从两个点着手

  1. 第一我们需要知道flutter里的tabview当前是第几个位置,而且还和flutterFrament在viewpager中的位置相关,假设flutterFrament是在第一个位置,那我们只要保证tabview滑到最后一个位置,且手势是往左滑的时候,把事件交给viewPager处理,其他情况都要flutterView拦截下来自己处理
  2. 由于flutter层拦截不了Natvie的事件,因为flutterView只实现了onTouchEvent,所以在Native层也就是flutterFragment需要自定义ViewGroup来处理拦截事件

解决方案

基本方案理清楚了,整理下具体实现,预估需要以下几个类

  • Native层
    • FlutterTabBarViewWrapper: 重写canScrollHorizontally,根据flutterTabBar当前位置以及滑动方向确定自己是否还能滚动
    • EventConflictTabControllerPlugin: 和Flutter层 EventConflictTabControllerPlugin对应
    • ViewPagerFlutterDelegateFragment:fragment在Viewpager下生命周期会有不同,需要特殊处理下setUserVisibleHint方法
  • flutter层
    • EventConflictTabController: 自定义tabController,监听tabview的滚动情况
    • EventConflictTabControllerPlugin: plugin,主要是通知Native当前tabview的滚动情况,以及和Native绑定FlutterFragment

下面列出核心代码

Flutter层

Flutter层EventConflictTabController

class EventConflictTabController extends TabController {
  static int id = 0;

  /// 当前tabController Id,唯一标识符
  int _currentId;

  /// 监听TabController滚动
  Function _changeListener;
 
 EventConflictTabController(
      {int initialIndex = 0,
      @required int length,
      @required TickerProvider vsync,})
      : super(initialIndex: initialIndex, length: length, vsync: vsync) {
  
      /// 增加监听
      _changeListener = () {
        if (!indexIsChanging) {
          int currentIndex = index;
          /// 记录当前TabController的index,并通过plugin通知到Native
          EventConflictTabControllerPlugin.getInstance()
              .notifyCurrentIndex(_currentId, currentIndex, length);
        }
      };
      addListener(_changeListener);
      _currentId = id++;

      /// 在EventConflictTabController构造函数里和Native绑定,因为flutter是依附于Native的,
      /// 所有肯定是Native侧FlutterFragment先构造好,才会执行到flutter
      EventConflictTabControllerPlugin.getInstance()
          .bindControllerById(_currentId, this); 
      
      /// 首次绑定时需要通知客户端,因为初始化的位置不一定是0
      EventConflictTabControllerPlugin.getInstance()
    .notifyCurrentIndex(_currentId, initialIndex,length);   
  }


  /// 通知Native拦截事件,flutter里并不是所有Widget需要拦截,只有tabView和一些横向滚动的ui需要开启拦截
  /// 而是否开启拦截可以在相应的Widget包一层Listener,在Listener的onPointerDown里调用这个方法
  /// 对于横向滚动的ListView,左滑和右滑都需要拦截,所以此时bHookAll= true
  /// 对于TabView,只有index是0或者length-1的时候,才需要拦截,且只拦截一个方向的move事件,所以bHookAll=false,bHookSingleDirection=true
  enableEventHook(bool bHookAll,bool bHookSingleDirection) {
    if (enablePlugin) {
      EventConflictTabControllerPlugin.getInstance()
          .enableEventHook(_currentId, bHookAll,bHookSingleDirection);
    }
  }

  @override
  void dispose() {
    if (enablePlugin) {
      removeListener(_changeListener);
      /// 解除绑定
      EventConflictTabControllerPlugin.getInstance()
          .unbindControllerById(_currentId);
    }
    super.dispose();
  }
}

Flutter层EventConflictTabControllerPlugin

/// plugin 职责
/// 主动通知客户端  当前位置 长度
class EventConflictTabControllerPlugin {
  static EventConflictTabControllerPlugin _instance;

  static EventConflictTabControllerPlugin getInstance() {
    if (_instance == null) {
      _instance = EventConflictTabControllerPlugin._();
    }
    return _instance;
  }

  MethodChannel _channel;

  EventConflictTabControllerPlugin._() {
      _channel = MethodChannel("conflict_tab_view") 
  }

  // 双向绑定
  bindControllerById(int id, EventConflictTabController conflictTabController) {
      _channel.invokeMethod("bindControllerById", {"id": id});
  }

  // 解绑
  unbindControllerById(int id) {
      _channel.invokeMethod("unbindControllerById", {"id": id});
  }

  /// 通知Native当前TabView滚动到哪个位置了
  notifyCurrentIndex(int id, int currentIndex, int length) {
   _channel.invokeMethod("notifyCurrentIndex",
          {"id": id, "currentIndex": currentIndex, "length": length});
  }

  /// 点击返回按钮了
  clickBackBtn(int id) {
    WdbLog.log("clickBackBtn currentId is $id");
    _channel.invokeMethod("onBackClick", {"id": id});
  }

  /// 是否开启  事件拦截
  enableEventHook(int id, bool bHookAll,bool bHookSingleDirection) {
    _channel.invokeMethod("enableEventHook", {"id": id, "bHookAll": bHookAll,"bHookSingleDirection":bHookSingleDirection});
  }
}

Native层

Native-EventConflictTabControllerPlugin,plugin里没什么逻辑,主要是起到桥接的作用

public class EventConflictTabControllerPlugin extends SafeMethodCallHandler implements FlutterPlugin {
    private static MethodChannel channel;

    ///  有序存储当前活跃的FlutterTabBarViewWrapper
    private static LinkedList<FlutterTabBarViewWrapper> currentFlutterTabBarViewWrapperQueue;

    public static void registerWith(PluginRegistry.Registrar registrar) {
        channel = new MethodChannel(registrar.messenger(), "conflict_tab_view");
        EventConflictTabControllerPlugin flutterPlugin = new EventConflictTabControllerPlugin();
        channel.setMethodCallHandler(flutterPlugin);
    }

    // 绑定flutterTabBarViewWrapper
    public static void bindController(FlutterTabBarViewWrapper flutterTabBarViewWrapper) {
        if (currentFlutterTabBarViewWrapperQueue == null) {
            currentFlutterTabBarViewWrapperQueue = new LinkedList<>();
        }
        currentFlutterTabBarViewWrapperQueue.offerFirst(flutterTabBarViewWrapper);
    }

    // 解绑flutterTabBarViewWrapper
    public static void unbindController(FlutterTabBarViewWrapper flutterTabBarViewWrapper) {
        currentFlutterTabBarViewWrapperQueue.remove(flutterTabBarViewWrapper);
    }

    private static FlutterTabBarViewWrapper findTopAliveWrapper(){
        if (currentFlutterTabBarViewWrapperQueue == null || currentFlutterTabBarViewWrapperQueue.isEmpty())
            return null;
        return currentFlutterTabBarViewWrapperQueue.peekFirst();
    }

    @Override
    public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
        channel = new MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), "conflict_tab_view");
        channel.setMethodCallHandler(this);
    }

    private void clear() {
        currentFlutterTabBarViewWrapperQueue = null;
    }

    @Override
    protected void onSafeMethodCall(MethodCall call, SafeResult result) throws Exception {
        Map args;
        FlutterTabBarViewWrapper currentTabBarViewWrapper = findTopAliveWrapper();
        switch (call.method) {
            case "bindControllerById":
                args = (Map) call.arguments;
                if (currentTabBarViewWrapper != null) {
                    currentTabBarViewWrapper.setCurrentTabControllerId((int) args.get("id"));
                }
                result.success(null);
                break;
            case "unbindControllerById":
                args = (Map) call.arguments;
                if (currentT abBarViewWrapper != null && currentTabBarViewWrapper.getCurrentTabControllerId() == (int) args.get("id")) {
                    currentTabBarViewWrapper.setCurrentTabControllerId(-1);
                }
                result.success(null);
                break;
            case "notifyCurrentIndex":
                // 通知currentTabBarViewWrapper tabview的位置
                args = (Map) call.arguments;
                int id = (int) args.get("id");
                int currentIndex = (int) args.get("currentIndex");
                int length = (int) args.get("length");
                if (currentTabBarViewWrapper != null && currentTabBarViewWrapper.getCurrentTabControllerId() == id) {
                    currentTabBarViewWrapper.setFlutterCurrentIndex(currentIndex);
                    currentTabBarViewWrapper.setFlutterTabBarLength(length);
                }
                result.success(null);
                break;
            case "enableEventHook":
                args = (Map) call.arguments;
                int currentId2 = (int) args.get("id");
                if (currentTabBarViewWrapper != null && currentTabBarViewWrapper.getCurrentTabControllerId() == currentId2) {
                    currentTabBarViewWrapper.setEnableHookEvent((boolean)args.get("bHookAll"),(boolean)args.get("bHookSingleDirection"));
                }
                result.success(null);
                break;
            default:
                result.notImplemented();
        }
    }

    @Override
    public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
        channel.setMethodCallHandler(null);
        clear();
    }
}

Native-FlutterTabBarViewWrapper,拦截事件的核心逻辑

package com.vdian.flutter.buyer_base.page;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.ViewParent;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.ViewPager;

import static androidx.customview.widget.ViewDragHelper.INVALID_POINTER;

/**
 * @author yulun
 * @sinice 2021-01-06 16:01
 * 在viewpager下 配合 flutter tabBarView使用,主要是为了处理事件冲突
 */
public class FlutterTabBarViewWrapper extends FrameLayout {
    // 返回按钮回调,没地方放了
    OnClickBackListener onClickBack;

    /// flutter tabview当前位置
    private int flutterCurrentIndex;
    /// flutter tabview长度,有几个tab
    private int flutterTabBarLength;
    /// flutter 当前tabController的Id;
    private int currentTabControllerId;



    /// 当次事件内 down-move..move-up,如何hook
    private boolean enableHookEventAll;
    private boolean enableHookEventDirection;

    /// left middle right,flutterFragment在ViewPager的位置
    private WDBViewPagerFlutterDelegateFragment.PositionViewPager positionViewPager;

    public FlutterTabBarViewWrapper(@NonNull Context context) {
        super(context);
    }

    public FlutterTabBarViewWrapper(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public FlutterTabBarViewWrapper(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setPositionViewPager(WDBViewPagerFlutterDelegateFragment.PositionViewPager positionViewPager) {
        this.positionViewPager = positionViewPager;
    }

    @Override
    public boolean canScrollHorizontally(int direction) {
        // 当前flutter TabBar处在中间位置,不管左滑还是右滑,都需要flutterView自己处理
        if (enableHookEventAll){
            return true;
        }

        // 某个方向需要拦截
        if (enableHookEventDirection){
            return checkCanScrollFlutterTabBarView(-direction);
        }
        return false;
    }

    public void setFlutterCurrentIndex(int flutterCurrentIndex) {
        this.flutterCurrentIndex = flutterCurrentIndex;
    }

    public void setFlutterTabBarLength(int flutterTabBarLength) {
        this.flutterTabBarLength = flutterTabBarLength;
    }

    public int getCurrentTabControllerId() {
        return currentTabControllerId;
    }

    public void setCurrentTabControllerId(int currentTabControllerId) {
        this.currentTabControllerId = currentTabControllerId;
    }

    /// 设置hook方式,如果enableHookEventAll=true,那不需要判断当前是第几个问题,当前往那边滑,都需要拦截
    /// enableHookEventAll=false,enableHookEventDirection=true, 那根据TabView的位置以及滑动方向来决定
    public void setEnableHookEvent(boolean enableHookEventAll, boolean enableHookEventDirection) {
        this.enableHookEventAll = enableHookEventAll;
        this.enableHookEventDirection = enableHookEventDirection;
    }

    /// xDiff> 0 , 手势向左滑动,viewPager是切换到 pageIndex+1的
    /// xDiff< 0 , 手势向右滑动,viewPager是切换到 pageIndex-1的
    private boolean checkCanScrollFlutterTabBarView(float xDiff) {
        return canScroll(xDiff > 0 ? 1 : 0);
    }


    // 0 手势向左,viewPager是向右的
    // 1 手势向右,viewPager是向左
    boolean canScroll(int direction) {
        /// TabView不是第一个也不是最后一个,那肯定需要拦截事件交给flutter自己处理
        if (flutterCurrentIndex > 0 && flutterCurrentIndex < flutterTabBarLength - 1) {
            return true;
        }

        // flutterFragment在ViewPager的最左边
        if (positionViewPager == WDBViewPagerFlutterDelegateFragment.PositionViewPager.left) {
            /// TabView在第一个,需要拦截
            if (flutterCurrentIndex == 0) {
                return true;
            }

            /// TabView在最后一个,手势向右滑,且enableHookEventDirection=true才拦截
            /// 为什么有enableHookEventDirection,因为flutter内,手势不一定在tabview上滑动
            if (flutterCurrentIndex == flutterTabBarLength - 1) {
                return direction == 1 && enableHookEventDirection;
            }
            return true;
        } else if (positionViewPager == WDBViewPagerFlutterDelegateFragment.PositionViewPager.middle) {
            /// flutterFragment在ViewPager的中间,TabView在第一个位置,手势向左滑,且enableHookEventDirection=true需要拦截
            if (flutterCurrentIndex == 0 && direction == 0 && enableHookEventDirection) {
                return true;
            }

            /// flutterFragment在ViewPager的中间,TabView在最后一个位置,手势向右滑,且enableHookEventDirection=true需要拦截
            if (flutterCurrentIndex == flutterTabBarLength - 1 && direction == 1 && enableHookEventDirection) {
                return true;
            }

            return false;
        } else if (positionViewPager == WDBViewPagerFlutterDelegateFragment.PositionViewPager.right) {
            // flutterFragment在ViewPager的最右边,TabView在第一个位置,enableHookEventDirection=true需要拦截
            if (flutterCurrentIndex == 0) {
                return direction == 0 && enableHookEventDirection;
            }
            // fragment在right
            return true;
        }
        return true;
    }
}


Navtive Frament ,主要就是外层View需要是FlutterTabBarViewWrapper,且需要管理好和EventConflictTabControllerPlugin的绑定关系,其他就没有了,简单贴下代码

@Override
public View onCreateView(@NonNull LayoutInflater inflater,
                         @Nullable ViewGroup container,
                         @Nullable Bundle savedInstanceState) {
    flutterTabBarViewWrapper = (FlutterTabBarViewWrapper)inflater.inflate(R.layout.flutterwrap_viewpager_fragment_wrap, container, false);
    flutterTabBarViewWrapper.setPositionViewPager(position);
    return flutterTabBarViewWrapper;
}

@Override
public void onStart() {
    super.onStart();
    // 注册当前事件拦截
    EventConflictTabControllerPlugin.bindController(flutterTabBarViewWrapper);
}

@Override
public void onStop() {
    super.onStop();
    // 取消当前事件拦截
    EventConflictTabControllerPlugin.unbindController(flutterTabBarViewWrapper);
}

结论

至此,可以解决android Viewpager和Flutter tabBarView的事件冲突,但处理起来还是比较麻烦,建议如果有碰到这种需求,尽量还是只有一端实现比较好,全Android或者全Flutter,笔者这次是特殊场景。