text_selection.dart 10.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
xster's avatar
xster committed
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' show clampDouble;
xster's avatar
xster committed
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/widgets.dart';
xster's avatar
xster committed
10

11
import 'localizations.dart';
12 13
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_button.dart';
14
import 'theme.dart';
xster's avatar
xster committed
15

16 17
// Read off from the output on iOS 12. This color does not vary with the
// application's theme color.
18
const double _kSelectionHandleOverlap = 1.5;
19 20
// Extracted from https://developer.apple.com/design/resources/.
const double _kSelectionHandleRadius = 6;
21 22 23 24 25

// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
// screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0;

26
// Generates the child that's passed into CupertinoTextSelectionToolbar.
27 28 29 30 31 32 33 34 35 36 37
class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
  const _CupertinoTextSelectionControlsToolbar({
    required this.clipboardStatus,
    required this.endpoints,
    required this.globalEditableRegion,
    required this.handleCopy,
    required this.handleCut,
    required this.handlePaste,
    required this.handleSelectAll,
    required this.selectionMidpoint,
    required this.textLineHeight,
38
  });
39

40
  final ClipboardStatusNotifier? clipboardStatus;
41 42
  final List<TextSelectionPoint> endpoints;
  final Rect globalEditableRegion;
43
  final VoidCallback? handleCopy;
44
  final VoidCallback? handleCut;
45 46
  final VoidCallback? handlePaste;
  final VoidCallback? handleSelectAll;
47 48
  final Offset selectionMidpoint;
  final double textLineHeight;
49 50

  @override
51
  _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
52 53
}

54
class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
55 56 57 58 59 60 61 62 63
  void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

  @override
  void initState() {
    super.initState();
64
    widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
65 66 67
  }

  @override
68
  void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
69
    super.didUpdateWidget(oldWidget);
70
    if (oldWidget.clipboardStatus != widget.clipboardStatus) {
71 72
      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
73 74 75 76 77 78
    }
  }

  @override
  void dispose() {
    super.dispose();
79
    widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
80 81 82 83 84
  }

  @override
  Widget build(BuildContext context) {
    // Don't render the menu until the state of the clipboard is known.
85
    if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
86 87 88
      return const SizedBox(width: 0.0, height: 0.0);
    }

89 90 91 92 93 94
    assert(debugCheckHasMediaQuery(context));
    final MediaQueryData mediaQuery = MediaQuery.of(context);

    // The toolbar should appear below the TextField when there is not enough
    // space above the TextField to show it, assuming there's always enough
    // space at the bottom in this case.
95
    final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
96 97 98 99
      _kArrowScreenPadding + mediaQuery.padding.left,
      mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
    );

100 101 102
    final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
    final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;

103 104 105 106 107 108
    // The y-coordinate has to be calculated instead of directly quoting
    // selectionMidpoint.dy, since the caller
    // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
    // going to be facing up or down.
    final Offset anchorAbove = Offset(
      anchorX,
109
      anchorTop,
110 111 112 113 114 115
    );
    final Offset anchorBelow = Offset(
      anchorX,
      widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
    );

116
    final List<Widget> items = <Widget>[];
117
    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
118
    final Widget onePhysicalPixelVerticalDivider =
119
        SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
120 121 122 123 124 125 126 127 128

    void addToolbarButton(
      String text,
      VoidCallback onPressed,
    ) {
      if (items.isNotEmpty) {
        items.add(onePhysicalPixelVerticalDivider);
      }

129
      items.add(CupertinoTextSelectionToolbarButton.text(
130
        onPressed: onPressed,
131
        text: text,
132 133 134 135
      ));
    }

    if (widget.handleCut != null) {
136
      addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
137 138
    }
    if (widget.handleCopy != null) {
139
      addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
140 141
    }
    if (widget.handlePaste != null
142
        && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
143
      addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
144 145
    }
    if (widget.handleSelectAll != null) {
146
      addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
147 148
    }

149 150 151
    // If there is no option available, build an empty widget.
    if (items.isEmpty) {
      return const SizedBox(width: 0.0, height: 0.0);
152
    }
xster's avatar
xster committed
153

154 155 156 157
    return CupertinoTextSelectionToolbar(
      anchorAbove: anchorAbove,
      anchorBelow: anchorBelow,
      children: items,
158
    );
xster's avatar
xster committed
159 160 161 162 163
  }
}

/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
164 165 166
  const _TextSelectionHandlePainter(this.color);

  final Color color;
