text_selection.dart 15 KB
Newer Older
xster's avatar
xster committed
1 2 3 4 5
// Copyright 2017 The Chromium Authors. All rights reserved.
// 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;
6
import 'dart:ui' as ui;
xster's avatar
xster committed
7 8 9 10 11

import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';

import 'button.dart';
12
import 'colors.dart';
13
import 'localizations.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 26 27 28 29 30 31 32 33 34

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

// Vertical distance between the tip of the arrow and the line of text the arrow
// is pointing to. The value used here is eyeballed.
const double _kToolbarContentDistance = 8.0;
// Values derived from https://developer.apple.com/design/resources/.
// 92% Opacity ~= 0xEB

35
// Values extracted from https://developer.apple.com/design/resources/.
36 37 38 39
// The height of the toolbar, including the arrow.
const double _kToolbarHeight = 43.0;
const Size _kToolbarArrowSize = Size(14.0, 7.0);
const Radius _kToolbarBorderRadius = Radius.circular(8);
40 41 42 43 44
// Colors extracted from https://developer.apple.com/design/resources/.
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const Color _kToolbarBackgroundColor = Color(0xEB202020);
const Color _kToolbarDividerColor = Color(0xFF808080);

xster's avatar
xster committed
45

46
const TextStyle _kToolbarButtonFontStyle = TextStyle(
47
  inherit: false,
xster's avatar
xster committed
48
  fontSize: 14.0,
49 50
  letterSpacing: -0.15,
  fontWeight: FontWeight.w400,
51
  color: CupertinoColors.white,
xster's avatar
xster committed
52 53
);

54 55 56
// Eyeballed value.
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0);

57 58 59 60 61
/// An iOS-style toolbar that appears in response to text selection.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting text.
///
/// See also:
62
///
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
/// * [TextSelectionControls.buildToolbar], where [CupertinoTextSelectionToolbar]
///   will be used to build an iOS-style toolbar.
@visibleForTesting
class CupertinoTextSelectionToolbar extends SingleChildRenderObjectWidget {
  const CupertinoTextSelectionToolbar._({
    Key key,
    double barTopY,
    double arrowTipX,
    bool isArrowPointingDown,
    Widget child,
  }) : _barTopY = barTopY,
       _arrowTipX = arrowTipX,
       _isArrowPointingDown = isArrowPointingDown,
       super(key: key, child: child);

  // The y-coordinate of toolbar's top edge, in global coordinate system.
  final double _barTopY;
80

81 82
  // The y-coordinate of the tip of the arrow, in global coordinate system.
  final double _arrowTipX;
83

84 85 86
  // Whether the arrow should point down and be attached to the bottom
  // of the toolbar, or point up and be attached to the top of the toolbar.
  final bool _isArrowPointingDown;
87

xster's avatar
xster committed
88
  @override
89 90 91 92 93 94 95 96
  _ToolbarRenderBox createRenderObject(BuildContext context) => _ToolbarRenderBox(_barTopY, _arrowTipX, _isArrowPointingDown, null);

  @override
  void updateRenderObject(BuildContext context, _ToolbarRenderBox renderObject) {
    renderObject
      ..barTopY = _barTopY
      ..arrowTipX = _arrowTipX
      ..isArrowPointingDown = _isArrowPointingDown;
xster's avatar
xster committed
97
  }
98
}
xster's avatar
xster committed
99

100 101 102 103 104
class _ToolbarParentData extends BoxParentData {
  // The x offset from the tip of the arrow to the center of the toolbar.
  // Positive if the tip of the arrow has a larger x-coordinate than the
  // center of the toolbar.
  double arrowXOffsetFromCenter;
xster's avatar
xster committed
105
  @override
106
  String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter';
xster's avatar
xster committed
107 108
}

109 110 111 112 113 114 115 116
class _ToolbarRenderBox extends RenderShiftedBox {
  _ToolbarRenderBox(
    this._barTopY,
    this._arrowTipX,
    this._isArrowPointingDown,
    RenderBox child,
  ) : super(child);

xster's avatar
xster committed
117 118

  @override
119
  bool get isRepaintBoundary => true;
xster's avatar
xster committed
120

121 122 123 124 125 126 127 128 129
  double _barTopY;
  set barTopY(double value) {
    if (_barTopY == value) {
      return;
    }
    _barTopY = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
130

131 132 133 134
  double _arrowTipX;
  set arrowTipX(double value) {
    if (_arrowTipX == value) {
      return;
xster's avatar
xster committed
135
    }
136 137 138 139
    _arrowTipX = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
xster's avatar
xster committed
140

141 142 143 144
  bool _isArrowPointingDown;
  set isArrowPointingDown(bool value) {
    if (_isArrowPointingDown == value) {
      return;
145
    }
146 147 148 149
    _isArrowPointingDown = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
xster's avatar
xster committed
150

151 152 153 154 155 156
  final BoxConstraints heightConstraint = const BoxConstraints.tightFor(height: _kToolbarHeight);

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! _ToolbarParentData) {
      child.parentData = _ToolbarParentData();
xster's avatar
xster committed
157
    }
158 159 160 161 162 163 164 165
  }

  @override
  void performLayout() {
    size = constraints.biggest;

    if (child == null) {
      return;
166
    }
167 168 169
    final BoxConstraints enforcedConstraint = constraints
      .deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding))
      .loosen();
xster's avatar
xster committed
170

171 172
    child.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,);
    final _ToolbarParentData childParentData = child.parentData;
