Context Menus (#107193)

* Can show context menus anywhere in the app, not just on text.
* Unifies all desktop/mobile context menus to go through one class (ContextMenuController).
* All context menus are now just plain widgets that can be fully customized.
* Existing default context menus can be customized and reused.
This commit is contained in:
Justin McCandless 2022-10-28 12:40:09 -07:00 committed by GitHub
parent ef1236e038
commit 0b451b6dfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 5068 additions and 920 deletions

View File

@ -0,0 +1,159 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This sample demonstrates allowing a context menu to be shown in a widget
// subtree in response to user gestures.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
/// A builder that includes an Offset to draw the context menu at.
typedef ContextMenuBuilder = Widget Function(BuildContext context, Offset offset);
class MyApp extends StatelessWidget {
const MyApp({super.key});
void _showDialog (BuildContext context) {
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) =>
const AlertDialog(title: Text('You clicked print!')),
),
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Context menu outside of text'),
),
body: _ContextMenuRegion(
contextMenuBuilder: (BuildContext context, Offset offset) {
// The custom context menu will look like the default context menu
// on the current platform with a single 'Print' button.
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
_showDialog(context);
},
label: 'Print',
),
],
);
},
// In this case this wraps a big open space in a GestureDetector in
// order to show the context menu, but it could also wrap a single
// wiget like an Image to give it a context menu.
child: ListView(
children: <Widget>[
Container(height: 20.0),
const Text('Right click or long press anywhere (not just on this text!) to show the custom menu.'),
],
),
),
),
);
}
}
/// Shows and hides the context menu based on user gestures.
///
/// By default, shows the menu on right clicks and long presses.
class _ContextMenuRegion extends StatefulWidget {
/// Creates an instance of [_ContextMenuRegion].
const _ContextMenuRegion({
required this.child,
required this.contextMenuBuilder,
});
/// Builds the context menu.
final ContextMenuBuilder contextMenuBuilder;
/// The child widget that will be listened to for gestures.
final Widget child;
@override
State<_ContextMenuRegion> createState() => _ContextMenuRegionState();
}
class _ContextMenuRegionState extends State<_ContextMenuRegion> {
Offset? _longPressOffset;
final ContextMenuController _contextMenuController = ContextMenuController();
static bool get _longPressEnabled {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return true;
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return false;
}
}
void _onSecondaryTapUp(TapUpDetails details) {
_show(details.globalPosition);
}
void _onTap() {
if (!_contextMenuController.isShown) {
return;
}
_hide();
}
void _onLongPressStart(LongPressStartDetails details) {
_longPressOffset = details.globalPosition;
}
void _onLongPress() {
assert(_longPressOffset != null);
_show(_longPressOffset!);
_longPressOffset = null;
}
void _show(Offset position) {
_contextMenuController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return widget.contextMenuBuilder(context, position);
},
);
}
void _hide() {
_contextMenuController.remove();
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapUp: _onSecondaryTapUp,
onTap: _onTap,
onLongPress: _longPressEnabled ? _onLongPress : null,
onLongPressStart: _longPressEnabled ? _onLongPressStart : null,
child: widget.child,
);
}
}

View File

@ -0,0 +1,64 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This example demonstrates showing the default buttons, but customizing their
// appearance.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
MyApp({super.key});
final TextEditingController _controller = TextEditingController(
text: 'Right click or long press to see the menu with custom buttons.',
);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom button appearance'),
),
body: Center(
child: Column(
children: <Widget>[
const SizedBox(height: 20.0),
TextField(
controller: _controller,
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar(
anchors: editableTextState.contextMenuAnchors,
// Build the default buttons, but make them look custom.
// In a real project you may want to build different
// buttons depending on the platform.
children: editableTextState.contextMenuButtonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoButton(
borderRadius: null,
color: const Color(0xffaaaa00),
disabledColor: const Color(0xffaaaaff),
onPressed: buttonItem.onPressed,
padding: const EdgeInsets.all(10.0),
pressedOpacity: 0.7,
child: SizedBox(
width: 200.0,
child: Text(
CupertinoTextSelectionToolbarButton.getButtonLabel(context, buttonItem),
),
),
);
}).toList(),
);
},
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This example demonstrates showing a custom context menu only when some
// narrowly defined text is selected.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
const String emailAddress = 'me@example.com';
const String text = 'Select the email address and open the menu: $emailAddress';
class MyApp extends StatelessWidget {
MyApp({super.key});
final TextEditingController _controller = TextEditingController(
text: text,
);
void _showDialog (BuildContext context) {
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) =>
const AlertDialog(title: Text('You clicked send email!')),
),
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom button for emails'),
),
body: Center(
child: Column(
children: <Widget>[
Container(height: 20.0),
TextField(
controller: _controller,
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
final List<ContextMenuButtonItem> buttonItems =
editableTextState.contextMenuButtonItems;
// Here we add an "Email" button to the default TextField
// context menu for the current platform, but only if an email
// address is currently selected.
final TextEditingValue value = _controller.value;
if (_isValidEmail(value.selection.textInside(value.text))) {
buttonItems.insert(0, ContextMenuButtonItem(
label: 'Send email',
onPressed: () {
ContextMenuController.removeAny();
_showDialog(context);
},
));
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
},
),
],
),
),
),
);
}
}
bool _isValidEmail(String text) {
return RegExp(
r'(?<name>[a-zA-Z0-9]+)'
r'@'
r'(?<domain>[a-zA-Z0-9]+)'
r'\.'
r'(?<topLevelDomain>[a-zA-Z0-9]+)',
).hasMatch(text);
}

View File

@ -0,0 +1,107 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This example demonstrates how to create a custom toolbar that retains the
// look of the default buttons for the current platform.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
MyApp({super.key});
final TextEditingController _controller = TextEditingController(
text: 'Right click or long press to see the menu with a custom toolbar.',
);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom toolbar, default-looking buttons'),
),
body: Center(
child: Column(
children: <Widget>[
const SizedBox(height: 20.0),
TextField(
controller: _controller,
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
return _MyTextSelectionToolbar(
anchor: editableTextState.contextMenuAnchors.primaryAnchor,
// getAdaptiveButtons creates the default button widgets for
// the current platform.
children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
context,
// These buttons just close the menu when clicked.
<ContextMenuButtonItem>[
ContextMenuButtonItem(
label: 'One',
onPressed: () => ContextMenuController.removeAny(),
),
ContextMenuButtonItem(
label: 'Two',
onPressed: () => ContextMenuController.removeAny(),
),
ContextMenuButtonItem(
label: 'Three',
onPressed: () => ContextMenuController.removeAny(),
),
ContextMenuButtonItem(
label: 'Four',
onPressed: () => ContextMenuController.removeAny(),
),
ContextMenuButtonItem(
label: 'Five',
onPressed: () => ContextMenuController.removeAny(),
),
],
).toList(),
);
},
),
],
),
),
),
);
}
}
/// A simple, yet totally custom, text selection toolbar.
///
/// Displays its children in a scrollable grid.
class _MyTextSelectionToolbar extends StatelessWidget {
const _MyTextSelectionToolbar({
required this.anchor,
required this.children,
});
final Offset anchor;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: anchor.dy,
left: anchor.dx,
child: Container(
width: 200.0,
height: 200.0,
color: Colors.cyanAccent.withOpacity(0.5),
child: GridView.count(
padding: const EdgeInsets.all(12.0),
crossAxisCount: 2,
children: children,
),
),
),
],
);
}
}

View File

@ -0,0 +1,68 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This example demonstrates a custom context menu in non-editable text using
// SelectionArea.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
const String text = 'I am some text inside of SelectionArea. Right click or long press me to show the customized context menu.';
class MyApp extends StatelessWidget {
const MyApp({super.key});
void _showDialog (BuildContext context) {
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) =>
const AlertDialog(title: Text('You clicked print!')),
),
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Context menu anywhere'),
),
body: Center(
child: SizedBox(
width: 200.0,
child: SelectionArea(
contextMenuBuilder: (
BuildContext context,
SelectableRegionState selectableRegionState,
) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: selectableRegionState.contextMenuAnchors,
buttonItems: <ContextMenuButtonItem>[
...selectableRegionState.contextMenuButtonItems,
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
_showDialog(context);
},
label: 'Print',
),
],
);
},
child: ListView(
children: const <Widget>[
SizedBox(height: 20.0),
Text(text),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,44 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/context_menu/context_menu_controller.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('showing and hiding the custom context menu in the whole app', (WidgetTester tester) async {
await tester.pumpWidget(
const example.MyApp(),
);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Right clicking the middle of the app shows the custom context menu.
final Offset center = tester.getCenter(find.byType(Scaffold));
final TestGesture gesture = await tester.startGesture(
center,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text('Print'), findsOneWidget);
// Tap to dismiss.
await tester.tapAt(center);
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Long pressing also shows the custom context menu.
await tester.longPressAt(center);
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text('Print'), findsOneWidget);
});
}

View File

@ -0,0 +1,36 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('showing and hiding the context menu in TextField with custom buttons', (WidgetTester tester) async {
await tester.pumpWidget(
example.MyApp(),
);
await tester.tap(find.byType(EditableText));
await tester.pump();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Long pressing the field shows the default context menu but with custom
// buttons.
await tester.longPress(find.byType(EditableText));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.byType(CupertinoButton), findsAtLeastNWidgets(1));
// Tap to dismiss.
await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoButton), findsNothing);
});
}

View File

@ -0,0 +1,69 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('showing and hiding the custom context menu in TextField with a specific selection', (WidgetTester tester) async {
await tester.pumpWidget(
example.MyApp(),
);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Right clicking the Text in the TextField shows the custom context menu,
// but no email button since no email address is selected.
TestGesture gesture = await tester.startGesture(
tester.getTopLeft(find.text(example.text)),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text('Send email'), findsNothing);
// Tap to dismiss.
await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Select the email address.
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
state.updateEditingValue(state.textEditingValue.copyWith(
selection: TextSelection(
baseOffset: example.text.indexOf(example.emailAddress),
extentOffset: example.text.length,
),
));
await tester.pump();
// Right clicking the Text in the TextField shows the custom context menu
// with the email button.
gesture = await tester.startGesture(
tester.getCenter(find.text(example.text)),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text('Send email'), findsOneWidget);
// Tap to dismiss.
await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
});
}

View File

@ -0,0 +1,64 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.2.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('showing and hiding the context menu in TextField with a custom toolbar', (WidgetTester tester) async {
await tester.pumpWidget(
example.MyApp(),
);
await tester.tap(find.byType(EditableText));
await tester.pump();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Long pressing the field shows the custom context menu.
await tester.longPress(find.byType(EditableText));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// The buttons use the default widgets but with custom labels.
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsAtLeastNWidgets(1));
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(TextSelectionToolbarTextButton), findsAtLeastNWidgets(1));
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(DesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
break;
case TargetPlatform.macOS:
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
break;
}
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
// Tap to dismiss.
await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
});
}

View File

@ -0,0 +1,41 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/context_menu/selectable_region_toolbar_builder.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('showing and hiding the custom context menu on SelectionArea', (WidgetTester tester) async {
await tester.pumpWidget(
const example.MyApp(),
);
// Allow the selection overlay geometry to be created.
await tester.pump();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Right clicking the Text in the SelectionArea shows the custom context
// menu.
final TestGesture gesture = await tester.startGesture(
tester.getCenter(find.text(example.text)),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.text('Print'), findsOneWidget);
// Tap to dismiss.
await tester.tapAt(tester.getCenter(find.byType(Scaffold)));
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
});
}

View File

@ -23,6 +23,7 @@
library cupertino;
export 'src/cupertino/activity_indicator.dart';
export 'src/cupertino/adaptive_text_selection_toolbar.dart';
export 'src/cupertino/app.dart';
export 'src/cupertino/bottom_tab_bar.dart';
export 'src/cupertino/button.dart';
@ -33,6 +34,8 @@ export 'src/cupertino/context_menu_action.dart';
export 'src/cupertino/date_picker.dart';
export 'src/cupertino/debug.dart';
export 'src/cupertino/desktop_text_selection.dart';
export 'src/cupertino/desktop_text_selection_toolbar.dart';
export 'src/cupertino/desktop_text_selection_toolbar_button.dart';
export 'src/cupertino/dialog.dart';
export 'src/cupertino/form_row.dart';
export 'src/cupertino/form_section.dart';

View File

@ -22,6 +22,7 @@ library material;
export 'src/material/about.dart';
export 'src/material/action_chip.dart';
export 'src/material/adaptive_text_selection_toolbar.dart';
export 'src/material/animated_icons.dart';
export 'src/material/app.dart';
export 'src/material/app_bar.dart';
@ -64,6 +65,8 @@ export 'src/material/date.dart';
export 'src/material/date_picker.dart';
export 'src/material/debug.dart';
export 'src/material/desktop_text_selection.dart';
export 'src/material/desktop_text_selection_toolbar.dart';
export 'src/material/desktop_text_selection_toolbar_button.dart';
export 'src/material/dialog.dart';
export 'src/material/dialog_theme.dart';
export 'src/material/divider.dart';

View File

@ -0,0 +1,226 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'desktop_text_selection_toolbar.dart';
import 'desktop_text_selection_toolbar_button.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_button.dart';
/// The default Cupertino context menu for text selection for the current
/// platform with the given children.
///
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms}
/// Builds the mobile Cupertino context menu on all mobile platforms, not just
/// iOS, and builds the desktop Cupertino context menu on all desktop platforms,
/// not just MacOS. For a widget that builds the native-looking context menu for
/// all platforms, see [AdaptiveTextSelectionToolbar].
/// {@endtemplate}
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget
/// but for all platforms, not just the Cupertino-styled platforms.
/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds
/// the Cupertino button Widgets for the current platform given
/// [ContextMenuButtonItem]s.
class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget {
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
/// given [children].
///
/// See also:
///
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
/// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list
/// of [ContextMenuButtonItem]s instead of [children] widgets.
/// {@endtemplate}
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
/// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the
/// default Cupertino children for an editable field.
/// {@endtemplate}
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
/// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the
/// default Cupertino children for an [EditableText].
/// {@endtemplate}
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
/// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the
/// Cupertino children for content that is selectable but not editable.
/// {@endtemplate}
const CupertinoAdaptiveTextSelectionToolbar({
super.key,
required this.children,
required this.anchors,
}) : buttonItems = null;
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose
/// children will be built from the given [buttonItems].
///
/// See also:
///
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
/// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children
/// directly as a list of widgets.
/// {@endtemplate}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
const CupertinoAdaptiveTextSelectionToolbar.buttonItems({
super.key,
required this.buttonItems,
required this.anchors,
}) : children = null;
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
/// default children for an editable field.
///
/// If a callback is null, then its corresponding button will not be built.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but
/// includes Material and Cupertino toolbars.
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
CupertinoAdaptiveTextSelectionToolbar.editable({
super.key,
required ClipboardStatus clipboardStatus,
required VoidCallback? onCopy,
required VoidCallback? onCut,
required VoidCallback? onPaste,
required VoidCallback? onSelectAll,
required this.anchors,
}) : children = null,
buttonItems = EditableText.getEditableButtonItems(
clipboardStatus: clipboardStatus,
onCopy: onCopy,
onCut: onCut,
onPaste: onPaste,
onSelectAll: onSelectAll,
);
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
/// default children for an [EditableText].
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this
/// but includes Material and Cupertino toolbars.
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
CupertinoAdaptiveTextSelectionToolbar.editableText({
super.key,
required EditableTextState editableTextState,
}) : children = null,
buttonItems = editableTextState.contextMenuButtonItems,
anchors = editableTextState.contextMenuAnchors;
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
/// default children for selectable, but not editable, content.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but
/// includes Material and Cupertino toolbars.
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
CupertinoAdaptiveTextSelectionToolbar.selectable({
super.key,
required VoidCallback onCopy,
required VoidCallback onSelectAll,
required SelectionGeometry selectionGeometry,
required this.anchors,
}) : children = null,
buttonItems = SelectableRegion.getSelectableButtonItems(
selectionGeometry: selectionGeometry,
onCopy: onCopy,
onSelectAll: onSelectAll,
);
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
final TextSelectionToolbarAnchors anchors;
/// The children of the toolbar, typically buttons.
final List<Widget>? children;
/// The [ContextMenuButtonItem]s that will be turned into the correct button
/// widgets for the current platform.
final List<ContextMenuButtonItem>? buttonItems;
/// Returns a List of Widgets generated by turning [buttonItems] into the
/// the default context menu buttons for Cupertino on the current platform.
///
/// This is useful when building a text selection toolbar with the default
/// button appearance for the given platform, but where the toolbar and/or the
/// button actions and labels may be custom.
///
/// Does not build Material buttons. On non-Apple platforms, Cupertino buttons
/// will still be used, because the Cupertino library does not access the
/// Material library. To get the native-looking buttons on every platform, use
/// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material
/// library.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material
/// equivalent of this class and builds only the Material buttons. It
/// includes a live example of using `getAdaptiveButtons`.
static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoTextSelectionToolbarButton.buttonItem(
buttonItem: buttonItem,
);
});
case TargetPlatform.linux:
case TargetPlatform.windows:
case TargetPlatform.macOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoDesktopTextSelectionToolbarButton.buttonItem(
buttonItem: buttonItem,
);
});
}
}
@override
Widget build(BuildContext context) {
// If there aren't any buttons to build, build an empty toolbar.
if ((children?.isEmpty ?? false) || (buttonItems?.isEmpty ?? false)) {
return const SizedBox.shrink();
}
final List<Widget> resultChildren = children
?? getAdaptiveButtons(context, buttonItems!).toList();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
return CupertinoTextSelectionToolbar(
anchorAbove: anchors.primaryAnchor,
anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor,
children: resultChildren,
);
case TargetPlatform.linux:
case TargetPlatform.windows:
case TargetPlatform.macOS:
return CupertinoDesktopTextSelectionToolbar(
anchor: anchors.primaryAnchor,
children: resultChildren,
);
}
}
}

View File

