Commit 61f82ee1 authored by Adam Barth's avatar Adam Barth

Improve the factoring between Input and RawEditableLine

RawEditableLine is now responsible for the scrolling behavior, which
removes the need for callbacks between RawEditableLine and Input. It
also fixes a bug whereby the whole Input widget (including its icon)
would scroll when the text got long.
parent 432bfb47
...@@ -15,7 +15,7 @@ export 'package:flutter/rendering.dart' show ValueChanged; ...@@ -15,7 +15,7 @@ export 'package:flutter/rendering.dart' show ValueChanged;
export 'package:flutter/services.dart' show KeyboardType; export 'package:flutter/services.dart' show KeyboardType;
/// A material design text input field. /// A material design text input field.
class Input extends Scrollable { class Input extends StatefulComponent {
Input({ Input({
GlobalKey key, GlobalKey key,
this.initialValue: '', this.initialValue: '',
...@@ -30,11 +30,7 @@ class Input extends Scrollable { ...@@ -30,11 +30,7 @@ class Input extends Scrollable {
this.autofocus: false, this.autofocus: false,
this.onChanged, this.onChanged,
this.onSubmitted this.onSubmitted
}) : super( }) : super(key: key) {
key: key,
initialScrollOffset: 0.0,
scrollDirection: Axis.horizontal
) {
assert(key != null); assert(key != null);
} }
...@@ -74,17 +70,15 @@ class Input extends Scrollable { ...@@ -74,17 +70,15 @@ class Input extends Scrollable {
/// Called when the user indicates that they are done editing the text in the field. /// Called when the user indicates that they are done editing the text in the field.
final ValueChanged<String> onSubmitted; final ValueChanged<String> onSubmitted;
InputState createState() => new InputState(); _InputState createState() => new _InputState();
} }
class InputState extends ScrollableState<Input> { class _InputState extends State<Input> {
String _value; String _value;
EditableString _editableString; EditableString _editableString;
KeyboardHandle _keyboardHandle = KeyboardHandle.unattached; KeyboardHandle _keyboardHandle = KeyboardHandle.unattached;
double _contentWidth = 0.0; // Used by tests.
double _containerWidth = 0.0;
EditableString get editableValue => _editableString; EditableString get editableValue => _editableString;
void initState() { void initState() {
...@@ -97,6 +91,23 @@ class InputState extends ScrollableState<Input> { ...@@ -97,6 +91,23 @@ class InputState extends ScrollableState<Input> {
); );
} }
void dispose() {
if (_keyboardHandle.attached)
_keyboardHandle.release();
super.dispose();
}
void _attachOrDetachKeyboard(bool focused) {
if (focused && !_keyboardHandle.attached) {
_keyboardHandle = keyboard.show(_editableString.stub, config.keyboardType);
_keyboardHandle.setText(_editableString.text);
_keyboardHandle.setSelection(_editableString.selection.start,
_editableString.selection.end);
} else if (!focused && _keyboardHandle.attached) {
_keyboardHandle.release();
}
}
void _handleTextUpdated() { void _handleTextUpdated() {
if (_value != _editableString.text) { if (_value != _editableString.text) {
setState(() { setState(() {
...@@ -113,13 +124,40 @@ class InputState extends ScrollableState<Input> { ...@@ -113,13 +124,40 @@ class InputState extends ScrollableState<Input> {
config.onSubmitted(_value); config.onSubmitted(_value);
} }
Widget _buildEditableField({ Widget build(BuildContext context) {
ThemeData themeData, assert(debugCheckHasMaterial(context));
bool focused, ThemeData themeData = Theme.of(context);
Color focusHighlightColor, bool focused = Focus.at(context, autofocus: config.autofocus);
TextStyle textStyle,
double topPadding _attachOrDetachKeyboard(focused);
}) {
TextStyle textStyle = config.style ?? themeData.text.subhead;
Color focusHighlightColor = themeData.accentColor;
if (themeData.primarySwatch != null)
focusHighlightColor = focused ? themeData.primarySwatch[400] : themeData.hintColor;
double topPadding = config.isDense ? 12.0 : 16.0;
List<Widget> stackChildren = <Widget>[];
if (config.labelText != null) {
TextStyle labelStyle = themeData.text.caption.copyWith(color: focused ? focusHighlightColor : themeData.hintColor);
stackChildren.add(new Positioned(
left: 0.0,
top: topPadding,
child: new Text(config.labelText, style: labelStyle)
));
topPadding += labelStyle.fontSize + (config.isDense ? 4.0 : 8.0);
}
if (config.hintText != null && _value.isEmpty) {
TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor);
stackChildren.add(new Positioned(
left: 0.0,
top: topPadding,
child: new Text(config.hintText, style: hintStyle)
));
}
Color cursorColor = themeData.primarySwatch == null ? Color cursorColor = themeData.primarySwatch == null ?
themeData.accentColor : themeData.accentColor :
themeData.primarySwatch[200]; themeData.primarySwatch[200];
...@@ -138,7 +176,7 @@ class InputState extends ScrollableState<Input> { ...@@ -138,7 +176,7 @@ class InputState extends ScrollableState<Input> {
} }
} }
return new Container( stackChildren.add(new Container(
margin: margin, margin: margin,
padding: padding, padding: padding,
decoration: new BoxDecoration( decoration: new BoxDecoration(
...@@ -149,68 +187,13 @@ class InputState extends ScrollableState<Input> { ...@@ -149,68 +187,13 @@ class InputState extends ScrollableState<Input> {
) )
) )
), ),
child: new SizeObserver(
onSizeChanged: _handleContainerSizeChanged,
child: new RawEditableLine( child: new RawEditableLine(
value: _editableString, value: _editableString,
focused: focused, focused: focused,
style: textStyle, style: textStyle,
hideText: config.hideText, hideText: config.hideText,
cursorColor: cursorColor, cursorColor: cursorColor
onContentSizeChanged: _handleContentSizeChanged,
scrollOffset: scrollOffsetVector
)
) )
);
}
Widget buildContent(BuildContext context) {
assert(debugCheckHasMaterial(context));
ThemeData themeData = Theme.of(context);
bool focused = Focus.at(context, autofocus: config.autofocus);
if (focused && !_keyboardHandle.attached) {
_keyboardHandle = keyboard.show(_editableString.stub, config.keyboardType);
_keyboardHandle.setText(_editableString.text);
_keyboardHandle.setSelection(_editableString.selection.start,
_editableString.selection.end);
} else if (!focused && _keyboardHandle.attached) {
_keyboardHandle.release();
}
TextStyle textStyle = config.style ?? themeData.text.subhead;
Color focusHighlightColor = themeData.accentColor;
if (themeData.primarySwatch != null)
focusHighlightColor = focused ? themeData.primarySwatch[400] : themeData.hintColor;
double topPadding = config.isDense ? 12.0 : 16.0;
List<Widget> stackChildren = <Widget>[];
if (config.labelText != null) {
TextStyle labelStyle = themeData.text.caption.copyWith(color: focused ? focusHighlightColor : themeData.hintColor);
stackChildren.add(new Positioned(
left: 0.0,
top: topPadding,
child: new Text(config.labelText, style: labelStyle)
));
topPadding += labelStyle.fontSize + (config.isDense ? 4.0 : 8.0);
}
if (config.hintText != null && _value.isEmpty) {
TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor);
stackChildren.add(new Positioned(
left: 0.0,
top: topPadding,
child: new Text(config.hintText, style: hintStyle)
));
}
stackChildren.add(_buildEditableField(
themeData: themeData,
focused: focused,
focusHighlightColor: focusHighlightColor,
textStyle: textStyle,
topPadding: topPadding
)); ));
if (config.errorText != null && !config.isDense) { if (config.errorText != null && !config.isDense) {
...@@ -261,33 +244,4 @@ class InputState extends ScrollableState<Input> { ...@@ -261,33 +244,4 @@ class InputState extends ScrollableState<Input> {
) )
); );
} }
void dispose() {
if (_keyboardHandle.attached)
_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
));
}
} }
...@@ -22,13 +22,14 @@ class RenderEditableLine extends RenderBox { ...@@ -22,13 +22,14 @@ class RenderEditableLine extends RenderBox {
RenderEditableLine({ RenderEditableLine({
StyledTextSpan text, StyledTextSpan text,
Color cursorColor, Color cursorColor,
bool showCursor, bool showCursor: false,
this.onContentSizeChanged, Offset paintOffset: Offset.zero,
Offset scrollOffset this.onContentSizeChanged
}) : _textPainter = new TextPainter(text), }) : _textPainter = new TextPainter(text),
_cursorColor = cursorColor, _cursorColor = cursorColor,
_showCursor = showCursor, _showCursor = showCursor,
_scrollOffset = scrollOffset { _paintOffset = paintOffset {
assert(!showCursor || cursorColor != null);
// TODO(abarth): These min/max values should be the default for TextPainter. // TODO(abarth): These min/max values should be the default for TextPainter.
_textPainter _textPainter
..minWidth = 0.0 ..minWidth = 0.0
...@@ -71,12 +72,12 @@ class RenderEditableLine extends RenderBox { ...@@ -71,12 +72,12 @@ class RenderEditableLine extends RenderBox {
markNeedsPaint(); markNeedsPaint();
} }
Offset get scrollOffset => _scrollOffset; Offset get paintOffset => _paintOffset;
Offset _scrollOffset; Offset _paintOffset;
void set scrollOffset(Offset value) { void set paintOffset(Offset value) {
if (_scrollOffset == value) if (_paintOffset == value)
return; return;
_scrollOffset = value; _paintOffset = value;
markNeedsPaint(); markNeedsPaint();
} }
...@@ -155,12 +156,12 @@ class RenderEditableLine extends RenderBox { ...@@ -155,12 +156,12 @@ class RenderEditableLine extends RenderBox {
} }
void _paintContents(PaintingContext context, Offset offset) { void _paintContents(PaintingContext context, Offset offset) {
_textPainter.paint(context.canvas, offset - _scrollOffset); _textPainter.paint(context.canvas, offset + _paintOffset);
if (_showCursor) { if (_showCursor) {
Rect cursorRect = new Rect.fromLTWH( Rect cursorRect = new Rect.fromLTWH(
offset.dx + _contentSize.width - _kCursorWidth - _scrollOffset.dx, offset.dx + _paintOffset.dx + _contentSize.width - _kCursorWidth,
offset.dy + _kCursorHeightOffset - _scrollOffset.dy, offset.dy + _paintOffset.dy + _kCursorHeightOffset,
_kCursorWidth, _kCursorWidth,
size.height - 2.0 * _kCursorHeightOffset size.height - 2.0 * _kCursorHeightOffset
); );
......
...@@ -11,26 +11,39 @@ import 'package:flutter/rendering.dart'; ...@@ -11,26 +11,39 @@ import 'package:flutter/rendering.dart';
import 'basic.dart'; import 'basic.dart';
import 'framework.dart'; import 'framework.dart';
import 'scrollable.dart';
import 'scroll_behavior.dart';
const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500);
/// A range of characters in a string of tet. /// A range of characters in a string of tet.
class TextRange { class TextRange {
const TextRange({ this.start, this.end }); const TextRange({ this.start, this.end });
/// A text range that starts and ends at position.
const TextRange.collapsed(int position) const TextRange.collapsed(int position)
: start = position, : start = position,
end = position; end = position;
/// A text range that contains nothing and is not in the text.
const TextRange.empty() const TextRange.empty()
: start = -1, : start = -1,
end = -1; end = -1;
/// The index of the first character in the range.
final int start; final int start;
/// The next index after the characters in this range.
final int end; final int end;
/// Whether this range represents a valid position in the text.
bool get isValid => start >= 0 && end >= 0; bool get isValid => start >= 0 && end >= 0;
/// Whether this range is empty (but still potentially placed inside the text).
bool get isCollapsed => start == end; bool get isCollapsed => start == end;
} }
/// A string that can be manipulated by a keyboard.
class EditableString implements KeyboardClient { class EditableString implements KeyboardClient {
EditableString({this.text: '', this.onUpdated, this.onSubmitted}) { EditableString({this.text: '', this.onUpdated, this.onSubmitted}) {
assert(onUpdated != null); assert(onUpdated != null);
...@@ -39,23 +52,35 @@ class EditableString implements KeyboardClient { ...@@ -39,23 +52,35 @@ class EditableString implements KeyboardClient {
selection = new TextRange(start: text.length, end: text.length); selection = new TextRange(start: text.length, end: text.length);
} }
/// The current text being edited.
String text; String text;
// The range of text that is still being composed.
TextRange composing = const TextRange.empty(); TextRange composing = const TextRange.empty();
/// The range of text that is currently selected.
TextRange selection; TextRange selection;
/// Called whenever the text changes.
final VoidCallback onUpdated; final VoidCallback onUpdated;
/// Called whenever the user indicates they are done editing the string.
final VoidCallback onSubmitted; final VoidCallback onSubmitted;
/// A keyboard client stub that can be attached to a keyboard service.
KeyboardClientStub stub; KeyboardClientStub stub;
/// The text before the given range.
String textBefore(TextRange range) { String textBefore(TextRange range) {
return text.substring(0, range.start); return text.substring(0, range.start);
} }
/// The text after the given range.
String textAfter(TextRange range) { String textAfter(TextRange range) {
return text.substring(range.end); return text.substring(range.end);
} }
/// The text inside the given range.
String textInside(TextRange range) { String textInside(TextRange range) {
return text.substring(range.start, range.end); return text.substring(range.start, range.end);
} }
...@@ -141,35 +166,72 @@ class EditableString implements KeyboardClient { ...@@ -141,35 +166,72 @@ class EditableString implements KeyboardClient {
} }
} }
class RawEditableLine extends StatefulComponent { /// A basic single-line input control.
///
/// This control is not intended to be used directly. Instead, consider using
/// [Input], which provides focus management and material design.
class RawEditableLine extends Scrollable {
RawEditableLine({ RawEditableLine({
Key key, Key key,
this.value, this.value,
this.focused: false, this.focused: false,
this.hideText: false, this.hideText: false,
this.style, this.style,
this.cursorColor, this.cursorColor
this.onContentSizeChanged, }) : super(
this.scrollOffset key: key,
}) : super(key: key); initialScrollOffset: 0.0,
scrollDirection: Axis.horizontal
);
/// The editable string being displayed in this widget.
final EditableString value; final EditableString value;
/// Whether this widget is focused.
final bool focused; final bool focused;
/// Whether to hide the text being edited (e.g., for passwords).
final bool hideText; final bool hideText;
/// The text style to use for the editable text.
final TextStyle style; final TextStyle style;
/// The color to use when painting the cursor.
final Color cursorColor; final Color cursorColor;
final SizeChangedCallback onContentSizeChanged;
final Offset scrollOffset;
RawEditableTextState createState() => new RawEditableTextState(); RawEditableTextState createState() => new RawEditableTextState();
} }
class RawEditableTextState extends State<RawEditableLine> { class RawEditableTextState extends ScrollableState<RawEditableLine> {
// TODO(abarth): Move the cursor timer into RenderEditableLine so we can
// remove this extra widget.
Timer _cursorTimer; Timer _cursorTimer;
bool _showCursor = false; bool _showCursor = false;
double _contentWidth = 0.0;
double _containerWidth = 0.0;
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
));
}
/// Whether the blinking cursor is actually visible at this precise moment /// Whether the blinking cursor is actually visible at this precise moment
/// (it's hidden half the time, since it blinks). /// (it's hidden half the time, since it blinks).
bool get cursorCurrentlyVisible => _showCursor; bool get cursorCurrentlyVisible => _showCursor;
...@@ -202,7 +264,7 @@ class RawEditableTextState extends State<RawEditableLine> { ...@@ -202,7 +264,7 @@ class RawEditableTextState extends State<RawEditableLine> {
_showCursor = false; _showCursor = false;
} }
Widget build(BuildContext context) { Widget buildContent(BuildContext context) {
assert(config.style != null); assert(config.style != null);
assert(config.focused != null); assert(config.focused != null);
assert(config.cursorColor != null); assert(config.cursorColor != null);
...@@ -212,14 +274,17 @@ class RawEditableTextState extends State<RawEditableLine> { ...@@ -212,14 +274,17 @@ class RawEditableTextState extends State<RawEditableLine> {
else if (!config.focused && _cursorTimer != null) else if (!config.focused && _cursorTimer != null)
_stopCursorTimer(); _stopCursorTimer();
return new _EditableLineWidget( return new SizeObserver(
onSizeChanged: _handleContainerSizeChanged,
child: new _EditableLineWidget(
value: config.value, value: config.value,
style: config.style, style: config.style,
cursorColor: config.cursorColor, cursorColor: config.cursorColor,
showCursor: _showCursor, showCursor: _showCursor,
hideText: config.hideText, hideText: config.hideText,
onContentSizeChanged: config.onContentSizeChanged, onContentSizeChanged: _handleContentSizeChanged,
scrollOffset: config.scrollOffset paintOffset: new Offset(-scrollOffset, 0.0)
)
); );
} }
} }
...@@ -233,7 +298,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -233,7 +298,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
this.showCursor, this.showCursor,
this.hideText, this.hideText,
this.onContentSizeChanged, this.onContentSizeChanged,
this.scrollOffset this.paintOffset
}) : super(key: key); }) : super(key: key);
final EditableString value; final EditableString value;
...@@ -242,7 +307,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -242,7 +307,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
final bool showCursor; final bool showCursor;
final bool hideText; final bool hideText;
final SizeChangedCallback onContentSizeChanged; final SizeChangedCallback onContentSizeChanged;
final Offset scrollOffset; final Offset paintOffset;
RenderEditableLine createRenderObject() { RenderEditableLine createRenderObject() {
return new RenderEditableLine( return new RenderEditableLine(
...@@ -250,7 +315,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -250,7 +315,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
cursorColor: cursorColor, cursorColor: cursorColor,
showCursor: showCursor, showCursor: showCursor,
onContentSizeChanged: onContentSizeChanged, onContentSizeChanged: onContentSizeChanged,
scrollOffset: scrollOffset paintOffset: paintOffset
); );
} }
...@@ -260,7 +325,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget { ...@@ -260,7 +325,7 @@ class _EditableLineWidget extends LeafRenderObjectWidget {
renderObject.cursorColor = cursorColor; renderObject.cursorColor = cursorColor;
renderObject.showCursor = showCursor; renderObject.showCursor = showCursor;
renderObject.onContentSizeChanged = onContentSizeChanged; renderObject.onContentSizeChanged = onContentSizeChanged;
renderObject.scrollOffset = scrollOffset; renderObject.paintOffset = paintOffset;
} }
StyledTextSpan get _styledTextSpan { StyledTextSpan get _styledTextSpan {
......
...@@ -133,7 +133,7 @@ void main() { ...@@ -133,7 +133,7 @@ void main() {
const String testValue = 'ABC'; const String testValue = 'ABC';
mockKeyboard.client.commitText(testValue, testValue.length); mockKeyboard.client.commitText(testValue, testValue.length);
InputState input = tester.findStateOfType(InputState); dynamic input = inputKey.currentState;
// Delete characters and verify that the selection follows the length // Delete characters and verify that the selection follows the length
// of the text. // of the text.
......
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