text_selection.dart 11.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// 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;

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/widgets.dart';
9

10
import 'debug.dart';
11
import 'material_localizations.dart';
12
import 'text_selection_theme.dart';
13 14
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';
15 16
import 'theme.dart';

xster's avatar
xster committed
17
const double _kHandleSize = 22.0;
18

19
// Padding between the toolbar and the anchor.
20
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
21
const double _kToolbarContentDistance = 8.0;
22

23 24 25 26 27 28 29 30 31 32 33
/// 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 {
}

34
/// Android Material styled text selection controls.
35 36 37
///
/// The [materialTextSelectionControls] global variable has a
/// suitable instance of this class.
38 39 40 41 42 43
class MaterialTextSelectionControls extends TextSelectionControls {
  /// Returns the size of the Material handle.
  @override
  Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);

  /// Builder for material-style copy/paste text selection toolbar.
44 45 46 47
  @Deprecated(
    'Use `contextMenuBuilder` instead. '
    'This feature was deprecated after v3.3.0-0.5.pre.',
  )
48 49 50 51 52 53 54 55
  @override
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
    double textLineHeight,
    Offset selectionMidpoint,
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
56
    ValueListenable<ClipboardStatus>? clipboardStatus,
57
    Offset? lastSecondaryTapDownPosition,
58
  ) {
59
    return _TextSelectionControlsToolbar(
60 61 62 63 64 65
      globalEditableRegion: globalEditableRegion,
      textLineHeight: textLineHeight,
      selectionMidpoint: selectionMidpoint,
      endpoints: endpoints,
      delegate: delegate,
      clipboardStatus: clipboardStatus,
66 67
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
68 69 70 71 72 73 74
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
    );
  }

  /// Builder for material-style text selection handles.
  @override
75
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap]) {
76 77 78 79 80 81 82 83 84
    final ThemeData theme = Theme.of(context);
    final Color handleColor = TextSelectionTheme.of(context).selectionHandleColor ?? theme.colorScheme.primary;
    final Widget handle = SizedBox(
      width: _kHandleSize,
      height: _kHandleSize,
      child: CustomPaint(
        painter: _TextSelectionHandlePainter(
          color: handleColor,
        ),
85 86 87 88
        child: GestureDetector(
          onTap: onTap,
          behavior: HitTestBehavior.translucent,
        ),
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
      ),
    );

    // [handle] is a circle, with a rectangle in the top left quadrant of that
    // circle (an onion pointing to 10:30). We rotate [handle] to point
    // straight up or up-right depending on the handle type.
    switch (type) {
      case TextSelectionHandleType.left: // points up-right
        return Transform.rotate(
          angle: math.pi / 2.0,
          child: handle,
        );
      case TextSelectionHandleType.right: // points up-left
        return handle;
      case TextSelectionHandleType.collapsed: // points up
        return Transform.rotate(
          angle: math.pi / 4.0,
          child: handle,
        );
    }
  }

  /// Gets anchor for material-style text selection handles.
  ///
  /// See [TextSelectionControls.getHandleAnchor].
  @override
115
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
116 117 118 119 120
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
121
      case TextSelectionHandleType.collapsed:
122 123 124 125
        return const Offset(_kHandleSize / 2, -4);
    }
  }

126 127 128 129
  @Deprecated(
    'Use `contextMenuBuilder` instead. '
    'This feature was deprecated after v3.3.0-0.5.pre.',
  )
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
  @override
  bool canSelectAll(TextSelectionDelegate delegate) {
    // Android allows SelectAll when selection is not collapsed, unless
    // everything has already been selected.
    final TextEditingValue value = delegate.textEditingValue;
    return delegate.selectAllEnabled &&
           value.text.isNotEmpty &&
           !(value.selection.start == 0 && value.selection.end == value.text.length);
  }
}

// The label and callback for the available default text selection menu buttons.
class _TextSelectionToolbarItemData {
  const _TextSelectionToolbarItemData({
    required this.label,
    required this.onPressed,
  });

  final String label;
  final VoidCallback onPressed;
}

// The highest level toolbar widget, built directly by buildToolbar.
class _TextSelectionControlsToolbar extends StatefulWidget {
  const _TextSelectionControlsToolbar({
155
    required this.clipboardStatus,
156 157 158
    required this.delegate,
    required this.endpoints,
    required this.globalEditableRegion,
159 160 161 162
    required this.handleCut,
    required this.handleCopy,
    required this.handlePaste,
    required this.handleSelectAll,
163 164
    required this.selectionMidpoint,
    required this.textLineHeight,
165
  });
166

167
  final ValueListenable<ClipboardStatus>? clipboardStatus;
168 169 170
  final TextSelectionDelegate delegate;
  final List<TextSelectionPoint> endpoints;
  final Rect globalEditableRegion;
171 172 173 174
  final VoidCallback? handleCut;
  final VoidCallback? handleCopy;
  final VoidCallback? handlePaste;
  final VoidCallback? handleSelectAll;
175 176
  final Offset selectionMidpoint;
  final double textLineHeight;
177 178