@ -3,33 +3,18 @@
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
import 'colors.dart';
import 'desktop_text_selection_toolbar.dart';
import 'desktop_text_selection_toolbar_button.dart';
import 'localizations.dart';
import 'theme.dart';
// Minimal padding from all edges of the selection toolbar to all edges of the
// screen.
const double _kToolbarScreenPadding = 8.0;
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
const double _kToolbarWidth = 222.0;
const Radius _kToolbarBorderRadius = Radius.circular(4.0);
// These values were measured from a screenshot of TextEdit on MacOS 10.16 on a
// Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFBBBBBB),
darkColor: Color(0xFF505152),
);
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xffECE8E6),
darkColor: Color(0xff302928),
);
/// MacOS Cupertino styled text selection handle controls.
///
/// Specifically does not manage the toolbar, which is left to
/// [EditableText.contextMenuBuilder].
class _CupertinoDesktopTextSelectionHandleControls extends CupertinoDesktopTextSelectionControls with TextSelectionHandleControls {
}
/// Desktop Cupertino styled text selection controls.
///
@ -42,7 +27,11 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls {
return Size.zero;
}
/// Builder for the Mac-style copy/paste text selection toolbar.
/// Builder for the MacOS-style copy/paste text selection toolbar.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
Widget buildToolbar(
BuildContext context,
@ -80,6 +69,10 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls {
return Offset.zero;
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void handleSelectAll(TextSelectionDelegate delegate) {
super.handleSelectAll(delegate);
@ -87,7 +80,15 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls {
}
}
/// Text selection controls that follows Mac design conventions.
/// Text selection handle controls that follow MacOS design conventions.
@Deprecated(
'Use `cupertinoDesktopTextSelectionControls` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final TextSelectionControls cupertinoDesktopTextSelectionHandleControls =
_CupertinoDesktopTextSelectionHandleControls();
/// Text selection controls that follows MacOS design conventions.
final TextSelectionControls cupertinoDesktopTextSelectionControls =
CupertinoDesktopTextSelectionControls();
@ -145,8 +146,8 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin
@override
void dispose() {
super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
super.dispose();
}
@override
@ -180,7 +181,7 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin
items.add(onePhysicalPixelVerticalDivider);
}
items.add(_CupertinoDesktopTextSelectionToolbarButton.text(
items.add(CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
@ -206,182 +207,9 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin
return const SizedBox.shrink();
}
return _CupertinoDesktopTextSelectionToolbar(
return CupertinoDesktopTextSelectionToolbar(
anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
children: items,
);
}
}
/// A Mac-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position itself as closely as possible to [anchor] while remaining
/// fully on-screen.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build a Mac-style toolbar.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class _CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
/// Creates an instance of CupertinoTextSelectionToolbar.
const _CupertinoDesktopTextSelectionToolbar({
required this.anchor,
required this.children,
}) : assert(children.length > 0);
/// The point at which the toolbar will attempt to position itself as closely
/// as possible.
final Offset anchor;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
/// Mac-style text selection toolbar text button.
final List<Widget> children;
// Builds a toolbar just like the default Mac toolbar, with the right color
// background, padding, and rounded corners.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return Container(
width: _kToolbarWidth,
decoration: BoxDecoration(
color: _kToolbarBackgroundColor.resolveFrom(context),
border: Border.all(
color: _kToolbarBorderColor.resolveFrom(context),
),
borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
),
child: Padding(
padding: const EdgeInsets.symmetric(
// This value was measured from a screenshot of TextEdit on MacOS
// 10.15.7 on a Macbook Pro.
vertical: 3.0,
),
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: DesktopTextSelectionToolbarLayoutDelegate(
anchor: anchor - localAdjustment,
),
child: _defaultToolbarBuilder(context, Column(
mainAxisSize: MainAxisSize.min,
children: children,
)),
),
);
}
}
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
// Macbook Pro.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
);
/// A button in the style of the Mac context menu buttons.
class _CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
/// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
const _CupertinoDesktopTextSelectionToolbarButton({
required this.onPressed,
required this.child,
});
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default Mac context menu button.
_CupertinoDesktopTextSelectionToolbarButton.text({
required BuildContext context,
required this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final VoidCallback onPressed;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
final Widget child;
@override
_CupertinoDesktopTextSelectionToolbarButtonState createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
}
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<_CupertinoDesktopTextSelectionToolbarButton> {
bool _isHovered = false;
void _onEnter(PointerEnterEvent event) {
setState(() {
_isHovered = true;
});
}
void _onExit(PointerExitEvent event) {
setState(() {
_isHovered = false;
});
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: MouseRegion(
onEnter: _onEnter,
onExit: _onExit,
child: CupertinoButton(
alignment: Alignment.centerLeft,
borderRadius: null,
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
minSize: 0.0,
onPressed: widget.onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: 0.7,
child: widget.child,
),
),
);
}
}

View File

@ -0,0 +1,114 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'colors.dart';
// The minimum padding from all edges of the selection toolbar to all edges of
// the screen.
const double _kToolbarScreenPadding = 8.0;
// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
// a Macbook Pro.
const double _kToolbarWidth = 222.0;
const Radius _kToolbarBorderRadius = Radius.circular(4.0);
const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric(
vertical: 3.0,
);
// These values were measured from a screenshot of TextEdit on macOS 10.16 on a
// Macbook Pro.
const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFBBBBBB),
darkColor: Color(0xFF505152),
);
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
color: Color(0xffECE8E6),
darkColor: Color(0xff302928),
);
/// A macOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position itself as closely as possible to [anchor] while remaining
/// fully inside the viewport.
///
/// See also:
///
/// * [CupertinoAdaptiveTextSelectionToolbar], where this is used to build the
/// toolbar for desktop platforms.
/// * [AdaptiveTextSelectionToolbar], where this is used to build the toolbar on
/// macOS.
/// * [DesktopTextSelectionToolbar], which is similar but builds a
/// Material-style desktop toolbar.
class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
/// Creates a const instance of CupertinoTextSelectionToolbar.
const CupertinoDesktopTextSelectionToolbar({
super.key,
required this.anchor,
required this.children,
}) : assert(children.length > 0);
/// {@macro flutter.material.DesktopTextSelectionToolbar.anchor}
final Offset anchor;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
/// macOS-style text selection toolbar text button.
final List<Widget> children;
// Builds a toolbar just like the default Mac toolbar, with the right color
// background, padding, and rounded corners.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return Container(
width: _kToolbarWidth,
decoration: BoxDecoration(
color: _kToolbarBackgroundColor.resolveFrom(context),
border: Border.all(
color: _kToolbarBorderColor.resolveFrom(context),
),
borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
),
child: Padding(
padding: _kToolbarPadding,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: DesktopTextSelectionToolbarLayoutDelegate(
anchor: anchor - localAdjustment,
),
child: _defaultToolbarBuilder(
context,
Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
),
);
}
}

View File

@ -0,0 +1,131 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
import 'colors.dart';
import 'text_selection_toolbar_button.dart';
import 'theme.dart';
// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
// a Macbook Pro.
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
// Macbook Pro.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
);
/// A button in the style of the Mac context menu buttons.
class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
/// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
///
/// [child] cannot be null.
const CupertinoDesktopTextSelectionToolbarButton({
super.key,
required this.onPressed,
required Widget this.child,
}) : assert(child != null),
buttonItem = null;
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default Mac context menu button.
CupertinoDesktopTextSelectionToolbarButton.text({
super.key,
required BuildContext context,
required this.onPressed,
required String text,
}) : buttonItem = null,
child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
/// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
/// the given [ContextMenuButtonItem].
///
/// [buttonItem] cannot be null.
CupertinoDesktopTextSelectionToolbarButton.buttonItem({
super.key,
required ContextMenuButtonItem this.buttonItem,
}) : assert(buttonItem != null),
onPressed = buttonItem.onPressed,
child = null;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final VoidCallback onPressed;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
final Widget? child;
/// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
final ContextMenuButtonItem? buttonItem;
@override
State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
}
class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> {
bool _isHovered = false;
void _onEnter(PointerEnterEvent event) {
setState(() {
_isHovered = true;
});
}
void _onExit(PointerExitEvent event) {
setState(() {
_isHovered = false;
});
}
@override
Widget build(BuildContext context) {
final Widget child = widget.child ?? Text(
CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: const CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
).resolveFrom(context),
),
);
return SizedBox(
width: double.infinity,
child: MouseRegion(
onEnter: _onEnter,
onExit: _onExit,
child: CupertinoButton(
alignment: Alignment.centerLeft,
borderRadius: null,
color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
minSize: 0.0,
onPressed: widget.onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: 0.7,
child: child,
),
),
);
}
}

View File

@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'desktop_text_selection.dart';
import 'icons.dart';
@ -234,7 +235,11 @@ class CupertinoTextField extends StatefulWidget {
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '',
@ -273,6 +278,7 @@ class CupertinoTextField extends StatefulWidget {
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@ -313,31 +319,7 @@ class CupertinoTextField extends StatefulWidget {
),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
toolbarOptions = toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
? const ToolbarOptions()
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
paste: true,
))
: (readOnly
// Read-only, not obscured.
? const ToolbarOptions(
selectAll: true,
copy: true,
)
// Writable, not obscured.
: const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
)));
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// Creates a borderless iOS-style text field.
///
@ -397,7 +379,11 @@ class CupertinoTextField extends StatefulWidget {
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '',
@ -436,6 +422,7 @@ class CupertinoTextField extends StatefulWidget {
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@ -477,31 +464,7 @@ class CupertinoTextField extends StatefulWidget {
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
toolbarOptions = toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
? const ToolbarOptions()
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
paste: true,
))
: (readOnly
// Read-only, not obscured.
? const ToolbarOptions(
selectAll: true,
copy: true,
)
// Writable, not obscured.
: const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
)));
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// Controls the text being edited.
///
@ -605,7 +568,11 @@ class CupertinoTextField extends StatefulWidget {
/// If not set, select all and paste will default to be enabled. Copy and cut
/// will be disabled if [obscureText] is true. If [readOnly] is true,
/// paste and cut will be disabled regardless.
final ToolbarOptions toolbarOptions;
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final ToolbarOptions? toolbarOptions;
/// {@macro flutter.material.InputDecorator.textAlignVertical}
final TextAlignVertical? textAlignVertical;
@ -787,6 +754,21 @@ class CupertinoTextField extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
///
/// If not provided, will build a default menu based on the platform.
///
/// See also:
///
/// * [CupertinoAdaptiveTextSelectionToolbar], which is built by default.
final EditableTextContextMenuBuilder? contextMenuBuilder;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return CupertinoAdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
@ -1226,12 +1208,12 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
textSelectionControls ??= cupertinoTextSelectionControls;
textSelectionControls ??= cupertinoTextSelectionHandleControls;
break;
case TargetPlatform.macOS:
case TargetPlatform.windows:
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
handleDidGainAccessibilityFocus = () {
// Automatically activate the TextField when it receives accessibility focus.
if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) {
@ -1380,6 +1362,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
contextMenuBuilder: widget.contextMenuBuilder,
spellCheckConfiguration: spellCheckConfiguration,
),
),

View File

@ -5,6 +5,7 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'form_row.dart';
import 'text_field.dart';
@ -116,6 +117,10 @@ class CupertinoTextFormFieldRow extends FormField<String> {
TextAlignVertical? textAlignVertical,
bool autofocus = false,
bool readOnly = false,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
ToolbarOptions? toolbarOptions,
bool? showCursor,
String obscuringCharacter = '',
@ -151,6 +156,7 @@ class CupertinoTextFormFieldRow extends FormField<String> {
fontWeight: FontWeight.w400,
color: CupertinoColors.placeholderText,
),
EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
}) : assert(initialValue == null || controller == null),
assert(textAlign != null),
assert(autofocus != null),
@ -234,6 +240,7 @@ class CupertinoTextFormFieldRow extends FormField<String> {
autofillHints: autofillHints,
placeholder: placeholder,
placeholderStyle: placeholderStyle,
contextMenuBuilder: contextMenuBuilder,
),
);
},
@ -262,6 +269,12 @@ class CupertinoTextFormFieldRow extends FormField<String> {
/// initialize its [TextEditingController.text] with [initialValue].
final TextEditingController? controller;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return CupertinoAdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
@override
FormFieldState<String> createState() => _CupertinoTextFormFieldRowState();
}

View File

@ -22,142 +22,6 @@ const double _kSelectionHandleRadius = 6;
// screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0;
// Generates the child that's passed into CupertinoTextSelectionToolbar.
class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
const _CupertinoTextSelectionControlsToolbar({
required this.clipboardStatus,
required this.endpoints,
required this.globalEditableRegion,
required this.handleCopy,
required this.handleCut,
required this.handlePaste,
required this.handleSelectAll,
required this.selectionMidpoint,
required this.textLineHeight,
});
final ClipboardStatusNotifier? clipboardStatus;
final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
final VoidCallback? handleCopy;
final VoidCallback? handleCut;
final VoidCallback? handlePaste;
final VoidCallback? handleSelectAll;
final Offset selectionMidpoint;
final double textLineHeight;
@override
_CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
}
class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}
@override
void initState() {
super.initState();
widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
}
@override
void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.clipboardStatus != widget.clipboardStatus) {
oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
}
}
@override
void dispose() {
super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
}
@override
Widget build(BuildContext context) {
// Don't render the menu until the state of the clipboard is known.
if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
return const SizedBox.shrink();
}
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
// The toolbar should appear below the TextField when there is not enough
// space above the TextField to show it, assuming there's always enough
// space at the bottom in this case.
final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
_kArrowScreenPadding + mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
);
final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
// The y-coordinate has to be calculated instead of directly quoting
// selectionMidpoint.dy, since the caller
// (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
// going to be facing up or down.
final Offset anchorAbove = Offset(
anchorX,
anchorTop,
);
final Offset anchorBelow = Offset(
anchorX,
widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
);
final List<Widget> items = <Widget>[];
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final Widget onePhysicalPixelVerticalDivider =
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
void addToolbarButton(
String text,
VoidCallback onPressed,
) {
if (items.isNotEmpty) {
items.add(onePhysicalPixelVerticalDivider);
}
items.add(CupertinoTextSelectionToolbarButton.text(
onPressed: onPressed,
text: text,
));
}
if (widget.handleCut != null) {
addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
}
if (widget.handleCopy != null) {
addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
}
if (widget.handlePaste != null
&& widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
}
if (widget.handleSelectAll != null) {
addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
}
// If there is no option available, build an empty widget.
if (items.isEmpty) {
return const SizedBox.shrink();
}
return CupertinoTextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: items,
);
}
}
/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
const _TextSelectionHandlePainter(this.color);
@ -190,6 +54,17 @@ class _TextSelectionHandlePainter extends CustomPainter {
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
}
/// iOS Cupertino styled text selection handle controls.
///
/// Specifically does not manage the toolbar, which is left to
/// [EditableText.contextMenuBuilder].
@Deprecated(
'Use `CupertinoTextSelectionControls`. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
class CupertinoTextSelectionHandleControls extends CupertinoTextSelectionControls with TextSelectionHandleControls {
}
/// iOS Cupertino styled text selection controls.
///
/// The [cupertinoTextSelectionControls] global variable has a
@ -205,6 +80,10 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
}
/// Builder for iOS-style copy/paste text selection toolbar.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
Widget buildToolbar(
BuildContext context,
@ -213,7 +92,7 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier? clipboardStatus,
ValueNotifier<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _CupertinoTextSelectionControlsToolbar(
@ -305,5 +184,150 @@ class CupertinoTextSelectionControls extends TextSelectionControls {
}
}
/// Text selection controls that follows iOS design conventions.
final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls();
/// Text selection handle controls that follow iOS design conventions.
@Deprecated(
'Use `cupertinoTextSelectionControls` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final TextSelectionControls cupertinoTextSelectionHandleControls =
CupertinoTextSelectionHandleControls();
/// Text selection controls that follow iOS design conventions.
final TextSelectionControls cupertinoTextSelectionControls =
CupertinoTextSelectionControls();
// Generates the child that's passed into CupertinoTextSelectionToolbar.
class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
const _CupertinoTextSelectionControlsToolbar({
required this.clipboardStatus,
required this.endpoints,
required this.globalEditableRegion,
required this.handleCopy,
required this.handleCut,
required this.handlePaste,
required this.handleSelectAll,
required this.selectionMidpoint,
required this.textLineHeight,
});
final ValueNotifier<ClipboardStatus>? clipboardStatus;
final List<TextSelectionPoint> endpoints;
final Rect globalEditableRegion;
final VoidCallback? handleCopy;
final VoidCallback? handleCut;
final VoidCallback? handlePaste;
final VoidCallback? handleSelectAll;
final Offset selectionMidpoint;
final double textLineHeight;
@override
_CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
}
class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}
@override
void initState() {
super.initState();
widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
}
@override
void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.clipboardStatus != widget.clipboardStatus) {
oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
}
}
@override
void dispose() {
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
super.dispose();
}
@override
Widget build(BuildContext context) {
// Don't render the menu until the state of the clipboard is known.
if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
return const SizedBox.shrink();
}
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
// The toolbar should appear below the TextField when there is not enough
// space above the TextField to show it, assuming there's always enough
// space at the bottom in this case.
final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
_kArrowScreenPadding + mediaQuery.padding.left,
mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
);
final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
// The y-coordinate has to be calculated instead of directly quoting
// selectionMidpoint.dy, since the caller
// (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
// going to be facing up or down.
final Offset anchorAbove = Offset(
anchorX,
anchorTop,
);
final Offset anchorBelow = Offset(
anchorX,
widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
);
final List<Widget> items = <Widget>[];
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
final Widget onePhysicalPixelVerticalDivider =
SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
void addToolbarButton(
String text,
VoidCallback onPressed,
) {
if (items.isNotEmpty) {
items.add(onePhysicalPixelVerticalDivider);
}
items.add(CupertinoTextSelectionToolbarButton.text(
onPressed: onPressed,
text: text,
));
}
if (widget.handleCut != null) {
addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
}
if (widget.handleCopy != null) {
addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
}
if (widget.handlePaste != null
&& widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
}
if (widget.handleSelectAll != null) {
addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
}
// If there is no option available, build an empty widget.
if (items.isEmpty) {
return const SizedBox.shrink();
}
return CupertinoTextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: items,
);
}
}

View File

