text_selection.dart 14.8 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';
xster's avatar
xster committed
14

15 16 17
// Read off from the output on iOS 12. This color does not vary with the
// application's theme color.
const Color _kHandlesColor = Color(0xFF136FE0);
18 19
const double _kSelectionHandleOverlap = 1.5;
const double _kSelectionHandleRadius = 5.5;
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38

// 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

// The height of the toolbar, including the arrow.
const double _kToolbarHeight = 43.0;
const Color _kToolbarBackgroundColor = Color(0xEB202020);
const Color _kToolbarDividerColor = Color(0xFF808080);
const Size _kToolbarArrowSize = Size(14.0, 7.0);
39
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0);
40
const Radius _kToolbarBorderRadius = Radius.circular(8);
xster's avatar
xster committed
41

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

50 51 52 53 54
/// 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:
55
///
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
/// * [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;
73

74 75
  // The y-coordinate of the tip of the arrow, in global coordinate system.
  final double _arrowTipX;
76

77 78 79
  // 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;
80

xster's avatar
xster committed
81
  @override
82 83 84 85 86 87 88 89
  _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
90
  }
91
}
xster's avatar
xster committed
92

93 94 95 96 97
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
98
  @override
99
  String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter';
xster's avatar
xster committed
100 101
}

102 103 104 105 106 107 108 109
class _ToolbarRenderBox extends RenderShiftedBox {
  _ToolbarRenderBox(
    this._barTopY,
    this._arrowTipX,
    this._isArrowPointingDown,
    RenderBox child,
  ) : super(child);

xster's avatar
xster committed
110 111

  @override
112
  bool get isRepaintBoundary => true;
xster's avatar
xster committed
113

114 115 116 117 118 119 120 121 122
  double _barTopY;
  set barTopY(double value) {
    if (_barTopY == value) {
      return;
    }
    _barTopY = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
123

124 125 126 127
  double _arrowTipX;
  set arrowTipX(double value) {
    if (_arrowTipX == value) {
      return;
xster's avatar
xster committed
128
    }
129 130 131 132
    _arrowTipX = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
xster's avatar
xster committed
133

134 135 136 137
  bool _isArrowPointingDown;
  set isArrowPointingDown(bool value) {
    if (_isArrowPointingDown == value) {
      return;
138
    }
139 140 141 142
    _isArrowPointingDown = value;
    markNeedsLayout();
    markNeedsSemanticsUpdate();
  }
xster's avatar
xster committed
143

144 145 146 147 148 149
  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
150
    }
151 152 153 154 155 156 157 158
  }

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

    if (child == null) {
      return;
159
    }
160 161 162
    final BoxConstraints enforcedConstraint = constraints
      .deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding))
      .loosen();
xster's avatar
xster committed
163

164 165
    child.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,);
    final _ToolbarParentData childParentData = child.parentData;
166

167
    final Offset localTopCenter = globalToLocal(Offset(_arrowTipX, _barTopY));
xster's avatar
xster committed
168

169 170 171 172
    // 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;
    final double adjustedCenterX = localTopCenter.dx.clamp(lowerBound, upperBound);
173

174 175
    childParentData.offset = Offset(adjustedCenterX - child.size.width / 2, localTopCenter.dy);
    childParentData.arrowXOffsetFromCenter = localTopCenter.dx - adjustedCenterX;
xster's avatar
xster committed
176 177
  }

178 179 180 181 182 183 184 185 186 187 188
  // 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
189

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

192 193 194
    final double arrowBottomY = _isArrowPointingDown
      ? child.size.height - _kToolbarArrowSize.height
      : _kToolbarArrowSize.height;
xster's avatar
xster committed
195

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

198 199 200 201 202
    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
203

204
    return Path.combine(PathOperation.union, rrect, arrow);
xster's avatar
xster committed
205 206 207
  }

  @override
208 209 210 211
  void paint(PaintingContext context, Offset offset) {
    if (child == null) {
      return;
    }
xster's avatar
xster committed
212

213 214 215 216 217 218 219 220
    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
221 222
  }

223 224
  Paint _debugPaint;

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

      _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
247 248 249 250 251
  }
}

/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
252
  const _TextSelectionHandlePainter();
xster's avatar
xster committed
253 254 255

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

  @override
279
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => false;
xster's avatar
xster committed
280 281 282
}

class _CupertinoTextSelectionControls extends TextSelectionControls {
283
  /// Returns the size of the Cupertino handle.
xster's avatar
xster committed
284
  @override
285 286 287 288 289 290
  Size getHandleSize(double textLineHeight) {
    return Size(
      _kSelectionHandleRadius * 2,
      textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap,
    );
  }
xster's avatar
xster committed
291 292 293

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

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

320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
    // 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,
338
      void Function(TextSelectionDelegate) onPressed,
339 340 341 342 343 344 345 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
    ) {
      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),
371
      ),
xster's avatar
xster committed
372 373 374 375 376 377 378 379
    );
  }

  /// 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.
380
    final Size desiredSize = getHandleSize(textLineHeight);
xster's avatar
xster committed
381

382
    final Widget handle = SizedBox.fromSize(
xster's avatar
xster committed
383
      size: desiredSize,
384 385
      child: const CustomPaint(
        painter: _TextSelectionHandlePainter(),
xster's avatar
xster committed
386 387 388 389 390 391 392
      ),
    );

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

  /// 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
441 442 443
}

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