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 {
selectionGeometry: selectionGeometry,
onCopy: onCopy,
onSelectAll: onSelectAll,
onShare: null, // See https://github.com/flutter/flutter/issues/141775.
);
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
......
......@@ -150,6 +150,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
super.key,
required VoidCallback onCopy,
required VoidCallback onSelectAll,
required VoidCallback? onShare,
required SelectionGeometry selectionGeometry,
required this.anchors,
}) : children = null,
......@@ -157,6 +158,7 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
selectionGeometry: selectionGeometry,
onCopy: onCopy,
onSelectAll: onSelectAll,
onShare: onShare,
);
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
......
......@@ -267,9 +267,29 @@ class SelectableRegion extends StatefulWidget {
required final SelectionGeometry selectionGeometry,
required final VoidCallback onCopy,
required final VoidCallback onSelectAll,
required final VoidCallback? onShare,
}) {
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,
};
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
// known. A button's position in the menu can slightly affect its
......@@ -280,11 +300,21 @@ class SelectableRegion extends StatefulWidget {
onPressed: onCopy,
type: ContextMenuButtonType.copy,
),
if (canShare && showShareBeforeSelectAll)
ContextMenuButtonItem(
onPressed: onShare,
type: ContextMenuButtonType.share,
),
if (canSelectAll)
ContextMenuButtonItem(
onPressed: onSelectAll,
type: ContextMenuButtonType.selectAll,
),
if (canShare && !showShareBeforeSelectAll)
ContextMenuButtonItem(
onPressed: onShare,
type: ContextMenuButtonType.share,
),
];
}
......@@ -1089,6 +1119,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
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}
///
/// See also:
......@@ -1191,7 +1229,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
onCopy: () {
_copy();
// In Android copy should clear the selection.
// On Android copy should clear the selection.
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
......@@ -1217,6 +1255,22 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
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);
}
......
......@@ -378,40 +378,40 @@ void main() {
expect(edgeEvent.granularity, TextGranularity.word);
});
testWidgets(
'touch long press cancel does not send ClearSelectionEvent',
(WidgetTester tester) async {
final UniqueKey spy = UniqueKey();
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
testWidgets(
'touch long press cancel does not send ClearSelectionEvent',
(WidgetTester tester) async {
final UniqueKey spy = UniqueKey();
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: SelectionSpy(key: spy),
),
),
),
);
await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
final RenderSelectionSpy renderSelectionSpy =
tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
renderSelectionSpy.events.clear();
final TestGesture gesture =
await tester.startGesture(const Offset(200.0, 200.0));
final RenderSelectionSpy renderSelectionSpy =
tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
renderSelectionSpy.events.clear();
final TestGesture gesture =
await tester.startGesture(const Offset(200.0, 200.0));
addTearDown(gesture.removePointer);
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
await gesture.cancel();
expect(
renderSelectionSpy.events.any((SelectionEvent element) => element is ClearSelectionEvent),
isFalse,
);
},
);
await tester.pump(const Duration(milliseconds: 500));
await gesture.cancel();
expect(
renderSelectionSpy.events.any((SelectionEvent element) => element is ClearSelectionEvent),
isFalse,
);
},
);
testWidgets(
'scrolling after the selection does not send ClearSelectionEvent',
......@@ -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);
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
......@@ -3214,8 +3214,8 @@ void main() {
defaultTargetPlatform == TargetPlatform.android ? findsNothing : findsNWidgets(2),
);
},
skip: kIsWeb, // [intended] Web uses its native context menu.
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 {
......@@ -3247,10 +3247,8 @@ void main() {
// `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
expect(buttonItems.length, 2);
// Press `Copy` item.
expect(buttonItems[0].type, ContextMenuButtonType.copy);
// Press `Copy` item
buttonItems[0].onPressed?.call();
final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion));
......@@ -3269,10 +3267,11 @@ void main() {
case TargetPlatform.linux:
case TargetPlatform.macOS:
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 {
......@@ -3304,11 +3303,23 @@ void main() {
// `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
expect(buttonItems.length, 2);
expect(buttonItems[1].type, ContextMenuButtonType.selectAll);
late ContextMenuButtonItem selectAllButton;
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
buttonItems[1].onPressed?.call();
// Press `Select All` item.
selectAllButton.onPressed?.call();
final SelectableRegionState regionState = tester.state<SelectableRegionState>(find.byType(SelectableRegion));
......@@ -3325,14 +3336,73 @@ void main() {
// Test doesn't run these platforms.
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]
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.fuchsia }),
skip: kIsWeb, // [intended] Web uses its native context menu.
);
testWidgets('builds the correct button items', (WidgetTester tester) async {
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
......@@ -3345,9 +3415,7 @@ void main() {
BuildContext context,
SelectableRegionState selectableRegionState,
) {
buttonTypes = selectableRegionState.contextMenuButtonItems
.map((ContextMenuButtonItem buttonItem) => buttonItem.type)
.toSet();
buttonItems = selectableRegionState.contextMenuButtonItems;
return const SizedBox.shrink();
},
child: const Text('How are you?'),
......@@ -3358,21 +3426,40 @@ void main() {
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
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'
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(
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);
await tester.pump(const Duration(milliseconds: 500));
// `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 tester.pumpAndSettle();
expect(buttonTypes, contains(ContextMenuButtonType.copy));
expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
switch (defaultTargetPlatform) {
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(),
skip: kIsWeb, // [intended]
skip: kIsWeb, // [intended] Web uses its native context menu.
);
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {
......@@ -3426,7 +3513,7 @@ void main() {
expect(buttonLabels.contains(fakeAction2Label), areTextActionsSupported);
},
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 {
......
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