Unverified Commit bef97630 authored by Bruno Leroux's avatar Bruno Leroux Committed by GitHub

Add Share button to the SelectableRegion toolbar on Android (#141447)

## Description

This PR adds the share button to text selection toolbar buttons on Android ~~and iOS~~ for `SelectableRegion` (and therefore `SelectionArea`).

https://github.com/flutter/flutter/pull/139479 adds this button for `EditableText` (which is used by `TextField` and `SelectableText` but not by `SelectionArea`).

**Edit**: supporting this on iOS will need more work (see https://github.com/flutter/flutter/pull/141447#issuecomment-1889942622 and https://github.com/flutter/flutter/issues/141775).

## Related Issue

Follow up for https://github.com/flutter/flutter/issues/138728

## Tests

Adds 1 test.
parent 63018fe6
...@@ -152,6 +152,7 @@ class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget { ...@@ -152,6 +152,7 @@ class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget {
selectionGeometry: selectionGeometry, selectionGeometry: selectionGeometry,
onCopy: onCopy, onCopy: onCopy,
onSelectAll: onSelectAll, onSelectAll: onSelectAll,
onShare: null, // See https://github.com/flutter/flutter/issues/141775.
); );
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors} /// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
......
...@@ -150,6 +150,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget { ...@@ -150,6 +150,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
super.key, super.key,
required VoidCallback onCopy, required VoidCallback onCopy,
required VoidCallback onSelectAll, required VoidCallback onSelectAll,
required VoidCallback? onShare,
required SelectionGeometry selectionGeometry, required SelectionGeometry selectionGeometry,
required this.anchors, required this.anchors,
}) : children = null, }) : children = null,
...@@ -157,6 +158,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget { ...@@ -157,6 +158,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
selectionGeometry: selectionGeometry, selectionGeometry: selectionGeometry,
onCopy: onCopy, onCopy: onCopy,
onSelectAll: onSelectAll, onSelectAll: onSelectAll,
onShare: onShare,
); );
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
......
...@@ -267,9 +267,29 @@ class SelectableRegion extends StatefulWidget { ...@@ -267,9 +267,29 @@ class SelectableRegion extends StatefulWidget {
required final SelectionGeometry selectionGeometry, required final SelectionGeometry selectionGeometry,
required final VoidCallback onCopy, required final VoidCallback onCopy,
required final VoidCallback onSelectAll, required final VoidCallback onSelectAll,
required final VoidCallback? onShare,
}) { }) {
final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed; final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed;
final bool canSelectAll = selectionGeometry.hasContent; 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,
};
final bool canShare = onShare != null && platformCanShare;
// On Android, the share button is before the select all button.
final bool showShareBeforeSelectAll = defaultTargetPlatform == TargetPlatform.android;
// Determine which buttons will appear so that the order and total number is // Determine which buttons will appear so that the order and total number is
// known. A button's position in the menu can slightly affect its // known. A button's position in the menu can slightly affect its
...@@ -280,11 +300,21 @@ class SelectableRegion extends StatefulWidget { ...@@ -280,11 +300,21 @@ class SelectableRegion extends StatefulWidget {
onPressed: onCopy, onPressed: onCopy,
type: ContextMenuButtonType.copy, type: ContextMenuButtonType.copy,
), ),
if (canShare && showShareBeforeSelectAll)
ContextMenuButtonItem(
onPressed: onShare,
type: ContextMenuButtonType.share,
),
if (canSelectAll) if (canSelectAll)
ContextMenuButtonItem( ContextMenuButtonItem(
onPressed: onSelectAll, onPressed: onSelectAll,
type: ContextMenuButtonType.selectAll, type: ContextMenuButtonType.selectAll,
), ),
if (canShare && !showShareBeforeSelectAll)
ContextMenuButtonItem(
onPressed: onShare,
type: ContextMenuButtonType.share,
),
]; ];
} }
...@@ -1089,6 +1119,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -1089,6 +1119,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
await Clipboard.setData(ClipboardData(text: data.plainText)); await Clipboard.setData(ClipboardData(text: data.plainText));
} }
Future<void> _share() async {
final SelectedContent? data = _selectable?.getSelectedContent();
if (data == null) {
return;
}
await SystemChannels.platform.invokeMethod('Share.invoke', data.plainText);
}
/// {@macro flutter.widgets.EditableText.getAnchors} /// {@macro flutter.widgets.EditableText.getAnchors}
/// ///
/// See also: /// See also:
...@@ -1191,7 +1229,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -1191,7 +1229,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
onCopy: () { onCopy: () {
_copy(); _copy();
// In Android copy should clear the selection. // On Android copy should clear the selection.
switch (defaultTargetPlatform) { switch (defaultTargetPlatform) {
case TargetPlatform.android: case TargetPlatform.android:
case TargetPlatform.fuchsia: case TargetPlatform.fuchsia:
...@@ -1217,6 +1255,22 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe ...@@ -1217,6 +1255,22 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
hideToolbar(); hideToolbar();
} }
}, },
onShare: () {
_share();
// On Android, share should clear the selection.
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
_clearSelection();
case TargetPlatform.iOS:
hideToolbar(false);
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
hideToolbar();
}
},
)..addAll(_textProcessingActionButtonItems); )..addAll(_textProcessingActionButtonItems);
} }
......
...@@ -378,40 +378,40 @@ void main() { ...@@ -378,40 +378,40 @@ void main() {
expect(edgeEvent.granularity, TextGranularity.word); expect(edgeEvent.granularity, TextGranularity.word);
}); });
testWidgets( testWidgets(
'touch long press cancel does not send ClearSelectionEvent', 'touch long press cancel does not send ClearSelectionEvent',
(WidgetTester tester) async { (WidgetTester tester) async {
final UniqueKey spy = UniqueKey(); final UniqueKey spy = UniqueKey();
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose); addTearDown(focusNode.dispose);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: SelectableRegion( home: SelectableRegion(
focusNode: focusNode, focusNode: focusNode,
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy), child: SelectionSpy(key: spy),
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle();
final RenderSelectionSpy renderSelectionSpy = final RenderSelectionSpy renderSelectionSpy =
tester.renderObject<RenderSelectionSpy>(find.byKey(spy)); tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
renderSelectionSpy.events.clear(); renderSelectionSpy.events.clear();
final TestGesture gesture = final TestGesture gesture =
await tester.startGesture(const Offset(200.0, 200.0)); await tester.startGesture(const Offset(200.0, 200.0));
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
await gesture.cancel(); await gesture.cancel();
expect( expect(
renderSelectionSpy.events.any((SelectionEvent element) => element is ClearSelectionEvent), renderSelectionSpy.events.any((SelectionEvent element) => element is ClearSelectionEvent),
isFalse, isFalse,
); );
}, },
); );
testWidgets( testWidgets(
'scrolling after the selection does not send ClearSelectionEvent', 'scrolling after the selection does not send ClearSelectionEvent',
...@@ -3171,7 +3171,7 @@ void main() { ...@@ -3171,7 +3171,7 @@ void main() {
}); });
}); });
testWidgets('toolbar is hidden on mobile when orientation changes', (WidgetTester tester) async { testWidgets('toolbar is hidden on Android and iOS when orientation changes', (WidgetTester tester) async {
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose); addTearDown(focusNode.dispose);
...@@ -3214,8 +3214,8 @@ void main() { ...@@ -3214,8 +3214,8 @@ void main() {
defaultTargetPlatform == TargetPlatform.android ? findsNothing : findsNWidgets(2), defaultTargetPlatform == TargetPlatform.android ? findsNothing : findsNWidgets(2),
); );
}, },
skip: kIsWeb, // [intended] Web uses its native context menu.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }), variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
skip: kIsWeb, // [intended] Web uses its native context menu.
); );
testWidgets('the selection behavior when clicking `Copy` item in mobile platforms', (WidgetTester tester) async { testWidgets('the selection behavior when clicking `Copy` item in mobile platforms', (WidgetTester tester) async {
...@@ -3247,10 +3247,8 @@ void main() { ...@@ -3247,10 +3247,8 @@ void main() {
// `are` is selected. // `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
expect(buttonItems.length, 2); // Press `Copy` item.
expect(buttonItems[0].type, ContextMenuButtonType.copy); expect(buttonItems[0].type, ContextMenuButtonType.copy);
// Press `Copy` item
buttonItems[0].onPressed?.call(); buttonItems[0].onPressed?.call();
final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion)); final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion));
...@@ -3269,10 +3267,11 @@ void main() { ...@@ -3269,10 +3267,11 @@ void main() {
case TargetPlatform.linux: case TargetPlatform.linux:
case TargetPlatform.macOS: case TargetPlatform.macOS:
case TargetPlatform.windows: case TargetPlatform.windows:
expect(regionState.selectionOverlay, isNotNull); // Test doesn't run these platforms.
} break; }
}, },
skip: kIsWeb, // [intended] variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web uses its native context menu.
); );
testWidgets('the handles do not disappear when clicking `Select all` item in mobile platforms', (WidgetTester tester) async { testWidgets('the handles do not disappear when clicking `Select all` item in mobile platforms', (WidgetTester tester) async {
...@@ -3304,11 +3303,23 @@ void main() { ...@@ -3304,11 +3303,23 @@ void main() {
// `are` is selected. // `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
expect(buttonItems.length, 2); late ContextMenuButtonItem selectAllButton;
expect(buttonItems[1].type, ContextMenuButtonType.selectAll); switch (defaultTargetPlatform) {
case TargetPlatform.android:
// On Android, the select all button is after the share button.
expect(buttonItems[2].type, ContextMenuButtonType.selectAll);
selectAllButton = buttonItems[2];
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(buttonItems[1].type, ContextMenuButtonType.selectAll);
selectAllButton = buttonItems[1];
}
// Press `Select All` item // Press `Select All` item.
buttonItems[1].onPressed?.call(); selectAllButton.onPressed?.call();
final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion)); final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion));
...@@ -3325,14 +3336,73 @@ void main() { ...@@ -3325,14 +3336,73 @@ void main() {
// Test doesn't run these platforms. // Test doesn't run these platforms.
break; break;
} }
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // [intended] Web uses its native context menu.
);
testWidgets('Selection behavior when clicking the `Share` button on Android', (WidgetTester tester) async {
List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
SelectableRegionState selectableRegionState,
) {
buttonItems = selectableRegionState.contextMenuButtonItems;
return const SizedBox.shrink();
},
child: const Text('How are you?'),
),
),
);
await tester.pumpAndSettle();
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.text('How are you?'),
matching: find.byType(RichText),
),
);
await tester.longPressAt(textOffsetToPosition(paragraph, 6)); // at the 'r'
await tester.pump(kLongPressTimeout);
// `are` is selected.
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
String? lastShare;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Share.invoke') {
expect(methodCall.arguments, isA<String>());
lastShare = methodCall.arguments as String;
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion));
// Press the `Share` button.
expect(buttonItems[1].type, ContextMenuButtonType.share);
buttonItems[1].onPressed?.call();
expect(lastShare, 'are');
// On Android, share should clear the selection.
expect(regionState.selectionOverlay, isNull);
expect(regionState.selectionOverlay?.startHandleLayerLink, isNull);
expect(regionState.selectionOverlay?.endHandleLayerLink, isNull);
}, },
skip: kIsWeb, // [intended] skip: kIsWeb, // [intended] Web uses its native context menu.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.fuchsia }),
); );
testWidgets('builds the correct button items', (WidgetTester tester) async { testWidgets('builds the correct button items', (WidgetTester tester) async {
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{}; List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose); addTearDown(focusNode.dispose);
...@@ -3345,9 +3415,7 @@ void main() { ...@@ -3345,9 +3415,7 @@ void main() {
BuildContext context, BuildContext context,
SelectableRegionState selectableRegionState, SelectableRegionState selectableRegionState,
) { ) {
buttonTypes = selectableRegionState.contextMenuButtonItems buttonItems = selectableRegionState.contextMenuButtonItems;
.map((ContextMenuButtonItem buttonItem) => buttonItem.type)
.toSet();
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
child: const Text('How are you?'), child: const Text('How are you?'),
...@@ -3358,21 +3426,40 @@ void main() { ...@@ -3358,21 +3426,40 @@ void main() {
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r' find.descendant(
of: find.text('How are you?'),
matching: find.byType(RichText),
),
);
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r'
addTearDown(gesture.removePointer); addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
// `are` is selected. // `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(buttonTypes, contains(ContextMenuButtonType.copy)); switch (defaultTargetPlatform) {
expect(buttonTypes, contains(ContextMenuButtonType.selectAll)); case TargetPlatform.android:
// On Android, the share button is before the select all button.
expect(buttonItems.length, 3);
expect(buttonItems[0].type, ContextMenuButtonType.copy);
expect(buttonItems[1].type, ContextMenuButtonType.share);
expect(buttonItems[2].type, ContextMenuButtonType.selectAll);
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(buttonItems.length, 2);
expect(buttonItems[0].type, ContextMenuButtonType.copy);
expect(buttonItems[1].type, ContextMenuButtonType.selectAll);
}
}, },
variant: TargetPlatformVariant.all(), variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended] skip: kIsWeb, // [intended] Web uses its native context menu.
); );
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async { testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
...@@ -3426,7 +3513,7 @@ void main() { ...@@ -3426,7 +3513,7 @@ void main() {
expect(buttonLabels.contains(fakeAction2Label), areTextActionsSupported); expect(buttonLabels.contains(fakeAction2Label), areTextActionsSupported);
}, },
variant: TargetPlatformVariant.all(), variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended] skip: kIsWeb, // [intended] Web uses its native context menu.
); );
testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async { testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment