text_selection.dart 10.2 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/rendering.dart';
9
import 'package:flutter/widgets.dart';
10

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

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

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

24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
/// 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,
39
    ClipboardStatusNotifier? clipboardStatus,
40
    Offset? lastSecondaryTapDownPosition,
41 42 43 44 45 46 47 48
  ) {
    return _TextSelectionControlsToolbar(
      globalEditableRegion: globalEditableRegion,
      textLineHeight: textLineHeight,
      selectionMidpoint: selectionMidpoint,
      endpoints: endpoints,
      delegate: delegate,
      clipboardStatus: clipboardStatus,
49 50
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
51 52 53 54 55 56 57
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
    );
  }

  /// Builder for material-style text selection handles.
  @override
58
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap]) {
59 60 61 62 63 64 65 66 67
    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,
        ),
68 69 70 71
        child: GestureDetector(
          onTap: onTap,
          behavior: HitTestBehavior.translucent,
        ),
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 97
      ),
    );

    // [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
98
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
99 100 101 102 103
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
104
      case TextSelectionHandleType.collapsed:
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 133
        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({
134 135
    Key? key,
    required this.clipboardStatus,
136 137 138
    required this.delegate,
    required this.endpoints,
    required this.globalEditableRegion,
139 140 141 142
    required this.handleCut,
    required this.handleCopy,
    required this.handlePaste,
    required this.handleSelectAll,
143 144
    required this.selectionMidpoint,
    required this.textLineHeight,
xster's avatar
xster committed
145
  }) : super(key: key);
146

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

  @override
159
  _TextSelectionControlsToolbarState createState() => _TextSelectionControlsToolbarState();
160 161
}

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

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

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

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

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

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

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

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

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

  final Color color;

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

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

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