  @override
179
  _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
180 181
}

182
class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin {
183 184 185 186 187 188 189 190 191
  void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

  @override
  void initState() {
    super.initState();
192
    widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
193 194
  }

195
  @override
196
  void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) {
197
    super.didUpdateWidget(oldWidget);
198
    if (widget.clipboardStatus != oldWidget.clipboardStatus) {
199 200
      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
201 202 203 204 205
    }
  }

  @override
  void dispose() {
206
    widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
207
    super.dispose();
208 209
  }

210 211
  @override
  Widget build(BuildContext context) {
212 213 214 215 216 217 218
    // If there are no buttons to be shown, don't render anything.
    if (widget.handleCut == null && widget.handleCopy == null
        && widget.handlePaste == null && widget.handleSelectAll == null) {
      return const SizedBox.shrink();
    }
    // If the paste button is desired, don't render anything until the state of
    // the clipboard is known, since it's used to determine if paste is shown.
219
    if (widget.handlePaste != null
220
        && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
221
      return const SizedBox.shrink();
222 223
    }

224 225 226 227 228 229
    // Calculate the positioning of the menu. It is placed above the selection
    // if there is enough room, or otherwise below.
    final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0];
    final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1
      ? widget.endpoints[1]
      : widget.endpoints[0];
230 231 232
    final double topAmountInEditableRegion = startTextSelectionPoint.point.dy - widget.textLineHeight;
    final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top - _kToolbarContentDistance;

233 234
    final Offset anchorAbove = Offset(
      widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
235
      anchorTop,
236 237 238 239 240 241 242 243 244 245
    );
    final Offset anchorBelow = Offset(
      widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
      widget.globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
    );

    // 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.
    assert(debugCheckHasMaterialLocalizations(context));
246
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
247
    final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[
248
      if (widget.handleCut != null)
249 250 251 252
        _TextSelectionToolbarItemData(
          label: localizations.cutButtonLabel,
          onPressed: widget.handleCut!,
        ),
253
      if (widget.handleCopy != null)
254 255 256 257
        _TextSelectionToolbarItemData(
          label: localizations.copyButtonLabel,
          onPressed: widget.handleCopy!,
        ),
258
      if (widget.handlePaste != null
259
          && widget.clipboardStatus?.value == ClipboardStatus.pasteable)
260 261 262 263
        _TextSelectionToolbarItemData(
          label: localizations.pasteButtonLabel,
          onPressed: widget.handlePaste!,
        ),
264
      if (widget.handleSelectAll != null)
265 266 267 268
        _TextSelectionToolbarItemData(
          label: localizations.selectAllButtonLabel,
          onPressed: widget.handleSelectAll!,
        ),
269
    ];
270

271
    // If there is no option available, build an empty widget.
272
    if (itemDatas.isEmpty) {
273
      return const SizedBox.shrink();
274 275
    }

276 277 278 279 280 281 282 283
    return TextSelectionToolbar(
      anchorAbove: anchorAbove,
      anchorBelow: anchorBelow,
      children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) {
        return TextSelectionToolbarTextButton(
          padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length),
          onPressed: entry.value.onPressed,
          child: Text(entry.value.label),
284
        );
285
      }).toList(),
286
    );
287 288 289
  }
}

290
/// Draws a single text selection handle which points up and to the left.
291
class _TextSelectionHandlePainter extends CustomPainter {
292
  _TextSelectionHandlePainter({ required this.color });
293 294 295 296 297

  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
298
    final Paint paint = Paint()..color = color;
299
    final double radius = size.width/2.0;
300 301 302 303
    final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius);
    final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius);
    final Path path = Path()..addOval(circle)..addRect(point);
    canvas.drawPath(path, paint);
304 305 306 307 308 309 310 311
  }

  @override
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
    return color != oldPainter.color;
  }
}

312 313 314
// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is
// deleted, when users should migrate back to materialTextSelectionControls.
// See https://github.com/flutter/flutter/pull/124262
315 316 317
/// Text selection handle controls that follow the Material Design specification.
final TextSelectionControls materialTextSelectionHandleControls = MaterialTextSelectionHandleControls();

Adam Barth's avatar
Adam Barth committed
318
/// Text selection controls that follow the Material Design specification.
319
final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();