背景
最近有个比较麻烦的需求,是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事件的)
至此,问题已经定位到了,那怎么解决呢?解决可以从两个点着手
- 第一我们需要知道flutter里的tabview当前是第几个位置,而且还和flutterFrament在viewpager中的位置相关,假设flutterFrament是在第一个位置,那我们只要保证tabview滑到最后一个位置,且手势是往左滑的时候,把事件交给viewPager处理,其他情况都要flutterView拦截下来自己处理
- 由于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,笔者这次是特殊场景。