Unverified Commit 27eeb972 authored by Hans Muller's avatar Hans Muller Committed by GitHub

TextField splash integration (#14055)

parent c09736bb
......@@ -14,11 +14,11 @@ import 'material.dart';
const Duration _kUnconfirmedRippleDuration = const Duration(seconds: 1);
const Duration _kFadeInDuration = const Duration(milliseconds: 75);
const Duration _kRadiusDuration = const Duration(milliseconds: 225);
const Duration _kFadeOutDuration = const Duration(milliseconds: 450);
const Duration _kFadeOutDuration = const Duration(milliseconds: 375);
const Duration _kCancelDuration = const Duration(milliseconds: 75);
// The fade out begins 300ms after the _fadeOutController starts. See confirm().
const double _kFadeOutIntervalStart = 300.0 / 450.0;
// The fade out begins 225ms after the _fadeOutController starts. See confirm().
const double _kFadeOutIntervalStart = 225.0 / 375.0;
RectCallback _getClipCallback(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback) {
if (rectCallback != null) {
......@@ -31,19 +31,10 @@ RectCallback _getClipCallback(RenderBox referenceBox, bool containedInkWell, Rec
}
double _getTargetRadius(RenderBox referenceBox, bool containedInkWell, RectCallback rectCallback, Offset position) {
if (containedInkWell) {
final Size size = rectCallback != null ? rectCallback().size : referenceBox.size;
return _getRippleRadiusForPositionInSize(size, position);
}
return Material.defaultSplashRadius;
}
double _getRippleRadiusForPositionInSize(Size bounds, Offset position) {
final double d1 = (position - bounds.topLeft(Offset.zero)).distance;
final double d2 = (position - bounds.topRight(Offset.zero)).distance;
final double d3 = (position - bounds.bottomLeft(Offset.zero)).distance;
final double d4 = (position - bounds.bottomRight(Offset.zero)).distance;
return math.max(math.max(d1, d2), math.max(d3, d4)).ceilToDouble();
final double d1 = size.bottomRight(Offset.zero).distance;
final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance;
return math.max(d1, d2) / 2.0;
}
class _InkRippleFactory extends InteractiveInkFeatureFactory {
......@@ -205,7 +196,9 @@ class InkRipple extends InteractiveInkFeature {
@override
void cancel() {
_fadeInController.stop();
_fadeOutController.animateTo(1.0, duration: _kCancelDuration);
_fadeOutController
..value = 1.0 - _fadeInController.value
..animateTo(1.0, duration: _kCancelDuration);
}
void _handleAlphaStatusChanged(AnimationStatus status) {
......
......@@ -2,12 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:collection';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'feedback.dart';
import 'ink_well.dart' show InteractiveInkFeature;
import 'input_decorator.dart';
import 'material.dart';
import 'text_selection.dart';
......@@ -275,9 +279,12 @@ class TextField extends StatefulWidget {
}
}
class _TextFieldState extends State<TextField> {
class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixin {
final GlobalKey<EditableTextState> _editableTextKey = new GlobalKey<EditableTextState>();
Set<InteractiveInkFeature> _splashes;
InteractiveInkFeature _currentSplash;
TextEditingController _controller;
TextEditingController get _effectiveController => widget.controller ?? _controller;
......@@ -332,13 +339,104 @@ class _TextFieldState extends State<TextField> {
_editableTextKey.currentState?.requestKeyboard();
}
void _onSelectionChanged(BuildContext context, SelectionChangedCause cause) {
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
if (cause == SelectionChangedCause.longPress)
Feedback.forLongPress(context);
}
InteractiveInkFeature _createInkFeature(TapDownDetails details) {
final MaterialInkController inkController = Material.of(context);
final RenderBox referenceBox = InputDecorator.containerOf(_editableTextKey.currentContext);
final Offset position = referenceBox.globalToLocal(details.globalPosition);
final Color color = Theme.of(context).splashColor;
InteractiveInkFeature splash;
void handleRemoved() {
if (_splashes != null) {
assert(_splashes.contains(splash));
_splashes.remove(splash);
if (_currentSplash == splash)
_currentSplash = null;
updateKeepAlive();
} // else we're probably in deactivate()
}
splash = Theme.of(context).splashFactory.create(
controller: inkController,
referenceBox: referenceBox,
position: position,
color: color,
containedInkWell: true,
// TODO(hansmuller): splash clip borderRadius should match the input decorator's border.
borderRadius: BorderRadius.zero,
onRemoved: handleRemoved,
);
return splash;
}
RenderEditable get _renderEditable => _editableTextKey.currentState.renderEditable;
void _handleTapDown(TapDownDetails details) {
_renderEditable.handleTapDown(details);
_startSplash(details);
}
void _handleTap() {
_renderEditable.handleTap();
_requestKeyboard();
_confirmCurrentSplash();
}
void _handleTapCancel() {
_renderEditable.handleTapCancel();
_cancelCurrentSplash();
}
void _handleLongPress() {
_renderEditable.handleLongPress();
_confirmCurrentSplash();
}
void _startSplash(TapDownDetails details) {
if (_effectiveFocusNode.hasFocus)
return;
final InteractiveInkFeature splash = _createInkFeature(details);
_splashes ??= new HashSet<InteractiveInkFeature>();
_splashes.add(splash);
_currentSplash = splash;
updateKeepAlive();
}
void _confirmCurrentSplash() {
_currentSplash?.confirm();
_currentSplash = null;
}
void _cancelCurrentSplash() {
_currentSplash?.cancel();
_currentSplash = null;
}
@override
bool get wantKeepAlive => _splashes != null && _splashes.isNotEmpty;
@override
void deactivate() {
if (_splashes != null) {
final Set<InteractiveInkFeature> splashes = _splashes;
_splashes = null;
for (InteractiveInkFeature splash in splashes)
splash.dispose();
_currentSplash = null;
}
assert(_currentSplash == null);
super.deactivate();
}
@override
Widget build(BuildContext context) {
super.build(context); // See AutomaticKeepAliveClientMixin.
final ThemeData themeData = Theme.of(context);
final TextStyle style = widget.style ?? themeData.textTheme.subhead;
final TextEditingController controller = _effectiveController;
......@@ -366,8 +464,9 @@ class _TextFieldState extends State<TextField> {
: materialTextSelectionControls,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
onSelectionChanged: (TextSelection _, SelectionChangedCause cause) => _onSelectionChanged(context, cause),
onSelectionChanged: _handleSelectionChanged,
inputFormatters: formatters,
rendererIgnoresPointer: true,
),
);
......@@ -395,10 +494,13 @@ class _TextFieldState extends State<TextField> {
_requestKeyboard();
},
child: new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _requestKeyboard,
child: child,
behavior: HitTestBehavior.translucent,
onTapDown: _handleTapDown,
onTap: _handleTap,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
child: child,
),
);
}
......
......@@ -131,11 +131,13 @@ class RenderEditable extends RenderBox {
@required ViewportOffset offset,
this.onSelectionChanged,
this.onCaretChanged,
this.ignorePointer: false,
}) : assert(textAlign != null),
assert(textDirection != null, 'RenderEditable created without a textDirection.'),
assert(maxLines == null || maxLines > 0),
assert(textScaleFactor != null),
assert(offset != null),
assert(ignorePointer != null),
_textPainter = new TextPainter(
text: text,
textAlign: textAlign,
......@@ -167,6 +169,13 @@ class RenderEditable extends RenderBox {
/// Called during the paint phase when the caret location changes.
CaretChangedHandler onCaretChanged;
/// If true [handleEvent] does nothing and it's assumed that this
/// renderer will be notified of input gestures via [handleTapDown],
/// [handleTap], [handleTapCancel], and [handleLongPress].
///
/// The default value of this property is false.
bool ignorePointer;
Rect _lastCaretRect;
/// Marks the render object as needing to be laid out again and have its text
......@@ -550,6 +559,8 @@ class RenderEditable extends RenderBox {
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (ignorePointer)
return;
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && onSelectionChanged != null) {
_tap.addPointer(event);
......@@ -559,11 +570,15 @@ class RenderEditable extends RenderBox {
Offset _lastTapDownPosition;
Offset _longPressPosition;
void _handleTapDown(TapDownDetails details) {
void handleTapDown(TapDownDetails details) {
_lastTapDownPosition = details.globalPosition + -_paintOffset;
}
void _handleTapDown(TapDownDetails details) {
assert(!ignorePointer);
handleTapDown(details);
}
void _handleTap() {
void handleTap() {
_layoutText(constraints.maxWidth);
assert(_lastTapDownPosition != null);
final Offset globalPosition = _lastTapDownPosition;
......@@ -573,14 +588,22 @@ class RenderEditable extends RenderBox {
onSelectionChanged(new TextSelection.fromPosition(position), this, SelectionChangedCause.tap);
}
}
void _handleTap() {
assert(!ignorePointer);
handleTap();
}
void _handleTapCancel() {
void handleTapCancel() {
// longPress arrives after tapCancel, so remember the tap position.
_longPressPosition = _lastTapDownPosition;
_lastTapDownPosition = null;
}
void _handleTapCancel() {
assert(!ignorePointer);
handleTapCancel();
}
void _handleLongPress() {
void handleLongPress() {
_layoutText(constraints.maxWidth);
final Offset globalPosition = _longPressPosition;
_longPressPosition = null;
......@@ -589,6 +612,10 @@ class RenderEditable extends RenderBox {
onSelectionChanged(_selectWordAtOffset(position), this, SelectionChangedCause.longPress);
}
}
void _handleLongPress() {
assert(!ignorePointer);
handleLongPress();
}
TextSelection _selectWordAtOffset(TextPosition position) {
assert(_textLayoutLastWidth == constraints.maxWidth);
......
......@@ -149,8 +149,8 @@ class EditableText extends StatefulWidget {
/// [TextInputType.text] unless [maxLines] is greater than one, when it will
/// default to [TextInputType.multiline].
///
/// The [controller], [focusNode], [style], [cursorColor], and [textAlign]
/// arguments must not be null.
/// The [controller], [focusNode], [style], [cursorColor], [textAlign],
/// and [rendererIgnoresPointer], arguments must not be null.
EditableText({
Key key,
@required this.controller,
......@@ -171,6 +171,7 @@ class EditableText extends StatefulWidget {
this.onSubmitted,
this.onSelectionChanged,
List<TextInputFormatter> inputFormatters,
this.rendererIgnoresPointer: false,
}) : assert(controller != null),
assert(focusNode != null),
assert(obscureText != null),
......@@ -180,6 +181,7 @@ class EditableText extends StatefulWidget {
assert(textAlign != null),
assert(maxLines == null || maxLines > 0),
assert(autofocus != null),
assert(rendererIgnoresPointer != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
inputFormatters = maxLines == 1
? (
......@@ -279,6 +281,12 @@ class EditableText extends StatefulWidget {
/// in the provided order when the text input changes.
final List<TextInputFormatter> inputFormatters;
/// If true, the [RenderEditable] created by this widget will not handle
/// pointer events, see [renderEditable] and [RenderEditable.ignorePointer].
///
/// This property is false by default.
final bool rendererIgnoresPointer;
@override
EditableTextState createState() => new EditableTextState();
......@@ -303,6 +311,7 @@ class EditableText extends StatefulWidget {
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin implements TextInputClient {
Timer _cursorTimer;
final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false);
final GlobalKey _editableKey = new GlobalKey();
TextInputConnection _textInputConnection;
TextSelectionOverlay _selectionOverlay;
......@@ -628,6 +637,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return result;
}
/// The renderer for this widget's [Editable] descendant.
///
/// This property is typically used to notify the renderer of input gestures
/// when [ignorePointer] is true. See [RenderEditable.ignorePointer].
RenderEditable get renderEditable => _editableKey.currentContext.findRenderObject();
@override
Widget build(BuildContext context) {
FocusScope.of(context).reparentIfNeeded(widget.focusNode);
......@@ -640,6 +655,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return new CompositedTransformTarget(
link: _layerLink,
child: new _Editable(
key: _editableKey,
value: _value,
style: widget.style,
cursorColor: widget.cursorColor,
......@@ -656,6 +672,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
offset: offset,
onSelectionChanged: _handleSelectionChanged,
onCaretChanged: _handleCaretChanged,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
),
);
},
......@@ -682,7 +699,9 @@ class _Editable extends LeafRenderObjectWidget {
this.offset,
this.onSelectionChanged,
this.onCaretChanged,
this.rendererIgnoresPointer: false,
}) : assert(textDirection != null),
assert(rendererIgnoresPointer != null),
super(key: key);
final TextEditingValue value;
......@@ -701,6 +720,7 @@ class _Editable extends LeafRenderObjectWidget {
final ViewportOffset offset;
final SelectionChangedHandler onSelectionChanged;
final CaretChangedHandler onCaretChanged;
final bool rendererIgnoresPointer;
@override
RenderEditable createRenderObject(BuildContext context) {
......@@ -718,6 +738,7 @@ class _Editable extends LeafRenderObjectWidget {
offset: offset,
onSelectionChanged: onSelectionChanged,
onCaretChanged: onCaretChanged,
ignorePointer: rendererIgnoresPointer,
);
}
......@@ -736,7 +757,8 @@ class _Editable extends LeafRenderObjectWidget {
..selection = value.selection
..offset = offset
..onSelectionChanged = onSelectionChanged
..onCaretChanged = onCaretChanged;
..onCaretChanged = onCaretChanged
..ignorePointer = rendererIgnoresPointer;
}
TextSpan get _styledTextSpan {
......
......@@ -140,7 +140,8 @@ void main() {
// At this point the splash radius has expanded to its limit: 5 past the
// ink well's radius parameter. The splash center has moved to its final
// location at the inkwell's center and the fade-out is about to start.
await tester.pump(const Duration(milliseconds: 225));
// The fade-out begins at 225ms = 50ms + 25ms + 150ms.
await tester.pump(const Duration(milliseconds: 150));
expect(box, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawCircle)
return false;
......
// 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 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
int confirmCount = 0;
int cancelCount = 0;
class TestInkSplash extends InkSplash {
TestInkSplash({
MaterialInkController controller,
RenderBox referenceBox,
Offset position,
Color color,
bool containedInkWell: false,
RectCallback rectCallback,
BorderRadius borderRadius,
double radius,
VoidCallback onRemoved,
}) : super(
controller: controller,
referenceBox: referenceBox,
position: position,
color: color,
containedInkWell: containedInkWell,
rectCallback: rectCallback,
borderRadius: borderRadius,
radius: radius,
onRemoved: onRemoved,
);
@override
void confirm() {
confirmCount += 1;
super.confirm();
}
@override
void cancel() {
cancelCount += 1;
super.cancel();
}
}
class TestInkSplashFactory extends InteractiveInkFeatureFactory {
const TestInkSplashFactory();
@override
InteractiveInkFeature create({
MaterialInkController controller,
RenderBox referenceBox,
Offset position,
Color color,
bool containedInkWell: false,
RectCallback rectCallback,
BorderRadius borderRadius,
double radius,
VoidCallback onRemoved,
}) {
return new TestInkSplash(
controller: controller,
referenceBox: referenceBox,
position: position,
color: color,
containedInkWell: containedInkWell,
rectCallback: rectCallback,
borderRadius: borderRadius,
radius: radius,
onRemoved: onRemoved,
);
}
}
void main() {
testWidgets('Tap and no focus causes a splash', (WidgetTester tester) async {
final Key textField1 = new UniqueKey();
final Key textField2 = new UniqueKey();
await tester.pumpWidget(
new MaterialApp(
home: new Theme(
data: new ThemeData.light().copyWith(splashFactory: const TestInkSplashFactory()),
child: new Material(
child: new Container(
alignment: Alignment.topLeft,
child: new Column(
children: <Widget>[
new TextField(
key: textField1,
decoration: const InputDecoration(
labelText: 'label',
),
),
new TextField(
key: textField2,
decoration: const InputDecoration(
labelText: 'label',
),
),
],
),
),
),
),
)
);
confirmCount = 0;
cancelCount = 0;
await tester.tap(find.byKey(textField1));
await tester.pumpAndSettle();
expect(confirmCount, 1);
expect(cancelCount, 0);
// textField1 already has the focus, no new splash
await tester.tap(find.byKey(textField1));
await tester.pumpAndSettle();
expect(confirmCount, 1);
expect(cancelCount, 0);
// textField2 gets the focus and a splash
await tester.tap(find.byKey(textField2));
await tester.pumpAndSettle();
expect(confirmCount, 2);
expect(cancelCount, 0);
// Tap outside of textField1's editable. It still gets focus and splash.
await tester.tapAt(tester.getTopLeft(find.byKey(textField1)));
await tester.pumpAndSettle();
expect(confirmCount, 3);
expect(cancelCount, 0);
// Tap in the center of textField2's editable. It still gets the focus
// and the splash. There is no splash cancel.
await tester.tap(find.byKey(textField2));
await tester.pumpAndSettle();
expect(confirmCount, 4);
expect(cancelCount, 0);
});
testWidgets('Splash cancel', (WidgetTester tester) async {
await tester.pumpWidget(
new MaterialApp(
home: new Theme(
data: new ThemeData.light().copyWith(splashFactory: const TestInkSplashFactory()),
child: new Material(
child: new ListView(
children: <Widget>[
const TextField(
decoration: const InputDecoration(
labelText: 'label1',
),
),
const TextField(
decoration: const InputDecoration(
labelText: 'label2',
),
),
new Container(
height: 1000.0,
color: const Color(0xFF00FF00),
),
],
),
),
),
)
);
confirmCount = 0;
cancelCount = 0;
// Pointer is dragged below the textfield, splash is canceled.
final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('label1')));
await tester.pumpAndSettle();
await gesture1.moveTo(const Offset(400.0, 300.0));
await gesture1.up();
expect(confirmCount, 0);
expect(cancelCount, 1);
// Pointer is dragged upwards causing a scroll, splash is canceled.
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.text('label2')));
await tester.pumpAndSettle();
await gesture2.moveBy(const Offset(0.0, -200.0), timeStamp: const Duration(milliseconds: 32));
await gesture2.up();
expect(confirmCount, 0);
expect(cancelCount, 2);
});
}
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