From e6e44de33c6586b694b462a2013363126a1c2b83 Mon Sep 17 00:00:00 2001 From: yaakovschectman <109111084+yaakovschectman@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:48:12 -0400 Subject: [PATCH] Add MacOS AppKitView class. (#132583) Add derived classes from the Darwin platform view base classes for MacOS. Functionality is largely the same as the `UiKitView`, but the two are decoupled and and can further diverge in the future as needed. Some unit tests remain skipped for now as the gesture recognizers for MacOS are not yet implemented. https://github.com/flutter/flutter/issues/128519 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat --------- Co-authored-by: Chris Bracken --- .../lib/src/rendering/platform_view.dart | 39 +- .../lib/src/services/platform_views.dart | 53 +- .../lib/src/widgets/platform_view.dart | 75 +- .../test/services/fake_platform_views.dart | 131 +++ .../test/widgets/platform_view_test.dart | 921 ++++++++++++++++++ 5 files changed, 1205 insertions(+), 14 deletions(-) diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index 6a0c19953c..e2bfb28177 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -279,7 +279,10 @@ abstract class RenderDarwinPlatformView RenderDarwinPlatformView({ required T viewController, required this.hitTestBehavior, - }) : _viewController = viewController; + required Set> gestureRecognizers, + }) : _viewController = viewController { + updateGestureRecognizers(gestureRecognizers); + } /// The unique identifier of the platform view controlled by this controller. @@ -313,6 +316,8 @@ abstract class RenderDarwinPlatformView PointerEvent? _lastPointerDownEvent; + _UiKitViewGestureRecognizer? _gestureRecognizer; + @override Size computeDryLayout(BoxConstraints constraints) { return constraints.biggest; @@ -374,6 +379,9 @@ abstract class RenderDarwinPlatformView GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent); super.detach(); } + + /// {@macro flutter.rendering.PlatformViewRenderBox.updateGestureRecognizers} + void updateGestureRecognizers(Set> gestureRecognizers); } /// A render object for an iOS UIKit UIView. @@ -401,14 +409,11 @@ class RenderUiKitView extends RenderDarwinPlatformView { RenderUiKitView({ required super.viewController, required super.hitTestBehavior, - required Set> gestureRecognizers - }) { - updateGestureRecognizers(gestureRecognizers); - } + required super.gestureRecognizers, + }); - // TODO(schectman): Add gesture functionality to macOS platform view when implemented. - // https://github.com/flutter/flutter/issues/128519 /// {@macro flutter.rendering.PlatformViewRenderBox.updateGestureRecognizers} + @override void updateGestureRecognizers(Set> gestureRecognizers) { assert( _factoriesTypeSet(gestureRecognizers).length == gestureRecognizers.length, @@ -431,8 +436,6 @@ class RenderUiKitView extends RenderDarwinPlatformView { _lastPointerDownEvent = event.original ?? event; } - _UiKitViewGestureRecognizer? _gestureRecognizer; - @override void detach() { _gestureRecognizer!.reset(); @@ -440,6 +443,24 @@ class RenderUiKitView extends RenderDarwinPlatformView { } } +/// A render object for a macOS platform view. +class RenderAppKitView extends RenderDarwinPlatformView { + /// Creates a render object for a macOS AppKitView. + RenderAppKitView({ + required super.viewController, + required super.hitTestBehavior, + required super.gestureRecognizers, + }); + + // TODO(schectman): Add gesture functionality to macOS platform view when implemented. + // https://github.com/flutter/flutter/issues/128519 + // This method will need to behave the same as the same-named method for RenderUiKitView, + // but use a _AppKitViewGestureRecognizer or equivalent, whose constructor shall accept an + // AppKitViewController. + @override + void updateGestureRecognizers(Set> gestureRecognizers) {} +} + // This recognizer constructs gesture recognizers from a set of gesture recognizer factories // it was give, adds all of them to a gesture arena team with the _UiKitViewGestureRecognizer // as the team captain. diff --git a/packages/flutter/lib/src/services/platform_views.dart b/packages/flutter/lib/src/services/platform_views.dart index 0375d42747..c1774998ff 100644 --- a/packages/flutter/lib/src/services/platform_views.dart +++ b/packages/flutter/lib/src/services/platform_views.dart @@ -220,6 +220,7 @@ class PlatformViewsService { assert(creationParams == null || creationParamsCodec != null); // TODO(amirh): pass layoutDirection once the system channel supports it. + // https://github.com/flutter/flutter/issues/133682 final Map args = { 'id': id, 'viewType': viewType, @@ -238,6 +239,49 @@ class PlatformViewsService { } return UiKitViewController._(id, layoutDirection); } + + /// Factory method to create an `AppKitView`. + /// + /// `id` is an unused unique identifier generated with [platformViewsRegistry]. + /// + /// `viewType` is the identifier of the iOS view type to be created, a + /// factory for this view type must have been registered on the platform side. + /// Platform view factories are typically registered by plugin code. + /// + /// `onFocus` is a callback that will be invoked when the UIKit view asks to + /// get the input focus. + /// The `id, `viewType, and `layoutDirection` parameters must not be null. + /// If `creationParams` is non null then `creationParamsCodec` must not be null. + static Future initAppKitView({ + required int id, + required String viewType, + required TextDirection layoutDirection, + dynamic creationParams, + MessageCodec? creationParamsCodec, + VoidCallback? onFocus, + }) async { + assert(creationParams == null || creationParamsCodec != null); + + // TODO(amirh): pass layoutDirection once the system channel supports it. + // https://github.com/flutter/flutter/issues/133682 + final Map args = { + 'id': id, + 'viewType': viewType, + }; + if (creationParams != null) { + final ByteData paramsByteData = creationParamsCodec!.encodeMessage(creationParams)!; + args['params'] = Uint8List.view( + paramsByteData.buffer, + 0, + paramsByteData.lengthInBytes, + ); + } + await SystemChannels.platform_views.invokeMethod('create', args); + if (onFocus != null) { + _instance._focusCallbacks[id] = onFocus; + } + return AppKitViewController._(id, layoutDirection); + } } /// Properties of an Android pointer. @@ -1317,7 +1361,6 @@ abstract class DarwinPlatformViewController { TextDirection layoutDirection, ) : _layoutDirection = layoutDirection; - /// The unique identifier of the iOS view controlled by this controller. /// /// This identifier is typically generated by @@ -1389,6 +1432,14 @@ class UiKitViewController extends DarwinPlatformViewController { ); } +/// Controller for a macOS platform view. +class AppKitViewController extends DarwinPlatformViewController { + AppKitViewController._( + super.id, + super.layoutDirection, + ); +} + /// An interface for controlling a single platform view. /// /// Used by [PlatformViewSurface] to interface with the platform view it embeds. diff --git a/packages/flutter/lib/src/widgets/platform_view.dart b/packages/flutter/lib/src/widgets/platform_view.dart index 96389c014b..05b2b5753d 100644 --- a/packages/flutter/lib/src/widgets/platform_view.dart +++ b/packages/flutter/lib/src/widgets/platform_view.dart @@ -325,6 +325,40 @@ class UiKitView extends _DarwinView { State createState() => _UiKitViewState(); } +/// Widget that contains a macOS AppKit view. +/// +/// Embedding macOS views is an expensive operation and should be avoided where +/// a Flutter equivalent is possible. +/// +/// The platform view's lifetime is the same as the lifetime of the [State] +/// object for this widget. When the [State] is disposed the platform view (and +/// auxiliary resources) are lazily released (some resources are immediately +/// released and some by platform garbage collector). A stateful widget's state +/// is disposed when the widget is removed from the tree or when it is moved +/// within the tree. If the stateful widget has a key and it's only moved +/// relative to its siblings, or it has a [GlobalKey] and it's moved within the +/// tree, it will not be disposed. +/// +/// Construction of AppKitViews is done asynchronously, before the underlying +/// NSView is ready this widget paints nothing while maintaining the same +/// layout constraints. +class AppKitView extends _DarwinView { + /// Creates a widget that embeds a macOS AppKit NSView. + const AppKitView({ + super.key, + required super.viewType, + super.onPlatformViewCreated, + super.hitTestBehavior = PlatformViewHitTestBehavior.opaque, + super.layoutDirection, + super.creationParams, + super.creationParamsCodec, + super.gestureRecognizers, + }); + + @override + State createState() => _AppKitViewState(); +} + /// Callback signature for when the platform view's DOM element was created. /// /// [element] is the DOM element that was created. @@ -711,6 +745,31 @@ class _UiKitViewState extends _DarwinViewState { + @override + Future createNewViewController(int id) async { + return PlatformViewsService.initAppKitView( + id: id, + viewType: widget.viewType, + layoutDirection: _layoutDirection!, + creationParams: widget.creationParams, + creationParamsCodec: widget.creationParamsCodec, + onFocus: () { + focusNode?.requestFocus(); + } + ); + } + + @override + _AppKitPlatformView childPlatformView() { + return _AppKitPlatformView( + controller: _controller!, + hitTestBehavior: widget.hitTestBehavior, + gestureRecognizers: widget.gestureRecognizers ?? _DarwinViewState._emptyRecognizersSet, + ); + } +} + class _AndroidPlatformView extends LeafRenderObjectWidget { const _AndroidPlatformView({ required this.controller, @@ -758,7 +817,8 @@ abstract class _DarwinPlatformView { + const _AppKitPlatformView({required super.controller, required super.hitTestBehavior, required super.gestureRecognizers}); @override - void updateRenderObject(BuildContext context, RenderUiKitView renderObject) { - super.updateRenderObject(context, renderObject); - renderObject.updateGestureRecognizers(gestureRecognizers); + RenderObject createRenderObject(BuildContext context) { + return RenderAppKitView( + viewController: controller, + hitTestBehavior: hitTestBehavior, + gestureRecognizers: gestureRecognizers, + ); } } diff --git a/packages/flutter/test/services/fake_platform_views.dart b/packages/flutter/test/services/fake_platform_views.dart index e3dfdd5ada..e67589bff1 100644 --- a/packages/flutter/test/services/fake_platform_views.dart +++ b/packages/flutter/test/services/fake_platform_views.dart @@ -471,6 +471,109 @@ class FakeIosPlatformViewsController { } } +class FakeMacosPlatformViewsController { + FakeMacosPlatformViewsController() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform_views, _onMethodCall); + } + + Iterable get views => _views.values; + final Map _views = {}; + + final Set _registeredViewTypes = {}; + + // When this completer is non null, the 'create' method channel call will be + // delayed until it completes. + Completer? creationDelay; + + // Maps a view id to the number of gestures it accepted so far. + final Map gesturesAccepted = {}; + + // Maps a view id to the number of gestures it rejected so far. + final Map gesturesRejected = {}; + + void registerViewType(String viewType) { + _registeredViewTypes.add(viewType); + } + + void invokeViewFocused(int viewId) { + final MethodCodec codec = SystemChannels.platform_views.codec; + final ByteData data = codec.encodeMethodCall(MethodCall('viewFocused', viewId)); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage(SystemChannels.platform_views.name, data, (ByteData? data) {}); + } + + Future _onMethodCall(MethodCall call) { + switch (call.method) { + case 'create': + return _create(call); + case 'dispose': + return _dispose(call); + case 'acceptGesture': + return _acceptGesture(call); + case 'rejectGesture': + return _rejectGesture(call); + } + return Future.sync(() => null); + } + + Future _create(MethodCall call) async { + if (creationDelay != null) { + await creationDelay!.future; + } + final Map args = call.arguments as Map; + final int id = args['id'] as int; + final String viewType = args['viewType'] as String; + final Uint8List? creationParams = args['params'] as Uint8List?; + + if (_views.containsKey(id)) { + throw PlatformException( + code: 'error', + message: 'Trying to create an already created platform view, view id: $id', + ); + } + + if (!_registeredViewTypes.contains(viewType)) { + throw PlatformException( + code: 'error', + message: 'Trying to create a platform view of unregistered type: $viewType', + ); + } + + _views[id] = FakeAppKitView(id, viewType, creationParams); + gesturesAccepted[id] = 0; + gesturesRejected[id] = 0; + return Future.sync(() => null); + } + + Future _acceptGesture(MethodCall call) async { + final Map args = call.arguments as Map; + final int id = args['id'] as int; + gesturesAccepted[id] = gesturesAccepted[id]! + 1; + return Future.sync(() => null); + } + + Future _rejectGesture(MethodCall call) async { + final Map args = call.arguments as Map; + final int id = args['id'] as int; + gesturesRejected[id] = gesturesRejected[id]! + 1; + return Future.sync(() => null); + } + + Future _dispose(MethodCall call) { + final int id = call.arguments as int; + + if (!_views.containsKey(id)) { + throw PlatformException( + code: 'error', + message: 'Trying to dispose a platform view with unknown id: $id', + ); + } + + _views.remove(id); + return Future.sync(() => null); + } +} + @immutable class FakeAndroidPlatformView { const FakeAndroidPlatformView(this.id, this.type, this.size, this.layoutDirection, @@ -585,3 +688,31 @@ class FakeUiKitView { return 'FakeUiKitView(id: $id, type: $type, creationParams: $creationParams)'; } } + +@immutable +class FakeAppKitView { + const FakeAppKitView(this.id, this.type, [this.creationParams]); + + final int id; + final String type; + final Uint8List? creationParams; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is FakeAppKitView + && other.id == id + && other.type == type + && other.creationParams == creationParams; + } + + @override + int get hashCode => Object.hash(id, type); + + @override + String toString() { + return 'FakeAppKitView(id: $id, type: $type, creationParams: $creationParams)'; + } +} diff --git a/packages/flutter/test/widgets/platform_view_test.dart b/packages/flutter/test/widgets/platform_view_test.dart index d9164b59c3..ba01eb040b 100644 --- a/packages/flutter/test/widgets/platform_view_test.dart +++ b/packages/flutter/test/widgets/platform_view_test.dart @@ -2224,6 +2224,927 @@ void main() { }); }); + group('AppKitView', () { + testWidgets('Create AppView', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + + expect( + viewsController.views, + unorderedEquals([ + FakeAppKitView(currentViewId + 1, 'webview'), + ]), + ); + }); + + testWidgets('Change AppKitView view type', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + viewsController.registerViewType('maps'); + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'maps', layoutDirection: TextDirection.ltr), + ), + ), + ); + + expect( + viewsController.views, + unorderedEquals([ + FakeAppKitView(currentViewId + 2, 'maps'), + ]), + ); + }); + + testWidgets('Dispose AppKitView ', (WidgetTester tester) async { + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + ), + ), + ); + + expect( + viewsController.views, + isEmpty, + ); + }); + + testWidgets('Dispose AppKitView before creation completed ', (WidgetTester tester) async { + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + viewsController.creationDelay = Completer(); + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + ), + ), + ); + + viewsController.creationDelay!.complete(); + + expect( + viewsController.views, + isEmpty, + ); + }); + + testWidgets('AppKitView survives widget tree change', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr, key: key), + ), + ), + ); + + await tester.pumpWidget( + Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr, key: key), + ), + ), + ); + + expect( + viewsController.views, + unorderedEquals([ + FakeAppKitView(currentViewId + 1, 'webview'), + ]), + ); + }); + + testWidgets('Create AppKitView with params', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + creationParams: 'creation parameters', + creationParamsCodec: StringCodec(), + ), + ), + ), + ); + + final FakeAppKitView fakeView = viewsController.views.first; + final Uint8List rawCreationParams = fakeView.creationParams!; + final ByteData byteData = ByteData.view( + rawCreationParams.buffer, + rawCreationParams.offsetInBytes, + rawCreationParams.lengthInBytes, + ); + final dynamic actualParams = const StringCodec().decodeMessage(byteData); + + expect(actualParams, 'creation parameters'); + expect( + viewsController.views, + unorderedEquals([ + FakeAppKitView(currentViewId + 1, 'webview', fakeView.creationParams), + ]), + ); + }); + + // TODO(schectman): De-skip the following tests once macOS gesture recognizers are present. + // https://github.com/flutter/flutter/issues/128519 + testWidgets('AppKitView accepts gestures', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 0); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + }, skip: true); // https://github.com/flutter/flutter/issues/128519 + + testWidgets('AppKitView transparent hit test behavior', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + int numPointerDownsOnParent = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (PointerDownEvent e) { + numPointerDownsOnParent++; + }, + ), + const Positioned( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView( + viewType: 'webview', + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ], + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 0); + + expect(numPointerDownsOnParent, 1); + }, skip: true); // https://github.com/flutter/flutter/issues/128519 + + testWidgets('AppKitView translucent hit test behavior', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + int numPointerDownsOnParent = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (PointerDownEvent e) { + numPointerDownsOnParent++; + }, + ), + const Positioned( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView( + viewType: 'webview', + hitTestBehavior: PlatformViewHitTestBehavior.translucent, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ], + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + + expect(numPointerDownsOnParent, 1); + }, skip: true); // https://github.com/flutter/flutter/issues/128519 + + testWidgets('AppKitView opaque hit test behavior', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + int numPointerDownsOnParent = 0; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (PointerDownEvent e) { + numPointerDownsOnParent++; + }, + ), + const Positioned( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + ), + ), + ), + ], + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(numPointerDownsOnParent, 0); + }, skip: true); // https://github.com/flutter/flutter/issues/128519 + + testWidgets('UiKitView can lose gesture arenas', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + + bool verticalDragAcceptedByParent = false; + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: Container( + margin: const EdgeInsets.all(10.0), + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { + verticalDragAcceptedByParent = true; + }, + child: const SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + 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.gesturesAccepted[currentViewId + 1], 0); + expect(viewsController.gesturesRejected[currentViewId + 1], 1); + }); + + testWidgets('UiKitView tap gesture recognizers', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + bool gestureAcceptedByParent = false; + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { + gestureAcceptedByParent = true; + }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + gestureRecognizers: >{ + Factory( + () { + return VerticalDragGestureRecognizer(); + }, + ), + }, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + 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(gestureAcceptedByParent, false); + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView long press gesture recognizers', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + bool gestureAcceptedByParent = false; + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onLongPress: () { + gestureAcceptedByParent = true; + }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + gestureRecognizers: >{ + Factory( + () { + return LongPressGestureRecognizer(); + }, + ), + }, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + await tester.longPressAt(const Offset(50.0, 50.0)); + + expect(gestureAcceptedByParent, false); + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView drag gesture recognizers', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + bool verticalDragAcceptedByParent = false; + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { + verticalDragAcceptedByParent = true; + }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + gestureRecognizers: >{ + Factory( + () { + return TapGestureRecognizer(); + }, + ), + }, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + await tester.tapAt(const Offset(50.0, 50.0)); + + expect(verticalDragAcceptedByParent, false); + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView can claim gesture after all pointers are up', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + bool verticalDragAcceptedByParent = false; + // The long press recognizer rejects the gesture after the AndroidView gets the pointer up event. + // This test makes sure that the Android view can win the gesture after it got the pointer up event. + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { + verticalDragAcceptedByParent = true; + }, + onLongPress: () { }, + child: const SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(verticalDragAcceptedByParent, false); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView rebuilt during gesture', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + await tester.pumpWidget( + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.moveBy(const Offset(0.0, 100.0)); + + await tester.pumpWidget( + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + ), + ), + ), + ); + + await gesture.up(); + + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView with eager gesture recognizer', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + await tester.pumpWidget( + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onVerticalDragStart: (DragStartDetails d) { }, + child: SizedBox( + width: 200.0, + height: 100.0, + child: UiKitView( + viewType: 'webview', + gestureRecognizers: >{ + Factory( + () => EagerGestureRecognizer(), + ), + }, + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + await tester.startGesture(const Offset(50.0, 50.0)); + + // Normally (without the eager gesture recognizer) after just the pointer down event + // no gesture arena member will claim the arena (so no motion events will be dispatched to + // the Android view). Here we assert that with the eager recognizer in the gesture team the + // pointer down event is immediately dispatched. + expect(viewsController.gesturesAccepted[currentViewId + 1], 1); + expect(viewsController.gesturesRejected[currentViewId + 1], 0); + }); + + testWidgets('UiKitView rejects gestures absorbed by siblings', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + Stack( + alignment: Alignment.topLeft, + children: [ + const UiKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + Container( + color: const Color.fromARGB(255, 255, 255, 255), + width: 100, + height: 100, + ), + ], + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 50.0)); + await gesture.up(); + + expect(viewsController.gesturesRejected[currentViewId + 1], 1); + expect(viewsController.gesturesAccepted[currentViewId + 1], 0); + }); + + testWidgets( + 'UiKitView rejects gestures absorbed by siblings if the touch is outside of the platform view bounds but inside platform view frame', + (WidgetTester tester) async { + // UiKitView is positioned at (left=0, top=100, right=300, bottom=600). + // Opaque container is on top of the UiKitView positioned at (left=0, top=500, right=300, bottom=600). + // Touch on (550, 150) is expected to be absorbed by the container. + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + SizedBox( + width: 300, + height: 600, + child: Stack( + alignment: Alignment.topLeft, + children: [ + Transform.translate( + offset: const Offset(0, 100), + child: const SizedBox( + width: 300, + height: 500, + child: UiKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + Transform.translate( + offset: const Offset(0, 500), + child: Container( + color: const Color.fromARGB(255, 255, 255, 255), + width: 300, + height: 100, + ), + ), + ], + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final TestGesture gesture = await tester.startGesture(const Offset(150, 550)); + await gesture.up(); + + expect(viewsController.gesturesRejected[currentViewId + 1], 1); + expect(viewsController.gesturesAccepted[currentViewId + 1], 0); + }, + ); + + testWidgets('UiKitView rebuilt with same gestureRecognizers', (WidgetTester tester) async { + final FakeIosPlatformViewsController viewsController = FakeIosPlatformViewsController(); + viewsController.registerViewType('webview'); + + int factoryInvocationCount = 0; + EagerGestureRecognizer constructRecognizer() { + factoryInvocationCount += 1; + return EagerGestureRecognizer(); + } + + await tester.pumpWidget( + UiKitView( + viewType: 'webview', + gestureRecognizers: >{ + Factory(constructRecognizer), + }, + layoutDirection: TextDirection.ltr, + ), + ); + + await tester.pumpWidget( + UiKitView( + viewType: 'webview', + hitTestBehavior: PlatformViewHitTestBehavior.translucent, + gestureRecognizers: >{ + Factory(constructRecognizer), + }, + layoutDirection: TextDirection.ltr, + ), + ); + + expect(factoryInvocationCount, 1); + }); + + testWidgets('AppKitView can take input focus', (WidgetTester tester) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + final GlobalKey containerKey = GlobalKey(); + await tester.pumpWidget( + Center( + child: Column( + children: [ + const SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + Focus( + debugLabel: 'container', + child: Container(key: containerKey), + ), + ], + ), + ), + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final Focus uiKitViewFocusWidget = tester.widget( + find.descendant( + of: find.byType(AppKitView), + matching: find.byType(Focus), + ), + ); + final FocusNode uiKitViewFocusNode = uiKitViewFocusWidget.focusNode!; + final Element containerElement = tester.element(find.byKey(containerKey)); + final FocusNode containerFocusNode = Focus.of(containerElement); + + containerFocusNode.requestFocus(); + + await tester.pump(); + + expect(containerFocusNode.hasFocus, isTrue); + expect(uiKitViewFocusNode.hasFocus, isFalse); + + viewsController.invokeViewFocused(currentViewId + 1); + + await tester.pump(); + + expect(containerFocusNode.hasFocus, isFalse); + expect(uiKitViewFocusNode.hasFocus, isTrue); + }); + + testWidgets('AppKitView sends TextInput.setPlatformViewClient when focused', (WidgetTester tester) async { + + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + const AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr) + ); + + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final Focus uiKitViewFocusWidget = tester.widget( + find.descendant( + of: find.byType(AppKitView), + matching: find.byType(Focus), + ), + ); + final FocusNode uiKitViewFocusNode = uiKitViewFocusWidget.focusNode!; + + late Map channelArguments; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.textInput, (MethodCall call) { + if (call.method == 'TextInput.setPlatformViewClient') { + channelArguments = call.arguments as Map; + } + return null; + }); + + expect(uiKitViewFocusNode.hasFocus, false); + + uiKitViewFocusNode.requestFocus(); + await tester.pump(); + + expect(uiKitViewFocusNode.hasFocus, true); + expect(channelArguments['platformViewId'], currentViewId + 1); + }); + + testWidgets('FocusNode is disposed on UIView dispose', (WidgetTester tester) async { + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView(viewType: 'webview', layoutDirection: TextDirection.ltr), + ), + ), + ); + // casting to dynamic is required since the state class is private. + // ignore: avoid_dynamic_calls, invalid_assignment + final FocusNode node = (tester.state(find.byType(AppKitView)) as dynamic).focusNode; + expect(() => ChangeNotifier.debugAssertNotDisposed(node), isNot(throwsAssertionError)); + await tester.pumpWidget( + const Center( + child: SizedBox( + width: 200.0, + height: 100.0, + ), + ), + ); + expect(() => ChangeNotifier.debugAssertNotDisposed(node), throwsAssertionError); + }); + + testWidgets('AppKitView has correct semantics', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + expect(currentViewId, greaterThanOrEqualTo(0)); + final FakeMacosPlatformViewsController viewsController = FakeMacosPlatformViewsController(); + viewsController.registerViewType('webview'); + + await tester.pumpWidget( + Semantics( + container: true, + child: const Align( + alignment: Alignment.bottomRight, + child: SizedBox( + width: 200.0, + height: 100.0, + child: AppKitView( + viewType: 'webview', + layoutDirection: TextDirection.ltr, + ), + ), + ), + ), + ); + // First frame is before the platform view was created so the render object + // is not yet in the tree. + await tester.pump(); + + final SemanticsNode semantics = tester.getSemantics( + find.descendant( + of: find.byType(AppKitView), + matching: find.byWidgetPredicate( + (Widget widget) => widget.runtimeType.toString() == '_AppKitPlatformView', + ), + ), + ); + + expect(semantics.platformViewId, currentViewId + 1); + expect(semantics.rect, const Rect.fromLTWH(0, 0, 200, 100)); + // A 200x100 rect positioned at bottom right of a 800x600 box. + expect(semantics.transform, Matrix4.translationValues(600, 500, 0)); + expect(semantics.childrenCount, 0); + + handle.dispose(); + }); + }); + group('Common PlatformView', () { late FakePlatformViewController controller;