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;