Unverified Commit d9f3d2e8 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Cupertino text selection menu customization (#73578)

  * Making a custom iOS-style text selection menu is now much easier.
  * Exposes a new widget for the toolbar, a new widget for the buttons, and a new widget for the layout.
parent 7cb0c16e
......@@ -55,6 +55,8 @@ export 'src/cupertino/tab_view.dart';
export 'src/cupertino/text_field.dart';
export 'src/cupertino/text_form_field_row.dart';
export 'src/cupertino/text_selection.dart';
export 'src/cupertino/text_selection_toolbar.dart';
export 'src/cupertino/text_selection_toolbar_button.dart';
export 'src/cupertino/text_theme.dart';
export 'src/cupertino/theme.dart';
export 'src/cupertino/thumb_painter.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 'button.dart';
import 'colors.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
);
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const Color _kToolbarBackgroundColor = Color(0xEB202020);
// Eyeballed value.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 16.0, horizontal: 18.0);
/// A button in the style of the iOS text selection toolbar buttons.
class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// Create an instance of [CupertinoTextSelectionToolbarButton].
const CupertinoTextSelectionToolbarButton({
Key? key,
this.onPressed,
required this.child,
}) : super(key: key);
/// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default iOS text selection toolbar button.
CupertinoTextSelectionToolbarButton.text({
Key? key,
this.onPressed,
required String text,
}) : child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
color: onPressed != null ? CupertinoColors.white : CupertinoColors.inactiveGray,
),
),
super(key: key);
/// The child of this button.
///
/// Usually a [Text] or an [Icon].
final Widget child;
/// Called when this button is pressed.
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
disabledColor: _kToolbarBackgroundColor,
onPressed: onPressed,
padding: _kToolbarButtonPadding,
pressedOpacity: onPressed == null ? 1.0 : 0.7,
child: child,
);
}
}
......@@ -25,6 +25,8 @@ const double _kToolbarHeight = 44.0;
/// See also:
///
/// * [TextSelectionToolbar.toolbarBuilder], which is of this type.
/// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but
/// for a Cupertino-style toolbar.
typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
/// A fully-functional Material-style text selection toolbar.
......@@ -34,6 +36,13 @@ typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child);
///
/// If any children don't fit in the menu, an overflow menu will automatically
/// be created.
///
/// See also:
///
/// * [TextSelectionControls.buildToolbar], where this is used by default to
/// build an Android-style toolbar.
/// * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS-
/// style toolbar.
class TextSelectionToolbar extends StatelessWidget {
/// Creates an instance of TextSelectionToolbar.
const TextSelectionToolbar({
......@@ -45,31 +54,39 @@ class TextSelectionToolbar extends StatelessWidget {
}) : assert(children.length > 0),
super(key: key);
/// {@template flutter.material.TextSelectionToolbar.anchorAbove}
/// The focal point above which the toolbar attempts to position itself.
///
/// If there is not enough room above before reaching the top of the screen,
/// then the toolbar will position itself below [anchorBelow].
/// {@endtemplate}
final Offset anchorAbove;
/// {@template flutter.material.TextSelectionToolbar.anchorBelow}
/// The focal point below which the toolbar attempts to position itself, if it
/// doesn't fit above [anchorAbove].
/// {@endtemplate}
final Offset anchorBelow;
/// {@template flutter.material.TextSelectionToolbar.children}
/// The children that will be displayed in the text selection toolbar.
///
/// Typically these are buttons.
///
/// Must not be empty.
/// {@endtemplate}
///
/// See also:
/// * [TextSelectionToolbarTextButton], which builds a default Material-
/// style text selection toolbar text button.
final List<Widget> children;
/// {@template flutter.material.TextSelectionToolbar.toolbarBuilder}
/// Builds the toolbar container.
///
/// Useful for customizing the high-level background of the toolbar. The given
/// child Widget will contain all of the [children].
/// {@endtemplate}
final ToolbarBuilder toolbarBuilder;
// Build the default Android Material text selection menu toolbar.
......@@ -81,29 +98,26 @@ class TextSelectionToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double paddingTop = MediaQuery.of(context).padding.top
final double paddingAbove = MediaQuery.of(context).padding.top
+ _kToolbarScreenPadding;
final double availableHeight = anchorAbove.dy - paddingTop;
final double availableHeight = anchorAbove.dy - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight;
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow;
final Offset localAnchor = Offset(
anchor.dx - _kToolbarScreenPadding,
anchor.dy - paddingTop,
);
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
paddingTop,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
child: Stack(
children: <Widget>[
CustomSingleChildLayout(
delegate: _TextSelectionToolbarLayoutDelegate(
localAnchor,
fitsAbove,
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: anchorAbove - localAdjustment,
anchorBelow: anchorBelow - localAdjustment,
fitsAbove: fitsAbove,
),
child: _TextSelectionToolbarOverflowable(
isAbove: fitsAbove,
......@@ -117,66 +131,6 @@ class TextSelectionToolbar extends StatelessWidget {
}
}
// Positions the toolbar at the given anchor, ensuring that it remains on
// screen.
class _TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
_TextSelectionToolbarLayoutDelegate(
this.anchor,
this.fitsAbove,
);
// Anchor position of the toolbar in global coordinates.
final Offset anchor;
// Whether the closed toolbar fits above the anchor position.
//
// If the closed toolbar doesn't fit, then the menu is rendered below the
// anchor position. It should never happen that the toolbar extends below the
// padded bottom of the screen.
final bool fitsAbove;
// Return the value that centers width as closely as possible to position
// while fitting inside of min and max.
static double _centerOn(double position, double width, double max) {
// If it overflows on the left, put it as far left as possible.
if (position - width / 2.0 < 0.0) {
return 0.0;
}
// If it overflows on the right, put it as far right as possible.
if (position + width / 2.0 > max) {
return max - width;
}
// Otherwise it fits while perfectly centered.
return position - width / 2.0;
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
@override
Offset getPositionForChild(Size size, Size childSize) {
return Offset(
_centerOn(
anchor.dx,
childSize.width,
size.width,
),
fitsAbove
? math.max(0.0, anchor.dy - childSize.height)
: anchor.dy,
);
}
@override
bool shouldRelayout(_TextSelectionToolbarLayoutDelegate oldDelegate) {
return anchor != oldDelegate.anchor || fitsAbove != oldDelegate.fitsAbove;
}
}
// A toolbar containing the given children. If they overflow the width
// available, then the overflowing children will be displayed in an overflow
// menu.
......
// 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 'dart:math' as math;
import 'package:flutter/rendering.dart';
/// Positions the toolbar above [anchorAbove] if it fits, or otherwise below
/// [anchorBelow].
///
/// See also:
///
/// * [TextSelectionToolbar], which uses this to position itself.
/// * [CupertinoTextSelectionToolbar], which also uses this to position
/// itself.
class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of TextSelectionToolbarLayoutDelegate.
TextSelectionToolbarLayoutDelegate({
required this.anchorAbove,
required this.anchorBelow,
this.fitsAbove,
});
/// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
///
/// Should be provided in local coordinates.
final Offset anchorAbove;
/// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
///
/// Should be provided in local coordinates.
final Offset anchorBelow;
/// Whether or not the child should be considered to fit above anchorAbove.
///
/// Typically used to force the child to be drawn at anchorAbove even when it
/// doesn't fit, such as when the Material [TextSelectionToolbar] draws an
/// open overflow menu.
///
/// If not provided, it will be calculated.
final bool? fitsAbove;
// Return the value that centers width as closely as possible to position
// while fitting inside of min and max.
static double _centerOn(double position, double width, double max) {
// If it overflows on the left, put it as far left as possible.
if (position - width / 2.0 < 0.0) {
return 0.0;
}
// If it overflows on the right, put it as far right as possible.
if (position + width / 2.0 > max) {
return max - width;
}
// Otherwise it fits while perfectly centered.
return position - width / 2.0;
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final bool fitsAbove = this.fitsAbove ?? anchorAbove.dy >= childSize.height;
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow;
return Offset(
_centerOn(
anchor.dx,
childSize.width,
size.width,
),
fitsAbove
? math.max(0.0, anchor.dy - childSize.height)
: anchor.dy,
);
}
@override
bool shouldRelayout(TextSelectionToolbarLayoutDelegate oldDelegate) {
return anchorAbove != oldDelegate.anchorAbove
|| anchorBelow != oldDelegate.anchorBelow
|| fitsAbove != oldDelegate.fitsAbove;
}
}
......@@ -115,6 +115,7 @@ export 'src/widgets/status_transitions.dart';
export 'src/widgets/table.dart';
export 'src/widgets/text.dart';
export 'src/widgets/text_selection.dart';
export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/texture.dart';
export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart';
......
// 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/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('can press', (WidgetTester tester) async {
bool pressed = false;
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () {
pressed = true;
},
),
),
),
);
expect(pressed, false);
await tester.tap(find.byType(CupertinoTextSelectionToolbarButton));
expect(pressed, true);
});
testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbarButton(
child: const Text('Tap me'),
onPressed: () { },
),
),
),
);
// Originall at full opacity.
FadeTransition opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
// Make a "down" gesture on the button.
final Offset center = tester.getCenter(find.byType(CupertinoTextSelectionToolbarButton));
final TestGesture gesture = await tester.startGesture(center);
await tester.pumpAndSettle();
// Opacity reduces during the down gesture.
opacity = tester.widget(find.descendant(
of: find.byType(CupertinoTextSelectionToolbarButton),
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(CupertinoTextSelectionToolbarButton),
matching: find.byType(FadeTransition),
));
expect(opacity.opacity.value, 1.0);
});
}
......@@ -14,7 +14,6 @@ import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind;
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
import '../widgets/text.dart' show findRenderEditable, globalize, textOffsetToPosition;
import 'feedback_tester.dart';
......@@ -1134,7 +1133,7 @@ void main() {
// Wait for context menu to be built.
await tester.pumpAndSettle();
expect(find.byType(CupertinoTextSelectionToolbar), paintsNothing);
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('text field build empty toolbar when no options available', (WidgetTester tester) async {
......
// 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/rendering.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async {
late StateSetter setState;
const double height = 43.0;
const double anchorBelowY = 500.0;
double anchorAboveY = 0.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, anchorBelowY),
),
child: Container(
width: 200.0,
height: height,
color: const Color(0xffff0000),
),
);
},
),
),
),
);
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorBelowY));
// Even when it barely doesn't fit.
setState(() {
anchorAboveY = height - 1.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorBelowY));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
anchorAboveY = height;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorAboveY - height));
});
}
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