173

174 175 176
    // The local x-coordinate of the center of the toolbar.
    final double lowerBound = child.size.width/2 + _kToolbarScreenPadding;
    final double upperBound = size.width - child.size.width/2 - _kToolbarScreenPadding;
177
    final double adjustedCenterX = _arrowTipX.clamp(lowerBound, upperBound);
178

179 180
    childParentData.offset = Offset(adjustedCenterX - child.size.width / 2, _barTopY);
    childParentData.arrowXOffsetFromCenter = _arrowTipX - adjustedCenterX;
xster's avatar
xster committed
181 182
  }

183 184 185 186 187 188 189 190 191 192 193
  // The path is described in the toolbar's coordinate system.
  Path _clipPath() {
    final _ToolbarParentData childParentData = child.parentData;
    final Path rrect = Path()
      ..addRRect(
        RRect.fromRectAndRadius(
          Offset(0, _isArrowPointingDown ? 0 : _kToolbarArrowSize.height,)
          & Size(child.size.width, child.size.height - _kToolbarArrowSize.height),
          _kToolbarBorderRadius,
        ),
      );
xster's avatar
xster committed
194

195
    final double arrowTipX = child.size.width / 2 + childParentData.arrowXOffsetFromCenter;
xster's avatar
xster committed
196

197 198 199
    final double arrowBottomY = _isArrowPointingDown
      ? child.size.height - _kToolbarArrowSize.height
      : _kToolbarArrowSize.height;
xster's avatar
xster committed
200

201
    final double arrowTipY = _isArrowPointingDown ? child.size.height : 0;
xster's avatar
xster committed
202

203 204 205 206 207
    final Path arrow = Path()
      ..moveTo(arrowTipX, arrowTipY)
      ..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBottomY)
      ..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBottomY)
      ..close();
xster's avatar
xster committed
208

209
    return Path.combine(PathOperation.union, rrect, arrow);
xster's avatar
xster committed
210 211 212
  }

  @override
213 214 215 216
  void paint(PaintingContext context, Offset offset) {
    if (child == null) {
      return;
    }
xster's avatar
xster committed
217

218 219 220 221 222 223 224 225
    final _ToolbarParentData childParentData = child.parentData;
    context.pushClipPath(
      needsCompositing,
      offset + childParentData.offset,
      Offset.zero & child.size,
      _clipPath(),
      (PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child, innerOffset),
    );
xster's avatar
xster committed
226 227
  }

228 229
  Paint _debugPaint;

xster's avatar
xster committed
230
  @override
231 232
  void debugPaintSize(PaintingContext context, Offset offset) {
    assert(() {
233 234 235
      if (child == null) {
        return true;
      }
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251

      _debugPaint ??= Paint()
      ..shader = ui.Gradient.linear(
        const Offset(0.0, 0.0),
        const Offset(10.0, 10.0),
        <Color>[const Color(0x00000000), const Color(0xFFFF00FF), const Color(0xFFFF00FF), const Color(0x00000000)],
        <double>[0.25, 0.25, 0.75, 0.75],
        TileMode.repeated,
      )
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;

      final _ToolbarParentData childParentData = child.parentData;
      context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint);
      return true;
    }());
xster's avatar
xster committed
252 253 254 255 256
  }
}

/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
257 258 259
  const _TextSelectionHandlePainter(this.color);

  final Color color;
xster's avatar
xster committed
260 261 262

  @override
  void paint(Canvas canvas, Size size) {
263
    final Paint paint = Paint()
264
        ..color = color
xster's avatar
xster committed
265
        ..strokeWidth = 2.0;
266 267 268 269 270 271
    canvas.drawCircle(
      const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
      _kSelectionHandleRadius,
      paint,
    );
    // Draw line so it slightly overlaps the circle.
xster's avatar
xster committed
272
    canvas.drawLine(
273 274 275 276 277 278 279
      const Offset(
        _kSelectionHandleRadius,
        2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
      ),
      Offset(
        _kSelectionHandleRadius,
        size.height,
xster's avatar
xster committed
280 281 282 283 284 285
      ),
      paint,
    );
  }

  @override
286
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
xster's avatar
xster committed
287 288 289
}

class _CupertinoTextSelectionControls extends TextSelectionControls {
290
  /// Returns the size of the Cupertino handle.
xster's avatar
xster committed
291
  @override
292 293 294 295 296 297
  Size getHandleSize(double textLineHeight) {
    return Size(
      _kSelectionHandleRadius * 2,
      textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
    );
  }
xster's avatar
xster committed
298 299 300

  /// Builder for iOS-style copy/paste text selection toolbar.
  @override
301 302 303
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
304
    double textLineHeight,
305 306 307 308
    Offset position,
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
  ) {
xster's avatar
xster committed
309
    assert(debugCheckHasMediaQuery(context));
310 311 312 313 314
    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.
315
    final double toolbarHeightNeeded = mediaQuery.padding.top
316 317
      + _kToolbarScreenPadding
      + _kToolbarHeight
318 319 320
      + _kToolbarContentDistance;
    final double availableHeight = globalEditableRegion.top + endpoints.first.point.dy - textLineHeight;
    final bool isArrowPointingDown = toolbarHeightNeeded <= availableHeight;
321 322 323 324 325

    final double arrowTipX = (position.dx + globalEditableRegion.left).clamp(
      _kArrowScreenPadding + mediaQuery.padding.left,
      mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
    );
326

327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
    // The y-coordinate has to be calculated instead of directly quoting postion.dy,
    // since the caller (TextSelectionOverlay._buildToolbar) does not know whether
    // the toolbar is going to be facing up or down.
    final double localBarTopY = isArrowPointingDown
      ? endpoints.first.point.dy - textLineHeight - _kToolbarContentDistance - _kToolbarHeight
      : endpoints.last.point.dy + _kToolbarContentDistance;

    final List<Widget> items = <Widget>[];
    final Widget onePhysicalPixelVerticalDivider =
    SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
    final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
    final EdgeInsets arrowPadding = isArrowPointingDown
      ? EdgeInsets.only(bottom: _kToolbarArrowSize.height)
      : EdgeInsets.only(top: _kToolbarArrowSize.height);

    void addToolbarButtonIfNeeded(
      String text,
      bool Function(TextSelectionDelegate) predicate,
345
      void Function(TextSelectionDelegate) onPressed,
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
    ) {
      if (!predicate(delegate)) {
        return;
      }

      if (items.isNotEmpty) {
        items.add(onePhysicalPixelVerticalDivider);
      }

      items.add(CupertinoButton(
        child: Text(text, style: _kToolbarButtonFontStyle),
        color: _kToolbarBackgroundColor,
        minSize: _kToolbarHeight,
        padding: _kToolbarButtonPadding.add(arrowPadding),
        borderRadius: null,
        pressedOpacity: 0.7,
        onPressed: () => onPressed(delegate),
      ));
    }

    addToolbarButtonIfNeeded(localizations.cutButtonLabel, canCut, handleCut);
    addToolbarButtonIfNeeded(localizations.copyButtonLabel, canCopy, handleCopy);
    addToolbarButtonIfNeeded(localizations.pasteButtonLabel, canPaste, handlePaste);
    addToolbarButtonIfNeeded(localizations.selectAllButtonLabel, canSelectAll, handleSelectAll);

    return CupertinoTextSelectionToolbar._(
      barTopY: localBarTopY + globalEditableRegion.top,
      arrowTipX: arrowTipX,
      isArrowPointingDown: isArrowPointingDown,
      child: items.isEmpty ? null : DecoratedBox(
        decoration: const BoxDecoration(color: _kToolbarDividerColor),
        child: Row(mainAxisSize: MainAxisSize.min, children: items),
378
      ),
xster's avatar
xster committed
379 380 381 382 383 384 385 386
    );
  }

  /// Builder for iOS text selection edges.
  @override
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight) {
    // 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.
387
    final Size desiredSize = getHandleSize(textLineHeight);
xster's avatar
xster committed
388

389
    final Widget handle = SizedBox.fromSize(
xster's avatar
xster committed
390
      size: desiredSize,
391 392
      child: CustomPaint(
        painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor),
xster's avatar
xster committed
393 394 395 396 397 398 399
      ),
    );

    // [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) {
400 401
      case TextSelectionHandleType.left:
        return handle;
xster's avatar
xster committed
402
      case TextSelectionHandleType.right:
403
        // Right handle is a vertical mirror of the left.
404
        return Transform(
405 406 407 408
          transform: Matrix4.identity()
            ..translate(desiredSize.width / 2, desiredSize.height / 2)
            ..rotateZ(math.pi)
            ..translate(-desiredSize.width / 2, -desiredSize.height / 2),
409
          child: handle,
xster's avatar
xster committed
410
        );
411 412
      // iOS doesn't draw anything for collapsed selections.
      case TextSelectionHandleType.collapsed:
413
        return const SizedBox();
xster's avatar
xster committed
414 415 416 417
    }
    assert(type != null);
    return null;
  }
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447

  /// 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.
      default:
        return Offset(
          handleSize.width / 2,
          textLineHeight + (handleSize.height - textLineHeight) / 2,
        );
    }
  }
xster's avatar
xster committed
448 449 450
}

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