mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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:
parent
ef1236e038
commit
0b451b6dfd
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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';
|
||||
}
|
123
packages/flutter/lib/src/widgets/context_menu_controller.dart
Normal file
123
packages/flutter/lib/src/widgets/context_menu_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
@ -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.
|
||||
///
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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) {}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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';
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
263
packages/flutter/test/widgets/context_menu_controller_test.dart
Normal file
263
packages/flutter/test/widgets/context_menu_controller_test.dart
Normal 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.
|
||||
);
|
||||
}
|
@ -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',
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user