mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
SelectableRegion should use flutter rendered menu on the web for Android and iOS (#177122)
This change updates the default context menu when using `SelectionArea`/`SelectableRegion` on Android or iOS web. Previously: the native browser menu would be used. After this change: the flutter rendered context menu is used. Fixes https://github.com/flutter/flutter/issues/151468 - [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]. - [x] 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] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --------- Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
parent
0ed3ad930d
commit
6fc3313409
@ -303,18 +303,21 @@ class SelectableRegion extends StatefulWidget {
|
||||
}) {
|
||||
final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed;
|
||||
final bool canSelectAll = selectionGeometry.hasContent;
|
||||
final bool platformCanShare = switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android => selectionGeometry.status == SelectionStatus.uncollapsed,
|
||||
TargetPlatform.macOS ||
|
||||
TargetPlatform.fuchsia ||
|
||||
TargetPlatform.linux ||
|
||||
TargetPlatform.windows => false,
|
||||
// TODO(bleroux): the share button should be shown on iOS but the share
|
||||
// functionality requires some changes on the engine side because, on iPad,
|
||||
// it needs an anchor for the popup.
|
||||
// See: https://github.com/flutter/flutter/issues/141775.
|
||||
TargetPlatform.iOS => false,
|
||||
};
|
||||
// The share button is not supported on the web.
|
||||
final bool platformCanShare =
|
||||
!kIsWeb &&
|
||||
switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android => selectionGeometry.status == SelectionStatus.uncollapsed,
|
||||
TargetPlatform.macOS ||
|
||||
TargetPlatform.fuchsia ||
|
||||
TargetPlatform.linux ||
|
||||
TargetPlatform.windows => false,
|
||||
// TODO(bleroux): the share button should be shown on iOS but the share
|
||||
// functionality requires some changes on the engine side because, on iPad,
|
||||
// it needs an anchor for the popup.
|
||||
// See: https://github.com/flutter/flutter/issues/141775.
|
||||
TargetPlatform.iOS => false,
|
||||
};
|
||||
final bool canShare = onShare != null && platformCanShare;
|
||||
|
||||
// On Android, the share button is before the select all button.
|
||||
@ -410,7 +413,14 @@ class SelectableRegionState extends State<SelectableRegion>
|
||||
SelectedContent? _lastSelectedContent;
|
||||
|
||||
/// Whether the native browser context menu is enabled.
|
||||
bool get _webContextMenuEnabled => kIsWeb && BrowserContextMenu.enabled;
|
||||
// TODO(Renzo-Olivares): Re-enable web context menu for Android
|
||||
// and iOS when https://github.com/flutter/flutter/issues/177123
|
||||
// is resolved.
|
||||
bool get _webContextMenuEnabled =>
|
||||
kIsWeb &&
|
||||
BrowserContextMenu.enabled &&
|
||||
defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS;
|
||||
|
||||
/// The [SelectionOverlay] that is currently visible on the screen.
|
||||
///
|
||||
|
||||
@ -130,6 +130,50 @@ void main() {
|
||||
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.iOS}),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'builds the default context menu by default on Android and iOS web',
|
||||
(WidgetTester tester) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectionArea(focusNode: focusNode, child: const Text('How are you?')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
|
||||
|
||||
// Show the toolbar by longpressing.
|
||||
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(
|
||||
find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)),
|
||||
);
|
||||
final TestGesture gesture = await tester.startGesture(
|
||||
textOffsetToPosition(paragraph1, 6),
|
||||
); // at the 'r'
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
// `are` is selected.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
|
||||
},
|
||||
// TODO(Renzo-Olivares): Remove this test when the web context menu
|
||||
// for Android and iOS is re-enabled.
|
||||
// See: https://github.com/flutter/flutter/issues/177123.
|
||||
// [intended] Android and iOS use the flutter rendered menu on the web.
|
||||
skip:
|
||||
!kIsWeb ||
|
||||
!<TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
}.contains(defaultTargetPlatform),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'builds the default context menu by default',
|
||||
(WidgetTester tester) async {
|
||||
|
||||
@ -24,6 +24,14 @@ extension on web.CSSRuleList {
|
||||
Iterable<web.CSSRule?>.generate(length, (int index) => item(index));
|
||||
}
|
||||
|
||||
bool _browserContextMenuEnabled() {
|
||||
// TODO(Renzo-Olivares): Remove this when the web context menu
|
||||
// for Android and iOS is re-enabled.
|
||||
// See: https://github.com/flutter/flutter/issues/177123.
|
||||
return kIsWeb &&
|
||||
!<TargetPlatform>{TargetPlatform.android, TargetPlatform.iOS}.contains(defaultTargetPlatform);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakePlatformViewRegistry fakePlatformViewRegistry;
|
||||
|
||||
@ -39,128 +47,144 @@ void main() {
|
||||
PlatformSelectableRegionContextMenu.debugResetRegistry();
|
||||
});
|
||||
|
||||
testWidgets('DOM element is set up correctly', (WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
testWidgets(
|
||||
'DOM element is set up correctly',
|
||||
(WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
|
||||
expect(element, isNotNull);
|
||||
expect(element.style.width, '100%');
|
||||
expect(element.style.height, '100%');
|
||||
expect(element.classList.length, 1);
|
||||
expect(element, isNotNull);
|
||||
expect(element.style.width, '100%');
|
||||
expect(element.style.height, '100%');
|
||||
expect(element.classList.length, 1);
|
||||
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
});
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
},
|
||||
skip: !_browserContextMenuEnabled(), // [intended]
|
||||
);
|
||||
|
||||
testWidgets('only one <style> is inserted into the DOM', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ListView(
|
||||
children: <Widget>[
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
],
|
||||
testWidgets(
|
||||
'only one <style> is inserted into the DOM',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ListView(
|
||||
children: <Widget>[
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
});
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
},
|
||||
skip: !_browserContextMenuEnabled(), // [intended]
|
||||
);
|
||||
|
||||
testWidgets('right click can trigger select word', (WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
testWidgets(
|
||||
'right click can trigger select word',
|
||||
(WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final UniqueKey spy = UniqueKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: focusNode,
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: SelectionSpy(key: spy),
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final UniqueKey spy = UniqueKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: focusNode,
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: SelectionSpy(key: spy),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
expect(element, isNotNull);
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
expect(element, isNotNull);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
// Dispatch right click.
|
||||
element.dispatchEvent(
|
||||
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
|
||||
);
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
|
||||
find.byKey(spy),
|
||||
);
|
||||
expect(renderSelectionSpy.events, isNotEmpty);
|
||||
// Dispatch right click.
|
||||
element.dispatchEvent(
|
||||
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
|
||||
);
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
|
||||
find.byKey(spy),
|
||||
);
|
||||
expect(renderSelectionSpy.events, isNotEmpty);
|
||||
|
||||
SelectWordSelectionEvent? selectWordEvent;
|
||||
for (final SelectionEvent event in renderSelectionSpy.events) {
|
||||
if (event is SelectWordSelectionEvent) {
|
||||
selectWordEvent = event;
|
||||
break;
|
||||
SelectWordSelectionEvent? selectWordEvent;
|
||||
for (final SelectionEvent event in renderSelectionSpy.events) {
|
||||
if (event is SelectWordSelectionEvent) {
|
||||
selectWordEvent = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(selectWordEvent, isNotNull);
|
||||
expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue);
|
||||
expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue);
|
||||
});
|
||||
expect(selectWordEvent, isNotNull);
|
||||
expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue);
|
||||
expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue);
|
||||
},
|
||||
skip: !_browserContextMenuEnabled(), // [intended]
|
||||
);
|
||||
|
||||
// Regression test for https://github.com/flutter/flutter/issues/157579
|
||||
testWidgets('prevents default action of mousedown events', (WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
testWidgets(
|
||||
'prevents default action of mousedown events',
|
||||
(WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: emptyTextSelectionControls,
|
||||
child: const SizedBox.shrink(),
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: emptyTextSelectionControls,
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
expect(element, isNotNull);
|
||||
|
||||
for (int i = 0; i <= 4; i++) {
|
||||
final web.MouseEvent event = web.MouseEvent(
|
||||
'mousedown',
|
||||
web.MouseEventInit(button: i, clientX: 200, clientY: 300, cancelable: true),
|
||||
);
|
||||
element.dispatchEvent(event);
|
||||
expect(event.defaultPrevented, isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
expect(element, isNotNull);
|
||||
|
||||
for (int i = 0; i <= 4; i++) {
|
||||
final web.MouseEvent event = web.MouseEvent(
|
||||
'mousedown',
|
||||
web.MouseEventInit(button: i, clientX: 200, clientY: 300, cancelable: true),
|
||||
);
|
||||
element.dispatchEvent(event);
|
||||
expect(event.defaultPrevented, isTrue);
|
||||
}
|
||||
},
|
||||
skip: !_browserContextMenuEnabled(), // [intended]
|
||||
);
|
||||
}
|
||||
|
||||
void removeAllStyleElements() {
|
||||
|
||||
@ -6446,6 +6446,56 @@ void main() {
|
||||
},
|
||||
skip: !kIsWeb, // [intended] This test verifies web behavior.
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'uses contextMenuBuilder by default on Android and iOS web',
|
||||
(WidgetTester tester) async {
|
||||
final UniqueKey contextMenu = UniqueKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: materialTextSelectionHandleControls,
|
||||
contextMenuBuilder:
|
||||
(BuildContext context, SelectableRegionState selectableRegionState) {
|
||||
return SizedBox.shrink(key: contextMenu);
|
||||
},
|
||||
child: const Text('How are you?'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(contextMenu), findsNothing);
|
||||
|
||||
// Show the toolbar by longpressing.
|
||||
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(
|
||||
find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)),
|
||||
);
|
||||
final TestGesture gesture = await tester.startGesture(
|
||||
textOffsetToPosition(paragraph1, 6),
|
||||
); // at the 'r'
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
// `are` is selected.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(contextMenu), findsOneWidget);
|
||||
},
|
||||
// TODO(Renzo-Olivares): Remove this test when the web context menu
|
||||
// for Android and iOS is re-enabled.
|
||||
// See: https://github.com/flutter/flutter/issues/177123.
|
||||
// [intended] Android and iOS use the flutter rendered menu on the web.
|
||||
skip:
|
||||
!kIsWeb ||
|
||||
!<TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
}.contains(defaultTargetPlatform),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Multiple selectables on a single line should be in screen order', (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user