// Copyright 2014 The Flutter 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; import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; import 'button.dart'; import 'colors.dart'; import 'localizations.dart'; import 'theme.dart'; // Read off from the output on iOS 12. This color does not vary with the // application's theme color. const double _kSelectionHandleOverlap = 1.5; // Extracted from https://developer.apple.com/design/resources/. const double _kSelectionHandleRadius = 6; // 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 // Values extracted from https://developer.apple.com/design/resources/. // 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); // 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); const TextStyle _kToolbarButtonFontStyle = TextStyle( inherit: false, fontSize: 14.0, letterSpacing: -0.15, fontWeight: FontWeight.w400, color: CupertinoColors.white, ); // Eyeballed value. const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0); /// 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: /// /// * [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; // The y-coordinate of the tip of the arrow, in global coordinate system. final double _arrowTipX; // 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; @override _ToolbarRenderBox createRenderObject(BuildContext context) => _ToolbarRenderBox(_barTopY, _arrowTipX, _isArrowPointingDown, null); @override void updateRenderObject(BuildContext context, _ToolbarRenderBox renderObject) { renderObject ..barTopY = _barTopY ..arrowTipX = _arrowTipX ..isArrowPointingDown = _isArrowPointingDown; } } 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; @override String toString() => 'offset=$offset, arrowXOffsetFromCenter=$arrowXOffsetFromCenter'; } class _ToolbarRenderBox extends RenderShiftedBox { _ToolbarRenderBox( this._barTopY, this._arrowTipX, this._isArrowPointingDown, RenderBox child, ) : super(child); @override bool get isRepaintBoundary => true; double _barTopY; set barTopY(double value) { if (_barTopY == value) { return; } _barTopY = value; markNeedsLayout(); markNeedsSemanticsUpdate(); } double _arrowTipX; set arrowTipX(double value) { if (_arrowTipX == value) { return; } _arrowTipX = value; markNeedsLayout(); markNeedsSemanticsUpdate(); } bool _isArrowPointingDown; set isArrowPointingDown(bool value) { if (_isArrowPointingDown == value) { return; } _isArrowPointingDown = value; markNeedsLayout(); markNeedsSemanticsUpdate(); } final BoxConstraints heightConstraint = const BoxConstraints.tightFor(height: _kToolbarHeight); @override void setupParentData(RenderObject child) { if (child.parentData is! _ToolbarParentData) { child.parentData = _ToolbarParentData(); } } @override void performLayout() { size = constraints.biggest; if (child == null) { return; } final BoxConstraints enforcedConstraint = constraints .deflate(const EdgeInsets.symmetric(horizontal: _kToolbarScreenPadding)) .loosen(); child.layout(heightConstraint.enforce(enforcedConstraint), parentUsesSize: true,); final _ToolbarParentData childParentData = child.parentData as _ToolbarParentData; // 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 = _arrowTipX.clamp(lowerBound, upperBound) as double; childParentData.offset = Offset(adjustedCenterX - child.size.width / 2, _barTopY); childParentData.arrowXOffsetFromCenter = _arrowTipX - adjustedCenterX; } // The path is described in the toolbar's coordinate system. Path _clipPath() { final _ToolbarParentData childParentData = child.parentData as _ToolbarParentData; final Path rrect = Path() ..addRRect( RRect.fromRectAndRadius( Offset(0, _isArrowPointingDown ? 0 : _kToolbarArrowSize.height,) & Size(child.size.width, child.size.height - _kToolbarArrowSize.height), _kToolbarBorderRadius, ), ); final double arrowTipX = child.size.width / 2 + childParentData.arrowXOffsetFromCenter; final double arrowBottomY = _isArrowPointingDown ? child.size.height - _kToolbarArrowSize.height : _kToolbarArrowSize.height; final double arrowTipY = _isArrowPointingDown ? child.size.height : 0; final Path arrow = Path() ..moveTo(arrowTipX, arrowTipY) ..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBottomY) ..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBottomY) ..close(); return Path.combine(PathOperation.union, rrect, arrow); } @override void paint(PaintingContext context, Offset offset) { if (child == null) { return; } final _ToolbarParentData childParentData = child.parentData as _ToolbarParentData; context.pushClipPath( needsCompositing, offset + childParentData.offset, Offset.zero & child.size, _clipPath(), (PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child, innerOffset), ); } Paint _debugPaint; @override void debugPaintSize(PaintingContext context, Offset offset) { assert(() { if (child == null) { return true; } _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 as _ToolbarParentData; context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint); return true; }()); } } /// Draws a single text selection handle with a bar and a ball. class _TextSelectionHandlePainter extends CustomPainter { const _TextSelectionHandlePainter(this.color); final Color color; @override void paint(Canvas canvas, Size size) { const double halfStrokeWidth = 1.0; final Paint paint = Paint()..color = color; final Rect circle = Rect.fromCircle( center: const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius), radius: _kSelectionHandleRadius, ); final Rect line = Rect.fromPoints( const Offset( _kSelectionHandleRadius - halfStrokeWidth, 2 * _kSelectionHandleRadius - _kSelectionHandleOverlap, ), Offset(_kSelectionHandleRadius + halfStrokeWidth, size.height), ); final Path path = Path() ..addOval(circle) // Draw line so it slightly overlaps the circle. ..addRect(line); canvas.drawPath(path, paint); } @override bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color; } class _CupertinoTextSelectionControls extends TextSelectionControls { /// Returns the size of the Cupertino handle. @override Size getHandleSize(double textLineHeight) { return Size( _kSelectionHandleRadius * 2, textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap, ); } /// Builder for iOS-style copy/paste text selection toolbar. @override Widget buildToolbar( BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ) { 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 toolbarHeightNeeded = mediaQuery.padding.top + _kToolbarScreenPadding + _kToolbarHeight + _kToolbarContentDistance; final double availableHeight = globalEditableRegion.top + endpoints.first.point.dy - textLineHeight; final bool isArrowPointingDown = toolbarHeightNeeded <= availableHeight; final double arrowTipX = (position.dx + globalEditableRegion.left).clamp( _kArrowScreenPadding + mediaQuery.padding.left, mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding, ) as double; // 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, void Function(TextSelectionDelegate) onPressed, ) { 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), ), ); } /// 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. final Size desiredSize = getHandleSize(textLineHeight); final Widget handle = SizedBox.fromSize( size: desiredSize, child: CustomPaint( painter: _TextSelectionHandlePainter(CupertinoTheme.of(context).primaryColor), ), ); // [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) { case TextSelectionHandleType.left: return handle; case TextSelectionHandleType.right: // Right handle is a vertical mirror of the left. return Transform( transform: Matrix4.identity() ..translate(desiredSize.width / 2, desiredSize.height / 2) ..rotateZ(math.pi) ..translate(-desiredSize.width / 2, -desiredSize.height / 2), child: handle, ); // iOS doesn't draw anything for collapsed selections. case TextSelectionHandleType.collapsed: return const SizedBox(); } assert(type != null); return null; } /// 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, ); } } } /// Text selection controls that follows iOS design conventions. final TextSelectionControls cupertinoTextSelectionControls = _CupertinoTextSelectionControls();