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/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 134
    Key? key,
    required this.clipboardStatus,
135 136 137
    required this.delegate,
    required this.endpoints,
    required this.globalEditableRegion,
138 139 140 141
    required this.handleCut,
    required this.handleCopy,
    required this.handlePaste,
    required this.handleSelectAll,
142 143
    required this.selectionMidpoint,
    required this.textLineHeight,
xster's avatar
xster committed
144
  }) : super(key: key);
145

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

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

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

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

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

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

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

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

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

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

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

  final Color color;

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

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

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