@ -5,6 +5,7 @@
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@ -21,6 +22,10 @@ const double _kToolbarContentDistance = 8.0;
const double _kToolbarScreenPadding = 8.0;
const Size _kToolbarArrowSize = Size(14.0, 7.0);
// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
// screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0;
// Values extracted from https://developer.apple.com/design/resources/.
const Radius _kToolbarBorderRadius = Radius.circular(8);
@ -45,6 +50,13 @@ typedef CupertinoToolbarBuilder = Widget Function(
Widget child,
);
class _CupertinoToolbarButtonDivider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
}
}
/// An iOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
@ -58,8 +70,8 @@ typedef CupertinoToolbarBuilder = Widget Function(
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build an iOS-style toolbar.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
/// platform.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class CupertinoTextSelectionToolbar extends StatelessWidget {
@ -91,6 +103,19 @@ class CupertinoTextSelectionToolbar extends StatelessWidget {
/// default Cupertino toolbar.
final CupertinoToolbarBuilder toolbarBuilder;
// Add the visial vertical line spacer between children buttons.
static List<Widget> _addChildrenSpacers(List<Widget> children) {
final List<Widget> nextChildren = <Widget>[];
for (int i = 0; i < children.length; i++) {
final Widget child = children[i];
if (i != 0) {
nextChildren.add(_CupertinoToolbarButtonDivider());
}
nextChildren.add(child);
}
return nextChildren;
}
// Builds a toolbar just like the default iOS toolbar, with the right color
// background and a rounded cutout with an arrow.
static Widget _defaultToolbarBuilder(BuildContext context, Offset anchor, bool isAbove, Widget child) {
@ -115,8 +140,19 @@ class CupertinoTextSelectionToolbar extends StatelessWidget {
+ _kToolbarHeight;
final bool fitsAbove = anchorAbove.dy >= toolbarHeightNeeded;
const Offset contentPaddingAdjustment = Offset(0.0, _kToolbarContentDistance);
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
// The arrow, which points to the anchor, has some margin so it can't get
// too close to the horizontal edges of the screen.
final double leftMargin = _kArrowScreenPadding + mediaQuery.padding.left;
final double rightMargin = mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding;
final Offset anchorAboveAdjusted = Offset(
clampDouble(anchorAbove.dx, leftMargin, rightMargin),
anchorAbove.dy - _kToolbarContentDistance - paddingAbove,
);
final Offset anchorBelowAdjusted = Offset(
clampDouble(anchorBelow.dx, leftMargin, rightMargin),
anchorBelow.dy - _kToolbarContentDistance + paddingAbove,
);
return Padding(
padding: EdgeInsets.fromLTRB(
@ -127,15 +163,15 @@ class CupertinoTextSelectionToolbar extends StatelessWidget {
),
child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: anchorAbove - localAdjustment - contentPaddingAdjustment,
anchorBelow: anchorBelow - localAdjustment + contentPaddingAdjustment,
anchorAbove: anchorAboveAdjusted,
anchorBelow: anchorBelowAdjusted,
fitsAbove: fitsAbove,
),
child: _CupertinoTextSelectionToolbarContent(
anchor: fitsAbove ? anchorAbove : anchorBelow,
anchor: fitsAbove ? anchorAboveAdjusted : anchorBelowAdjusted,
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
children: children,
children: _addChildrenSpacers(children),
),
),
);

View File

@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart';
import 'button.dart';
import 'colors.dart';
import 'debug.dart';
import 'localizations.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
@ -24,11 +26,14 @@ const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 16.0, h
/// A button in the style of the iOS text selection toolbar buttons.
class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// Create an instance of [CupertinoTextSelectionToolbarButton].
///
/// [child] cannot be null.
const CupertinoTextSelectionToolbarButton({
super.key,
this.onPressed,
required this.child,
});
required Widget this.child,
}) : assert(child != null),
buttonItem = null;
/// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default iOS text selection toolbar button.
@ -36,7 +41,8 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
super.key,
this.onPressed,
required String text,
}) : child = Text(
}) : buttonItem = null,
child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
@ -44,20 +50,67 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
),
);
/// Create an instance of [CupertinoTextSelectionToolbarButton] from the given
/// [ContextMenuButtonItem].
///
/// [buttonItem] cannot be null.
CupertinoTextSelectionToolbarButton.buttonItem({
super.key,
required ContextMenuButtonItem this.buttonItem,
}) : assert(buttonItem != null),
child = null,
onPressed = buttonItem.onPressed;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
/// The child of this button.
///
/// Usually a [Text] or an [Icon].
/// {@endtemplate}
final Widget child;
final Widget? child;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
/// Called when this button is pressed.
/// {@endtemplate}
final VoidCallback? onPressed;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
/// The buttonItem used to generate the button when using
/// [CupertinoTextSelectionToolbarButton.buttonItem].
/// {@endtemplate}
final ContextMenuButtonItem? buttonItem;
/// Returns the default button label String for the button of the given
/// [ContextMenuButtonItem]'s [ContextMenuButtonType].
static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
if (buttonItem.label != null) {
return buttonItem.label!;
}
assert(debugCheckHasCupertinoLocalizations(context));
final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
switch (buttonItem.type) {
case ContextMenuButtonType.cut:
return localizations.cutButtonLabel;
case ContextMenuButtonType.copy:
return localizations.copyButtonLabel;
case ContextMenuButtonType.paste:
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.custom:
return '';
}
}
@override
Widget build(BuildContext context) {
final Widget child = this.child ?? Text(
getButtonLabel(context, buttonItem!),
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: onPressed != null ? CupertinoColors.white : CupertinoColors.inactiveGray,
),
);
return CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,

View File

@ -0,0 +1,319 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'debug.dart';
import 'desktop_text_selection_toolbar.dart';
import 'desktop_text_selection_toolbar_button.dart';
import 'material_localizations.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';
import 'theme.dart';
/// The default context menu for text selection for the current platform.
///
/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders}
/// Typically, this widget would be passed to `contextMenuBuilder` in a
/// supported parent widget, such as:
///
/// * [EditableText.contextMenuBuilder]
/// * [TextField.contextMenuBuilder]
/// * [CupertinoTextField.contextMenuBuilder]
/// * [SelectionArea.contextMenuBuilder]
/// * [SelectableText.contextMenuBuilder]
/// {@endtemplate}
///
/// See also:
///
/// * [EditableText.getEditableButtonItems], which returns the default
/// [ContextMenuButtonItem]s for [EditableText] on the platform.
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
/// Widgets for the current platform given [ContextMenuButtonItem]s.
/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this
/// widget but only for Cupertino context menus.
/// * [TextSelectionToolbar], the default toolbar for Android.
/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms
/// other than MacOS.
/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS.
/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS.
class AdaptiveTextSelectionToolbar extends StatelessWidget {
/// Create an instance of [AdaptiveTextSelectionToolbar] with the
/// given [children].
///
/// See also:
///
/// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of
/// [ContextMenuButtonItem]s instead of [children] widgets.
/// {@endtemplate}
/// {@template flutter.material.AdaptiveTextSelectionToolbar.editable}
/// * [AdaptiveTextSelectionToolbar.editable], which builds the default
/// children for an editable field.
/// {@endtemplate}
/// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText}
/// * [AdaptiveTextSelectionToolbar.editableText], which builds the default
/// children for an [EditableText].
/// {@endtemplate}
/// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable}
/// * [AdaptiveTextSelectionToolbar.selectable], which builds the default
/// children for content that is selectable but not editable.
/// {@endtemplate}
const AdaptiveTextSelectionToolbar({
super.key,
required this.children,
required this.anchors,
}) : buttonItems = null;
/// Create an instance of [AdaptiveTextSelectionToolbar] whose children will
/// be built from the given [buttonItems].
///
/// See also:
///
/// {@template flutter.material.AdaptiveTextSelectionToolbar.new}
/// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as
/// a list of widgets.
/// {@endtemplate}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
const AdaptiveTextSelectionToolbar.buttonItems({
super.key,
required this.buttonItems,
required this.anchors,
}) : children = null;
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
/// children for an editable field.
///
/// If a callback is null, then its corresponding button will not be built.
///
/// See also:
///
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
AdaptiveTextSelectionToolbar.editable({
super.key,
required ClipboardStatus clipboardStatus,
required VoidCallback? onCopy,
required VoidCallback? onCut,
required VoidCallback? onPaste,
required VoidCallback? onSelectAll,
required this.anchors,
}) : children = null,
buttonItems = EditableText.getEditableButtonItems(
clipboardStatus: clipboardStatus,
onCopy: onCopy,
onCut: onCut,
onPaste: onPaste,
onSelectAll: onSelectAll,
);
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
/// children for an [EditableText].
///
/// See also:
///
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
AdaptiveTextSelectionToolbar.editableText({
super.key,
required EditableTextState editableTextState,
}) : children = null,
buttonItems = editableTextState.contextMenuButtonItems,
anchors = editableTextState.contextMenuAnchors;
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
/// children for selectable, but not editable, content.
///
/// See also:
///
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
AdaptiveTextSelectionToolbar.selectable({
super.key,
required VoidCallback onCopy,
required VoidCallback onSelectAll,
required SelectionGeometry selectionGeometry,
required this.anchors,
}) : children = null,
buttonItems = SelectableRegion.getSelectableButtonItems(
selectionGeometry: selectionGeometry,
onCopy: onCopy,
onSelectAll: onSelectAll,
);
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
/// children for a [SelectableRegion].
///
/// See also:
///
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
AdaptiveTextSelectionToolbar.selectableRegion({
super.key,
required SelectableRegionState selectableRegionState,
}) : children = null,
buttonItems = selectableRegionState.contextMenuButtonItems,
anchors = selectableRegionState.contextMenuAnchors;
/// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
/// The [ContextMenuButtonItem]s that will be turned into the correct button
/// widgets for the current platform.
/// {@endtemplate}
final List<ContextMenuButtonItem>? buttonItems;
/// The children of the toolbar, typically buttons.
final List<Widget>? children;
/// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors}
/// The location on which to anchor the menu.
/// {@endtemplate}
final TextSelectionToolbarAnchors anchors;
/// Returns the default button label String for the button of the given
/// [ContextMenuButtonType] on any platform.
static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
if (buttonItem.label != null) {
return buttonItem.label!;
}
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return CupertinoTextSelectionToolbarButton.getButtonLabel(
context,
buttonItem,
);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
assert(debugCheckHasMaterialLocalizations(context));
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
switch (buttonItem.type) {
case ContextMenuButtonType.cut:
return localizations.cutButtonLabel;
case ContextMenuButtonType.copy:
return localizations.copyButtonLabel;
case ContextMenuButtonType.paste:
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.custom:
return '';
}
}
}
/// Returns a List of Widgets generated by turning [buttonItems] into the
/// the default context menu buttons for the current platform.
///
/// This is useful when building a text selection toolbar with the default
/// button appearance for the given platform, but where the toolbar and/or the
/// button actions and labels may be custom.
///
/// {@tool dartpad}
/// This sample demonstrates how to use `getAdaptiveButtons` to generate
/// default button widgets in a custom toolbar.
///
/// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart **
/// {@end-tool}
///
/// See also:
///
/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the
/// Cupertino equivalent of this class and builds only the Cupertino
/// buttons.
static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoTextSelectionToolbarButton.text(
onPressed: buttonItem.onPressed,
text: getButtonLabel(context, buttonItem),
);
});
case TargetPlatform.fuchsia:
case TargetPlatform.android:
final List<Widget> buttons = <Widget>[];
for (int i = 0; i < buttonItems.length; i++) {
final ContextMenuButtonItem buttonItem = buttonItems[i];
buttons.add(TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(i, buttonItems.length),
onPressed: buttonItem.onPressed,
child: Text(getButtonLabel(context, buttonItem)),
));
}
return buttons;
case TargetPlatform.linux:
case TargetPlatform.windows:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return DesktopTextSelectionToolbarButton.text(
context: context,
onPressed: buttonItem.onPressed,
text: getButtonLabel(context, buttonItem),
);
});
case TargetPlatform.macOS:
return buttonItems.map((ContextMenuButtonItem buttonItem) {
return CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: buttonItem.onPressed,
text: getButtonLabel(context, buttonItem),
);
});
}
}
@override
Widget build(BuildContext context) {
// If there aren't any buttons to build, build an empty toolbar.
if ((children != null && children!.isEmpty)
|| (buttonItems != null && buttonItems!.isEmpty)) {
return const SizedBox.shrink();
}
final List<Widget> resultChildren = children != null
? children!
: getAdaptiveButtons(context, buttonItems!).toList();
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
return CupertinoTextSelectionToolbar(
anchorAbove: anchors.primaryAnchor,
anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
children: resultChildren,
);
case TargetPlatform.android:
return TextSelectionToolbar(
anchorAbove: anchors.primaryAnchor,
anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
children: resultChildren,
);
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return DesktopTextSelectionToolbar(
anchor: anchors.primaryAnchor,
children: resultChildren,
);
case TargetPlatform.macOS:
return CupertinoDesktopTextSelectionToolbar(
anchor: anchors.primaryAnchor,
children: resultChildren,
);
}
}
}

View File

