Unverified Commit 7d2e3a18 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Material Text Selection Toolbar improvements (#69428)

Exposes the ability to create custom Material text selection toolbar menus, and cleans up the code around these menus.
parent 12232294
......@@ -136,6 +136,8 @@ export 'src/material/text_field.dart';
export 'src/material/text_form_field.dart';
export 'src/material/text_selection.dart';
export 'src/material/text_selection_theme.dart';
export 'src/material/text_selection_toolbar.dart';
export 'src/material/text_selection_toolbar_text_button.dart';
export 'src/material/text_theme.dart';
export 'src/material/theme.dart';
export 'src/material/theme_data.dart';
......
This diff is collapsed.
// 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';
import 'constants.dart';
import 'text_button.dart';
import 'theme.dart';
enum _TextSelectionToolbarItemPosition {
/// The first item among multiple in the menu.
first,
/// One of several items, not the first or last.
middle,
/// The last item among multiple in the menu.
last,
/// The only item in the menu.
only,
}
/// A button styled like a Material native Android text selection menu button.
class TextSelectionToolbarTextButton extends StatelessWidget {
/// Creates an instance of TextSelectionToolbarTextButton.
const TextSelectionToolbarTextButton({
Key? key,
required this.child,
required this.padding,
this.onPressed,
}) : super(key: key);
// These values were eyeballed to match the native text selection menu on a
// Pixel 2 running Android 10.
static const double _kMiddlePadding = 9.5;
static const double _kEndPadding = 14.5;
/// The child of this button.
///
/// Usually a [Text].
final Widget child;
/// Called when this button is pressed.
final VoidCallback? onPressed;
/// The padding between the button's edge and its child.
///
/// In a standard Material [TextSelectionToolbar], the padding depends on the
/// button's position within the toolbar.
///
/// See also:
///
/// * [getPadding], which calculates the standard padding based on the
/// button's position.
/// * [ButtonStyle.padding], which is where this padding is applied.
final EdgeInsets padding;
/// Returns the standard padding for a button at index out of a total number
/// of buttons.
///
/// Standard Material [TextSelectionToolbar]s have buttons with different
/// padding depending on their position in the toolbar.
static EdgeInsets getPadding(int index, int total) {
assert(total > 0 && index >= 0 && index < total);
final _TextSelectionToolbarItemPosition position = _getPosition(index, total);
return EdgeInsets.only(
left: _getLeftPadding(position),
right: _getRightPadding(position),
);
}
static double _getLeftPadding(_TextSelectionToolbarItemPosition position) {
if (position == _TextSelectionToolbarItemPosition.first
|| position == _TextSelectionToolbarItemPosition.only) {
return _kEndPadding;
}
return _kMiddlePadding;
}
static double _getRightPadding(_TextSelectionToolbarItemPosition position) {
if (position == _TextSelectionToolbarItemPosition.last
|| position == _TextSelectionToolbarItemPosition.only) {
return _kEndPadding;
}
return _kMiddlePadding;
}
static _TextSelectionToolbarItemPosition _getPosition(int index, int total) {
if (index == 0) {
return total == 1
? _TextSelectionToolbarItemPosition.only
: _TextSelectionToolbarItemPosition.first;
}
if (index == total - 1) {
return _TextSelectionToolbarItemPosition.last;
}
return _TextSelectionToolbarItemPosition.middle;
}
@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 primary = isDark ? Colors.white : Colors.black87;
return TextButton(
style: TextButton.styleFrom(
primary: primary,
shape: const RoundedRectangleBorder(),
minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension),
padding: padding,
),
onPressed: onPressed,
child: child,
);
}
}
......@@ -567,20 +567,27 @@ class TextSelectionOverlay {
endpoints[0].point.dy - renderObject.preferredLineHeight,
);
return FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: toolbarLayerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionControls!.buildToolbar(
context,
editingRegion,
renderObject.preferredLineHeight,
midpoint,
endpoints,
selectionDelegate!,
clipboardStatus!,
return Directionality(
textDirection: Directionality.of(this.context),
child: FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: toolbarLayerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return selectionControls!.buildToolbar(
context,
editingRegion,
renderObject.preferredLineHeight,
midpoint,
endpoints,
selectionDelegate!,
clipboardStatus!,
);
},
),
),
),
);
......@@ -1516,7 +1523,7 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget
Future<void> update() async {
// iOS 14 added a notification that appears when an app accesses the
// clipboard. To avoid the notification, don't access the clipboard on iOS,
// and instead always shown the paste button, even when the clipboard is
// and instead always show the paste button, even when the clipboard is
// empty.
// TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
// won't trigger the notification.
......
// 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_test/flutter_test.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../widgets/text.dart' show textOffsetToPosition;
// 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,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier clipboardStatus,
) {
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
? endpoints[1]
: endpoints[0];
final Offset anchorAbove = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + startTextSelectionPoint.point.dy - textLineHeight - _kToolbarContentDistance
);
final Offset anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
);
return TextSelectionToolbar(
anchorAbove: anchorAbove,
anchorBelow: anchorBelow,
children: <Widget>[
TextSelectionToolbarTextButton(
padding: TextSelectionToolbarTextButton.getPadding(0, 1),
onPressed: () {},
child: const Text('Custom button'),
),
],
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Find by a runtimeType String, including private types.
Finder _findPrivate(String type) {
return find.descendant(
of: find.byType(MaterialApp),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type),
);
}
// Finding TextSelectionToolbar won't give you the position as the user sees
// it because it's a full-sized Stack at the top level. This method finds the
// visible part of the toolbar for use in measurements.
Finder _findToolbar() => _findPrivate('_TextSelectionToolbarOverflowable');
Finder _findOverflowButton() => _findPrivate('_TextSelectionToolbarOverflowButton');
testWidgets('puts children in an overflow menu if they overflow', (WidgetTester tester) async {
late StateSetter setState;
const double height = 44.0;
const double itemWidth = 100.0;
final List<Widget> children = <Widget>[
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
Container(width: itemWidth, height: height),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
);
},
),
),
),
);
// All children fit on the screen, so they are all rendered.
expect(find.byType(Container), findsNWidgets(children.length));
expect(_findOverflowButton(), findsNothing);
// Adding one more child makes the children overflow.
setState(() {
children.add(
Container(width: itemWidth, height: height),
);
});
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(children.length - 1));
expect(_findOverflowButton(), findsOneWidget);
// Tap the overflow button to show the overflow menu.
await tester.tap(_findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(1));
expect(_findOverflowButton(), findsOneWidget);
// Tap the overflow button again to hide the overflow menu.
await tester.tap(_findOverflowButton());
await tester.pumpAndSettle();
expect(find.byType(Container), findsNWidgets(children.length - 1));
expect(_findOverflowButton(), findsOneWidget);
});
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const double height = 44.0;
const double anchorBelowY = 500.0;
double anchorAboveY = 0.0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbar(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
children: <Widget>[
Container(color: Colors.red, width: 50.0, height: height),
Container(color: Colors.green, width: 50.0, height: height),
Container(color: Colors.blue, width: 50.0, height: height),
],
);
},
),
),
),
);
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(_findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY));
// 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));
});
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SelectableText(
'Select me custom menu',
selectionControls: _CustomMaterialTextSelectionControls(),
),
),
),
),
);
// The selection menu is not initially shown.
expect(find.text('Custom button'), findsNothing);
// Long press on "custom" to select it.
final Offset customPos = textOffsetToPosition(tester, 11);
final TestGesture gesture = await tester.startGesture(customPos, pointer: 7);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
// The custom selection menu is shown.
expect(find.text('Custom button'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
}, skip: kIsWeb);
}
// 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('position in the toolbar changes width', (WidgetTester tester) async {
late StateSetter setState;
int index = 1;
int total = 3;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return TextSelectionToolbarTextButton(
child: const Text('button'),
padding: TextSelectionToolbarTextButton.getPadding(index, total),
);
},
),
),
),
),
);
final Size middleSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
setState(() {
index = 0;
total = 3;
});
await tester.pump();
final Size firstSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
expect(firstSize.width, greaterThan(middleSize.width));
setState(() {
index = 2;
total = 3;
});
await tester.pump();
final Size lastSize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
expect(lastSize.width, greaterThan(middleSize.width));
expect(lastSize.width, equals(firstSize.width));
setState(() {
index = 0;
total = 1;
});
await tester.pump();
final Size onlySize = tester.getSize(find.byType(TextSelectionToolbarTextButton));
expect(onlySize.width, greaterThan(middleSize.width));
expect(onlySize.width, greaterThan(firstSize.width));
expect(onlySize.width, greaterThan(lastSize.width));
});
}
......@@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show textOffsetToPosition;
class MockClipboard {
dynamic _clipboardData = <String, dynamic>{
......@@ -160,18 +161,6 @@ void main() {
}).toList();
}
Offset textOffsetToPosition(WidgetTester tester, int offset) {
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(
TextSelection.collapsed(offset: offset),
),
renderEditable,
);
expect(endpoints.length, 1);
return endpoints[0].point + const Offset(0.0, -2.0);
}
setUp(() {
debugResetSemanticsIdCounter();
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment