text_selection.dart 11.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 ValueListenable, clampDouble;
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;

xster's avatar
xster committed
25 26
/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
27 28 29
  const _TextSelectionHandlePainter(this.color);

  final Color color;
xster's avatar
xster committed
30 31 32

  @override
  void paint(Canvas canvas, Size size) {
33 34 35 36 37
    const double halfStrokeWidth = 1.0;
    final Paint paint = Paint()..color = color;
    final Rect circle = Rect.fromCircle(
      center: const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
      radius: _kSelectionHandleRadius,
38
    );
39
    final Rect line = Rect.fromPoints(
40
      const Offset(
41
        _kSelectionHandleRadius - halfStrokeWidth,
42 43
        2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
      ),
44
      Offset(_kSelectionHandleRadius + halfStrokeWidth, size.height),
xster's avatar
xster committed
45
    );
46 47 48 49 50
    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
51 52 53
  }

  @override
54
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
xster's avatar
xster committed
55 56
}

57 58 59 60 61 62 63 64 65 66 67
/// iOS Cupertino styled text selection handle controls.
///
/// Specifically does not manage the toolbar, which is left to
/// [EditableText.contextMenuBuilder].
@Deprecated(
  'Use `CupertinoTextSelectionControls`. '
  'This feature was deprecated after v3.3.0-0.5.pre.',
)
class CupertinoTextSelectionHandleControls extends CupertinoTextSelectionControls with TextSelectionHandleControls {
}

68
/// iOS Cupertino styled text selection controls.
69 70 71
///
/// The [cupertinoTextSelectionControls] global variable has a
/// suitable instance of this class.
72
class CupertinoTextSelectionControls extends TextSelectionControls {
73
  /// Returns the size of the Cupertino handle.
xster's avatar
xster committed
74
  @override
75 76 77 78 79 80
  Size getHandleSize(double textLineHeight) {
    return Size(
      _kSelectionHandleRadius * 2,
      textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
    );
  }
xster's avatar
xster committed
81 82

  /// Builder for iOS-style copy/paste text selection toolbar.
83 84 85 86
  @Deprecated(
    'Use `contextMenuBuilder` instead. '
    'This feature was deprecated after v3.3.0-0.5.pre.',
  )
xster's avatar
xster committed
87
  @override
88 89 90
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
91
    double textLineHeight,
92
    Offset selectionMidpoint,
93 94
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
95
    ValueListenable<ClipboardStatus>? clipboardStatus,
96
    Offset? lastSecondaryTapDownPosition,
97
  ) {
98
    return _CupertinoTextSelectionControlsToolbar(
99
      clipboardStatus: clipboardStatus,
100 101
      endpoints: endpoints,
      globalEditableRegion: globalEditableRegion,
102 103
      handleCut: canCut(delegate) ? () => handleCut(delegate) : null,
      handleCopy: canCopy(delegate) ? () => handleCopy(delegate) : null,
104 105
      handlePaste: canPaste(delegate) ? () => handlePaste(delegate) : null,
      handleSelectAll: canSelectAll(delegate) ? () => handleSelectAll(delegate) : null,
106 107
      selectionMidpoint: selectionMidpoint,
      textLineHeight: textLineHeight,
xster's avatar
xster committed
108 109 110 111 112
    );
  }

  /// Builder for iOS text selection edges.
  @override
113
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
114
    // iOS selection handles do not respond to taps.
115 116 117 118 119
    final Size desiredSize;
    final Widget handle;

    final Widget customPaint = CustomPaint(
      painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
xster's avatar
xster committed
120 121 122 123 124 125
    );

    // [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) {
126
      case TextSelectionHandleType.left:
127
        desiredSize = getHandleSize(textLineHeight);
128 129 130 131
        handle = SizedBox.fromSize(
          size: desiredSize,
          child: customPaint,
        );
132
        return handle;
xster's avatar
xster committed
133
      case TextSelectionHandleType.right:
134
        desiredSize = getHandleSize(textLineHeight);
135 136 137 138
        handle = SizedBox.fromSize(
          size: desiredSize,
          child: customPaint,
        );
139
        return Transform(
140 141 142 143
          transform: Matrix4.identity()
            ..translate(desiredSize.width / 2, desiredSize.height / 2)
            ..rotateZ(math.pi)
            ..translate(-desiredSize.width / 2, -desiredSize.height / 2),
144
          child: handle,
xster's avatar
xster committed
145
        );
146 147
      // iOS doesn't draw anything for collapsed selections.
      case TextSelectionHandleType.collapsed:
148
        return const SizedBox.shrink();
xster's avatar
xster committed
149 150
    }
  }
151 152 153 154 155

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

159 160 161 162
    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:
163
        handleSize = getHandleSize(textLineHeight);
164 165 166 167 168 169 170
        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:
171
        handleSize = getHandleSize(textLineHeight);
172 173 174 175 176
        return Offset(
          handleSize.width / 2,
          handleSize.height - 2 * _kSelectionHandleRadius + _kSelectionHandleOverlap,
        );
      // A collapsed handle anchors itself so that it's centered.
177
      case TextSelectionHandleType.collapsed:
178
        handleSize = getHandleSize(textLineHeight);
179 180 181 182 183 184
        return Offset(
          handleSize.width / 2,
          textLineHeight + (handleSize.height - textLineHeight) / 2,
        );
    }
  }
xster's avatar
xster committed
185 186
}

187 188 189
// TODO(justinmc): Deprecate this after TextSelectionControls.buildToolbar is
// deleted, when users should migrate back to cupertinoTextSelectionControls.
// See https://github.com/flutter/flutter/pull/124262
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
/// Text selection handle controls that follow iOS design conventions.
final TextSelectionControls cupertinoTextSelectionHandleControls =
    CupertinoTextSelectionHandleControls();

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

// Generates the child that's passed into CupertinoTextSelectionToolbar.
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,
  });

212
  final ValueListenable<ClipboardStatus>? clipboardStatus;
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
  final List<TextSelectionPoint> endpoints;
  final Rect globalEditableRegion;
  final VoidCallback? handleCopy;
  final VoidCallback? handleCut;
  final VoidCallback? handlePaste;
  final VoidCallback? handleSelectAll;
  final Offset selectionMidpoint;
  final double textLineHeight;

  @override
  _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
}

class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
  void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

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

  @override
  void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.clipboardStatus != widget.clipboardStatus) {
      oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
      widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
    }
  }

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

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

    assert(debugCheckHasMediaQuery(context));
262
    final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context);
263 264 265 266 267

    // 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 = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
268 269
      _kArrowScreenPadding + mediaQueryPadding.left,
      MediaQuery.sizeOf(context).width - mediaQueryPadding.right - _kArrowScreenPadding,
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
    );

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

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

    final List<Widget> items = <Widget>[];
    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
    final Widget onePhysicalPixelVerticalDivider =
291
        SizedBox(width: 1.0 / MediaQuery.devicePixelRatioOf(context));
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332

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

      items.add(CupertinoTextSelectionToolbarButton.text(
        onPressed: onPressed,
        text: text,
      ));
    }

    if (widget.handleCut != null) {
      addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
    }
    if (widget.handleCopy != null) {
      addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
    }
    if (widget.handlePaste != null
        && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
      addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
    }
    if (widget.handleSelectAll != null) {
      addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
    }

    // If there is no option available, build an empty widget.
    if (items.isEmpty) {
      return const SizedBox.shrink();
    }

    return CupertinoTextSelectionToolbar(
      anchorAbove: anchorAbove,
      anchorBelow: anchorBelow,
      children: items,
    );
  }
}