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';
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';
......
......@@ -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<EditableText> {
_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<EditableText> {
else if (!config.focused && _cursorTimer != null)
_stopCursorTimer();
final EditableString value = config.value;
final TextStyle style = config.style;
return new _EditableTextWidget(
value: config.value,
style: config.style,
cursorColor: config.cursorColor,
showCursor: _showCursor,
onContentSizeChanged: config.onContentSizeChanged,
scrollOffset: config.scrollOffset
);
}
}
class _EditableTextWidget extends LeafRenderObjectWidget {
_EditableTextWidget({
Key key,
this.value,
this.style,
this.cursorColor,
this.showCursor,
this.onContentSizeChanged,
this.scrollOffset
}) : super(key: key);
Widget text;
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));
text = new StyledText(elements: [
style,
value.textBefore(value.composing),
[composingStyle, value.textInside(value.composing)],
value.textAfter(value.composing)
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))
]);
} 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 Row([text, cursor]);
return new StyledTextSpan(style, [
new PlainTextSpan(value.text)
]);
}
}
......@@ -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<Input> {
class InputState extends ScrollableState<Input> {
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<Input> {
}
}
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<Input> {
value: _editableValue,
focused: focused,
style: textStyle,
cursorColor: cursorColor
));
Border focusHighlight = new Border(bottom: new BorderSide(
color: focusHighlightColor,
width: focused ? 2.0 : 1.0
cursorColor: cursorColor,
onContentSizeChanged: _handleContentSizeChanged,
scrollOffset: scrollOffsetVector
));
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<Input> {
_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)
);
}
}
......@@ -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
......
// 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';
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
......
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