@ -3,20 +3,19 @@
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'debug.dart';
import 'material.dart';
import 'desktop_text_selection_toolbar.dart';
import 'desktop_text_selection_toolbar_button.dart';
import 'material_localizations.dart';
import 'text_button.dart';
import 'text_selection_toolbar.dart';
import 'theme.dart';
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarWidth = 222.0;
/// Desktop Material styled text selection handle controls.
///
/// Specifically does not manage the toolbar, which is left to
/// [EditableText.contextMenuBuilder].
class _DesktopTextSelectionHandleControls extends DesktopTextSelectionControls with TextSelectionHandleControls {
}
/// Desktop Material styled text selection controls.
///
@ -30,6 +29,10 @@ class DesktopTextSelectionControls extends TextSelectionControls {
}
/// Builder for the Material-style desktop copy/paste text selection toolbar.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
Widget buildToolbar(
BuildContext context,
@ -67,6 +70,10 @@ class DesktopTextSelectionControls extends TextSelectionControls {
return Offset.zero;
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
bool canSelectAll(TextSelectionDelegate delegate) {
// Allow SelectAll when selection is not collapsed, unless everything has
@ -77,6 +84,10 @@ class DesktopTextSelectionControls extends TextSelectionControls {
!(value.selection.start == 0 && value.selection.end == value.text.length);
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void handleSelectAll(TextSelectionDelegate delegate) {
super.handleSelectAll(delegate);
@ -84,7 +95,17 @@ class DesktopTextSelectionControls extends TextSelectionControls {
}
}
/// Text selection controls that loosely follows Material design conventions.
/// Desktop text selection handle controls that loosely follow Material design
/// conventions.
@Deprecated(
'Use `desktopTextSelectionControls` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final TextSelectionControls desktopTextSelectionHandleControls =
_DesktopTextSelectionHandleControls();
/// Desktop text selection controls that loosely follow Material design
/// conventions.
final TextSelectionControls desktopTextSelectionControls =
DesktopTextSelectionControls();
@ -142,8 +163,8 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect
@override
void dispose() {
super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
super.dispose();
}
@override
@ -173,7 +194,7 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect
String text,
VoidCallback onPressed,
) {
items.add(_DesktopTextSelectionToolbarButton.text(
items.add(DesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
@ -199,153 +220,9 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect
return const SizedBox.shrink();
}
return _DesktopTextSelectionToolbar(
return DesktopTextSelectionToolbar(
anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
children: items,
);
}
}
/// A Material-style desktop text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position itself as closely as possible to [anchor] while remaining
/// fully on-screen.
///
/// See also:
///
/// * [_DesktopTextSelectionControls.buildToolbar], where this is used by
/// default to build a Material-style desktop toolbar.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class _DesktopTextSelectionToolbar extends StatelessWidget {
/// Creates an instance of _DesktopTextSelectionToolbar.
const _DesktopTextSelectionToolbar({
required this.anchor,
required this.children,
}) : assert(children.length > 0);
/// The point at which the toolbar will attempt to position itself as closely
/// as possible.
final Offset anchor;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [DesktopTextSelectionToolbarButton], which builds a default
/// Material-style desktop text selection toolbar text button.
final List<Widget> children;
// Builds a desktop toolbar in the Material style.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return SizedBox(
width: _kToolbarWidth,
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
clipBehavior: Clip.antiAlias,
elevation: 1.0,
type: MaterialType.card,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: DesktopTextSelectionToolbarLayoutDelegate(
anchor: anchor - localAdjustment,
),
child: _defaultToolbarBuilder(context, Column(
mainAxisSize: MainAxisSize.min,
children: children,
)),
),
);
}
}
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
);
/// A [TextButton] for the Material desktop text selection toolbar.
class _DesktopTextSelectionToolbarButton extends StatelessWidget {
/// Creates an instance of DesktopTextSelectionToolbarButton.
const _DesktopTextSelectionToolbarButton({
required this.onPressed,
required this.child,
});
/// Create an instance of [_DesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget in the style of the Material text selection toolbar.
_DesktopTextSelectionToolbarButton.text({
required BuildContext context,
required this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: Theme.of(context).colorScheme.brightness == Brightness.dark
? Colors.white
: Colors.black87,
),
);
/// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
final VoidCallback onPressed;
/// {@macro flutter.material.TextSelectionToolbarTextButton.child}
final Widget child;
@override
Widget build(BuildContext context) {
// TODO(hansmuller): Should be colorScheme.onSurface
final ThemeData theme = Theme.of(context);
final bool isDark = theme.colorScheme.brightness == Brightness.dark;
final Color foregroundColor = isDark ? Colors.white : Colors.black87;
return SizedBox(
width: double.infinity,
child: TextButton(
style: TextButton.styleFrom(
alignment: Alignment.centerLeft,
enabledMouseCursor: SystemMouseCursors.basic,
disabledMouseCursor: SystemMouseCursors.basic,
foregroundColor: foregroundColor,
shape: const RoundedRectangleBorder(),
minimumSize: const Size(kMinInteractiveDimension, 36.0),
padding: _kToolbarButtonPadding,
),
onPressed: onPressed,
child: child,
),
);
}
}

View File

@ -0,0 +1,93 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'material.dart';
import 'text_selection_toolbar.dart';
// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
// a Macbook Pro.
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarWidth = 222.0;
/// A Material-style desktop text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position its top left corner as closely as possible to [anchor]
/// while remaining fully inside the viewport.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
/// platform.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class DesktopTextSelectionToolbar extends StatelessWidget {
/// Creates a const instance of DesktopTextSelectionToolbar.
const DesktopTextSelectionToolbar({
super.key,
required this.anchor,
required this.children,
}) : assert(children.length > 0);
/// {@template flutter.material.DesktopTextSelectionToolbar.anchor}
/// The point where the toolbar will attempt to position itself as closely as
/// possible.
/// {@endtemplate}
final Offset anchor;
/// {@macro flutter.material.TextSelectionToolbar.children}
///
/// See also:
/// * [DesktopTextSelectionToolbarButton], which builds a default
/// Material-style desktop text selection toolbar text button.
final List<Widget> children;
// Builds a desktop toolbar in the Material style.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return SizedBox(
width: _kToolbarWidth,
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
clipBehavior: Clip.antiAlias,
elevation: 1.0,
type: MaterialType.card,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: DesktopTextSelectionToolbarLayoutDelegate(
anchor: anchor - localAdjustment,
),
child: _defaultToolbarBuilder(
context,
Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'text_button.dart';
import 'theme.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
20.0,
0.0,
20.0,
3.0,
);
/// A [TextButton] for the Material desktop text selection toolbar.
class DesktopTextSelectionToolbarButton extends StatelessWidget {
/// Creates an instance of DesktopTextSelectionToolbarButton.
const DesktopTextSelectionToolbarButton({
super.key,
required this.onPressed,
required this.child,
});
/// Create an instance of [DesktopTextSelectionToolbarButton] whose child is
/// a [Text] widget in the style of the Material text selection toolbar.
DesktopTextSelectionToolbarButton.text({
super.key,
required BuildContext context,
required this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: Theme.of(context).colorScheme.brightness == Brightness.dark
? Colors.white
: Colors.black87,
),
);
/// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
final VoidCallback onPressed;
/// {@macro flutter.material.TextSelectionToolbarTextButton.child}
final Widget child;
@override
Widget build(BuildContext context) {
// TODO(hansmuller): Should be colorScheme.onSurface
final ThemeData theme = Theme.of(context);
final bool isDark = theme.colorScheme.brightness == Brightness.dark;
final Color foregroundColor = isDark ? Colors.white : Colors.black87;
return SizedBox(
width: double.infinity,
child: TextButton(
style: TextButton.styleFrom(
alignment: Alignment.centerLeft,
enabledMouseCursor: SystemMouseCursors.basic,
disabledMouseCursor: SystemMouseCursors.basic,
foregroundColor: foregroundColor,
shape: const RoundedRectangleBorder(),
minimumSize: const Size(kMinInteractiveDimension, 36.0),
padding: _kToolbarButtonPadding,
),
onPressed: onPressed,
child: child,
),
);
}
}

View File

@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'desktop_text_selection.dart';
import 'feedback.dart';
import 'magnifier.dart';
@ -190,7 +191,11 @@ class SelectableText extends StatefulWidget {
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
ToolbarOptions? toolbarOptions,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
this.toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
@ -208,6 +213,7 @@ class SelectableText extends StatefulWidget {
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
}) : assert(showCursor != null),
assert(autofocus != null),
@ -224,12 +230,7 @@ class SelectableText extends StatefulWidget {
data != null,
'A non-null String must be provided to a SelectableText widget.',
),
textSpan = null,
toolbarOptions = toolbarOptions ??
const ToolbarOptions(
selectAll: true,
copy: true,
);
textSpan = null;
/// Creates a selectable text widget with a [TextSpan].
///
@ -248,7 +249,11 @@ class SelectableText extends StatefulWidget {
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
ToolbarOptions? toolbarOptions,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
this.toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
@ -266,6 +271,7 @@ class SelectableText extends StatefulWidget {
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
}) : assert(showCursor != null),
assert(autofocus != null),
@ -280,12 +286,7 @@ class SelectableText extends StatefulWidget {
textSpan != null,
'A non-null TextSpan must be provided to a SelectableText.rich widget.',
),
data = null,
toolbarOptions = toolbarOptions ??
const ToolbarOptions(
selectAll: true,
copy: true,
);
data = null;
/// The text to display.
///
@ -397,7 +398,11 @@ class SelectableText extends StatefulWidget {
/// Paste and cut will be disabled regardless.
///
/// If not set, select all and copy will be enabled by default.
final ToolbarOptions toolbarOptions;
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final ToolbarOptions? toolbarOptions;
/// {@macro flutter.widgets.editableText.selectionEnabled}
bool get selectionEnabled => enableInteractiveSelection;
@ -434,6 +439,15 @@ class SelectableText extends StatefulWidget {
/// {@macro flutter.widgets.editableText.onSelectionChanged}
final SelectionChangedCallback? onSelectionChanged;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
final EditableTextContextMenuBuilder? contextMenuBuilder;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
@ -639,7 +653,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
case TargetPlatform.iOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
textSelectionControls ??= cupertinoTextSelectionControls;
textSelectionControls ??= cupertinoTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@ -651,7 +665,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@ -663,7 +677,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
textSelectionControls ??= materialTextSelectionControls;
textSelectionControls ??= materialTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@ -673,7 +687,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
case TargetPlatform.linux:
case TargetPlatform.windows:
forcePressEnabled = false;
textSelectionControls ??= desktopTextSelectionControls;
textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@ -694,6 +708,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
key: editableTextKey,
style: effectiveTextStyle,
readOnly: true,
toolbarOptions: widget.toolbarOptions,
textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
showSelectionHandles: _showSelectionHandles,
@ -706,7 +721,6 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
textScaleFactor: widget.textScaleFactor,
autofocus: widget.autofocus,
forceLine: false,
toolbarOptions: widget.toolbarOptions,
minLines: widget.minLines,
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
selectionColor: selectionColor,
@ -729,6 +743,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
dragStartBehavior: widget.dragStartBehavior,
scrollPhysics: widget.scrollPhysics,
autofillHints: null,
contextMenuBuilder: widget.contextMenuBuilder,
),
);

View File

@ -5,6 +5,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'debug.dart';
import 'desktop_text_selection.dart';
import 'magnifier.dart';
@ -41,6 +42,7 @@ class SelectionArea extends StatefulWidget {
super.key,
this.focusNode,
this.selectionControls,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
this.onSelectionChanged,
required this.child,
@ -65,6 +67,23 @@ class SelectionArea extends StatefulWidget {
/// If it is null, the platform specific selection control is used.
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
///
/// If not provided, will build a default menu based on the ambient
/// [ThemeData.platform].
///
/// {@tool dartpad}
/// This example shows how to build a custom context menu for any selected
/// content in a SelectionArea.
///
/// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which is built by default.
final SelectableRegionContextMenuBuilder? contextMenuBuilder;
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
@ -73,6 +92,12 @@ class SelectionArea extends StatefulWidget {
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
static Widget _defaultContextMenuBuilder(BuildContext context, SelectableRegionState selectableRegionState) {
return AdaptiveTextSelectionToolbar.selectableRegion(
selectableRegionState: selectableRegionState,
);
}
@override
State<StatefulWidget> createState() => _SelectionAreaState();
}
@ -100,22 +125,24 @@ class _SelectionAreaState extends State<SelectionArea> {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
controls ??= materialTextSelectionControls;
controls ??= materialTextSelectionHandleControls;
break;
case TargetPlatform.iOS:
controls ??= cupertinoTextSelectionControls;
controls ??= cupertinoTextSelectionHandleControls;
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
controls ??= desktopTextSelectionControls;
controls ??= desktopTextSelectionHandleControls;
break;
case TargetPlatform.macOS:
controls ??= cupertinoDesktopTextSelectionControls;
controls ??= cupertinoDesktopTextSelectionHandleControls;
break;
}
return SelectableRegion(
focusNode: _effectiveFocusNode,
selectionControls: controls,
focusNode: _effectiveFocusNode,
contextMenuBuilder: widget.contextMenuBuilder,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
onSelectionChanged: widget.onSelectionChanged,
child: widget.child,

View File

@ -10,6 +10,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'debug.dart';
import 'desktop_text_selection.dart';
@ -263,7 +264,11 @@ class TextField extends StatefulWidget {
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '',
@ -305,6 +310,7 @@ class TextField extends StatefulWidget {
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@ -343,31 +349,7 @@ class TextField extends StatefulWidget {
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
toolbarOptions = toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
? const ToolbarOptions()
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
paste: true,
))
: (readOnly
// Read-only, not obscured.
? const ToolbarOptions(
selectAll: true,
copy: true,
)
// Writable, not obscured.
: const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
)));
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
@ -513,7 +495,11 @@ class TextField extends StatefulWidget {
/// If not set, select all and paste will default to be enabled. Copy and cut
/// will be disabled if [obscureText] is true. If [readOnly] is true,
/// paste and cut will be disabled regardless.
final ToolbarOptions toolbarOptions;
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final ToolbarOptions? toolbarOptions;
/// {@macro flutter.widgets.editableText.showCursor}
final bool? showCursor;
@ -779,6 +765,21 @@ class TextField extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
///
/// If not provided, will build a default menu based on the platform.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which is built by default.
final EditableTextContextMenuBuilder? contextMenuBuilder;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
/// {@macro flutter.widgets.EditableText.spellCheckConfiguration}
///
/// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this
@ -1208,7 +1209,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
case TargetPlatform.iOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
textSelectionControls ??= cupertinoTextSelectionControls;
textSelectionControls ??= cupertinoTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@ -1221,7 +1222,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@ -1239,7 +1240,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
textSelectionControls ??= materialTextSelectionControls;
textSelectionControls ??= materialTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@ -1248,7 +1249,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
case TargetPlatform.linux:
forcePressEnabled = false;
textSelectionControls ??= desktopTextSelectionControls;
textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@ -1257,7 +1258,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
case TargetPlatform.windows:
forcePressEnabled = false;
textSelectionControls ??= desktopTextSelectionControls;
textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@ -1334,6 +1335,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
contextMenuBuilder: widget.contextMenuBuilder,
spellCheckConfiguration: spellCheckConfiguration,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
),

View File

@ -5,6 +5,7 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'input_decorator.dart';
import 'text_field.dart';
import 'theme.dart';
@ -110,6 +111,10 @@ class TextFormField extends FormField<String> {
TextAlignVertical? textAlignVertical,
bool autofocus = false,
bool readOnly = false,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
ToolbarOptions? toolbarOptions,
bool? showCursor,
String obscuringCharacter = '',
@ -148,6 +153,7 @@ class TextFormField extends FormField<String> {
super.restorationId,
bool enableIMEPersonalizedLearning = true,
MouseCursor? mouseCursor,
EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
}) : assert(initialValue == null || controller == null),
assert(textAlign != null),
assert(autofocus != null),
@ -236,6 +242,7 @@ class TextFormField extends FormField<String> {
scrollController: scrollController,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning,
mouseCursor: mouseCursor,
contextMenuBuilder: contextMenuBuilder,
),
);
},
@ -247,6 +254,12 @@ class TextFormField extends FormField<String> {
/// initialize its [TextEditingController.text] with [initialValue].
final TextEditingController? controller;
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
@override
FormFieldState<String> createState() => _TextFormFieldState();
}

View File

@ -19,6 +19,17 @@ const double _kHandleSize = 22.0;
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
/// Android Material styled text selection handle controls.
///
/// Specifically does not manage the toolbar, which is left to
/// [EditableText.contextMenuBuilder].
@Deprecated(
'Use `MaterialTextSelectionControls`. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
class MaterialTextSelectionHandleControls extends MaterialTextSelectionControls with TextSelectionHandleControls {
}
/// Android Material styled text selection controls.
///
/// The [materialTextSelectionControls] global variable has a
@ -29,6 +40,10 @@ class MaterialTextSelectionControls extends TextSelectionControls {
Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style copy/paste text selection toolbar.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
Widget buildToolbar(
BuildContext context,
@ -40,7 +55,7 @@ class MaterialTextSelectionControls extends TextSelectionControls {
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _TextSelectionControlsToolbar(
return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion,
textLineHeight: textLineHeight,
selectionMidpoint: selectionMidpoint,
@ -107,6 +122,10 @@ class MaterialTextSelectionControls extends TextSelectionControls {
}
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless
@ -183,8 +202,8 @@ class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToo
@override
void dispose() {
super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
super.dispose();
}
@override
@ -289,5 +308,12 @@ class _TextSelectionHandlePainter extends CustomPainter {
}
}
/// Text selection handle controls that follow the Material Design specification.
@Deprecated(
'Use `materialTextSelectionControls` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final TextSelectionControls materialTextSelectionHandleControls = MaterialTextSelectionHandleControls();
/// Text selection controls that follow the Material Design specification.
final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();

View File

@ -19,6 +19,12 @@ import 'material_localizations.dart';
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
const double _kHandleSize = 22.0;
// Padding between the toolbar and the anchor.
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
/// A fully-functional Material-style text selection toolbar.
///
/// Tries to position itself above [anchorAbove], but if it doesn't fit, then
@ -29,8 +35,8 @@ const double _kToolbarHeight = 44.0;
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build an Android-style toolbar.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
/// platform.
/// * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS-
/// style toolbar.
class TextSelectionToolbar extends StatelessWidget {
@ -87,10 +93,17 @@ class TextSelectionToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Incorporate the padding distance between the content and toolbar.
final Offset anchorAbovePadded =
anchorAbove - const Offset(0.0, _kToolbarContentDistance);
final Offset anchorBelowPadded =
anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow);
final double paddingAbove = MediaQuery.of(context).padding.top
+ _kToolbarScreenPadding;
final double availableHeight = anchorAbove.dy - paddingAbove;
final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight;
// Makes up for the Padding above the Stack.
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
@ -100,21 +113,17 @@ class TextSelectionToolbar extends StatelessWidget {
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: Stack(
children: <Widget>[
CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: anchorAbove - localAdjustment,
anchorBelow: anchorBelow - localAdjustment,
fitsAbove: fitsAbove,
),
child: _TextSelectionToolbarOverflowable(
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
children: children,
),
),
],
child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: anchorAbovePadded - localAdjustment,
anchorBelow: anchorBelowPadded - localAdjustment,
fitsAbove: fitsAbove,
),
child: _TextSelectionToolbarOverflowable(
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
children: children,
),
),
);
}
@ -156,8 +165,8 @@ class _TextSelectionToolbarOverflowableState extends State<_TextSelectionToolbar
// changed and saved values are no longer relevant. This should be called in
// setState or another context where a rebuild is happening.
void _reset() {
// Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes in
// order to cause it to rebuild. This lets it recalculate its
// Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes
// in order to cause it to rebuild. This lets it recalculate its
// saved width for the new set of children, and it prevents AnimatedSize
// from animating the size change.
_containerKey = UniqueKey();

View File

@ -1100,7 +1100,7 @@ class TextPainter {
/// visually contiguous.
///
/// Leading or trailing newline characters will be represented by zero-width
/// `Textbox`es.
/// `TextBox`es.
///
/// The method only returns `TextBox`es of glyphs that are entirely enclosed by
/// the given `selection`: a multi-code-unit glyph will be excluded if only

View File

@ -1981,8 +1981,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin,
Offset? _lastTapDownPosition;
Offset? _lastSecondaryTapDownPosition;
/// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
/// The position of the most recent secondary tap down event on this text
/// input.
/// {@endtemplate}
Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
/// Tracks the position of a secondary tap event.

View File

@ -0,0 +1,89 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'framework.dart';
/// The buttons that can appear in a context menu by default.
///
/// See also:
///
/// * [ContextMenuButtonItem], which uses this enum to describe a button in a
/// context menu.
enum ContextMenuButtonType {
/// A button that cuts the current text selection.
cut,
/// A button that copies the current text selection.
copy,
/// A button that pastes the clipboard contents into the focused text field.
paste,
/// A button that selects all the contents of the focused text field.
selectAll,
/// Anything other than the default button types.
custom,
}
/// The type and callback for a context menu button.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar], which can take a list of
/// ContextMenuButtonItems and create a platform-specific context menu with
/// the indicated buttons.
@immutable
class ContextMenuButtonItem {
/// Creates a const instance of [ContextMenuButtonItem].
const ContextMenuButtonItem({
required this.onPressed,
this.type = ContextMenuButtonType.custom,
this.label,
});
/// The callback to be called when the button is pressed.
final VoidCallback onPressed;
/// The type of button this represents.
final ContextMenuButtonType type;
/// The label to display on the button.
///
/// If a [type] other than [ContextMenuButtonType.custom] is given
/// and a label is not provided, then the default label for that type for the
/// platform will be looked up.
final String? label;
/// Creates a new [ContextMenuButtonItem] with the provided parameters
/// overridden.
ContextMenuButtonItem copyWith({
VoidCallback? onPressed,
ContextMenuButtonType? type,
String? label,
}) {
return ContextMenuButtonItem(
onPressed: onPressed ?? this.onPressed,
type: type ?? this.type,
label: label ?? this.label,
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is ContextMenuButtonItem
&& other.label == label
&& other.onPressed == onPressed
&& other.type == type;
}
@override
int get hashCode => Object.hash(label, onPressed, type);
@override
String toString() => 'ContextMenuButtonItem $type, $label';
}

View File

@ -0,0 +1,123 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'framework.dart';
import 'inherited_theme.dart';
import 'navigator.dart';
import 'overlay.dart';
/// Builds and manages a context menu at a given location.
///
/// There can only ever be one context menu shown at a given time in the entire
/// app.
///
/// {@tool dartpad}
/// This example shows how to use a GestureDetector to show a context menu
/// anywhere in a widget subtree that receives a right click or long press.
///
/// ** See code in examples/api/lib/material/context_menu/context_menu_controller.0.dart **
/// {@end-tool}
class ContextMenuController {
/// Creates a context menu that can be shown with [show].
ContextMenuController({
this.onRemove,
});
/// Called when this menu is removed.
final VoidCallback? onRemove;
/// The currently shown instance, if any.
static ContextMenuController? _shownInstance;
// The OverlayEntry is static because only one context menu can be displayed
// at one time.
static OverlayEntry? _menuOverlayEntry;
/// Shows the given context menu.
///
/// Since there can only be one shown context menu at a time, calling this
/// will also remove any other context menu that is visible.
void show({
required BuildContext context,
required WidgetBuilder contextMenuBuilder,
Widget? debugRequiredFor,
}) {
removeAny();
final OverlayState overlayState = Overlay.of(
context,
rootOverlay: true,
debugRequiredFor: debugRequiredFor,
);
final CapturedThemes capturedThemes = InheritedTheme.capture(
from: context,
to: Navigator.maybeOf(context)?.context,
);
_menuOverlayEntry = OverlayEntry(
builder: (BuildContext context) {
return capturedThemes.wrap(contextMenuBuilder(context));
},
);
overlayState.insert(_menuOverlayEntry!);
_shownInstance = this;
}
/// Remove the currently shown context menu from the UI.
///
/// Does nothing if no context menu is currently shown.
///
/// If a menu is removed, and that menu provided an [onRemove] callback when
/// it was created, then that callback will be called.
///
/// See also:
///
/// * [remove], which removes only the current instance.
static void removeAny() {
_menuOverlayEntry?.remove();
_menuOverlayEntry = null;
if (_shownInstance != null) {
_shownInstance!.onRemove?.call();
_shownInstance = null;
}
}
/// True if and only if this menu is currently being shown.
bool get isShown => _shownInstance == this;
/// Cause the underlying [OverlayEntry] to rebuild during the next pipeline
/// flush.
///
/// It's necessary to call this function if the output of [contextMenuBuilder]
/// has changed.
///
/// Errors if the context menu is not currently shown.
///
/// See also:
///
/// * [OverlayEntry.markNeedsBuild]
void markNeedsBuild() {
assert(isShown);
_menuOverlayEntry?.markNeedsBuild();
}
/// Remove this menu from the UI.
///
/// Does nothing if this instance is not currently shown. In other words, if
/// another context menu is currently shown, that menu will not be removed.
///
/// This method should only be called once. The instance cannot be shown again
/// after removing. Create a new instance.
///
/// If an [onRemove] method was given to this instance, it will be called.
///
/// See also:
///
/// * [removeAny], which removes any shown instance of the context menu.
void remove() {
if (!isShown) {
return;
}
removeAny();
}
}

View File

@ -4,7 +4,6 @@
import 'package:flutter/rendering.dart';
/// Positions the toolbar at [anchor] if it fits, otherwise moves it so that it
/// just fits fully on-screen.
///

View File

@ -19,6 +19,7 @@ import 'automatic_keep_alive.dart';
import 'basic.dart';
import 'binding.dart';
import 'constants.dart';
import 'context_menu_button_item.dart';
import 'debug.dart';
import 'default_selection_style.dart';
import 'default_text_editing_shortcuts.dart';
@ -39,6 +40,7 @@ import 'tap_region.dart';
import 'text.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
import 'text_selection_toolbar_anchors.dart';
import 'ticker_provider.dart';
import 'widget_span.dart';
@ -54,6 +56,18 @@ typedef SelectionChangedCallback = void Function(TextSelection selection, Select
/// Signature for the callback that reports the app private command results.
typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
/// Signature for a widget builder that builds a context menu for the given
/// [EditableTextState].
///
/// See also:
///
/// * [SelectableRegionContextMenuBuilder], which performs the same role for
/// [SelectableRegion].
typedef EditableTextContextMenuBuilder = Widget Function(
BuildContext context,
EditableTextState editableTextState,
);
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
@ -264,10 +278,18 @@ class TextEditingController extends ValueNotifier<TextEditingValue> {
/// [EditableText] and its derived widgets have their own default [ToolbarOptions].
/// Create a custom [ToolbarOptions] if you want explicit control over the toolbar
/// option.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
class ToolbarOptions {
/// Create a toolbar configuration with given options.
///
/// All options default to false if they are not explicitly set.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
const ToolbarOptions({
this.copy = false,
this.cut = false,
@ -278,6 +300,9 @@ class ToolbarOptions {
assert(paste != null),
assert(selectAll != null);
/// An instance of [ToolbarOptions] with no options enabled.
static const ToolbarOptions empty = ToolbarOptions();
/// Whether to show copy option in toolbar.
///
/// Defaults to false. Must not be null.
@ -637,6 +662,10 @@ class EditableText extends StatefulWidget {
this.scrollController,
this.scrollPhysics,
this.autocorrectionTextRectColor,
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
ToolbarOptions? toolbarOptions,
this.autofillHints = const <String>[],
this.autofillClient,
@ -645,6 +674,7 @@ class EditableText extends StatefulWidget {
this.scrollBehavior,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : assert(controller != null),
@ -683,12 +713,12 @@ class EditableText extends StatefulWidget {
assert(scrollPadding != null),
assert(dragStartBehavior != null),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
toolbarOptions = toolbarOptions ??
toolbarOptions = selectionControls is TextSelectionHandleControls && toolbarOptions == null ? ToolbarOptions.empty : toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
? const ToolbarOptions()
? ToolbarOptions.empty
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
@ -1564,6 +1594,41 @@ class EditableText extends StatefulWidget {
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
/// {@template flutter.widgets.EditableText.contextMenuBuilder}
/// Builds the text selection toolbar when requested by the user.
///
/// `primaryAnchor` is the desired anchor position for the context menu, while
/// `secondaryAnchor` is the fallback location if the menu doesn't fit.
///
/// `buttonItems` represents the buttons that would be built by default for
/// this widget.
///
/// {@tool dartpad}
/// This example shows how to customize the menu, in this case by keeping the
/// default buttons for the platform but modifying their appearance.
///
/// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how to show a custom button only when an email address
/// is currently selected.
///
/// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart **
/// {@end-tool}
///
/// See also:
/// * [AdaptiveTextSelectionToolbar], which builds the default text selection
/// toolbar for the current platform, but allows customization of the
/// buttons.
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
/// button Widgets for the current platform given
/// [ContextMenuButtonItem]s.
/// {@endtemplate}
///
/// If not provided, no context menu will be shown.
final EditableTextContextMenuBuilder? contextMenuBuilder;
/// {@template flutter.widgets.EditableText.spellCheckConfiguration}
/// Configuration that details how spell check should be performed.
///
@ -1587,6 +1652,61 @@ class EditableText extends StatefulWidget {
bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu for an editable field.
///
/// For example, [EditableText] uses this to generate the default buttons for
/// its context menu.
///
/// See also:
///
/// * [EditableTextState.contextMenuButtonItems], which gives the
/// [ContextMenuButtonItem]s for a specific EditableText.
/// * [SelectableRegion.getSelectableButtonItems], which performs a similar
/// role but for content that is selectable but not editable.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
/// take a list of [ContextMenuButtonItem]s with
/// [AdaptiveTextSelectionToolbar.buttonItems].
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
/// Widgets for the current platform given [ContextMenuButtonItem]s.
static List<ContextMenuButtonItem> getEditableButtonItems({
required final ClipboardStatus? clipboardStatus,
required final VoidCallback? onCopy,
required final VoidCallback? onCut,
required final VoidCallback? onPaste,
required final VoidCallback? onSelectAll,
}) {
// If the paste button is enabled, don't render anything until the state
// of the clipboard is known, since it's used to determine if paste is
// shown.
if (onPaste != null && clipboardStatus == ClipboardStatus.unknown) {
return <ContextMenuButtonItem>[];
}
return <ContextMenuButtonItem>[
if (onCut != null)
ContextMenuButtonItem(
onPressed: onCut,
type: ContextMenuButtonType.cut,
),
if (onCopy != null)
ContextMenuButtonItem(
onPressed: onCopy,
type: ContextMenuButtonType.copy,
),
if (onPaste != null)
ContextMenuButtonItem(
onPressed: onPaste,
type: ContextMenuButtonType.paste,
),
if (onSelectAll != null)
ContextMenuButtonItem(
onPressed: onSelectAll,
type: ContextMenuButtonType.selectAll,
),
];
}
// Infer the keyboard type of an `EditableText` if it's not specified.
static TextInputType _inferKeyboardType({
required Iterable<String>? autofillHints,
@ -1778,7 +1898,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
final GlobalKey _editableKey = GlobalKey();
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
/// Detects whether the clipboard can paste.
final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
TextInputConnection? _textInputConnection;
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
@ -1854,16 +1976,61 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
bool get cutEnabled {
if (widget.selectionControls is! TextSelectionHandleControls) {
return widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
}
return !widget.readOnly
&& !widget.obscureText
&& !textEditingValue.selection.isCollapsed;
}
@override
bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
bool get copyEnabled {
if (widget.selectionControls is! TextSelectionHandleControls) {
return widget.toolbarOptions.copy && !widget.obscureText;
}
return !widget.obscureText
&& !textEditingValue.selection.isCollapsed;
}
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
bool get pasteEnabled {
if (widget.selectionControls is! TextSelectionHandleControls) {
return widget.toolbarOptions.paste && !widget.readOnly;
}
return !widget.readOnly
&& (clipboardStatus == null
|| clipboardStatus!.value == ClipboardStatus.pasteable);
}
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
bool get selectAllEnabled {
if (widget.selectionControls is! TextSelectionHandleControls) {
return widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
}
if (!widget.enableInteractiveSelection
|| (widget.readOnly
&& widget.obscureText)) {
return false;
}
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
return false;
case TargetPlatform.iOS:
return textEditingValue.text.isNotEmpty
&& textEditingValue.selection.isCollapsed;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return textEditingValue.text.isNotEmpty
&& !(textEditingValue.selection.start == 0
&& textEditingValue.selection.end == textEditingValue.text.length);
}
}
void _onChangedClipboardStatus() {
setState(() {
@ -1912,7 +2079,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
break;
}
}
_clipboardStatus?.update();
clipboardStatus?.update();
}
/// Cut current selection to [Clipboard].
@ -1938,7 +2105,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
});
hideToolbar();
}
_clipboardStatus?.update();
clipboardStatus?.update();
}
/// Paste text from [Clipboard].
@ -1997,6 +2164,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
);
if (cause == SelectionChangedCause.toolbar) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
hideToolbar();
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
@ -2033,12 +2210,154 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return configuration.copyWith(spellCheckService: spellCheckService);
}
/// Returns the [ContextMenuButtonItem]s for the given [ToolbarOptions].
@Deprecated(
'Use `contextMenuBuilder` instead of `toolbarOptions`. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
List<ContextMenuButtonItem>? buttonItemsForToolbarOptions([TargetPlatform? targetPlatform]) {
final ToolbarOptions toolbarOptions = widget.toolbarOptions;
if (toolbarOptions == ToolbarOptions.empty) {
return null;
}
return <ContextMenuButtonItem>[
if (toolbarOptions.cut && cutEnabled)
ContextMenuButtonItem(
onPressed: () {
selectAll(SelectionChangedCause.toolbar);
},
type: ContextMenuButtonType.selectAll,
),
if (toolbarOptions.copy && copyEnabled)
ContextMenuButtonItem(
onPressed: () {
copySelection(SelectionChangedCause.toolbar);
},
type: ContextMenuButtonType.copy,
),
if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled)
ContextMenuButtonItem(
onPressed: () {
pasteText(SelectionChangedCause.toolbar);
},
type: ContextMenuButtonType.paste,
),
if (toolbarOptions.selectAll && selectAllEnabled)
ContextMenuButtonItem(
onPressed: () {
selectAll(SelectionChangedCause.toolbar);
},
type: ContextMenuButtonType.selectAll,
),
];
}
/// Gets the line heights at the start and end of the selection for the given
/// [EditableTextState].
_GlyphHeights _getGlyphHeights() {
final TextSelection selection = textEditingValue.selection;
// Only calculate handle rects if the text in the previous frame
// is the same as the text in the current frame. This is done because
// widget.renderObject contains the renderEditable from the previous frame.
// If the text changed between the current and previous frames then
// widget.renderObject.getRectForComposingRange might fail. In cases where
// the current frame is different from the previous we fall back to
// renderObject.preferredLineHeight.
final InlineSpan span = renderEditable.text!;
final String prevText = span.toPlainText();
final String currText = textEditingValue.text;
if (prevText != currText || selection == null || !selection.isValid || selection.isCollapsed) {
return _GlyphHeights(
start: renderEditable.preferredLineHeight,
end: renderEditable.preferredLineHeight,
);
}
final String selectedGraphemes = selection.textInside(currText);
final int firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
final Rect? startCharacterRect = renderEditable.getRectForComposingRange(TextRange(
start: selection.start,
end: selection.start + firstSelectedGraphemeExtent,
));
final int lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
final Rect? endCharacterRect = renderEditable.getRectForComposingRange(TextRange(
start: selection.end - lastSelectedGraphemeExtent,
end: selection.end,
));
return _GlyphHeights(
start: startCharacterRect?.height ?? renderEditable.preferredLineHeight,
end: endCharacterRect?.height ?? renderEditable.preferredLineHeight,
);
}
/// {@template flutter.widgets.EditableText.getAnchors}
/// Returns the anchor points for the default context menu.
/// {@endtemplate}
///
/// See also:
///
/// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
/// for the default context menu buttons.
TextSelectionToolbarAnchors get contextMenuAnchors {
if (renderEditable.lastSecondaryTapDownPosition != null) {
return TextSelectionToolbarAnchors(
primaryAnchor: renderEditable.lastSecondaryTapDownPosition!,
);
}
final _GlyphHeights glyphHeights = _getGlyphHeights();
final TextSelection selection = textEditingValue.selection;
final List<TextSelectionPoint> points =
renderEditable.getEndpointsForSelection(selection);
return TextSelectionToolbarAnchors.fromSelection(
renderBox: renderEditable,
startGlyphHeight: glyphHeights.start,
endGlyphHeight: glyphHeights.end,
selectionEndpoints: points,
);
}
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu for [EditableText].
///
/// See also:
///
/// * [EditableText.getEditableButtonItems], which performs a similar role,
/// but for any editable field, not just specifically EditableText.
/// * [SelectableRegionState.contextMenuButtonItems], which peforms a similar
/// role but for content that is selectable but not editable.
/// * [contextMenuAnchors], which provides the anchor points for the default
/// context menu.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
/// take a list of [ContextMenuButtonItem]s with
/// [AdaptiveTextSelectionToolbar.buttonItems].
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
/// button Widgets for the current platform given [ContextMenuButtonItem]s.
List<ContextMenuButtonItem> get contextMenuButtonItems {
return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems(
clipboardStatus: clipboardStatus?.value,
onCopy: copyEnabled
? () => copySelection(SelectionChangedCause.toolbar)
: null,
onCut: cutEnabled
? () => cutSelection(SelectionChangedCause.toolbar)
: null,
onPaste: pasteEnabled
? () => pasteText(SelectionChangedCause.toolbar)
: null,
onSelectAll: selectAllEnabled
? () => selectAll(SelectionChangedCause.toolbar)
: null,
);
}
// State lifecycle:
@override
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_onEditableScroll);
@ -2159,8 +2478,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
);
}
}
if (widget.selectionEnabled && pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) {
_clipboardStatus?.update();
final bool canPaste = widget.selectionControls is TextSelectionHandleControls
? pasteEnabled
: widget.selectionControls?.canPaste(this) ?? false;
if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) {
clipboardStatus!.update();
}
}
@ -2181,8 +2503,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged);
WidgetsBinding.instance.removeObserver(this);
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
_clipboardStatus?.dispose();
clipboardStatus?.removeListener(_onChangedClipboardStatus);
clipboardStatus?.dispose();
_cursorVisibilityNotifier.dispose();
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
@ -2733,7 +3055,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
TextSelectionOverlay _createSelectionOverlay() {
final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
clipboardStatus: _clipboardStatus,
clipboardStatus: clipboardStatus,
context: context,
value: _value,
debugRequiredFor: widget,
@ -2745,6 +3067,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionDelegate: this,
dragStartBehavior: widget.dragStartBehavior,
onSelectionHandleTapped: widget.onSelectionHandleTapped,
contextMenuBuilder: widget.contextMenuBuilder == null
? null
: (BuildContext context) {
return widget.contextMenuBuilder!(
context,
this,
);
},
magnifierConfiguration: widget.magnifierConfiguration,
);
@ -2784,7 +3114,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
break;
}
if (widget.selectionControls == null) {
if (widget.selectionControls == null && widget.contextMenuBuilder == null) {
_selectionOverlay?.dispose();
_selectionOverlay = null;
} else {
@ -3305,10 +3635,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return false;
}
if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) {
if (_selectionOverlay == null) {
return false;
}
_clipboardStatus?.update();
clipboardStatus?.update();
_selectionOverlay!.showToolbar();
return true;
}
@ -3356,13 +3686,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
/// Hides the magnifier if it is visible.
void hideMagnifier({required bool shouldShowToolbar}) {
void hideMagnifier() {
if (_selectionOverlay == null) {
return;
}
if (_selectionOverlay!.magnifierIsVisible) {
_selectionOverlay!.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
_selectionOverlay!.hideMagnifier();
}
}
@ -3427,29 +3757,41 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) {
return widget.selectionEnabled
&& copyEnabled
&& _hasFocus
&& (controls?.canCopy(this) ?? false)
? () => controls!.handleCopy(this)
&& (widget.selectionControls is TextSelectionHandleControls
? copyEnabled
: copyEnabled && (widget.selectionControls?.canCopy(this) ?? false))
? () {
controls?.handleCopy(this);
copySelection(SelectionChangedCause.toolbar);
}
: null;
}
VoidCallback? _semanticsOnCut(TextSelectionControls? controls) {
return widget.selectionEnabled
&& cutEnabled
&& _hasFocus
&& (controls?.canCut(this) ?? false)
? () => controls!.handleCut(this)
&& (widget.selectionControls is TextSelectionHandleControls
? cutEnabled
: cutEnabled && (widget.selectionControls?.canCut(this) ?? false))
? () {
controls?.handleCut(this);
cutSelection(SelectionChangedCause.toolbar);
}
: null;
}
VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) {
return widget.selectionEnabled
&& pasteEnabled
&& _hasFocus
&& (controls?.canPaste(this) ?? false)
&& (_clipboardStatus == null || _clipboardStatus!.value == ClipboardStatus.pasteable)
? () => controls!.handlePaste(this)
&& (widget.selectionControls is TextSelectionHandleControls
? pasteEnabled
: pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false))
&& (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable)
? () {
controls?.handlePaste(this);
pasteText(SelectionChangedCause.toolbar);
}
: null;
}
@ -4983,3 +5325,18 @@ _Throttled<T> _throttle<T>({
return timer!;
};
}
/// The start and end glyph heights of some range of text.
@immutable
class _GlyphHeights {
const _GlyphHeights({
required this.start,
required this.end,
});
/// The glyph height of the first line.
final double start;
/// The glyph height of the last line.
final double end;
}

View File

@ -13,6 +13,7 @@ import 'package:vector_math/vector_math_64.dart';
import 'actions.dart';
import 'basic.dart';
import 'context_menu_button_item.dart';
import 'debug.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
@ -25,6 +26,7 @@ import 'platform_selectable_region_context_menu.dart';
import 'selection_container.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
import 'text_selection_toolbar_anchors.dart';
// Examples can assume:
// FocusNode _focusNode = FocusNode();
@ -200,6 +202,7 @@ class SelectableRegion extends StatefulWidget {
/// toolbar for mobile devices.
const SelectableRegion({
super.key,
this.contextMenuBuilder,
required this.focusNode,
required this.selectionControls,
required this.child,
@ -224,6 +227,9 @@ class SelectableRegion extends StatefulWidget {
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
final SelectableRegionContextMenuBuilder? contextMenuBuilder;
/// The delegate to build the selection handles and toolbar for mobile
/// devices.
///
@ -234,11 +240,54 @@ class SelectableRegion extends StatefulWidget {
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu.
///
/// For example, [SelectableRegion] uses this to generate the default buttons
/// for its context menu.
///
/// See also:
///
/// * [SelectableRegionState.contextMenuButtonItems], which gives the
/// [ContextMenuButtonItem]s for a specific SelectableRegion.
/// * [EditableText.getEditableButtonItems], which performs a similar role but
/// for content that is both selectable and editable.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
/// take a list of [ContextMenuButtonItem]s with
/// [AdaptiveTextSelectionToolbar.buttonItems].
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
/// Widgets for the current platform given [ContextMenuButtonItem]s.
static List<ContextMenuButtonItem> getSelectableButtonItems({
required final SelectionGeometry selectionGeometry,
required final VoidCallback onCopy,
required final VoidCallback onSelectAll,
}) {
final bool canCopy = selectionGeometry.hasSelection;
final bool canSelectAll = selectionGeometry.hasContent;
// 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
// appearance.
return <ContextMenuButtonItem>[
if (canCopy)
ContextMenuButtonItem(
onPressed: onCopy,
type: ContextMenuButtonType.copy,
),
if (canSelectAll)
ContextMenuButtonItem(
onPressed: onSelectAll,
type: ContextMenuButtonType.selectAll,
),
];
}
@override
State<StatefulWidget> createState() => _SelectableRegionState();
State<StatefulWidget> createState() => SelectableRegionState();
}
class _SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
/// State for a [SelectableRegion].
class SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
@ -258,6 +307,9 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
Orientation? _lastOrientation;
SelectedContent? _lastSelectedContent;
/// {@macro flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
Offset? lastSecondaryTapDownPosition;
@override
void initState() {
super.initState();
@ -424,6 +476,7 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
}
void _handleRightClickDown(TapDownDetails details) {
lastSecondaryTapDownPosition = details.globalPosition;
widget.focusNode.requestFocus();
_selectWordAt(offset: details.globalPosition);
_showHandles();
@ -465,7 +518,17 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
}
void _onAnyDragEnd(DragEndDetails details) {
_selectionOverlay!.hideMagnifier(shouldShowToolbar: true);
if (widget.selectionControls is! TextSelectionHandleControls) {
_selectionOverlay!.hideMagnifier();
_selectionOverlay!.showToolbar();
} else {
_selectionOverlay!.hideMagnifier();
_selectionOverlay!.showToolbar(
contextMenuBuilder: (BuildContext context) {
return widget.contextMenuBuilder!(context, this);
},
);
}
_stopSelectionEndEdgeUpdate();
_updateSelectedContentIfNeeded();
}
@ -516,8 +579,6 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
late Offset _selectionStartHandleDragPosition;
late Offset _selectionEndHandleDragPosition;
late List<TextSelectionPoint> points;
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
assert(_selectionDelegate.value.startSelectionPoint != null);
@ -595,19 +656,6 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
}
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: widget,
@ -621,7 +669,7 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onEndHandleDragEnd: _onAnyDragEnd,
selectionEndpoints: points,
selectionEndpoints: selectionEndpoints,
selectionControls: widget.selectionControls,
selectionDelegate: this,
clipboardStatus: null,
@ -639,26 +687,12 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
assert(_hasSelectionOverlayGeometry);
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
late List<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
_selectionOverlay!
..startHandleType = start?.handleType ?? TextSelectionHandleType.left
..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight
..endHandleType = end?.handleType ?? TextSelectionHandleType.right
..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight
..selectionEndpoints = points;
..selectionEndpoints = selectionEndpoints;
}
/// Shows the selection handles.
@ -706,7 +740,19 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
}
_selectionOverlay!.toolbarLocation = location;
_selectionOverlay!.showToolbar();
if (widget.selectionControls is! TextSelectionHandleControls) {
_selectionOverlay!.showToolbar();
return true;
}
_selectionOverlay!.hideToolbar();
_selectionOverlay!.showToolbar(
context: context,
contextMenuBuilder: (BuildContext context) {
return widget.contextMenuBuilder!(context, this);
},
);
return true;
}
@ -830,11 +876,104 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
await Clipboard.setData(ClipboardData(text: data.plainText));
}
// [TextSelectionDelegate] overrides.
/// {@macro flutter.widgets.EditableText.getAnchors}
///
/// See also:
///
/// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
/// for the default context menu buttons.
TextSelectionToolbarAnchors get contextMenuAnchors {
if (lastSecondaryTapDownPosition != null) {
return TextSelectionToolbarAnchors(
primaryAnchor: lastSecondaryTapDownPosition!,
);
}
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
return TextSelectionToolbarAnchors.fromSelection(
renderBox: renderBox,
startGlyphHeight: startGlyphHeight,
endGlyphHeight: endGlyphHeight,
selectionEndpoints: selectionEndpoints,
);
}
/// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu.
///
/// See also:
///
/// * [SelectableRegion.getSelectableButtonItems], which performs a similar role,
/// but for any selectable text, not just specifically SelectableRegion.
/// * [EditableTextState.contextMenuButtonItems], which peforms a similar role
/// but for content that is not just selectable but also editable.
/// * [contextMenuAnchors], which provides the anchor points for the default
/// context menu.
/// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
/// take a list of [ContextMenuButtonItem]s with
/// [AdaptiveTextSelectionToolbar.buttonItems].
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
/// button Widgets for the current platform given [ContextMenuButtonItem]s.
List<ContextMenuButtonItem> get contextMenuButtonItems {
return SelectableRegion.getSelectableButtonItems(
selectionGeometry: _selectionDelegate.value,
onCopy: () {
_copy();
hideToolbar();
},
onSelectAll: () {
selectAll();
hideToolbar();
},
);
}
/// The line height at the start of the current selection.
double get startGlyphHeight {
return _selectionDelegate.value.startSelectionPoint!.lineHeight;
}
/// The line height at the end of the current selection.
double get endGlyphHeight {
return _selectionDelegate.value.endSelectionPoint!.lineHeight;
}
/// Returns the local coordinates of the endpoints of the current selection.
List<TextSelectionPoint> get selectionEndpoints {
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
late List<TextSelectionPoint> points;
final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
if (startLocalPosition.dy > endLocalPosition.dy) {
points = <TextSelectionPoint>[
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
];
} else {
points = <TextSelectionPoint>[
TextSelectionPoint(startLocalPosition, TextDirection.ltr),
TextSelectionPoint(endLocalPosition, TextDirection.ltr),
];
}
return points;
}
// [TextSelectionDelegate] overrides.
// TODO(justinmc): After deprecations have been removed, remove
// TextSelectionDelegate from this class.
// https://github.com/flutter/flutter/issues/111213
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
bool get cutEnabled => false;
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
bool get pasteEnabled => false;
@ -857,28 +996,50 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
_updateSelectedContentIfNeeded();
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void copySelection(SelectionChangedCause cause) {
_copy();
_clearSelection();
}
// TODO(chunhtai): remove this workaround after decoupling text selection
// from text editing in TextSelectionDelegate.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
TextEditingValue textEditingValue = const TextEditingValue(text: '_');
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void cutSelection(SelectionChangedCause cause) {
assert(false);
}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */}
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
@override
Future<void> pasteText(SelectionChangedCause cause) async {
assert(false);
@ -909,7 +1070,7 @@ class _SelectableRegionState extends State<SelectableRegion> with TextSelectionD
_selectionDelegate.dispose();
// In case dispose was triggered before gesture end, remove the magnifier
// so it doesn't remain stuck in the overlay forever.
_selectionOverlay?.hideMagnifier(shouldShowToolbar: false);
_selectionOverlay?.hideMagnifier();
_selectionOverlay?.dispose();
_selectionOverlay = null;
super.dispose();
@ -967,7 +1128,7 @@ abstract class _NonOverrideAction<T extends Intent> extends ContextAction<T> {
class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
_SelectAllAction(this.state);
final _SelectableRegionState state;
final SelectableRegionState state;
@override
void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) {
@ -978,7 +1139,7 @@ class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
_CopySelectionAction(this.state);
final _SelectableRegionState state;
final SelectableRegionState state;
@override
void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) {
@ -1795,3 +1956,15 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return finalResult!;
}
}
/// Signature for a widget builder that builds a context menu for the given
/// [SelectableRegionState].
///
/// See also:
///
/// * [EditableTextContextMenuBuilder], which performs the same role for
/// [EditableText].
typedef SelectableRegionContextMenuBuilder = Widget Function(
BuildContext context,
SelectableRegionState selectableRegionState,
);

