blob: 6e323182ac5ebf311efff3412e80f53654e5fdf5 [file] [log] [blame]
// 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;
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'button.dart';
import 'colors.dart';
import 'localizations.dart';
// Read off from the output on iOS 12. This color does not vary with the
// application's theme color.
const Color _kHandlesColor = Color(0xFF136FE0);
const double _kSelectionHandleOverlap = 1.5;
const double _kSelectionHandleRadius = 5.5;
// 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);
const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 10.0, horizontal: 18.0);
const Radius _kToolbarBorderRadius = Radius.circular(8);
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
fontSize: 14.0,
letterSpacing: -0.15,
fontWeight: FontWeight.w400,
color: CupertinoColors.white,
);
/// 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;
final Offset localTopCenter = globalToLocal(Offset(_arrowTipX, _barTopY));
// 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);
childParentData.offset = Offset(adjustedCenterX - child.size.width / 2, localTopCenter.dy);
childParentData.arrowXOffsetFromCenter = localTopCenter.dx - adjustedCenterX;
}
// 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,
),
);
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;
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;
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();
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = _kHandlesColor
..strokeWidth = 2.0;
canvas.drawCircle(
const Offset(_kSelectionHandleRadius, _kSelectionHandleRadius),
_kSelectionHandleRadius,
paint,
);
// Draw line so it slightly overlaps the circle.
canvas.drawLine(
const Offset(
_kSelectionHandleRadius,
2 * _kSelectionHandleRadius - _kSelectionHandleOverlap,
),
Offset(
_kSelectionHandleRadius,
size.height,
),
paint,
);
}
@override
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => false;
}
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 bool isArrowPointingDown =
mediaQuery.padding.top
+ _kToolbarScreenPadding
+ _kToolbarHeight
+ _kToolbarContentDistance <= globalEditableRegion.top + endpoints.first.point.dy - textLineHeight;
final double arrowTipX = (position.dx + 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 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: const CustomPaint(
painter: _TextSelectionHandlePainter(),
),
);
// [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();