From a6f41c8a106542eabf07505b4eed082053f985a4 Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Fri, 25 Sep 2015 14:19:35 -0700 Subject: [PATCH] Allow the Input/EditableText widget to scroll horizontally EditableText is now rendered using a custom RenderObject (RenderEditableParagraph). RenderEditableParagraph draws the cursor, handles scroll offsets, and provides feedback about the size of the text for use by the scroll behavior. --- packages/flutter/lib/rendering.dart | 1 + .../flutter/lib/src/fn3/editable_text.dart | 114 ++++++++++------ packages/flutter/lib/src/fn3/input.dart | 70 +++++++--- .../lib/src/painting/text_painter.dart | 17 ++- .../lib/src/rendering/editable_paragraph.dart | 126 ++++++++++++++++++ .../flutter/lib/src/rendering/paragraph.dart | 72 +++++----- 6 files changed, 300 insertions(+), 100 deletions(-) create mode 100644 packages/flutter/lib/src/rendering/editable_paragraph.dart diff --git a/packages/flutter/lib/rendering.dart b/packages/flutter/lib/rendering.dart index b85e54fbd3..104f9ba18c 100644 --- a/packages/flutter/lib/rendering.dart +++ b/packages/flutter/lib/rendering.dart @@ -9,6 +9,7 @@ export 'package:sky/src/rendering/auto_layout.dart'; export 'package:sky/src/rendering/block.dart'; export 'package:sky/src/rendering/box.dart'; export 'package:sky/src/rendering/debug.dart'; +export 'package:sky/src/rendering/editable_paragraph.dart'; export 'package:sky/src/rendering/error.dart'; export 'package:sky/src/rendering/flex.dart'; export 'package:sky/src/rendering/grid.dart'; diff --git a/packages/flutter/lib/src/fn3/editable_text.dart b/packages/flutter/lib/src/fn3/editable_text.dart index ed5d57390f..93e378663e 100644 --- a/packages/flutter/lib/src/fn3/editable_text.dart +++ b/packages/flutter/lib/src/fn3/editable_text.dart @@ -7,13 +7,11 @@ import 'dart:sky' as sky; import 'package:mojo_services/keyboard/keyboard.mojom.dart'; import 'package:sky/painting.dart'; +import 'package:sky/rendering.dart'; import 'package:sky/src/fn3/basic.dart'; import 'package:sky/src/fn3/framework.dart'; const _kCursorBlinkPeriod = 500; // milliseconds -const _kCursorGap = 1.0; -const _kCursorHeightOffset = 2.0; -const _kCursorWidth = 1.0; typedef void StringUpdated(); @@ -138,12 +136,17 @@ class EditableText extends StatefulComponent { this.value, this.focused: false, this.style, - this.cursorColor}) : super(key: key); + this.cursorColor, + this.onContentSizeChanged, + this.scrollOffset + }) : super(key: key); final EditableString value; final bool focused; final TextStyle style; final Color cursorColor; + final SizeChangedCallback onContentSizeChanged; + final Offset scrollOffset; EditableTextState createState() => new EditableTextState(); } @@ -183,20 +186,6 @@ class EditableTextState extends State { _showCursor = false; } - void _paintCursor(sky.Canvas canvas, Size size) { - if (!_showCursor) - return; - - double cursorHeight = config.style.fontSize + 2.0 * _kCursorHeightOffset; - Rect cursorRect = new Rect.fromLTWH( - _kCursorGap, - (size.height - cursorHeight) / 2.0, - _kCursorWidth, - cursorHeight - ); - canvas.drawRect(cursorRect, new Paint()..color = config.cursorColor); - } - Widget build(BuildContext context) { assert(config.style != null); assert(config.focused != null); @@ -207,29 +196,72 @@ class EditableTextState extends State { else if (!config.focused && _cursorTimer != null) _stopCursorTimer(); - final EditableString value = config.value; - final TextStyle style = config.style; - - Widget text; - if (value.composing.isValid) { - TextStyle composingStyle = style.merge(const TextStyle(decoration: underline)); - text = new StyledText(elements: [ - style, - value.textBefore(value.composing), - [composingStyle, value.textInside(value.composing)], - value.textAfter(value.composing) - ]); - } else { - // TODO(eseidel): This is the wrong height if empty! - text = new Text(value.text, style: style); - } - - Widget cursor = new Container( - height: style.fontSize * style.height, - width: _kCursorGap + _kCursorWidth, - child: new CustomPaint(callback: _paintCursor, token: _showCursor) + return new _EditableTextWidget( + value: config.value, + style: config.style, + cursorColor: config.cursorColor, + showCursor: _showCursor, + onContentSizeChanged: config.onContentSizeChanged, + scrollOffset: config.scrollOffset ); - - return new Row([text, cursor]); + } +} + +class _EditableTextWidget extends LeafRenderObjectWidget { + _EditableTextWidget({ + Key key, + this.value, + this.style, + this.cursorColor, + this.showCursor, + this.onContentSizeChanged, + this.scrollOffset + }) : super(key: key); + + final EditableString value; + final TextStyle style; + final Color cursorColor; + final bool showCursor; + final SizeChangedCallback onContentSizeChanged; + final Offset scrollOffset; + + RenderEditableParagraph createRenderObject() { + return new RenderEditableParagraph( + text: _buildTextSpan(), + cursorColor: cursorColor, + showCursor: showCursor, + onContentSizeChanged: onContentSizeChanged, + scrollOffset: scrollOffset + ); + } + + void updateRenderObject(RenderEditableParagraph renderObject, + _EditableTextWidget oldWidget) { + renderObject.text = _buildTextSpan(); + renderObject.cursorColor = cursorColor; + renderObject.showCursor = showCursor; + renderObject.onContentSizeChanged = onContentSizeChanged; + renderObject.scrollOffset = scrollOffset; + } + + // Construct a TextSpan that renders the EditableString using the chosen style. + TextSpan _buildTextSpan() { + if (value.composing.isValid) { + TextStyle composingStyle = style.merge( + const TextStyle(decoration: underline) + ); + + return new StyledTextSpan(style, [ + new PlainTextSpan(value.textBefore(value.composing)), + new StyledTextSpan(composingStyle, [ + new PlainTextSpan(value.textInside(value.composing)) + ]), + new PlainTextSpan(value.textAfter(value.composing)) + ]); + } + + return new StyledTextSpan(style, [ + new PlainTextSpan(value.text) + ]); } } diff --git a/packages/flutter/lib/src/fn3/input.dart b/packages/flutter/lib/src/fn3/input.dart index 05fe77ec72..2717f69040 100644 --- a/packages/flutter/lib/src/fn3/input.dart +++ b/packages/flutter/lib/src/fn3/input.dart @@ -2,12 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:sky/animation.dart'; import 'package:sky/services.dart'; import 'package:sky/painting.dart'; +import 'package:sky/rendering.dart'; import 'package:sky/src/fn3/basic.dart'; import 'package:sky/src/fn3/editable_text.dart'; import 'package:sky/src/fn3/focus.dart'; import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/scrollable.dart'; import 'package:sky/src/fn3/theme.dart'; export 'package:sky/services.dart' show KeyboardType; @@ -18,14 +21,18 @@ typedef void StringValueChanged(String value); // http://www.google.com/design/spec/components/text-fields.html#text-fields-single-line-text-field const EdgeDims _kTextfieldPadding = const EdgeDims.symmetric(vertical: 8.0); -class Input extends StatefulComponent { +class Input extends Scrollable { Input({ GlobalKey key, this.initialValue: '', this.placeholder, this.onChanged, this.keyboardType: KeyboardType.TEXT - }): super(key: key); + }): super( + key: key, + initialScrollOffset: 0.0, + scrollDirection: ScrollDirection.horizontal + ); final String initialValue; final KeyboardType keyboardType; @@ -35,11 +42,14 @@ class Input extends StatefulComponent { InputState createState() => new InputState(); } -class InputState extends State { +class InputState extends ScrollableState { String _value; EditableString _editableValue; KeyboardHandle _keyboardHandle = KeyboardHandle.unattached; + double _contentWidth = 0.0; + double _containerWidth = 0.0; + void initState(BuildContext context) { super.initState(context); _value = config.initialValue; @@ -59,7 +69,7 @@ class InputState extends State { } } - Widget build(BuildContext context) { + Widget buildContent(BuildContext context) { ThemeData themeData = Theme.of(context); bool focused = FocusState.at(context, config); @@ -92,22 +102,25 @@ class InputState extends State { value: _editableValue, focused: focused, style: textStyle, - cursorColor: cursorColor + cursorColor: cursorColor, + onContentSizeChanged: _handleContentSizeChanged, + scrollOffset: scrollOffsetVector )); - Border focusHighlight = new Border(bottom: new BorderSide( - color: focusHighlightColor, - width: focused ? 2.0 : 1.0 - )); - - Container input = new Container( - child: new Stack(textChildren), - padding: _kTextfieldPadding, - decoration: new BoxDecoration(border: focusHighlight) - ); - return new Listener( - child: input, + child: new SizeObserver( + callback: _handleContainerSizeChanged, + child: new Container( + child: new Stack(textChildren), + padding: _kTextfieldPadding, + decoration: new BoxDecoration(border: new Border( + bottom: new BorderSide( + color: focusHighlightColor, + width: focused ? 2.0 : 1.0 + ) + )) + ) + ), onPointerDown: (_) { if (FocusState.at(context, config)) { assert(_keyboardHandle.attached); @@ -125,4 +138,27 @@ class InputState extends State { _keyboardHandle.release(); super.dispose(); } + + ScrollBehavior createScrollBehavior() => new BoundedBehavior(); + BoundedBehavior get scrollBehavior => super.scrollBehavior; + + void _handleContainerSizeChanged(Size newSize) { + _containerWidth = newSize.width; + _updateScrollBehavior(); + } + + void _handleContentSizeChanged(Size newSize) { + _contentWidth = newSize.width; + _updateScrollBehavior(); + } + + void _updateScrollBehavior() { + // Set the scroll offset to match the content width so that the cursor + // (which is always at the end of the text) will be visible. + scrollTo(scrollBehavior.updateExtents( + contentExtent: _contentWidth, + containerExtent: _containerWidth, + scrollOffset: _contentWidth) + ); + } } diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart index d1eb79998f..bb4709e8ef 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -157,22 +157,33 @@ class TextPainter { _layoutRoot.maxHeight = value; } + // Unfortunately, using full precision floating point here causes bad layouts + // because floating point math isn't associative. If we add and subtract + // padding, for example, we'll get different values when we estimate sizes and + // when we actually compute layout because the operations will end up associated + // differently. To work around this problem for now, we round fractional pixel + // values up to the nearest whole pixel value. The right long-term fix is to do + // layout using fixed precision arithmetic. + double _applyFloatingPointHack(double layoutValue) { + return layoutValue.ceilToDouble(); + } + /// The width at which decreasing the width of the text would prevent it from painting itself completely within its bounds double get minContentWidth { assert(!_needsLayout); - return _layoutRoot.rootElement.minContentWidth; + return _applyFloatingPointHack(_layoutRoot.rootElement.minContentWidth); } /// The width at which increasing the width of the text no longer decreases the height double get maxContentWidth { assert(!_needsLayout); - return _layoutRoot.rootElement.maxContentWidth; + return _applyFloatingPointHack(_layoutRoot.rootElement.maxContentWidth); } /// The height required to paint the text completely within its bounds double get height { assert(!_needsLayout); - return _layoutRoot.rootElement.height; + return _applyFloatingPointHack(_layoutRoot.rootElement.height); } /// The distance from the top of the text to the first baseline of the given type diff --git a/packages/flutter/lib/src/rendering/editable_paragraph.dart b/packages/flutter/lib/src/rendering/editable_paragraph.dart new file mode 100644 index 0000000000..f25dcdfdcb --- /dev/null +++ b/packages/flutter/lib/src/rendering/editable_paragraph.dart @@ -0,0 +1,126 @@ +// Copyright 2015 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 'package:sky/painting.dart'; +import 'package:sky/src/rendering/box.dart'; +import 'package:sky/src/rendering/object.dart'; +import 'package:sky/src/rendering/paragraph.dart'; +import 'package:sky/src/rendering/proxy_box.dart' show SizeChangedCallback; + +const _kCursorGap = 1.0; // pixels +const _kCursorHeightOffset = 2.0; // pixels +const _kCursorWidth = 1.0; // pixels + +/// A render object used by EditableText widgets. This is similar to +/// RenderParagraph but also renders a cursor and provides support for +/// scrolling. +class RenderEditableParagraph extends RenderParagraph { + + RenderEditableParagraph({ + TextSpan text, + Color cursorColor, + bool showCursor, + this.onContentSizeChanged, + Offset scrollOffset + }) : _cursorColor = cursorColor, + _showCursor = showCursor, + _scrollOffset = scrollOffset, + super(text); + + Color _cursorColor; + bool _showCursor; + SizeChangedCallback onContentSizeChanged; + Offset _scrollOffset; + + Size _contentSize; + + Color get cursorColor => _cursorColor; + void set cursorColor(Color value) { + if (_cursorColor == value) + return; + _cursorColor = value; + markNeedsPaint(); + } + + bool get showCursor => _showCursor; + void set showCursor(bool value) { + if (_showCursor == value) + return; + _showCursor = value; + markNeedsPaint(); + } + + Offset get scrollOffset => _scrollOffset; + void set scrollOffset(Offset value) { + if (_scrollOffset == value) + return; + _scrollOffset = value; + markNeedsPaint(); + } + + // Editable text does not support line wrap. + bool get allowLineWrap => false; + + double _getIntrinsicWidth(BoxConstraints constraints) { + // There should be no difference between the minimum and maximum width + // because we only support single-line text. + layoutText(constraints); + return constraints.constrainWidth( + textPainter.maxContentWidth + _kCursorGap + _kCursorWidth + ); + } + + double getMinIntrinsicWidth(BoxConstraints constraints) { + return _getIntrinsicWidth(constraints); + } + + double getMaxIntrinsicWidth(BoxConstraints constraints) { + return _getIntrinsicWidth(constraints); + } + + void performLayout() { + layoutText(constraints); + + Size newContentSize = new Size( + textPainter.maxContentWidth + _kCursorGap + _kCursorWidth, + textPainter.height + ); + size = constraints.constrain(newContentSize); + + if (_contentSize == null || _contentSize != newContentSize) { + _contentSize = newContentSize; + if (onContentSizeChanged != null) + onContentSizeChanged(newContentSize); + } + } + + void paint(PaintingContext context, Offset offset) { + layoutText(constraints); + + bool needsClipping = (_contentSize.width > size.width); + if (needsClipping) { + context.canvas.save(); + context.canvas.clipRect(offset & size); + } + + textPainter.paint(context.canvas, offset - _scrollOffset); + + if (_showCursor) { + Rect cursorRect = new Rect.fromLTWH( + textPainter.maxContentWidth + _kCursorGap, + _kCursorHeightOffset, + _kCursorWidth, + size.height - 2.0 * _kCursorHeightOffset + ); + context.canvas.drawRect( + cursorRect.shift(offset - _scrollOffset), + new Paint()..color = _cursorColor + ); + } + + if (needsClipping) + context.canvas.restore(); + } + +} diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 12bd78be18..1bc509154b 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -8,66 +8,57 @@ import 'package:sky/src/rendering/object.dart'; export 'package:sky/src/painting/text_painter.dart'; -// Unfortunately, using full precision floating point here causes bad layouts -// because floating point math isn't associative. If we add and subtract -// padding, for example, we'll get different values when we estimate sizes and -// when we actually compute layout because the operations will end up associated -// differently. To work around this problem for now, we round fractional pixel -// values up to the nearest whole pixel value. The right long-term fix is to do -// layout using fixed precision arithmetic. -double _applyFloatingPointHack(double layoutValue) { - return layoutValue.ceilToDouble(); -} - /// A render object that displays a paragraph of text class RenderParagraph extends RenderBox { - RenderParagraph(TextSpan text) : _textPainter = new TextPainter(text) { + RenderParagraph( + TextSpan text + ) : textPainter = new TextPainter(text) { assert(text != null); } - TextPainter _textPainter; + final TextPainter textPainter; BoxConstraints _constraintsForCurrentLayout; // when null, we don't have a current layout /// The text to display - TextSpan get text => _textPainter.text; + TextSpan get text => textPainter.text; void set text(TextSpan value) { - if (_textPainter.text == value) + if (textPainter.text == value) return; - _textPainter.text = value; + textPainter.text = value; _constraintsForCurrentLayout = null; markNeedsLayout(); } - void _layout(BoxConstraints constraints) { + // Whether the text should be allowed to wrap to multiple lines. + bool get allowLineWrap => true; + + void layoutText(BoxConstraints constraints) { assert(constraints != null); if (_constraintsForCurrentLayout == constraints) return; // already cached this layout - _textPainter.maxWidth = constraints.maxWidth; - _textPainter.minWidth = constraints.minWidth; - _textPainter.minHeight = constraints.minHeight; - _textPainter.maxHeight = constraints.maxHeight; - _textPainter.layout(); + textPainter.maxWidth = allowLineWrap ? constraints.maxWidth : double.INFINITY; + textPainter.minWidth = constraints.minWidth; + textPainter.minHeight = constraints.minHeight; + textPainter.maxHeight = constraints.maxHeight; + textPainter.layout(); _constraintsForCurrentLayout = constraints; } double getMinIntrinsicWidth(BoxConstraints constraints) { - _layout(constraints); - return constraints.constrainWidth( - _applyFloatingPointHack(_textPainter.minContentWidth)); + layoutText(constraints); + return constraints.constrainWidth(textPainter.minContentWidth); } double getMaxIntrinsicWidth(BoxConstraints constraints) { - _layout(constraints); - return constraints.constrainWidth( - _applyFloatingPointHack(_textPainter.maxContentWidth)); + layoutText(constraints); + return constraints.constrainWidth(textPainter.maxContentWidth); } double _getIntrinsicHeight(BoxConstraints constraints) { - _layout(constraints); - return constraints.constrainHeight( - _applyFloatingPointHack(_textPainter.height)); + layoutText(constraints); + return constraints.constrainHeight(textPainter.height); } double getMinIntrinsicHeight(BoxConstraints constraints) { @@ -80,15 +71,18 @@ class RenderParagraph extends RenderBox { double computeDistanceToActualBaseline(TextBaseline baseline) { assert(!needsLayout); - _layout(constraints); - return _textPainter.computeDistanceToActualBaseline(baseline); + layoutText(constraints); + return textPainter.computeDistanceToActualBaseline(baseline); } void performLayout() { - _layout(constraints); - // _paragraphPainter.width always expands to fill, use maxContentWidth instead. - size = constraints.constrain(new Size(_applyFloatingPointHack(_textPainter.maxContentWidth), - _applyFloatingPointHack(_textPainter.height))); + layoutText(constraints); + + // We use textPainter.maxContentWidth here, rather that textPainter.width, + // because the latter is the width that it used to wrap the text, whereas + // the former is the actual width of the text. + size = constraints.constrain(new Size(textPainter.maxContentWidth, + textPainter.height)); } void paint(PaintingContext context, Offset offset) { @@ -99,8 +93,8 @@ class RenderParagraph extends RenderBox { // // TODO(abarth): Make computing the min/max intrinsic width/height // a non-destructive operation. - _layout(constraints); - _textPainter.paint(context.canvas, offset); + layoutText(constraints); + textPainter.paint(context.canvas, offset); } // we should probably expose a way to do precise (inter-glpyh) hit testing