View File

@ -16,6 +16,7 @@ import 'basic.dart';
import 'binding.dart';
import 'constants.dart';
import 'container.dart';
import 'context_menu_controller.dart';
import 'debug.dart';
import 'editable_text.dart';
import 'framework.dart';
@ -113,6 +114,10 @@ abstract class TextSelectionControls {
/// The [selectionMidpoint] parameter is a general calculation midpoint
/// parameter of the toolbar. More detailed position information
/// is computable from the [endpoints] parameter.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
@ -137,6 +142,10 @@ abstract class TextSelectionControls {
///
/// Subclasses can use this to decide if they should expose the cut
/// functionality to the user.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
bool canCut(TextSelectionDelegate delegate) {
return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
@ -148,6 +157,10 @@ abstract class TextSelectionControls {
///
/// Subclasses can use this to decide if they should expose the copy
/// functionality to the user.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
bool canCopy(TextSelectionDelegate delegate) {
return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
@ -161,6 +174,10 @@ abstract class TextSelectionControls {
/// This does not consider the contents of the clipboard. Subclasses may want
/// to, for example, disallow pasting when the clipboard contains an empty
/// string.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
bool canPaste(TextSelectionDelegate delegate) {
return delegate.pasteEnabled;
}
@ -171,6 +188,10 @@ abstract class TextSelectionControls {
///
/// Subclasses can use this to decide if they should expose the select all
/// functionality to the user.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
bool canSelectAll(TextSelectionDelegate delegate) {
return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
}
@ -181,6 +202,10 @@ abstract class TextSelectionControls {
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.cutSelection(SelectionChangedCause.toolbar);
}
@ -191,6 +216,10 @@ abstract class TextSelectionControls {
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.copySelection(SelectionChangedCause.toolbar);
}
@ -204,6 +233,10 @@ abstract class TextSelectionControls {
/// asynchronous. Race conditions may exist with this API as currently
/// implemented.
// TODO(ianh): https://github.com/flutter/flutter/issues/11427
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
Future<void> handlePaste(TextSelectionDelegate delegate) async {
delegate.pasteText(SelectionChangedCause.toolbar);
}
@ -215,6 +248,10 @@ abstract class TextSelectionControls {
///
/// This is called by subclasses when their select-all affordance is activated
/// by the user.
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
void handleSelectAll(TextSelectionDelegate delegate) {
delegate.selectAll(SelectionChangedCause.toolbar);
}
@ -293,6 +330,7 @@ class TextSelectionOverlay {
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
VoidCallback? onSelectionHandleTapped,
ClipboardStatusNotifier? clipboardStatus,
this.contextMenuBuilder,
required TextMagnifierConfiguration magnifierConfiguration,
}) : assert(value != null),
assert(context != null),
@ -333,6 +371,14 @@ class TextSelectionOverlay {
);
}
/// {@template flutter.widgets.SelectionOverlay.context}
/// The context in which the selection UI should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
/// {@endtemplate}
final BuildContext context;
/// Controls the fade-in and fade-out animations for the toolbar and handles.
@Deprecated(
'Use `SelectionOverlay.fadeDuration` instead. '
@ -353,6 +399,11 @@ class TextSelectionOverlay {
late final SelectionOverlay _selectionOverlay;
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
///
/// If not provided, no context menu will be built.
final WidgetBuilder? contextMenuBuilder;
/// Retrieve current value.
@visibleForTesting
TextEditingValue get value => _value;
@ -365,12 +416,6 @@ class TextSelectionOverlay {
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
/// The context in which the selection handles should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
final BuildContext context;
void _updateTextSelectionOverlayVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
@ -406,7 +451,22 @@ class TextSelectionOverlay {
/// {@macro flutter.widgets.SelectionOverlay.showToolbar}
void showToolbar() {
_updateSelectionOverlay();
_selectionOverlay.showToolbar();
if (selectionControls is! TextSelectionHandleControls) {
_selectionOverlay.showToolbar();
return;
}
if (contextMenuBuilder == null) {
return;
}
assert(context.mounted);
_selectionOverlay.showToolbar(
context: context,
contextMenuBuilder: contextMenuBuilder,
);
return;
}
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
@ -436,8 +496,8 @@ class TextSelectionOverlay {
}
/// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
void hideMagnifier({required bool shouldShowToolbar}) {
_selectionOverlay.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
void hideMagnifier() {
_selectionOverlay.hideMagnifier();
}
/// Updates the overlay after the selection has changed.
@ -487,7 +547,11 @@ class TextSelectionOverlay {
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
bool get toolbarIsVisible {
return selectionControls is TextSelectionHandleControls
? _selectionOverlay._contextMenuControllerIsShown
: _selectionOverlay._toolbar != null;
}
/// Whether the magnifier is currently visible.
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
@ -506,6 +570,7 @@ class TextSelectionOverlay {
_effectiveToolbarVisibility.dispose();
_effectiveStartHandleVisibility.dispose();
_effectiveEndHandleVisibility.dispose();
hideToolbar();
}
double _getStartGlyphHeight() {
@ -728,7 +793,25 @@ class TextSelectionOverlay {
_handleSelectionHandleChanged(newSelection, isEnd: false);
}
void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
void _handleAnyDragEnd(DragEndDetails details) {
if (!context.mounted) {
return;
}
if (selectionControls is! TextSelectionHandleControls) {
_selectionOverlay.hideMagnifier();
if (!_selection.isCollapsed) {
_selectionOverlay.showToolbar();
}
return;
}
_selectionOverlay.hideMagnifier();
if (!_selection.isCollapsed) {
_selectionOverlay.showToolbar(
context: context,
contextMenuBuilder: contextMenuBuilder,
);
}
}
// Returns the offset that locates a drag on a handle to the correct line of text.
Offset _getOffsetToTextPositionPoint(TextSelectionHandleType type) {
@ -810,6 +893,10 @@ class SelectionOverlay {
this.toolbarVisible,
required List<TextSelectionPoint> selectionEndpoints,
required this.selectionControls,
@Deprecated(
'Use `contextMenuBuilder` in `showToolbar` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
required this.selectionDelegate,
required this.clipboardStatus,
required this.startHandleLayerLink,
@ -817,6 +904,10 @@ class SelectionOverlay {
required this.toolbarLayerLink,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
@Deprecated(
'Use `contextMenuBuilder` in `showToolbar` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
Offset? toolbarLocation,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : _startHandleType = startHandleType,
@ -827,13 +918,9 @@ class SelectionOverlay {
_toolbarLocation = toolbarLocation,
assert(debugCheckHasOverlay(context));
/// The context in which the selection handles should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
/// {@macro flutter.widgets.SelectionOverlay.context}
final BuildContext context;
final ValueNotifier<MagnifierInfo> _magnifierInfo =
ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
@ -863,7 +950,7 @@ class SelectionOverlay {
/// [MagnifierController.shown].
/// {@endtemplate}
void showMagnifier(MagnifierInfo initalMagnifierInfo) {
if (_toolbar != null) {
if (_toolbar != null || _contextMenuControllerIsShown) {
hideToolbar();
}
@ -892,12 +979,11 @@ class SelectionOverlay {
}
/// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
/// Hide the current magnifier, optionally immediately showing
/// the toolbar.
/// Hide the current magnifier.
///
/// This does nothing if there is no magnifier.
/// {@endtemplate}
void hideMagnifier({required bool shouldShowToolbar}) {
void hideMagnifier() {
// This cannot be a check on `MagnifierController.shown`, since
// it's possible that the magnifier is still in the overlay, but
// not shown in cases where the magnifier hides itself.
@ -906,10 +992,6 @@ class SelectionOverlay {
}
_magnifierController.hide();
if (shouldShowToolbar) {
showToolbar();
}
}
/// The type of start selection handle.
@ -1046,7 +1128,11 @@ class SelectionOverlay {
/// The delegate for manipulating the current selection in the owning
/// text field.
/// {@endtemplate}
final TextSelectionDelegate selectionDelegate;
@Deprecated(
'Use `contextMenuBuilder` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
final TextSelectionDelegate? selectionDelegate;
/// Determines the way that drag start behavior is handled.
///
@ -1097,6 +1183,10 @@ class SelectionOverlay {
///
/// This is useful for displaying toolbars at the mouse right-click locations
/// in desktop devices.
@Deprecated(
'Use the `contextMenuBuilder` parameter in `showToolbar` instead. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
Offset? get toolbarLocation => _toolbarLocation;
Offset? _toolbarLocation;
set toolbarLocation(Offset? value) {
@ -1117,6 +1207,11 @@ class SelectionOverlay {
/// A copy/paste toolbar.
OverlayEntry? _toolbar;
// Manages the context menu. Not necessarily visible when non-null.
final ContextMenuController _contextMenuController = ContextMenuController();
bool get _contextMenuControllerIsShown => _contextMenuController.isShown;
/// {@template flutter.widgets.SelectionOverlay.showHandles}
/// Builds the handles by inserting them into the [context]'s overlay.
/// {@endtemplate}
@ -1147,12 +1242,34 @@ class SelectionOverlay {
/// {@template flutter.widgets.SelectionOverlay.showToolbar}
/// Shows the toolbar by inserting it into the [context]'s overlay.
/// {@endtemplate}
void showToolbar() {
if (_toolbar != null) {
void showToolbar({
BuildContext? context,
WidgetBuilder? contextMenuBuilder,
}) {
if (contextMenuBuilder == null) {
if (_toolbar != null) {
return;
}
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(this.context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
return;
}
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
if (context == null) {
return;
}
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
_contextMenuController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper(
layerLink: toolbarLayerLink,
offset: -renderBox.localToGlobal(Offset.zero),
child: contextMenuBuilder(context),
);
},
);
}
bool _buildScheduled = false;
@ -1174,6 +1291,9 @@ class SelectionOverlay {
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild();
}
});
} else {
if (_handles != null) {
@ -1181,6 +1301,9 @@ class SelectionOverlay {
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
if (_contextMenuController.isShown) {
_contextMenuController.markNeedsBuild();
}
}
}
@ -1194,7 +1317,7 @@ class SelectionOverlay {
_handles![1].remove();
_handles = null;
}
if (_toolbar != null) {
if (_toolbar != null || _contextMenuControllerIsShown) {
hideToolbar();
}
}
@ -1205,6 +1328,7 @@ class SelectionOverlay {
/// To hide the whole overlay, see [hide].
/// {@endtemplate}
void hideToolbar() {
_contextMenuController.remove();
if (_toolbar == null) {
return;
}
@ -1272,10 +1396,12 @@ class SelectionOverlay {
);
}
// Build the toolbar via TextSelectionControls.
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null) {
return const SizedBox.shrink();
}
assert(selectionDelegate != null, 'If not using contextMenuBuilder, must pass selectionDelegate.');
final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
@ -1299,21 +1425,23 @@ class SelectionOverlay {
selectionEndpoints.first.point.dy - lineHeightAtStart,
);
return TextFieldTapRegion(
child: Directionality(
textDirection: Directionality.of(this.context),
child: _SelectionToolbarOverlay(
preferredLineHeight: lineHeightAtStart,
toolbarLocation: toolbarLocation,
layerLink: toolbarLayerLink,
editingRegion: editingRegion,
selectionControls: selectionControls,
midpoint: midpoint,
selectionEndpoints: selectionEndpoints,
visibility: toolbarVisible,
selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus,
),
return _SelectionToolbarWrapper(
visibility: toolbarVisible,
layerLink: toolbarLayerLink,
offset: -editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return selectionControls!.buildToolbar(
context,
editingRegion,
lineHeightAtStart,
midpoint,
selectionEndpoints,
selectionDelegate!,
clipboardStatus,
toolbarLocation,
);
},
),
);
}
@ -1337,38 +1465,32 @@ class SelectionOverlay {
}
}
/// This widget represents a selection toolbar.
class _SelectionToolbarOverlay extends StatefulWidget {
/// Creates a toolbar overlay.
const _SelectionToolbarOverlay({
required this.preferredLineHeight,
required this.toolbarLocation,
required this.layerLink,
required this.editingRegion,
required this.selectionControls,
// TODO(justinmc): Currently this fades in but not out on all platforms. It
// should follow the correct fading behavior for the current platform, then be
// made public and de-duplicated with widgets/selectable_region.dart.
// https://github.com/flutter/flutter/issues/107732
// Wrap the given child in the widgets common to both contextMenuBuilder and
// TextSelectionControls.buildToolbar.
class _SelectionToolbarWrapper extends StatefulWidget {
const _SelectionToolbarWrapper({
this.visibility,
required this.midpoint,
required this.selectionEndpoints,
required this.selectionDelegate,
required this.clipboardStatus,
});
required this.layerLink,
required this.offset,
required this.child,
}) : assert(layerLink != null),
assert(offset != null),
assert(child != null);
final double preferredLineHeight;
final Offset? toolbarLocation;
final Widget child;
final Offset offset;
final LayerLink layerLink;
final Rect editingRegion;
final TextSelectionControls? selectionControls;
final ValueListenable<bool>? visibility;
final Offset midpoint;
final List<TextSelectionPoint> selectionEndpoints;
final TextSelectionDelegate? selectionDelegate;
final ClipboardStatusNotifier? clipboardStatus;
@override
_SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
State<_SelectionToolbarWrapper> createState() => _SelectionToolbarWrapperState();
}
class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@ -1383,7 +1505,7 @@ class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with
}
@override
void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
void didUpdateWidget(_SelectionToolbarWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.visibility == widget.visibility) {
return;
@ -1410,25 +1532,17 @@ class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
offset: -widget.editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return widget.selectionControls!.buildToolbar(
context,
widget.editingRegion,
widget.preferredLineHeight,
widget.midpoint,
widget.selectionEndpoints,
widget.selectionDelegate!,
widget.clipboardStatus,
widget.toolbarLocation,
);
},
return TextFieldTapRegion(
child: Directionality(
textDirection: Directionality.of(this.context),
child: FadeTransition(
opacity: _opacity,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
offset: widget.offset,
child: widget.child,
),
),
),
);
@ -1464,7 +1578,6 @@ class _SelectionHandleOverlay extends StatefulWidget {
@override
State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState();
}
class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin {
@ -1813,6 +1926,9 @@ class TextSelectionGestureDetectorBuilder {
// trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
final PointerDeviceKind? kind = details.kind;
// TODO(justinmc): Should a desktop platform show its selection toolbar when
// receiving a tap event? Say a Windows device with a touchscreen.
// https://github.com/flutter/flutter/issues/106586
_shouldShowSelectionToolbar = kind == null
|| kind == PointerDeviceKind.touch
|| kind == PointerDeviceKind.stylus;
@ -2122,7 +2238,7 @@ class TextSelectionGestureDetectorBuilder {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.hideMagnifier(shouldShowToolbar: false);
editableText.hideMagnifier();
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@ -2781,3 +2897,47 @@ enum ClipboardStatus {
/// The content on the clipboard is not pastable, such as when it is empty.
notPasteable,
}
/// [TextSelectionControls] that specifically do not manage the toolbar in order
/// to leave that to [EditableText.contextMenuBuilder].
@Deprecated(
'Use `TextSelectionControls`. '
'This feature was deprecated after v3.3.0-0.5.pre.',
)
mixin TextSelectionHandleControls on TextSelectionControls {
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ValueNotifier<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) => const SizedBox.shrink();
@override
bool canCut(TextSelectionDelegate delegate) => false;
@override
bool canCopy(TextSelectionDelegate delegate) => false;
@override
bool canPaste(TextSelectionDelegate delegate) => false;
@override
bool canSelectAll(TextSelectionDelegate delegate) => false;
@override
void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
@override
void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
@override
Future<void> handlePaste(TextSelectionDelegate delegate) async {}
@override
void handleSelectAll(TextSelectionDelegate delegate) {}
}

View File

@ -0,0 +1,71 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
/// The position information for a text selection toolbar.
///
/// Typically, a menu will attempt to position itself at [primaryAnchor], and
/// if that's not possible, then it will use [secondaryAnchor] instead, if it
/// exists.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.anchors], which is of this type.
@immutable
class TextSelectionToolbarAnchors {
/// Creates an instance of [TextSelectionToolbarAnchors] directly from the
/// anchor points.
const TextSelectionToolbarAnchors({
required this.primaryAnchor,
this.secondaryAnchor,
});
/// Creates an instance of [TextSelectionToolbarAnchors] for some selection.
factory TextSelectionToolbarAnchors.fromSelection({
required RenderBox renderBox,
required double startGlyphHeight,
required double endGlyphHeight,
required List<TextSelectionPoint> selectionEndpoints,
}) {
final Rect editingRegion = Rect.fromPoints(
renderBox.localToGlobal(Offset.zero),
renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
);
final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
endGlyphHeight / 2;
final Rect selectionRect = Rect.fromLTRB(
isMultiline
? editingRegion.left
: editingRegion.left + selectionEndpoints.first.point.dx,
editingRegion.top + selectionEndpoints.first.point.dy - startGlyphHeight,
isMultiline
? editingRegion.right
: editingRegion.left + selectionEndpoints.last.point.dx,
editingRegion.top + selectionEndpoints.last.point.dy,
);
return TextSelectionToolbarAnchors(
primaryAnchor: Offset(
selectionRect.left + selectionRect.width / 2,
clampDouble(selectionRect.top, editingRegion.top, editingRegion.bottom),
),
secondaryAnchor: Offset(
selectionRect.left + selectionRect.width / 2,
clampDouble(selectionRect.bottom, editingRegion.top, editingRegion.bottom),
),
);
}
/// The location that the toolbar should attempt to position itself at.
///
/// If the toolbar doesn't fit at this location, use [secondaryAnchor] if it
/// exists.
final Offset primaryAnchor;
/// The fallback position that should be used if [primaryAnchor] doesn't work.
final Offset? secondaryAnchor;
}

View File

@ -35,6 +35,8 @@ export 'src/widgets/binding.dart';
export 'src/widgets/bottom_navigation_bar_item.dart';
export 'src/widgets/color_filter.dart';
export 'src/widgets/container.dart';
export 'src/widgets/context_menu_button_item.dart';
export 'src/widgets/context_menu_controller.dart';
export 'src/widgets/debug.dart';
export 'src/widgets/default_selection_style.dart';
export 'src/widgets/default_text_editing_shortcuts.dart';
@ -137,6 +139,7 @@ export 'src/widgets/tap_region.dart';
export 'src/widgets/text.dart';
export 'src/widgets/text_editing_intents.dart';
export 'src/widgets/text_selection.dart';
export 'src/widgets/text_selection_toolbar_anchors.dart';
export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/texture.dart';
export 'src/widgets/ticker_provider.dart';

View File

@ -0,0 +1,245 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
void main() {
final MockClipboard mockClipboard = MockClipboard();
setUp(() async {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
const String buttonText = 'Click me';
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoAdaptiveTextSelectionToolbar.buttonItems(
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
label: buttonText,
onPressed: () {
},
),
],
),
),
),
);
expect(find.text(buttonText), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
break;
}
},
variant: TargetPlatformVariant.all(),
skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
);
testWidgets('Can build children directly as well', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoAdaptiveTextSelectionToolbar(
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
children: <Widget>[
Container(key: key),
],
),
),
),
);
expect(find.byKey(key), findsOneWidget);
},
skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
);
testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(CupertinoApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: EditableText(
controller: TextEditingController(),
backgroundCursorColor: const Color(0xff00ffff),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: const Color(0xff00ffff),
selectionControls: cupertinoTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return CupertinoAdaptiveTextSelectionToolbar.editableText(
key: key,
editableTextState: editableTextState,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(key), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(key), findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select all'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
break;
}
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.all(),
);
testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoAdaptiveTextSelectionToolbar.editable(
key: key,
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
clipboardStatus: ClipboardStatus.pasteable,
onCopy: () {},
onCut: () {},
onPaste: () {},
onSelectAll: () {},
),
),
));
expect(find.byKey(key), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Select All'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
break;
}
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.all(),
);
testWidgets('Builds the correct button per-platform', (WidgetTester tester) async {
const String buttonText = 'Click me';
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: Builder(
builder: (BuildContext context) {
return Column(
children: CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons(
context,
<ContextMenuButtonItem>[
ContextMenuButtonItem(
label: buttonText,
onPressed: () {
},
),
],
).toList(),
);
},
),
),
),
);
expect(find.text(buttonText), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
break;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
break;
}
},
variant: TargetPlatformVariant.all(),
);
}

View File

@ -0,0 +1,74 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('can press', (WidgetTester tester) async {
bool pressed = false;
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {
pressed = true;
},
),
),
),
);
expect(pressed, false);
await tester.tap(find.byType(CupertinoDesktopTextSelectionToolbarButton));
expect(pressed, true);
});
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () { },
),
),
),
);
// Original at full opacity.
FadeTransition opacity = tester.widget(find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
// Make a "down" gesture on the button.
final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle();
// Opacity reduces during the down gesture.
opacity = tester.widget(find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 0.7);
// Release the down gesture.
await gesture.up();
await tester.pumpAndSettle();
// Opacity is back to normal.
opacity = tester.widget(find.descendant(
of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
});
}

View File

@ -0,0 +1,37 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('positions itself at the anchor', (WidgetTester tester) async {
// An arbitrary point on the screen to position at.
const Offset anchor = Offset(30.0, 40.0);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoDesktopTextSelectionToolbar(
anchor: anchor,
children: <Widget>[
CupertinoDesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {},
),
],
),
),
),
);
expect(
tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
// Greater than due to padding internal to the toolbar.
greaterThan(anchor),
);
});
}

View File

@ -1578,30 +1578,30 @@ void main() {
),
);
// Long press to put the cursor after the "w".
const int index = 3;
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection.collapsed(offset: index),
);
// Long press to put the cursor after the "w".
const int index = 3;
await tester.longPressAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection.collapsed(offset: index),
);
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Selected text shows 'Copy'.
expect(find.text('Paste'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select All'), findsNothing);
// Selected text shows 'Copy'.
expect(find.text('Paste'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select All'), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
@ -1633,6 +1633,15 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Tap elsewhere to hide the context menu so that subsequent taps don't
// collide with it.
await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
expect(
controller.selection,
const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
);
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, 10));
await tester.pump(const Duration(milliseconds: 50));
@ -1966,7 +1975,7 @@ void main() {
);
testWidgets(
'double tap selects word and first tap of double tap moves cursor for non-Apple platforms',
'double tap selects word for non-Apple platforms',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@ -1990,6 +1999,15 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Tap elsewhere to hide the context menu so that subsequent taps don't
// collide with it.
await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
expect(
controller.selection,
const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
);
// Double tap in the middle of 'Peel' to select the word.
await tester.tapAt(textOffsetToPosition(tester, 10));
await tester.pump(const Duration(milliseconds: 50));
@ -2001,7 +2019,7 @@ void main() {
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
// Tap somewhere else to move the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
@ -2012,7 +2030,7 @@ void main() {
);
testWidgets(
'double tap selects word and first tap of double tap moves cursor for Apple platforms',
'double tap selects word for Apple platforms',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@ -2036,8 +2054,10 @@ void main() {
const TextSelection.collapsed(offset: index),
);
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
// Double tap to select the word around the cursor. Move slightly left of
// the previous tap in order to avoid hitting the text selection toolbar
// on Mac.
await tester.tapAt(textOffsetToPosition(tester, index) - const Offset(1.0, 0.0));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
@ -2046,8 +2066,22 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
if (isContextMenuProvidedByPlatform) {
expect(find.byType(CupertinoButton), findsNothing);
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.iOS:
expect(find.byType(CupertinoButton), findsNWidgets(3));
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(CupertinoButton), findsNWidgets(4));
break;
}
}
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -2184,8 +2218,24 @@ void main() {
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// Selected text shows 3 toolbar buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
final Matcher matchToolbarButtons;
if (isContextMenuProvidedByPlatform) {
matchToolbarButtons = findsNothing;
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.iOS:
matchToolbarButtons = findsNWidgets(3);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
matchToolbarButtons = findsNWidgets(4);
break;
}
}
expect(find.byType(CupertinoButton), matchToolbarButtons);
await gesture.up();
await tester.pump();
@ -2195,7 +2245,7 @@ void main() {
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
expect(find.byType(CupertinoButton), matchToolbarButtons);
},
);
@ -2348,8 +2398,10 @@ void main() {
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
// Put the cursor at the end of the field.
await tester.tapAt(textOffsetToPosition(tester, 10));
// Tap at the end of the text to move the selection to the end. On some
// platforms, the context menu "Cut" button blocks this tap, so move it out
// of the way by an Offset.
await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
@ -2646,8 +2698,8 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream),
);
// Non-Collapsed toolbar shows 3 buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
// Non-Collapsed toolbar shows 4 buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -2680,7 +2732,14 @@ void main() {
);
// Collapsed toolbar shows 2 buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform
? findsNothing
: defaultTargetPlatform == TargetPlatform.iOS
? findsNWidgets(2)
: findsNWidgets(1),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -2706,7 +2765,21 @@ void main() {
await tester.longPressAt(ePos);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(ePos);
final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
if (kIsWeb) {
expect(find.byType(CupertinoButton), findsNothing);
} else {
expect(find.byType(CupertinoButton), findsNWidgets(isTargetPlatformMobile ? 2 : 1));
}
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, 6);
// Tap in a slightly different position to avoid hitting the context menu
// on desktop.
final Offset secondTapPos = isTargetPlatformMobile
? ePos
: ePos + const Offset(-1.0, 0.0);
await tester.tapAt(secondTapPos);
await tester.pump();
// The cursor does not move and the toolbar is toggled.
@ -2776,7 +2849,7 @@ void main() {
const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -2840,7 +2913,14 @@ void main() {
const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform
? findsNothing
: defaultTargetPlatform == TargetPlatform.iOS
? findsNWidgets(2)
: findsNWidgets(1),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -3001,7 +3081,16 @@ void main() {
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int toolbarButtons;
if (isContextMenuProvidedByPlatform) {
toolbarButtons = 0;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
toolbarButtons = 2;
} else {
// MacOS has no 'Select all' button.
toolbarButtons = 1;
}
expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
@ -3059,7 +3148,16 @@ void main() {
);
// Long press toolbar.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int toolbarButtons;
if (isContextMenuProvidedByPlatform) {
toolbarButtons = 0;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
toolbarButtons = 2;
} else {
// MacOS has no 'Select all' button.
toolbarButtons = 1;
}
expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets(
@ -3081,17 +3179,30 @@ void main() {
),
);
final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
// Use a position higher than wPos to avoid tapping the context menu on
// desktop.
final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
await tester.longPressAt(wPos);
await tester.pump(const Duration(milliseconds: 50));
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, 3);
if (isContextMenuProvidedByPlatform) {
expect(find.byType(CupertinoButton), findsNothing);
} else {
expect(find.byType(CupertinoButton), isTargetPlatformMobile ? findsNWidgets(2) : findsNWidgets(1));
}
await tester.tapAt(pPos);
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, isTargetPlatformMobile ? 8 : 9);
await tester.tapAt(pPos);
await tester.pumpAndSettle();
@ -3208,7 +3319,24 @@ void main() {
await gesture.up();
await tester.pumpAndSettle();
// Shows toolbar.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
final Matcher matchToolbarButtons;
if (isContextMenuProvidedByPlatform) {
matchToolbarButtons = findsNothing;
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.iOS:
matchToolbarButtons = findsNWidgets(3);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
matchToolbarButtons = findsNWidgets(4);
break;
}
}
expect(find.byType(CupertinoButton), matchToolbarButtons);
});
testWidgets('force press on unsupported devices falls back to tap', (WidgetTester tester) async {
@ -3295,6 +3423,11 @@ void main() {
);
expect(endpoints.length, 2);
// On Mac, the toolbar blocks the drag on the right handle, so hide it.
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
editableTextState.hideToolbar(false);
await tester.pumpAndSettle();
// Drag the right handle until there's only 1 char selected.
// We use a small offset because the endpoint is on the very corner
// of the handle.
@ -6269,6 +6402,9 @@ void main() {
key: key1,
focusNode: focusNode1,
),
// This spacer prevents the context menu in one field from
// overlapping with the other field.
const SizedBox(height: 100.0),
CupertinoTextField(
key: key2,
focusNode: focusNode2,
@ -6406,6 +6542,75 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
});
group('context menu', () {
testWidgets('builds CupertinoAdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: '');
await tester.pumpWidget(
CupertinoApp(
home: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CupertinoTextField(
controller: controller,
),
],
),
),
);
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final TextEditingController controller = TextEditingController(text: '');
await tester.pumpWidget(
CupertinoApp(
home: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CupertinoTextField(
controller: controller,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return Placeholder(key: key);
},
),
],
),
),
);
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(key), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(key), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
});
group('magnifier', () {
late ValueNotifier<MagnifierInfo> magnifierInfo;
final Widget fakeMagnifier = Container(key: UniqueKey());
@ -6500,7 +6705,6 @@ void main() {
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(CupertinoTextField), testValue);
@ -6662,6 +6866,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
});
group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@ -6776,34 +6981,33 @@ void main() {
skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser.
);
testWidgets("Tapping on border doesn't lose focus",
(WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: SizedBox(
width: 100,
height: 100,
child: CupertinoTextField(
autofocus: true,
focusNode: focusNode,
),
testWidgets("Tapping on border doesn't lose focus",
(WidgetTester tester) async {
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: SizedBox(
width: 100,
height: 100,
child: CupertinoTextField(
autofocus: true,
focusNode: focusNode,
),
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
// Tap just inside the border, but not inside the EditableText.
await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
await tester.pump();
final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
// Tap just inside the border, but not inside the EditableText.
await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
}, variant: TargetPlatformVariant.all());
});
expect(focusNode.hasPrimaryFocus, isTrue);
}, variant: TargetPlatformVariant.all());
});
testWidgets('Can drag handles to change selection correctly in multiline', (WidgetTester tester) async {

View File

@ -23,7 +23,7 @@ class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionContro
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier? clipboardStatus,
ValueNotifier<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final MediaQueryData mediaQuery = MediaQuery.of(context);

View File

@ -0,0 +1,393 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart';
void main() {
final MockClipboard mockClipboard = MockClipboard();
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
const String buttonText = 'Click me';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: AdaptiveTextSelectionToolbar.buttonItems(
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
label: buttonText,
onPressed: () {
},
),
],
),
),
),
),
);
expect(find.text(buttonText), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
expect(find.byType(TextSelectionToolbar), findsOneWidget);
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
break;
case TargetPlatform.iOS:
expect(find.byType(TextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
break;
case TargetPlatform.macOS:
expect(find.byType(TextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(TextSelectionToolbar), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
expect(find.byType(DesktopTextSelectionToolbar), findsOneWidget);
expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
break;
}
},
variant: TargetPlatformVariant.all(),
skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
);
testWidgets('Can build children directly as well', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: AdaptiveTextSelectionToolbar(
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
children: <Widget>[
Container(key: key),
],
),
),
),
),
);
expect(find.byKey(key), findsOneWidget);
});
testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 400,
child: EditableText(
controller: TextEditingController(),
backgroundCursorColor: const Color(0xff00ffff),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: const Color(0xff00ffff),
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
key: key,
editableTextState: editableTextState,
);
},
),
),
),
),
),
);
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(key), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(key), findsOneWidget);
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select all'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
break;
case TargetPlatform.iOS:
expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
break;
case TargetPlatform.macOS:
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
break;
}
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.all(),
);
testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: AdaptiveTextSelectionToolbar.editable(
key: key,
anchors: const TextSelectionToolbarAnchors(
primaryAnchor: Offset.zero,
),
clipboardStatus: ClipboardStatus.pasteable,
onCopy: () {},
onCut: () {},
onPaste: () {},
onSelectAll: () {},
),
),
),
),
);
expect(find.byKey(key), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.text('Select all'), findsOneWidget);
expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(4));
break;
case TargetPlatform.iOS:
expect(find.text('Select All'), findsOneWidget);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.text('Select all'), findsOneWidget);
expect(find.byType(DesktopTextSelectionToolbarButton), findsNWidgets(4));
break;
case TargetPlatform.macOS:
expect(find.text('Select All'), findsOneWidget);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
break;
}
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.all(),
);
group('buttonItems', () {
testWidgets('getEditableTextButtonItems builds the correct button items per-platform', (WidgetTester tester) async {
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: EditableText(
controller: controller,
backgroundCursorColor: Colors.grey,
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.red,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
buttonTypes = editableTextState.contextMenuButtonItems
.map((ContextMenuButtonItem buttonItem) => buttonItem.type)
.toSet();
return const SizedBox.shrink();
},
),
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
// With no text in the field.
await tester.tapAt(textOffsetToPosition(tester, 0));
await tester.pump();
expect(state.showToolbar(), true);
await tester.pump();
expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
expect(buttonTypes, contains(ContextMenuButtonType.paste));
expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
// With text but no selection.
controller.text = 'lorem ipsum';
await tester.pump();
expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
expect(buttonTypes, contains(ContextMenuButtonType.paste));
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
break;
case TargetPlatform.macOS:
expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
break;
}
// With text and selection.
controller.value = controller.value.copyWith(
selection: const TextSelection(
baseOffset: 0,
extentOffset: 'lorem'.length,
),
);
await tester.pump();
expect(buttonTypes, contains(ContextMenuButtonType.cut));
expect(buttonTypes, contains(ContextMenuButtonType.copy));
expect(buttonTypes, contains(ContextMenuButtonType.paste));
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
break;
}
},
variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended]
);
testWidgets('getAdaptiveButtons builds the correct button widgets per-platform', (WidgetTester tester) async {
const String buttonText = 'Click me';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Builder(
builder: (BuildContext context) {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[
ContextMenuButtonItem(
label: buttonText,
onPressed: () {
},
),
];
return ListView(
children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
context,
buttonItems,
).toList(),
);
},
),
),
),
),
);
expect(find.text(buttonText), findsOneWidget);
switch (defaultTargetPlatform) {
case TargetPlatform.fuchsia:
case TargetPlatform.android:
expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
break;
case TargetPlatform.iOS:
expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
break;
case TargetPlatform.macOS:
expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
break;
}
},
variant: TargetPlatformVariant.all(),
);
});
}

