Commit a6f41c8a authored by Jason Simmons's avatar Jason Simmons

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.
parent ebd7fa3e
...@@ -9,6 +9,7 @@ export 'package:sky/src/rendering/auto_layout.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/block.dart';
export 'package:sky/src/rendering/box.dart'; export 'package:sky/src/rendering/box.dart';
export 'package:sky/src/rendering/debug.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/error.dart';
export 'package:sky/src/rendering/flex.dart'; export 'package:sky/src/rendering/flex.dart';
export 'package:sky/src/rendering/grid.dart'; export 'package:sky/src/rendering/grid.dart';
......
...@@ -7,13 +7,11 @@ import 'dart:sky' as sky; ...@@ -7,13 +7,11 @@ import 'dart:sky' as sky;
import 'package:mojo_services/keyboard/keyboard.mojom.dart'; import 'package:mojo_services/keyboard/keyboard.mojom.dart';
import 'package:sky/painting.dart'; import 'package:sky/painting.dart';
import 'package:sky/rendering.dart';
import 'package:sky/src/fn3/basic.dart'; import 'package:sky/src/fn3/basic.dart';
import 'package:sky/src/fn3/framework.dart'; import 'package:sky/src/fn3/framework.dart';
const _kCursorBlinkPeriod = 500; // milliseconds const _kCursorBlinkPeriod = 500; // milliseconds
const _kCursorGap = 1.0;
const _kCursorHeightOffset = 2.0;
const _kCursorWidth = 1.0;
typedef void StringUpdated(); typedef void StringUpdated();
...@@ -138,12 +136,17 @@ class EditableText extends StatefulComponent { ...@@ -138,12 +136,17 @@ class EditableText extends StatefulComponent {
this.value, this.value,
this.focused: false, this.focused: false,
this.style, this.style,
this.cursorColor}) : super(key: key); this.cursorColor,
this.onContentSizeChanged,
this.scrollOffset
}) : super(key: key);
final EditableString value; final EditableString value;
final bool focused; final bool focused;
final TextStyle style; final TextStyle style;
final Color cursorColor; final Color cursorColor;
final SizeChangedCallback onContentSizeChanged;
final Offset scrollOffset;
EditableTextState createState() => new EditableTextState(); EditableTextState createState() => new EditableTextState();
} }
...@@ -183,20 +186,6 @@ class EditableTextState extends State<EditableText> { ...@@ -183,20 +186,6 @@ class EditableTextState extends State<EditableText> {
_showCursor = false; _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) { Widget build(BuildContext context) {
assert(config.style != null); assert(config.style != null);
assert(config.focused != null); assert(config.focused != null);
...@@ -207,29 +196,72 @@ class EditableTextState extends State<EditableText> { ...@@ -207,29 +196,72 @@ class EditableTextState extends State<EditableText> {
else if (!config.focused && _cursorTimer != null) else if (!config.focused && _cursorTimer != null)
_stopCursorTimer(); _stopCursorTimer();
final EditableString value = config.value; return new _EditableTextWidget(
final TextStyle style = config.style; value: config.value,
style: config.style,
cursorColor: config.cursorColor,
showCursor: _showCursor,
onContentSizeChanged: config.onContentSizeChanged,
scrollOffset: config.scrollOffset
);
}
}
Widget text; class _EditableTextWidget extends LeafRenderObjectWidget {
if (value.composing.isValid) { _EditableTextWidget({
TextStyle composingStyle = style.merge(const TextStyle(decoration: underline)); Key key,
text = new StyledText(elements: [ this.value,
style, this.style,
value.textBefore(value.composing), this.cursorColor,
[composingStyle, value.textInside(value.composing)], this.showCursor,
value.textAfter(value.composing) this.onContentSizeChanged,
]); this.scrollOffset
} else { }) : super(key: key);
// TODO(eseidel): This is the wrong height if empty!
text = new Text(value.text, style: style); 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;
} }
Widget cursor = new Container( // Construct a TextSpan that renders the EditableString using the chosen style.
height: style.fontSize * style.height, TextSpan _buildTextSpan() {
width: _kCursorGap + _kCursorWidth, if (value.composing.isValid) {
child: new CustomPaint(callback: _paintCursor, token: _showCursor) TextStyle composingStyle = style.merge(
const TextStyle(decoration: underline)
); );
return new Row([text, cursor]); 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)
]);
} }
} }
...@@ -2,12 +2,15 @@ ...@@ -2,12 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:sky/animation.dart';
import 'package:sky/services.dart'; import 'package:sky/services.dart';
import 'package:sky/painting.dart'; import 'package:sky/painting.dart';
import 'package:sky/rendering.dart';
import 'package:sky/src/fn3/basic.dart'; import 'package:sky/src/fn3/basic.dart';
import 'package:sky/src/fn3/editable_text.dart'; import 'package:sky/src/fn3/editable_text.dart';
import 'package:sky/src/fn3/focus.dart'; import 'package:sky/src/fn3/focus.dart';
import 'package:sky/src/fn3/framework.dart'; import 'package:sky/src/fn3/framework.dart';
import 'package:sky/src/fn3/scrollable.dart';
import 'package:sky/src/fn3/theme.dart'; import 'package:sky/src/fn3/theme.dart';
export 'package:sky/services.dart' show KeyboardType; export 'package:sky/services.dart' show KeyboardType;
...@@ -18,14 +21,18 @@ typedef void StringValueChanged(String value); ...@@ -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 // 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); const EdgeDims _kTextfieldPadding = const EdgeDims.symmetric(vertical: 8.0);
class Input extends StatefulComponent { class Input extends Scrollable {
Input({ Input({
GlobalKey key, GlobalKey key,
this.initialValue: '', this.initialValue: '',
this.placeholder, this.placeholder,
this.onChanged, this.onChanged,
this.keyboardType: KeyboardType.TEXT this.keyboardType: KeyboardType.TEXT
}): super(key: key); }): super(
key: key,
initialScrollOffset: 0.0,
scrollDirection: ScrollDirection.horizontal
);
final String initialValue; final String initialValue;
final KeyboardType keyboardType; final KeyboardType keyboardType;
...@@ -35,11 +42,14 @@ class Input extends StatefulComponent { ...@@ -35,11 +42,14 @@ class Input extends StatefulComponent {
InputState createState() => new InputState(); InputState createState() => new InputState();
} }
class InputState extends State<Input> { class InputState extends ScrollableState<Input> {
String _value; String _value;
EditableString _editableValue; EditableString _editableValue;
KeyboardHandle _keyboardHandle = KeyboardHandle.unattached; KeyboardHandle _keyboardHandle = KeyboardHandle.unattached;
double _contentWidth = 0.0;
double _containerWidth = 0.0;
void initState(BuildContext context) { void initState(BuildContext context) {
super.initState(context); super.initState(context);
_value = config.initialValue; _value = config.initialValue;
...@@ -59,7 +69,7 @@ class InputState extends State<Input> { ...@@ -59,7 +69,7 @@ class InputState extends State<Input> {
} }
} }
Widget build(BuildContext context) { Widget buildContent(BuildContext context) {
ThemeData themeData = Theme.of(context); ThemeData themeData = Theme.of(context);
bool focused = FocusState.at(context, config); bool focused = FocusState.at(context, config);
...@@ -92,22 +102,25 @@ class InputState extends State<Input> { ...@@ -92,22 +102,25 @@ class InputState extends State<Input> {
value: _editableValue, value: _editableValue,
focused: focused, focused: focused,
style: textStyle, 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( return new Listener(
child: new SizeObserver(
callback: _handleContainerSizeChanged,
child: new Container(
child: new Stack(textChildren), child: new Stack(textChildren),
padding: _kTextfieldPadding, padding: _kTextfieldPadding,
decoration: new BoxDecoration(border: focusHighlight) decoration: new BoxDecoration(border: new Border(
); bottom: new BorderSide(
color: focusHighlightColor,
return new Listener( width: focused ? 2.0 : 1.0
child: input, )
))
)
),
onPointerDown: (_) { onPointerDown: (_) {
if (FocusState.at(context, config)) { if (FocusState.at(context, config)) {
assert(_keyboardHandle.attached); assert(_keyboardHandle.attached);
...@@ -125,4 +138,27 @@ class InputState extends State<Input> { ...@@ -125,4 +138,27 @@ class InputState extends State<Input> {
_keyboardHandle.release(); _keyboardHandle.release();
super.dispose(); 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)
);
}
} }
...@@ -157,22 +157,33 @@ class TextPainter { ...@@ -157,22 +157,33 @@ class TextPainter {
_layoutRoot.maxHeight = value; _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 /// The width at which decreasing the width of the text would prevent it from painting itself completely within its bounds
double get minContentWidth { double get minContentWidth {
assert(!_needsLayout); 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 /// The width at which increasing the width of the text no longer decreases the height
double get maxContentWidth { double get maxContentWidth {
assert(!_needsLayout); assert(!_needsLayout);
return _layoutRoot.rootElement.maxContentWidth; return _applyFloatingPointHack(_layoutRoot.rootElement.maxContentWidth);
} }
/// The height required to paint the text completely within its bounds /// The height required to paint the text completely within its bounds
double get height { double get height {
assert(!_needsLayout); 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 /// The distance from the top of the text to the first baseline of the given type
......
// 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();
}
}
...@@ -8,66 +8,57 @@ import 'package:sky/src/rendering/object.dart'; ...@@ -8,66 +8,57 @@ import 'package:sky/src/rendering/object.dart';
export 'package:sky/src/painting/text_painter.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 /// A render object that displays a paragraph of text
class RenderParagraph extends RenderBox { class RenderParagraph extends RenderBox {
RenderParagraph(TextSpan text) : _textPainter = new TextPainter(text) { RenderParagraph(
TextSpan text
) : textPainter = new TextPainter(text) {
assert(text != null); assert(text != null);
} }
TextPainter _textPainter; final TextPainter textPainter;
BoxConstraints _constraintsForCurrentLayout; // when null, we don't have a current layout BoxConstraints _constraintsForCurrentLayout; // when null, we don't have a current layout
/// The text to display /// The text to display
TextSpan get text => _textPainter.text; TextSpan get text => textPainter.text;
void set text(TextSpan value) { void set text(TextSpan value) {
if (_textPainter.text == value) if (textPainter.text == value)
return; return;
_textPainter.text = value; textPainter.text = value;
_constraintsForCurrentLayout = null; _constraintsForCurrentLayout = null;
markNeedsLayout(); 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); assert(constraints != null);
if (_constraintsForCurrentLayout == constraints) if (_constraintsForCurrentLayout == constraints)
return; // already cached this layout return; // already cached this layout
_textPainter.maxWidth = constraints.maxWidth; textPainter.maxWidth = allowLineWrap ? constraints.maxWidth : double.INFINITY;
_textPainter.minWidth = constraints.minWidth; textPainter.minWidth = constraints.minWidth;
_textPainter.minHeight = constraints.minHeight; textPainter.minHeight = constraints.minHeight;
_textPainter.maxHeight = constraints.maxHeight; textPainter.maxHeight = constraints.maxHeight;
_textPainter.layout(); textPainter.layout();
_constraintsForCurrentLayout = constraints; _constraintsForCurrentLayout = constraints;
} }
double getMinIntrinsicWidth(BoxConstraints constraints) { double getMinIntrinsicWidth(BoxConstraints constraints) {
_layout(constraints); layoutText(constraints);
return constraints.constrainWidth( return constraints.constrainWidth(textPainter.minContentWidth);
_applyFloatingPointHack(_textPainter.minContentWidth));
} }
double getMaxIntrinsicWidth(BoxConstraints constraints) { double getMaxIntrinsicWidth(BoxConstraints constraints) {
_layout(constraints); layoutText(constraints);
return constraints.constrainWidth( return constraints.constrainWidth(textPainter.maxContentWidth);
_applyFloatingPointHack(_textPainter.maxContentWidth));
} }
double _getIntrinsicHeight(BoxConstraints constraints) { double _getIntrinsicHeight(BoxConstraints constraints) {
_layout(constraints); layoutText(constraints);
return constraints.constrainHeight( return constraints.constrainHeight(textPainter.height);
_applyFloatingPointHack(_textPainter.height));
} }
double getMinIntrinsicHeight(BoxConstraints constraints) { double getMinIntrinsicHeight(BoxConstraints constraints) {
...@@ -80,15 +71,18 @@ class RenderParagraph extends RenderBox { ...@@ -80,15 +71,18 @@ class RenderParagraph extends RenderBox {
double computeDistanceToActualBaseline(TextBaseline baseline) { double computeDistanceToActualBaseline(TextBaseline baseline) {
assert(!needsLayout); assert(!needsLayout);
_layout(constraints); layoutText(constraints);
return _textPainter.computeDistanceToActualBaseline(baseline); return textPainter.computeDistanceToActualBaseline(baseline);
} }
void performLayout() { void performLayout() {
_layout(constraints); layoutText(constraints);
// _paragraphPainter.width always expands to fill, use maxContentWidth instead.
size = constraints.constrain(new Size(_applyFloatingPointHack(_textPainter.maxContentWidth), // We use textPainter.maxContentWidth here, rather that textPainter.width,
_applyFloatingPointHack(_textPainter.height))); // 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) { void paint(PaintingContext context, Offset offset) {
...@@ -99,8 +93,8 @@ class RenderParagraph extends RenderBox { ...@@ -99,8 +93,8 @@ class RenderParagraph extends RenderBox {
// //
// TODO(abarth): Make computing the min/max intrinsic width/height // TODO(abarth): Make computing the min/max intrinsic width/height
// a non-destructive operation. // a non-destructive operation.
_layout(constraints); layoutText(constraints);
_textPainter.paint(context.canvas, offset); textPainter.paint(context.canvas, offset);
} }
// we should probably expose a way to do precise (inter-glpyh) hit testing // we should probably expose a way to do precise (inter-glpyh) hit testing
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment