diff --git a/packages/flutter/lib/src/material/ink_ripple.dart b/packages/flutter/lib/src/material/ink_ripple.dart index b762b508fc..dd847c3f42 100644 --- a/packages/flutter/lib/src/material/ink_ripple.dart +++ b/packages/flutter/lib/src/material/ink_ripple.dart @@ -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 Size size = rectCallback != null ? rectCallback().size : referenceBox.size; + 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) { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index aac8fc97c8..a374f99c86 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -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 { +class _TextFieldState extends State with AutomaticKeepAliveClientMixin { final GlobalKey _editableTextKey = new GlobalKey(); + Set _splashes; + InteractiveInkFeature _currentSplash; + TextEditingController _controller; TextEditingController get _effectiveController => widget.controller ?? _controller; @@ -332,13 +339,104 @@ class _TextFieldState extends State { _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(); + _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 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 { : 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 { _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, ), ); } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index eeebe35aca..f1fe06cea0 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -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); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index cfc8273a7f..19c78403a6 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 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 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 with AutomaticKeepAliveClientMixin implements TextInputClient { Timer _cursorTimer; final ValueNotifier _showCursor = new ValueNotifier(false); + final GlobalKey _editableKey = new GlobalKey(); TextInputConnection _textInputConnection; TextSelectionOverlay _selectionOverlay; @@ -628,6 +637,12 @@ class EditableTextState extends State 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 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 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 { diff --git a/packages/flutter/test/material/ink_paint_test.dart b/packages/flutter/test/material/ink_paint_test.dart index dbd20ce5c2..789b784007 100644 --- a/packages/flutter/test/material/ink_paint_test.dart +++ b/packages/flutter/test/material/ink_paint_test.dart @@ -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 arguments) { if (method != #drawCircle) return false; diff --git a/packages/flutter/test/material/text_field_splash_test.dart b/packages/flutter/test/material/text_field_splash_test.dart new file mode 100644 index 0000000000..ed7aab092b --- /dev/null +++ b/packages/flutter/test/material/text_field_splash_test.dart @@ -0,0 +1,194 @@ +// 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: [ + 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: [ + 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); + }); + +}