xster's avatar
xster committed
167 168 169

  @override
  void paint(Canvas canvas, Size size) {
170 171 172 173 174
    const double halfStrokeWidth = 1.0;
    final Paint paint = Paint()..color = color;
    final Rect circle = Rect.fromCircle(
      center: const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
      radius: _kSelectionHandleRadius,
175
    );
176
    final Rect line = Rect.fromPoints(
177
      const Offset(
178
        _kSelectionHandleRadius - halfStrokeWidth,
179 180
        2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
      ),
181
      Offset(_kSelectionHandleRadius + halfStrokeWidth, size.height),
xster's avatar
xster committed
182
    );
183 184 185 186 187
    final Path path = Path()
      ..addOval(circle)
    // Draw line so it slightly overlaps the circle.
      ..addRect(line);
    canvas.drawPath(path, paint);
xster's avatar
xster committed
188 189 190
  }

  @override
191
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
xster's avatar
xster committed
192 193
}

194 195
/// iOS Cupertino styled text selection controls.
class CupertinoTextSelectionControls extends TextSelectionControls {
196
  /// Returns the size of the Cupertino handle.
xster's avatar
xster committed
197
  @override
198 199 200 201 202 203
  Size getHandleSize(double textLineHeight) {
    return Size(
      _kSelectionHandleRadius * 2,
      textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
    );
  }
xster's avatar
xster committed
204 205 206

  /// Builder for iOS-style copy/paste text selection toolbar.
  @override
207 208 209
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
210
    double textLineHeight,
211
    Offset selectionMidpoint,
212 213
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
214
    ClipboardStatusNotifier? clipboardStatus,
215
    Offset? lastSecondaryTapDownPosition,
216
  ) {
217
    return _CupertinoTextSelectionControlsToolbar(
218
      clipboardStatus: clipboardStatus,
219 220
      endpoints: endpoints,
      globalEditableRegion: globalEditableRegion,
221 222
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
223 224
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
225 226
      selectionMidpoint: selectionMidpoint,
      textLineHeight: textLineHeight,
xster's avatar
xster committed
227 228 229 230 231
    );
  }

  /// Builder for iOS text selection edges.
  @override
232
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
233
    // iOS selection handles do not respond to taps.
234 235 236 237 238
    final Size desiredSize;
    final Widget handle;

    final Widget customPaint = CustomPaint(
      painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
xster's avatar
xster committed
239 240 241 242 243 244
    );

    // [buildHandle]'s widget is positioned at the selection cursor's bottom
    // baseline. We transform the handle such that the SizedBox is superimposed
    // on top of the text selection endpoints.
    switch (type) {
245
      case TextSelectionHandleType.left:
246
        desiredSize = getHandleSize(textLineHeight);
247 248 249 250
        handle = SizedBox.fromSize(
          size: desiredSize,
          child: customPaint,
        );
251
        return handle;
xster's avatar
xster committed
252
      case TextSelectionHandleType.right:
253
        desiredSize = getHandleSize(textLineHeight);
254 255 256 257
        handle = SizedBox.fromSize(
          size: desiredSize,
          child: customPaint,
        );
258
        return Transform(
259 260 261 262
          transform: Matrix4.identity()
            ..translate(desiredSize.width / 2, desiredSize.height / 2)
            ..rotateZ(math.pi)
            ..translate(-desiredSize.width / 2, -desiredSize.height / 2),
263
          child: handle,
xster's avatar
xster committed
264
        );
265 266
      // iOS doesn't draw anything for collapsed selections.
      case TextSelectionHandleType.collapsed:
267
        return const SizedBox();
xster's avatar
xster committed
268 269
    }
  }
270 271 272 273 274

  /// Gets anchor for cupertino-style text selection handles.
  ///
  /// See [TextSelectionControls.getHandleAnchor].
  @override
275
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
276 277
    final Size handleSize;

278 279 280 281
    switch (type) {
      // The circle is at the top for the left handle, and the anchor point is
      // all the way at the bottom of the line.
      case TextSelectionHandleType.left:
282
        handleSize = getHandleSize(textLineHeight);
283 284 285 286 287 288 289
        return Offset(
          handleSize.width / 2,
          handleSize.height,
        );
      // The right handle is vertically flipped, and the anchor point is near
      // the top of the circle to give slight overlap.
      case TextSelectionHandleType.right:
290
        handleSize = getHandleSize(textLineHeight);
291 292 293 294 295
        return Offset(
          handleSize.width / 2,
          handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
        );
      // A collapsed handle anchors itself so that it's centered.
296
      case TextSelectionHandleType.collapsed:
297
        handleSize = getHandleSize(textLineHeight);
298 299 300 301 302 303
        return Offset(
          handleSize.width / 2,
          textLineHeight + (handleSize.height - textLineHeight) / 2,
        );
    }
  }
xster's avatar
xster committed
304 305 306
}

/// Text selection controls that follows iOS design conventions.
307
final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls();