text_selection.dart 10.3 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/rendering.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 34 35 36 37
/// Android Material styled text selection controls.
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.
  @override
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
    double textLineHeight,
    Offset selectionMidpoint,
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
38
    ClipboardStatusNotifier? clipboardStatus,
39
    Offset? lastSecondaryTapDownPosition,
40 41 42 43 44 45 46 47
  ) {
    return _TextSelectionControlsToolbar(
      globalEditableRegion: globalEditableRegion,
      textLineHeight: textLineHeight,
      selectionMidpoint: selectionMidpoint,
      endpoints: endpoints,
      delegate: delegate,
      clipboardStatus: clipboardStatus,
48 49
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
50 51 52 53 54 55 56
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
    );
  }

  /// Builder for material-style text selection handles.
  @override
57
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap]) {
58 59 60 61 62 63 64 65 66
    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,
        ),
67 68 69 70
        child: GestureDetector(
          onTap: onTap,
          behavior: HitTestBehavior.translucent,
        ),
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
      ),
    );

    // [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
97
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
98 99 100 101 102
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
103
      case TextSelectionHandleType.collapsed:
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
        return const Offset(_kHandleSize / 2, -4);
    }
  }

  @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({
133
    required this.clipboardStatus,
134 135 136
    required this.delegate,
    required this.endpoints,
    required this.globalEditableRegion,
137 138 139 140
    required this.handleCut,
    required this.handleCopy,
    required this.handlePaste,
    required this.handleSelectAll,
141 142
    required this.selectionMidpoint,
    required this.textLineHeight,
143
  });
144

145
  final ClipboardStatusNotifier? clipboardStatus;
146 147 148
  final TextSelectionDelegate delegate;
  final List<TextSelectionPoint> endpoints;
  final Rect globalEditableRegion;
149 150 151 152
  final VoidCallback? handleCut;
  final VoidCallback? handleCopy;
  final VoidCallback? handlePaste;
  final VoidCallback? handleSelectAll;
153 154
  final Offset selectionMidpoint;
  final double textLineHeight;
155 156

  @override
157
  _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
158 159
}

160
class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToolbar> with TickerProviderStateMixin {
161 162 163 164 165 166 167 168 169
  void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

  @override
  void initState() {
    super.initState();
170
    widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
171 172
  }

173
  @override
174
  void didUpdateWidget(_TextSelectionControlsToolbar oldWidget) {
175
    super.didUpdateWidget(oldWidget);
176
    if (widget.clipboardStatus != oldWidget.clipboardStatus) {
177 178
      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
179 180 181 182 183 184
    }
  }

  @override
  void dispose() {
    super.dispose();
185
    widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
186 187
  }

188 189
  @override
  Widget build(BuildContext context) {
190 191 192 193 194 195 196
    // 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.
197
    if (widget.handlePaste != null
198
        && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
199
      return const SizedBox.shrink();
200 201
    }

202 203 204 205 206 207
    // 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];
208 209 210
    final double topAmountInEditableRegion = startTextSelectionPoint.point.dy - widget.textLineHeight;
    final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top - _kToolbarContentDistance;

211 212
    final Offset anchorAbove = Offset(
      widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
213
      anchorTop,
214 215 216 217 218 219 220 221 222 223
    );
    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));
224
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
225
    final List<_TextSelectionToolbarItemData> itemDatas = <_TextSelectionToolbarItemData>[
226
      if (widget.handleCut != null)
227 228 229 230
        _TextSelectionToolbarItemData(
          label: localizations.cutButtonLabel,
          onPressed: widget.handleCut!,
        ),
231
      if (widget.handleCopy != null)
232 233 234 235
        _TextSelectionToolbarItemData(
          label: localizations.copyButtonLabel,
          onPressed: widget.handleCopy!,
        ),
236
      if (widget.handlePaste != null
237
          && widget.clipboardStatus?.value == ClipboardStatus.pasteable)
238 239 240 241
        _TextSelectionToolbarItemData(
          label: localizations.pasteButtonLabel,
          onPressed: widget.handlePaste!,
        ),
242
      if (widget.handleSelectAll != null)
243 244 245 246
        _TextSelectionToolbarItemData(
          label: localizations.selectAllButtonLabel,
          onPressed: widget.handleSelectAll!,
        ),
247
    ];
248

249
    // If there is no option available, build an empty widget.
250
    if (itemDatas.isEmpty) {
251
      return const SizedBox(width: 0.0, height: 0.0);
252 253
    }

254 255 256 257 258 259 260 261
    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),
262
        );
263
      }).toList(),
264
    );
265 266 267
  }
}

268
/// Draws a single text selection handle which points up and to the left.
269
class _TextSelectionHandlePainter extends CustomPainter {
270
  _TextSelectionHandlePainter({ required this.color });
271 272 273 274 275

  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
276
    final Paint paint = Paint()..color = color;
277
    final double radius = size.width/2.0;
278 279 280 281
    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);
282 283 284 285 286 287 288 289
  }

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

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