diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index 48c03b4450..bb3755ef36 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -42,7 +42,13 @@ enum _PlatformViewState { /// Android [View](https://developer.android.com/reference/android/view/View). /// /// The render object's layout behavior is to fill all available space, the parent of this object must -/// provide bounded layout constraints +/// provide bounded layout constraints. +/// +/// RenderAndroidView participates in Flutter's [GestureArena]s, and dispatches touch events to the +/// Android view iff it won the arena. Specific gestures that should be dispatched to the Android +/// view can be specified in [RenderAndroidView.gestureRecognizers]. If +/// [RenderAndroidView.gestureRecognizers] is empty, the gesture will be dispatched to the Android +/// view iff it was not claimed by any other gesture recognizer. /// /// See also: /// * [AndroidView] which is a widget that is used to show an Android view. @@ -53,10 +59,14 @@ class RenderAndroidView extends RenderBox { RenderAndroidView({ @required AndroidViewController viewController, @required this.hitTestBehavior, + List gestureRecognizers = const [], }) : assert(viewController != null), assert(hitTestBehavior != null), - _viewController = viewController { + assert(gestureRecognizers != null), + _viewController = viewController + { _motionEventsDispatcher = new _MotionEventsDispatcher(globalToLocal, viewController); + this.gestureRecognizers = gestureRecognizers; } _PlatformViewState _state = _PlatformViewState.uninitialized; @@ -80,6 +90,18 @@ class RenderAndroidView extends RenderBox { // any newly arriving events there's nothing we need to invalidate. PlatformViewHitTestBehavior hitTestBehavior; + /// Which gestures should be forwarded to the Android view. + /// + /// The gesture recognizers on this list participate in the gesture arena for each pointer + /// that was put down on the render box. If any of the recognizers on this list wins the + /// gesture arena, the entire pointer event sequence starting from the pointer down event + /// will be dispatched to the Android view. + set gestureRecognizers(List recognizers) { + assert(recognizers != null); + _gestureRecognizer?.dispose(); + _gestureRecognizer = new _AndroidViewGestureRecognizer(_motionEventsDispatcher, recognizers); + } + @override bool get sizedByParent => true; @@ -91,6 +113,8 @@ class RenderAndroidView extends RenderBox { _MotionEventsDispatcher _motionEventsDispatcher; + _AndroidViewGestureRecognizer _gestureRecognizer; + @override void performResize() { size = constraints.biggest; @@ -169,7 +193,109 @@ class RenderAndroidView extends RenderBox { @override void handleEvent(PointerEvent event, HitTestEntry entry) { - _motionEventsDispatcher.handlePointerEvent(event); + if (event is PointerDownEvent) { + _gestureRecognizer.addPointer(event); + } + } + + @override + void detach() { + _gestureRecognizer.reset(); + super.detach(); + } +} + +class _AndroidViewGestureRecognizer extends OneSequenceGestureRecognizer { + _AndroidViewGestureRecognizer(this.dispatcher, List gestureRecognizers) { + this.gestureRecognizers = gestureRecognizers; + } + + final _MotionEventsDispatcher dispatcher; + + // Maps a pointer to a list of its cached pointer events. + // Before the arena for a pointer is resolved all events are cached here, if we win the arena + // the cached events are dispatched to the view, if we lose the arena we clear the cache for + // the pointer. + final Map> cachedEvents = > {}; + + // Pointer for which we have already won the arena, events for pointers in this set are + // immediately dispatched to the Android view. + final Set forwardedPointers = new Set(); + + // We use OneSequenceGestureRecognizers as they support gesture arena teams. + // TODO(amirh): get a list of GestureRecognizers here. + // https://github.com/flutter/flutter/issues/20953 + List _gestureRecognizers; + set gestureRecognizers(List recognizers) { + _gestureRecognizers = recognizers; + team = new GestureArenaTeam(); + team.captain = this; + for (OneSequenceGestureRecognizer recognizer in _gestureRecognizers) { + recognizer.team = team; + } + } + + @override + void addPointer(PointerDownEvent event) { + startTrackingPointer(event.pointer); + for (OneSequenceGestureRecognizer recognizer in _gestureRecognizers) { + recognizer.addPointer(event); + } + } + + @override + String get debugDescription => 'Android view'; + + @override + void didStopTrackingLastPointer(int pointer) { + resolve(GestureDisposition.rejected); + } + + @override + void handleEvent(PointerEvent event) { + if (!forwardedPointers.contains(event.pointer)) { + cacheEvent(event); + } else { + dispatcher.handlePointerEvent(event); + } + stopTrackingIfPointerNoLongerDown(event); + } + + @override + void acceptGesture(int pointer) { + flushPointerCache(pointer); + forwardedPointers.add(pointer); + } + + @override + void rejectGesture(int pointer) { + stopTrackingPointer(pointer); + cachedEvents.remove(pointer); + } + + void cacheEvent(PointerEvent event) { + if (!cachedEvents.containsKey(event.pointer)) { + cachedEvents[event.pointer] = []; + } + cachedEvents[event.pointer].add(event); + } + + void flushPointerCache(int pointer) { + cachedEvents.remove(pointer)?.forEach(dispatcher.handlePointerEvent); + } + + @override + void stopTrackingPointer(int pointer) { + super.stopTrackingPointer(pointer); + forwardedPointers.remove(pointer); + } + + void reset() { + forwardedPointers.forEach(super.stopTrackingPointer); + forwardedPointers.clear(); + cachedEvents.keys.forEach(super.stopTrackingPointer); + cachedEvents.clear(); + resolve(GestureDisposition.rejected); } } diff --git a/packages/flutter/lib/src/widgets/platform_view.dart b/packages/flutter/lib/src/widgets/platform_view.dart index d8f83ce645..c2698a13b5 100644 --- a/packages/flutter/lib/src/widgets/platform_view.dart +++ b/packages/flutter/lib/src/widgets/platform_view.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -21,6 +22,12 @@ import 'framework.dart'; /// The widget fill all available space, the parent of this object must provide bounded layout /// constraints. /// +/// AndroidView participates in Flutter's [GestureArena]s, and dispatches touch events to the +/// Android view iff it won the arena. Specific gestures that should be dispatched to the Android +/// view can be specified in [AndroidView.gestureRecognizers]. If +/// [AndroidView.gestureRecognizers] is empty, the gesture will be dispatched to the Android +/// view iff it was not claimed by any other gesture recognizer. +/// /// The Android view object is created using a [PlatformViewFactory](/javadoc/io/flutter/plugin/platform/PlatformViewFactory.html). /// Plugins can register platform view factories with [PlatformViewRegistry#registerViewFactory](/javadoc/io/flutter/plugin/platform/PlatformViewRegistry.html#registerViewFactory-java.lang.String-io.flutter.plugin.platform.PlatformViewFactory-). /// @@ -41,7 +48,7 @@ import 'framework.dart'; class AndroidView extends StatefulWidget { /// Creates a widget that embeds an Android view. /// - /// The `viewType` and `hitTestBehavior` parameters must not be null. + /// The `viewType`, `hitTestBehavior`, and `gestureRecognizers` parameters must not be null. /// If `creationParams` is not null then `creationParamsCodec` must not be null. AndroidView({ Key key, @@ -49,10 +56,12 @@ class AndroidView extends StatefulWidget { this.onPlatformViewCreated, this.hitTestBehavior = PlatformViewHitTestBehavior.opaque, this.layoutDirection, + this.gestureRecognizers = const [], this.creationParams, this.creationParamsCodec }) : assert(viewType != null), assert(hitTestBehavior != null), + assert(gestureRecognizers != null), assert(creationParams == null || creationParamsCodec != null), super(key: key); @@ -78,6 +87,17 @@ class AndroidView extends StatefulWidget { /// If this is null, the ambient [Directionality] is used instead. final TextDirection layoutDirection; + /// Which gestures should be forwarded to the Android view. + /// + /// The gesture recognizers on this list participate in the gesture arena for each pointer + /// that was put down on the widget. If any of the recognizers on this list wins the + /// gesture arena, the entire pointer event sequence starting from the pointer down event + /// will be dispatched to the Android view. + // We use OneSequenceGestureRecognizers as they support gesture arena teams. + // TODO(amirh): get a list of GestureRecognizers here. + // https://github.com/flutter/flutter/issues/20953 + final List gestureRecognizers; + /// Passed as the args argument of [PlatformViewFactory#create](/javadoc/io/flutter/plugin/platform/PlatformViewFactory.html#create-android.content.Context-int-java.lang.Object-) /// /// This can be used by plugins to pass constructor parameters to the embedded Android view. @@ -105,7 +125,8 @@ class _AndroidViewState extends State { Widget build(BuildContext context) { return new _AndroidPlatformView( controller: _controller, - hitTestBehavior: widget.hitTestBehavior + hitTestBehavior: widget.hitTestBehavior, + gestureRecognizers: widget.gestureRecognizers, ); } @@ -182,20 +203,28 @@ class _AndroidPlatformView extends LeafRenderObjectWidget { Key key, @required this.controller, @required this.hitTestBehavior, + @required this.gestureRecognizers, }) : assert(controller != null), assert(hitTestBehavior != null), + assert(gestureRecognizers != null), super(key: key); final AndroidViewController controller; final PlatformViewHitTestBehavior hitTestBehavior; + final List gestureRecognizers; @override RenderObject createRenderObject(BuildContext context) => - new RenderAndroidView(viewController: controller, hitTestBehavior: hitTestBehavior); + new RenderAndroidView( + viewController: controller, + hitTestBehavior: hitTestBehavior, + gestureRecognizers: gestureRecognizers, + ); @override void updateRenderObject(BuildContext context, RenderAndroidView renderObject) { renderObject.viewController = controller; renderObject.hitTestBehavior = hitTestBehavior; + renderObject.gestureRecognizers = gestureRecognizers; } } diff --git a/packages/flutter/test/widgets/platform_view_test.dart b/packages/flutter/test/widgets/platform_view_test.dart index f5bc915c7c..5cfa0bc279 100644 --- a/packages/flutter/test/widgets/platform_view_test.dart +++ b/packages/flutter/test/widgets/platform_view_test.dart @@ -513,4 +513,75 @@ void main() { ]), ); }); + + testWidgets('Android view can lose gesture arenas', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakePlatformViewsController viewsController = new FakePlatformViewsController(TargetPlatform.android); + viewsController.registerViewType('webview'); + bool verticalDragAcceptedByParent = false; + await tester.pumpWidget( + new Align( + alignment: Alignment.topLeft, + child: new Container( + margin: const EdgeInsets.all(10.0), + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { verticalDragAcceptedByParent = true; }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: AndroidView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.moveBy(const Offset(0.0, 100.0)); + await gesture.up(); + + expect(verticalDragAcceptedByParent, true); + expect( + viewsController.motionEvents[currentViewId + 1], + isNull, + ); + }); + + testWidgets('Android view gesture recognizers', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakePlatformViewsController viewsController = new FakePlatformViewsController(TargetPlatform.android); + viewsController.registerViewType('webview'); + bool verticalDragAcceptedByParent = false; + await tester.pumpWidget( + new Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { verticalDragAcceptedByParent = true; }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: AndroidView( + viewType: 'webview', + gestureRecognizers: [new VerticalDragGestureRecognizer()], + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.moveBy(const Offset(0.0, 100.0)); + await gesture.up(); + + expect(verticalDragAcceptedByParent, false); + expect( + viewsController.motionEvents[currentViewId + 1], + orderedEquals( [ + const FakeMotionEvent(AndroidViewController.kActionDown, [0], [Offset(50.0, 50.0)]), + const FakeMotionEvent(AndroidViewController.kActionMove, [0], [Offset(50.0, 150.0)]), + const FakeMotionEvent(AndroidViewController.kActionUp, [0], [Offset(50.0, 150.0)]), + ]), + ); + }); }