text_selection.dart 11 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 7
// 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;

import 'package:flutter/rendering.dart';
8
import 'package:flutter/widgets.dart';
xster's avatar
xster committed
9

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

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

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

25
// Generates the child that's passed into CupertinoTextSelectionToolbar.
26 27
class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
  const _CupertinoTextSelectionControlsToolbar({
28
    Key? key,
29 30 31 32 33 34 35 36 37
    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
  }) : super(key: key);

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
  ClipboardStatusNotifier? _clipboardStatus;
56 57 58 59 60 61 62 63 64 65

  void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

  @override
  void initState() {
    super.initState();
66 67 68 69 70
    if (widget.handlePaste != null) {
      _clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
      _clipboardStatus!.addListener(_onChangedClipboardStatus);
      _clipboardStatus!.update();
    }
71 72 73
  }

  @override
74
  void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
75
    super.didUpdateWidget(oldWidget);
76 77 78 79 80 81 82 83 84
    if (oldWidget.clipboardStatus != widget.clipboardStatus) {
      if (_clipboardStatus != null) {
        _clipboardStatus!.removeListener(_onChangedClipboardStatus);
        _clipboardStatus!.dispose();
      }
      _clipboardStatus = widget.clipboardStatus ?? ClipboardStatusNotifier();
      _clipboardStatus!.addListener(_onChangedClipboardStatus);
      if (widget.handlePaste != null) {
        _clipboardStatus!.update();
85 86 87 88 89 90 91 92 93
      }
    }
  }

  @override
  void dispose() {
    super.dispose();
    // When used in an Overlay, this can be disposed after its creator has
    // already disposed _clipboardStatus.
94 95
    if (_clipboardStatus != null && !_clipboardStatus!.disposed) {
      _clipboardStatus!.removeListener(_onChangedClipboardStatus);
96
      if (widget.clipboardStatus == null) {
97
        _clipboardStatus!.dispose();
98 99 100 101 102 103 104 105
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    // Don't render the menu until the state of the clipboard is known.
    if (widget.handlePaste != null
106
        && _clipboardStatus!.value == ClipboardStatus.unknown) {
107 108 109
      return const SizedBox(width: 0.0, height: 0.0);
    }

110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
    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.
    final double anchorX = (widget.selectionMidpoint.dx + widget.globalEditableRegion.left).clamp(
      _kArrowScreenPadding + mediaQuery.padding.left,
      mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
    );

    // 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,
      widget.endpoints.first.point.dy - widget.textLineHeight + widget.globalEditableRegion.top,
    );
    final Offset anchorBelow = Offset(
      anchorX,
      widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
    );

134
    final List<Widget> items = <Widget>[];
135
    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
136
    final Widget onePhysicalPixelVerticalDivider =
137
        SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
138 139 140 141 142 143 144 145 146

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

147
      items.add(CupertinoTextSelectionToolbarButton.text(
148
        onPressed: onPressed,
149
        text: text,
150 151 152 153
      ));
    }

    if (widget.handleCut != null) {
154
      addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
155 156
    }
    if (widget.handleCopy != null) {
157
      addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
158 159
    }
    if (widget.handlePaste != null
160
        && _clipboardStatus!.value == ClipboardStatus.pasteable) {
161
      addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
162 163
    }
    if (widget.handleSelectAll != null) {
164
      addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
165 166
    }

167 168 169
    // If there is no option available, build an empty widget.
    if (items.isEmpty) {
      return const SizedBox(width: 0.0, height: 0.0);
170
    }
xster's avatar
xster committed
171

172 173 174 175
    return CupertinoTextSelectionToolbar(
      anchorAbove: anchorAbove,
      anchorBelow: anchorBelow,
      children: items,
176
    );
xster's avatar
xster committed
177 178 179 180 181
  }
}

/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
182 183 184
  const _TextSelectionHandlePainter(this.color);

  final Color color;