View File

@ -0,0 +1,31 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('can press', (WidgetTester tester) async {
bool pressed = false;
await tester.pumpWidget(
MaterialApp(
home: Center(
child: DesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {
pressed = true;
},
),
),
),
);
expect(pressed, false);
await tester.tap(find.byType(DesktopTextSelectionToolbarButton));
expect(pressed, true);
});
}

View File

@ -0,0 +1,36 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('positions itself at the anchor', (WidgetTester tester) async {
// An arbitrary point on the screen to position at.
const Offset anchor = Offset(30.0, 40.0);
await tester.pumpWidget(
MaterialApp(
home: Center(
child: DesktopTextSelectionToolbar(
anchor: anchor,
children: <Widget>[
DesktopTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {},
),
],
),
),
),
);
expect(
tester.getTopLeft(find.byType(DesktopTextSelectionToolbarButton)),
anchor,
);
});
}

View File

@ -27,21 +27,82 @@ void main() {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(region.selectionControls, materialTextSelectionControls);
expect(region.selectionControls, materialTextSelectionHandleControls);
break;
case TargetPlatform.iOS:
expect(region.selectionControls, cupertinoTextSelectionControls);
expect(region.selectionControls, cupertinoTextSelectionHandleControls);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(region.selectionControls, desktopTextSelectionControls);
expect(region.selectionControls, desktopTextSelectionHandleControls);
break;
case TargetPlatform.macOS:
expect(region.selectionControls, cupertinoDesktopTextSelectionControls);
expect(region.selectionControls, cupertinoDesktopTextSelectionHandleControls);
break;
}
}, variant: TargetPlatformVariant.all());
testWidgets('builds the default context menu by default', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectionArea(
focusNode: FocusNode(),
child: const Text('How are you?'),
),
),
);
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 tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
},
skip: kIsWeb, // [intended]
);
testWidgets('builds a custom context menu if provided', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: SelectionArea(
focusNode: FocusNode(),
contextMenuBuilder: (
BuildContext context,
SelectableRegionState selectableRegionState,
) {
return Placeholder(key: key);
},
child: const Text('How are you?'),
),
),
);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byKey(key), 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 tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byKey(key), findsOneWidget);
},
skip: kIsWeb, // [intended]
);
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
SelectedContent? content;

