background

Recently, there is a troublesome requirement that Android pages are ViewPager, which contains two fragments. The first is the Native fragment, and the second is the FlutterFragment, which contains TabBar and TabView. By default there will be event collisions. When you slide into the FlutterFragment, you will find that the TabView in the FlutterFragment cannot slide horizontally because the events are intercepted by the Native ViewPager.

Modify the record

1. The solution is 1.0 at the time of the down event, through the channnel + requestParentDisallowInterceptTouchEvent (true); To prevent the ViewPager from intercepting the event, but this method can easily lead to interception failure, because the channel communication needs time, if the move event within this time meets the ViewPager sliding distance, then the ViewPager will trigger interception 2. After optimization, Native processing of nested Viewpager can be referred toCopy the code

Cause analysis,

First of all, how does ViewPager intercept events? ViewPager provides onInterceptTouchEvent, onTouchEvent, and onInterceptTouchEvent. Android Native event intercepts are not required here.

@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; } // The horizontal drag distance is greater than the minimum drag distance, If (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {if (DEBUG) log. v(TAG, "Starting drag!" ); mIsBeingDragged = true; / / interception, notice my father don't intercept events 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; } // MisbeingPartying = true, viewPager returns misbeingPartying; }Copy the code

Notice that in the onInterceptTouchEvent of the ViewPager, the canScroll method is called

// this method iterates recursively through all child views. If the child views canScrollHorizontally, ViewPager will not intercept 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); }Copy the code

V. canScrollHorizontally(-dx), ViewPager overrides canScrollHorizontally

MFirstOffset = 0,mLastOffset viewpager length assuming viewPager length=4, Vieapager fills screen =1080 Swipe right (direction < 0) Swipe left (direction > 0) scrollX=0 when viewPager is in first, swipe right, direction < 0, (scrollX > (int) (width mFirstOffset)); Return false, swipe left, direction > 0, (scrollX < (int) (width mLastOffset)); Return true when viewPager is at the last one, scrollX = width * mLastOffset, slide right, return false, slide left, 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; }}Copy the code

The code of Flutter – Android side forwarding events to Flutter is in FlutterView. It can be seen that FlutterView implements onTouchEvent

@override public Boolean onTouchEvent(@nonnull MotionEvent) {if (! isAttachedToFlutterEngine()) { return super.onTouchEvent(event); } / / event of the actual processing in androidTouchProcessor. In return onTouchEven androidTouchProcessor. OnTouchEvent (event); } // AndroidTouchProcessor onTouchEvent method, the core logic of the event encapsulation, Then send the flutter public Boolean onTouchEvent(@nonnull MotionEvent event) {// This is not the point of this article. You can see that the end of the flutter returns true; }Copy the code

As you can see, flutterView simply implements the onTouchEvent method and returns true, so Viewpager must intercept the event when it moves. So since a horizontal move event starts, a flutter can’t receive any events.

So far, the problem has been located, how to solve it? There are two ways to solve this

  1. First, we need to know the current position of the tabView in the flutter, and also the position of the flutterFrament in the viewpager. Assuming that the flutterFrament is in the first position, we need to ensure that the tabView slides to the last position. When the gesture is left sliding, the event is handed over to the viewPager for processing. Otherwise, flutterView intercepts and handles the event itself
  2. Since flutter layer cannot intercept Natvie events, and Since flutterView only implements onTouchEvent, a custom ViewGroup is required in Native layer (I.e. FlutterFragment) to handle intercept events

The solution

The basic scheme is clear, and the specific implementation is sorted out. It is estimated that the following classes are needed

  • Native layer
    • FlutterTabBarViewWrapper: rewrite canScrollHorizontally, according to the location and the current flutterTabBar sliding direction to determine if they can roll
    • EventConflictTabControllerPlugin corresponding EventConflictTabControllerPlugin: and Flutter layer
    • ViewPagerFlutterDelegateFragment: fragments under the Viewpager will have different life cycle, need special handling setUserVisibleHint method
  • Flutter layers
    • EventConflictTabController: custom tabController, monitored tabview rolling conditions
    • EventConflictTabControllerPlugin: plugin, mainly Native current tabview rolling notice, and Native binding FlutterFragment

The core code is listed below

Flutter layers

Flutter layers EventConflictTabController

class EventConflictTabController extends TabController { static int id = 0; // current tabController Id, unique identifier int _currentId; Function _changeListener; EventConflictTabController( {int initialIndex = 0, @required int length, @required TickerProvider vsync,}) : Super (initialIndex: initialIndex, length: length, vsync: vsync) {/// Add listener _changeListener = () {if (! indexIsChanging) { int currentIndex = index; // Record the current TabController index, And via the plugin to the Native EventConflictTabControllerPlugin. GetInstance (). NotifyCurrentIndex (_currentId currentIndex, length); }}; addListener(_changeListener); _currentId = id++; / / / in EventConflictTabController constructor and Native binding, because the flutter is attached to the Native, / / / all must be Native side FlutterFragment structure first, Will perform to the flutter EventConflictTabControllerPlugin. GetInstance (). BindControllerById (_currentId, this); // The client needs to be notified when binding for the first time, Because the position of the initialization is not necessarily the 0 EventConflictTabControllerPlugin. GetInstance () notifyCurrentIndex (_currentId initialIndex, length); } // Notify Native of intercepting events. Not all widgets need to be intercepted in flutter. Only tabViews and horizontal scrolling UIs need to be intercepted. BHookAll = true // If the index is 0 or length-1, if the index is 0 or length-1, if the index is 0 or length-1, if the index is 0 or length-1 Intercept only move events in one direction. So bHookAll = false, bHookSingleDirection = true enableEventHook (bool bHookAll, bool bHookSingleDirection) {if (enablePlugin) { EventConflictTabControllerPlugin.getInstance() .enableEventHook(_currentId, bHookAll,bHookSingleDirection); } } @override void dispose() { if (enablePlugin) { removeListener(_changeListener); . / / / unbound EventConflictTabControllerPlugin getInstance () unbindControllerById (_currentId); } super.dispose(); }}Copy the code

Flutter layers EventConflictTabControllerPlugin

/ / / / / / the plugin duty notify the client's current position length class EventConflictTabControllerPlugin {static EventConflictTabControllerPlugin _instance; static EventConflictTabControllerPlugin getInstance() { if (_instance == null) { _instance = EventConflictTabControllerPlugin._(); } return _instance; } MethodChannel _channel; EventConflictTabControllerPlugin. _ () {_channel = MethodChannel (" conflict_tab_view ")} / / two-way binding bindControllerById (int id, EventConflictTabController conflictTabController) { _channel.invokeMethod("bindControllerById", {"id": id}); UnbindControllerById (int id) {_channel.invokemethod ("unbindControllerById", {"id": id}); } // notifyCurrentIndex(int ID, int currentIndex, int length) { _channel.invokeMethod("notifyCurrentIndex", {"id": id, "currentIndex": currentIndex, "length": length}); } {wdblog. log("clickBackBtn currentId is $ID "); _channel.invokeMethod("onBackClick", {"id": id}); } // Enable event blocking enableEventHook(int id, bool bHookAll,bool bHookSingleDirection) { _channel.invokeMethod("enableEventHook", {"id": id, "bHookAll": bHookAll,"bHookSingleDirection":bHookSingleDirection}); }}Copy the code

Native layer

Native – EventConflictTabControllerPlugin, no logic in the plugin, mainly plays a role of bridge

public class EventConflictTabControllerPlugin extends SafeMethodCallHandler implements FlutterPlugin { private static MethodChannel channel; 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); } public static void bindController(flutterTabBarViewWrapper flutterTabBarViewWrapper) { if (currentFlutterTabBarViewWrapperQueue == null) { currentFlutterTabBarViewWrapperQueue = new LinkedList<>(); } currentFlutterTabBarViewWrapperQueue.offerFirst(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": // Notify currentTabBarViewWrapper tabView position 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(); }}Copy the code

Native-FlutterTabBarViewWrapper, which intercepts the core logic of events

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 with flutter tabBarView, Public class FlutterTabBarViewWrapper extends FrameLayout {// Return button callback, There's no place for OnClickBackListener onClickBack; /// flutter tabView private int flutterCurrentIndex; Private int flutterTabBarLength; // The Id of the current flutter tabController; private int currentTabControllerId; // When the event is down move.. Hook private Boolean enableHookEventAll; private boolean enableHookEventDirection; // left middle right, FlutterFragment position in the 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) { If (enableHookEventAll){return true; } / / a direction to intercept the 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; } // set hook mode, if enableHookEventAll=true, there is no need to determine the current number of questions, when going to the other side of the slider, All need to intercept / / / enableHookEventAll = false, enableHookEventDirection = true, Public void setEnableHookEvent(Boolean enableHookEventAll, boolean enableHookEventDirection) { this.enableHookEventAll = enableHookEventAll; this.enableHookEventDirection = enableHookEventDirection; } // xDiff< 0, xDiff< 0, xDiff< 0, Switch to the viewPager is pageIndex - 1 private Boolean checkCanScrollFlutterTabBarView (float xDiff) {return canScroll (xDiff > 0? 1:0); } Boolean canScroll(int direction) {// TabView is not the first or the last TabView, If (flutterCurrentIndex > 0 && flutterCurrentIndex < FlutterTabBarLeng-1) {return true; } / / flutterFragment in the ViewPager the left the if (positionViewPager = = WDBViewPagerFlutterDelegateFragment. PositionViewPager. Left) If (flutterCurrentIndex == 0) {return true; } // TabView should be blocked only when the gesture is right and enableHookEventDirection=true. Gesture does not necessarily slide on tabView if (flutterCurrentIndex == FlutterTabBarLeng-1) {return direction == 1&& enableHookEventDirection; } return true; } else if (positionViewPager == WDBViewPagerFlutterDelegateFragment.PositionViewPager.middle) { /// The flutterFragment is in the middle of the ViewPager, the TabView is in the first position, the gesture slides left, If (flutterCurrentIndex == 0 && Direction= = 0 && enableHookEventDirection) {return true; } // flutterFragment is in the middle of the ViewPager, TabView is in the last position, And enableHookEventDirection=true intercepts if (flutterCurrentIndex == flutterTabBarLength -1 && direction == 1 && enableHookEventDirection) { return true; } return false; } else if (positionViewPager == WDBViewPagerFlutterDelegateFragment.PositionViewPager.right) { // The flutterFragment is on the far right of the ViewPager, the TabView is in the first position, EnableHookEventDirection =true If (flutterCurrentIndex == 0) {return direction == 0 && enableHookEventDirection; } // fragment in right return true; } return true; }}Copy the code

Navtive Frament, mainly is the outer View need to is FlutterTabBarViewWrapper, and the need to manage and EventConflictTabControllerPlugin binding relationship, other have no, under the simple code

@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(); / / register the current event interceptor EventConflictTabControllerPlugin bindController (flutterTabBarViewWrapper); } @Override public void onStop() { super.onStop(); / / cancel the current event interceptor EventConflictTabControllerPlugin unbindController (flutterTabBarViewWrapper); }Copy the code

conclusion

So far, we can resolve the conflict between Android Viewpager and Flutter tabBarView, but it is still difficult to handle. We suggest that if there is such a requirement, it is better to implement only one side of the Flutter, that is, all Android or all Flutter.