xster's avatar
xster committed
185 186 187

  @override
  void paint(Canvas canvas, Size size) {
188 189 190 191 192
    const double halfStrokeWidth = 1.0;
    final Paint paint = Paint()..color = color;
    final Rect circle = Rect.fromCircle(
      center: const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
      radius: _kSelectionHandleRadius,
193
    );
194
    final Rect line = Rect.fromPoints(
195
      const Offset(
196
        _kSelectionHandleRadius - halfStrokeWidth,
197 198
        2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
      ),
199
      Offset(_kSelectionHandleRadius + halfStrokeWidth, size.height),
xster's avatar
xster committed
200
    );
201 202 203 204 205
    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
206 207 208
  }

  @override
209
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
xster's avatar
xster committed
210 211
}

212 213
/// iOS Cupertino styled text selection controls.
class CupertinoTextSelectionControls extends TextSelectionControls {
214
  /// Returns the size of the Cupertino handle.
xster's avatar
xster committed
215
  @override
216 217 218 219 220 221
  Size getHandleSize(double textLineHeight) {
    return Size(
      _kSelectionHandleRadius * 2,
      textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
    );
  }
xster's avatar
xster committed
222 223 224

  /// Builder for iOS-style copy/paste text selection toolbar.
  @override
225 226 227
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
228
    double textLineHeight,
229
    Offset selectionMidpoint,
230 231
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
232
    ClipboardStatusNotifier clipboardStatus,
233
    Offset? lastSecondaryTapDownPosition,
234
  ) {
235
    return _CupertinoTextSelectionControlsToolbar(
236
      clipboardStatus: clipboardStatus,
237 238
      endpoints: endpoints,
      globalEditableRegion: globalEditableRegion,
239 240 241 242
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate, clipboardStatus) : null,
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
243 244
      selectionMidpoint: selectionMidpoint,
      textLineHeight: textLineHeight,
xster's avatar
xster committed
245 246 247 248 249
    );
  }

  /// Builder for iOS text selection edges.
  @override
250
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
251 252
    // iOS selection handles do not respond to taps.

xster's avatar
xster committed
253 254
    // We want a size that's a vertical line the height of the text plus a 18.0
    // padding in every direction that will constitute the selection drag area.
255
    final Size desiredSize = getHandleSize(textLineHeight);
xster's avatar
xster committed
256

257
    final Widget handle = SizedBox.fromSize(
xster's avatar
xster committed
258
      size: desiredSize,
259 260
      child: CustomPaint(
        painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
xster's avatar
xster committed
261 262 263 264 265 266 267
      ),
    );

    // [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) {
268 269
      case TextSelectionHandleType.left:
        return handle;
xster's avatar
xster committed
270
      case TextSelectionHandleType.right:
271
        // Right handle is a vertical mirror of the left.
272
        return Transform(
273 274 275 276
          transform: Matrix4.identity()
            ..translate(desiredSize.width / 2, desiredSize.height / 2)
            ..rotateZ(math.pi)
            ..translate(-desiredSize.width / 2, -desiredSize.height / 2),
277
          child: handle,
xster's avatar
xster committed
278
        );
279 280
      // iOS doesn't draw anything for collapsed selections.
      case TextSelectionHandleType.collapsed:
281
        return const SizedBox();
xster's avatar
xster committed
282 283
    }
  }
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306

  /// Gets anchor for cupertino-style text selection handles.
  ///
  /// See [TextSelectionControls.getHandleAnchor].
  @override
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
    final Size handleSize = getHandleSize(textLineHeight);
    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:
        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:
        return Offset(
          handleSize.width / 2,
          handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
        );
      // A collapsed handle anchors itself so that it's centered.
307
      case TextSelectionHandleType.collapsed:
308 309 310 311 312 313
        return Offset(
          handleSize.width / 2,
          textLineHeight + (handleSize.height - textLineHeight) / 2,
        );
    }
  }
xster's avatar
xster committed
314 315 316
}

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