View File

@ -24,15 +24,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart' show OverflowWidgetTextEditingController, findRenderEditable, globalize, textOffsetToPosition;
import '../widgets/editable_text_utils.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
typedef FormatEditUpdateCallback = void Function(TextEditingValue, TextEditingValue);
// On web, the context menu (aka toolbar) is provided by the browser.
const bool isContextMenuProvidedByPlatform = isBrowser;
// On web, key events in text fields are handled by the browser.
const bool areKeyEventsHandledByPlatform = isBrowser;
@ -1160,8 +1157,9 @@ void main() {
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
// Use toolbar to select all text.
if (isContextMenuProvidedByPlatform) {
// Select all text. Use the toolbar if possible. iOS only shows the toolbar
// when the selection is collapsed.
if (isContextMenuProvidedByPlatform || defaultTargetPlatform == TargetPlatform.iOS) {
controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
expect(controller.selection.extentOffset, controller.text.length);
} else {
@ -2914,6 +2912,7 @@ void main() {
await tester.pump();
// Toolbar should fade in. Starting at 0% opacity.
expect(find.text('Select all'), findsOneWidget);
final Element target = tester.element(find.text('Select all'));
final FadeTransition opacity = target.findAncestorWidgetOfExactType<FadeTransition>()!;
expect(opacity.opacity.value, equals(0.0));
@ -8474,7 +8473,11 @@ void main() {
);
// Collapsed toolbar shows 2 buttons.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -8535,7 +8538,14 @@ void main() {
await tester.longPressAt(ePos);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(ePos);
// Tap slightly behind the previous tap to avoid tapping the context menu
// on desktop.
final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
final Offset secondTapPos = isTargetPlatformMobile
? ePos
: ePos + const Offset(-1.0, 0.0);
await tester.tapAt(secondTapPos);
await tester.pump();
// The cursor does not move and the toolbar is toggled.
@ -8689,7 +8699,11 @@ void main() {
const TextSelection.collapsed(offset: 9),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -8852,7 +8866,11 @@ void main() {
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
);
lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
@ -9038,7 +9056,6 @@ void main() {
// Start long pressing on the first line.
final TestGesture gesture =
await tester.startGesture(textOffsetToPosition(tester, 19));
// TODO(justinmc): Make sure you've got all things torn down.
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.selection,
@ -9281,7 +9298,11 @@ void main() {
);
// Long press toolbar.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@ -9307,7 +9328,9 @@ void main() {
),
);
final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
// The second tap is slightly higher to avoid tapping the context menu on
// desktop.
final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
await tester.longPressAt(wPos);
@ -9662,8 +9685,10 @@ void main() {
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
// Put the cursor at the end of the field.
await tester.tapAt(textOffsetToPosition(tester, 10));
// Tap at the end of the text to move the selection to the end. On some
// platforms, the context menu "Cut" button blocks this tap, so move it out
// of the way by an Offset.
await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
@ -10679,10 +10704,12 @@ void main() {
// Tap the handle to show the toolbar.
final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0);
await tester.tapAt(handlePos, pointer: 7);
await tester.pump();
expect(editableText.selectionOverlay!.toolbarIsVisible, isContextMenuProvidedByPlatform ? isFalse : isTrue);
// Tap the handle again to hide the toolbar.
await tester.tapAt(handlePos, pointer: 7);
await tester.pump();
expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse);
});
@ -11405,7 +11432,8 @@ void main() {
expect(find.text(selectAll), findsNothing);
expect(find.text('Copy'), findsNothing);
},
variant: TargetPlatformVariant.desktop(),
// All desktop platforms except MacOS, which has no select all button.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.windows }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
@ -12455,6 +12483,7 @@ void main() {
key: key1,
focusNode: focusNode1,
),
const SizedBox(height: 100.0),
TextField(
key: key2,
focusNode: focusNode2,
@ -12594,6 +12623,77 @@ void main() {
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
});
group('context menu', () {
testWidgets('builds AdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: '');
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
TextField(
controller: controller,
),
],
),
),
),
);
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final TextEditingController controller = TextEditingController(text: '');
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
TextField(
controller: controller,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return Placeholder(key: key);
},
),
],
),
),
),
);
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(key), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(key), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
});
group('magnifier builder', () {
testWidgets('should build custom magnifier if given',
(WidgetTester tester) async {
@ -12816,6 +12916,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }));
});
group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@ -12966,6 +13067,7 @@ void main() {
),
),
);
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
@ -13033,10 +13135,9 @@ void main() {
case PointerDeviceKind.unknown:
expect(focusNode.hasPrimaryFocus, isFalse);
break;
}
}, variant: TargetPlatformVariant.all());
}
});
}
}, variant: TargetPlatformVariant.all());
}
});
}

View File

@ -611,7 +611,6 @@ void main() {
expect(find.text('Select all'), findsNothing);
expect(find.byType(IconButton), findsNothing);
// The menu appears at the top of the visible selection.
final Offset selectionOffset = tester
.getTopLeft(find.byType(TextSelectionToolbarTextButton).first);

View File

@ -8,11 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
const double _kHandleSize = 22.0;
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
// A custom text selection menu that just displays a single custom button.
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
static const double _kToolbarContentDistanceBelow = 20.0;
static const double _kToolbarContentDistance = 8.0;
@override
Widget buildToolbar(
BuildContext context,
@ -154,23 +155,23 @@ void main() {
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY));
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = 50.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = 60.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorAboveY - height));
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = 70.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance));
});
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {

View File

@ -0,0 +1,263 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'clipboard_utils.dart';
import 'editable_text_utils.dart';
void main() {
final MockClipboard mockClipboard = MockClipboard();
TestWidgetsFlutterBinding.ensureInitialized()
.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
setUp(() async {
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
testWidgets('Hides and shows only a single menu', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey();
final GlobalKey key2 = GlobalKey();
late final BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext localContext) {
context = localContext;
return const SizedBox.shrink();
},
),
),
),
);
expect(find.byKey(key1), findsNothing);
expect(find.byKey(key2), findsNothing);
final ContextMenuController controller1 = ContextMenuController();
await tester.pump();
expect(find.byKey(key1), findsNothing);
expect(find.byKey(key2), findsNothing);
controller1.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: key1);
},
);
await tester.pump();
expect(find.byKey(key1), findsOneWidget);
expect(find.byKey(key2), findsNothing);
// Showing the same thing again does nothing and is not an error.
controller1.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: key1);
},
);
await tester.pump();
expect(tester.takeException(), null);
expect(find.byKey(key1), findsOneWidget);
expect(find.byKey(key2), findsNothing);
// Showing a new menu hides the first.
final ContextMenuController controller2 = ContextMenuController();
controller2.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: key2);
},
);
await tester.pump();
expect(find.byKey(key1), findsNothing);
expect(find.byKey(key2), findsOneWidget);
controller2.remove();
await tester.pump();
expect(find.byKey(key1), findsNothing);
expect(find.byKey(key2), findsNothing);
});
testWidgets('A menu can be hidden and then reshown', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey();
late final BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext localContext) {
context = localContext;
return const SizedBox.shrink();
},
),
),
),
);
expect(find.byKey(key1), findsNothing);
final ContextMenuController controller = ContextMenuController();
// Instantiating the controller does not shown it.
await tester.pump();
expect(find.byKey(key1), findsNothing);
controller.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: key1);
},
);
await tester.pump();
expect(find.byKey(key1), findsOneWidget);
controller.remove();
await tester.pump();
expect(find.byKey(key1), findsNothing);
controller.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: key1);
},
);
await tester.pump();
expect(find.byKey(key1), findsOneWidget);
});
testWidgets('markNeedsBuild causes the builder to update', (WidgetTester tester) async {
int buildCount = 0;
late final BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext localContext) {
context = localContext;
return const SizedBox.shrink();
},
),
),
),
);
final ContextMenuController controller = ContextMenuController();
controller.show(
context: context,
contextMenuBuilder: (BuildContext context) {
buildCount++;
return const Placeholder();
},
);
expect(buildCount, 0);
await tester.pump();
expect(buildCount, 1);
controller.markNeedsBuild();
expect(buildCount, 1);
await tester.pump();
expect(buildCount, 2);
controller.remove();
});
testWidgets('Calling show when a built-in widget is already showing its context menu hides the built-in menu', (WidgetTester tester) async {
final GlobalKey builtInKey = GlobalKey();
final GlobalKey directKey = GlobalKey();
late final BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (BuildContext localContext) {
context = localContext;
return EditableText(
controller: TextEditingController(),
backgroundCursorColor: Colors.grey,
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.red,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return Placeholder(key: builtInKey);
},
);
},
),
),
),
);
expect(find.byKey(builtInKey), findsNothing);
expect(find.byKey(directKey), findsNothing);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
await tester.tapAt(textOffsetToPosition(tester, 0));
await tester.pump();
expect(state.showToolbar(), true);
await tester.pump();
expect(find.byKey(builtInKey), findsOneWidget);
expect(find.byKey(directKey), findsNothing);
final ContextMenuController controller = ContextMenuController();
controller.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return Placeholder(key: directKey);
},
);
await tester.pump();
expect(find.byKey(builtInKey), findsNothing);
expect(find.byKey(directKey), findsOneWidget);
expect(controller.isShown, isTrue);
// And showing the built-in menu hides the directly shown menu.
expect(state.showToolbar(), isTrue);
await tester.pump();
expect(find.byKey(builtInKey), findsOneWidget);
expect(find.byKey(directKey), findsNothing);
expect(controller.isShown, isFalse);
// Calling remove on the hidden ContextMenuController does not hide the
// built-in menu.
controller.remove();
await tester.pump();
expect(find.byKey(builtInKey), findsOneWidget);
expect(find.byKey(directKey), findsNothing);
expect(controller.isShown, isFalse);
state.hideToolbar();
await tester.pump();
expect(find.byKey(builtInKey), findsNothing);
expect(find.byKey(directKey), findsNothing);
expect(controller.isShown, isFalse);
},
skip: isContextMenuProvidedByPlatform, // [intended] no Flutter-drawn text selection toolbar on web.
);
}

View File

@ -1834,7 +1834,7 @@ void main() {
backgroundCursorColor: Colors.grey,
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
toolbarOptions: const ToolbarOptions(),
toolbarOptions: ToolbarOptions.empty,
style: textStyle,
cursorColor: cursorColor,
selectionControls: cupertinoTextSelectionControls,
@ -3293,7 +3293,7 @@ void main() {
);
controller.selection =
TextSelection.collapsed(offset:controller.text.length);
TextSelection.collapsed(offset: controller.text.length);
await tester.pumpAndSettle();
// At end, can only go backwards.
@ -5306,7 +5306,7 @@ void main() {
// Find the toolbar fade transition while the toolbar is still visible.
final List<FadeTransition> transitionsBefore = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
@ -5322,7 +5322,7 @@ void main() {
// Find the toolbar fade transition after the toolbar has been hidden.
final List<FadeTransition> transitionsAfter = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
@ -13575,6 +13575,56 @@ void main() {
});
});
testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final TextEditingController controller = TextEditingController(text: '');
await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: EditableText(
maxLines: 10,
controller: controller,
showSelectionHandles: true,
autofocus: true,
focusNode: FocusNode(),
style: Typography.material2018().black.subtitle1!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
keyboardType: TextInputType.text,
textAlign: TextAlign.right,
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) {
return SizedBox(
key: key,
width: 10.0,
height: 10.0,
);
},
),
),
),
));
await tester.pump(); // Wait for autofocus to take effect.
expect(find.byKey(key), findsNothing);
// Long-press to bring up the context menu.
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pumpAndSettle();
expect(find.byKey(key), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
group('Spell check', () {
testWidgets(
'Spell check configured properly when spell check disabled by default',

View File

@ -7,6 +7,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
/// On web, the context menu (aka toolbar) is provided by the browser.
const bool isContextMenuProvidedByPlatform = isBrowser;
// Returns the first RenderEditable.
RenderEditable findRenderEditable(WidgetTester tester) {
final RenderObject root = tester.renderObject(find.byType(EditableText));

View File

@ -1209,6 +1209,44 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
);
testWidgets('builds the correct button items', (WidgetTester tester) async {
Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionHandleControls,
contextMenuBuilder: (
BuildContext context,
SelectableRegionState selectableRegionState,
) {
buttonTypes = selectableRegionState.contextMenuButtonItems
.map((ContextMenuButtonItem buttonItem) => buttonItem.type)
.toSet();
return const SizedBox.shrink();
},
child: const Text('How are you?'),
),
),
);
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'
addTearDown(gesture.removePointer);
await tester.pump(const Duration(milliseconds: 500));
// `are` is selected.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
await tester.pumpAndSettle();
expect(buttonTypes, contains(ContextMenuButtonType.copy));
expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
},
variant: TargetPlatformVariant.all(),
skip: kIsWeb, // [intended]
);
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
SelectedContent? content;

View File

@ -3353,6 +3353,12 @@ void main() {
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// Hide the toolbar so it doesn't interfere with taps on the text.
final EditableTextState editableTextState =
tester.state<EditableTextState>(find.byType(EditableText));
editableTextState.hideToolbar();
await tester.pumpAndSettle();
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump();
@ -3829,6 +3835,12 @@ void main() {
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// Hide the toolbar so it doesn't interfere with taps on the text.
final EditableTextState editableTextState =
tester.state<EditableTextState>(find.byType(EditableText));
editableTextState.hideToolbar();
await tester.pumpAndSettle();
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
@ -3901,6 +3913,12 @@ void main() {
);
expect(find.byType(CupertinoButton), findsNWidgets(1));
// Hide the toolbar so it doesn't interfere with taps on the text.
final EditableTextState editableTextState =
tester.state<EditableTextState>(find.byType(EditableText));
editableTextState.hideToolbar();
await tester.pumpAndSettle();
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
@ -4411,7 +4429,7 @@ void main() {
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
await tester.tapAt(const Offset(20, 10));
@ -4971,7 +4989,11 @@ void main() {
expect(selection!.baseOffset, 5);
expect(selection!.extentOffset, 6);
// Put the cursor at the end of the field.
// Tap at the beginning of the text to hide the toolbar, then at the end to
// move the cursor to the end. On some platforms, the context menu would
// otherwise block a tap on the end of the field.
await tester.tapAt(textOffsetToPosition(tester, 0));
await tester.pumpAndSettle();
await tester.tapAt(textOffsetToPosition(tester, 10));
expect(selection, isNotNull);
expect(selection!.baseOffset, 10);