Nested InkWells only show the innermost splash (#56611)

This commit is contained in:
Tong Mu 2020-05-09 21:04:01 -07:00 committed by GitHub
parent 0786f29ff2
commit 06adde0bd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 629 additions and 24 deletions

View file

@ -174,6 +174,29 @@ abstract class InteractiveInkFeatureFactory {
});
}
abstract class _ParentInkResponseState {
void markChildInkResponsePressed(_ParentInkResponseState childState, bool value);
}
class _ParentInkResponseProvider extends InheritedWidget {
const _ParentInkResponseProvider({
this.state,
Widget child,
}) : super(child: child);
final _ParentInkResponseState state;
@override
bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state;
static _ParentInkResponseState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state;
}
}
typedef _GetRectCallback = RectCallback Function(RenderBox referenceBox);
typedef _CheckContext = bool Function(BuildContext context);
/// An area of a [Material] that responds to touch. Has a configurable shape and
/// can be configured to clip splashes that extend outside its bounds or not.
///
@ -255,7 +278,7 @@ abstract class InteractiveInkFeatureFactory {
/// * [GestureDetector], for listening for gestures without ink splashes.
/// * [RaisedButton] and [FlatButton], two kinds of buttons in material design.
/// * [IconButton], which combines [InkResponse] with an [Icon].
class InkResponse extends StatefulWidget {
class InkResponse extends StatelessWidget {
/// Creates an area of a [Material] that responds to touch.
///
/// Must have an ancestor [Material] widget in which to cause ink reactions.
@ -508,6 +531,40 @@ class InkResponse extends StatefulWidget {
/// slightly more efficient).
RectCallback getRectCallback(RenderBox referenceBox) => null;
@override
Widget build(BuildContext context) {
final _ParentInkResponseState parentState = _ParentInkResponseProvider.of(context);
return _InnerInkResponse(
child: child,
onTap: onTap,
onTapDown: onTapDown,
onTapCancel: onTapCancel,
onDoubleTap: onDoubleTap,
onLongPress: onLongPress,
onHighlightChanged: onHighlightChanged,
onHover: onHover,
containedInkWell: containedInkWell,
highlightShape: highlightShape,
radius: radius,
borderRadius: borderRadius,
customBorder: customBorder,
focusColor: focusColor,
hoverColor: hoverColor,
highlightColor: highlightColor,
splashColor: splashColor,
splashFactory: splashFactory,
enableFeedback: enableFeedback,
excludeFromSemantics: excludeFromSemantics,
focusNode: focusNode,
canRequestFocus: canRequestFocus,
onFocusChange: onFocusChange,
autofocus: autofocus,
parentState: parentState,
getRectCallback: getRectCallback,
debugCheckContext: debugCheckContext,
);
}
/// Asserts that the given context satisfies the prerequisites for
/// this class.
///
@ -521,9 +578,74 @@ class InkResponse extends StatefulWidget {
assert(debugCheckHasDirectionality(context));
return true;
}
}
class _InnerInkResponse extends StatefulWidget {
const _InnerInkResponse({
this.child,
this.onTap,
this.onTapDown,
this.onTapCancel,
this.onDoubleTap,
this.onLongPress,
this.onHighlightChanged,
this.onHover,
this.containedInkWell = false,
this.highlightShape = BoxShape.circle,
this.radius,
this.borderRadius,
this.customBorder,
this.focusColor,
this.hoverColor,
this.highlightColor,
this.splashColor,
this.splashFactory,
this.enableFeedback = true,
this.excludeFromSemantics = false,
this.focusNode,
this.canRequestFocus = true,
this.onFocusChange,
this.autofocus = false,
this.parentState,
this.getRectCallback,
this.debugCheckContext,
}) : assert(containedInkWell != null),
assert(highlightShape != null),
assert(enableFeedback != null),
assert(excludeFromSemantics != null),
assert(autofocus != null),
assert(canRequestFocus != null);
final Widget child;
final GestureTapCallback onTap;
final GestureTapDownCallback onTapDown;
final GestureTapCallback onTapCancel;
final GestureTapCallback onDoubleTap;
final GestureLongPressCallback onLongPress;
final ValueChanged<bool> onHighlightChanged;
final ValueChanged<bool> onHover;
final bool containedInkWell;
final BoxShape highlightShape;
final double radius;
final BorderRadius borderRadius;
final ShapeBorder customBorder;
final Color focusColor;
final Color hoverColor;
final Color highlightColor;
final Color splashColor;
final InteractiveInkFeatureFactory splashFactory;
final bool enableFeedback;
final bool excludeFromSemantics;
final ValueChanged<bool> onFocusChange;
final bool autofocus;
final FocusNode focusNode;
final bool canRequestFocus;
final _ParentInkResponseState parentState;
final _GetRectCallback getRectCallback;
final _CheckContext debugCheckContext;
@override
_InkResponseState<InkResponse> createState() => _InkResponseState<InkResponse>();
_InkResponseState createState() => _InkResponseState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@ -554,7 +676,9 @@ enum _HighlightType {
focus,
}
class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> {
class _InkResponseState extends State<_InnerInkResponse>
with AutomaticKeepAliveClientMixin<_InnerInkResponse>
implements _ParentInkResponseState {
Set<InteractiveInkFeature> _splashes;
InteractiveInkFeature _currentSplash;
bool _hovering = false;
@ -563,6 +687,23 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty;
final ObserverList<_ParentInkResponseState> _activeChildren = ObserverList<_ParentInkResponseState>();
@override
void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) {
assert(childState != null);
final bool lastAnyPressed = _anyChildInkResponsePressed;
if (value) {
_activeChildren.add(childState);
} else {
_activeChildren.remove(childState);
}
final bool nowAnyPressed = _anyChildInkResponsePressed;
if (nowAnyPressed != lastAnyPressed) {
widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed);
}
}
bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty;
void _handleAction(ActivateIntent intent) {
_startSplash(context: context);
_handleTap(context);
@ -578,7 +719,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
}
@override
void didUpdateWidget(T oldWidget) {
void didUpdateWidget(_InnerInkResponse oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) {
_handleHoverChange(_hovering);
@ -628,6 +769,9 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
updateKeepAlive();
}
if (type == _HighlightType.pressed) {
widget.parentState?.markChildInkResponsePressed(this, value);
}
if (value == (highlight != null && highlight.active))
return;
if (value) {
@ -737,6 +881,8 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
}
void _handleTapDown(TapDownDetails details) {
if (_anyChildInkResponsePressed)
return;
_startSplash(details: details);
if (widget.onTapDown != null) {
widget.onTapDown(details);
@ -813,10 +959,11 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
_highlights[highlight]?.dispose();
_highlights[highlight] = null;
}
widget.parentState?.markChildInkResponsePressed(this, false);
super.deactivate();
}
bool _isWidgetEnabled(InkResponse widget) {
bool _isWidgetEnabled(_InnerInkResponse widget) {
return widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null;
}
@ -840,25 +987,28 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
}
_currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor;
final bool canRequestFocus = enabled && widget.canRequestFocus;
return Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
canRequestFocus: canRequestFocus,
onFocusChange: _handleFocusUpdate,
autofocus: widget.autofocus,
child: MouseRegion(
onEnter: enabled ? _handleMouseEnter : null,
onExit: enabled ? _handleMouseExit : null,
child: GestureDetector(
onTapDown: enabled ? _handleTapDown : null,
onTap: enabled ? () => _handleTap(context) : null,
onTapCancel: enabled ? _handleTapCancel : null,
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: widget.excludeFromSemantics,
child: widget.child,
return _ParentInkResponseProvider(
state: this,
child: Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
canRequestFocus: canRequestFocus,
onFocusChange: _handleFocusUpdate,
autofocus: widget.autofocus,
child: MouseRegion(
onEnter: enabled ? _handleMouseEnter : null,
onExit: enabled ? _handleMouseExit : null,
child: GestureDetector(
onTapDown: enabled ? _handleTapDown : null,
onTap: enabled ? () => _handleTap(context) : null,
onTapCancel: enabled ? _handleTapCancel : null,
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null,
onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: widget.excludeFromSemantics,
child: widget.child,
),
),
),
),

View file

@ -411,4 +411,459 @@ void main() {
await tester.pumpAndSettle();
expect(focusNode.hasPrimaryFocus, isFalse);
});
testWidgets('When ink wells are nested, only the inner one is triggered by tap splash', (WidgetTester tester) async {
final GlobalKey middleKey = GlobalKey();
final GlobalKey innerKey = GlobalKey();
Widget paddedInkWell({Key key, Widget child}) {
return InkWell(
key: key,
onTap: () {},
child: Padding(
padding: const EdgeInsets.all(50),
child: child,
),
);
}
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: paddedInkWell(
child: paddedInkWell(
key: middleKey,
child: paddedInkWell(
key: innerKey,
child: Container(width: 50, height: 50),
),
),
),
),
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)));
// Press
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Up
await gesture.up();
await tester.pumpAndSettle();
expect(material, paintsNothing);
// Press again
await gesture.down(tester.getCenter(find.byKey(innerKey)));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Cancel
await gesture.cancel();
await tester.pumpAndSettle();
expect(material, paintsNothing);
// Press again
await gesture.down(tester.getCenter(find.byKey(innerKey)));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Use a second pointer to press
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 2);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
await gesture2.up();
});
testWidgets('Reparenting parent should allow both inkwells to show splash afterwards', (WidgetTester tester) async {
final GlobalKey middleKey = GlobalKey();
final GlobalKey innerKey = GlobalKey();
Widget paddedInkWell({Key key, Widget child}) {
return InkWell(
key: key,
onTap: () {},
child: Padding(
padding: const EdgeInsets.all(50),
child: child,
),
);
}
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: Container(
width: 200,
height: 100,
child: Row(
children: <Widget>[
paddedInkWell(
key: middleKey,
child: paddedInkWell(
key: innerKey,
),
),
Container(),
],
),
),
),
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)));
// Press
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Reparent parent
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: Container(
width: 200,
height: 100,
child: Row(
children: <Widget>[
paddedInkWell(
key: innerKey,
),
paddedInkWell(
key: middleKey,
),
],
),
),
),
),
),
);
// Up
await gesture.up();
await tester.pumpAndSettle();
expect(material, paintsNothing);
// Press the previous parent
await gesture.down(tester.getCenter(find.byKey(middleKey)));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Use a second pointer to press the previous child
await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 2);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 2));
});
testWidgets('Parent inkwell does not block child inkwells from splashes', (WidgetTester tester) async {
final GlobalKey middleKey = GlobalKey();
final GlobalKey innerKey = GlobalKey();
Widget paddedInkWell({Key key, Widget child}) {
return InkWell(
key: key,
onTap: () {},
child: Padding(
padding: const EdgeInsets.all(50),
child: child,
),
);
}
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: paddedInkWell(
child: paddedInkWell(
key: middleKey,
child: paddedInkWell(
key: innerKey,
child: Container(width: 50, height: 50),
),
),
),
),
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)));
// Press middle
await tester.startGesture(tester.getTopLeft(find.byKey(middleKey)) + const Offset(1, 1), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Press inner
await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 2);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 2));
});
testWidgets('Parent inkwell can count the number of pressed children to prevent splash', (WidgetTester tester) async {
final GlobalKey parentKey = GlobalKey();
final GlobalKey leftKey = GlobalKey();
final GlobalKey rightKey = GlobalKey();
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Container(
width: 100,
height: 100,
child: InkWell(
key: parentKey,
onTap: () {},
child: Center(
child: Container(
width: 100,
height: 50,
child: Row(
children: <Widget>[
Container(
width: 50,
height: 50,
child: InkWell(
key: leftKey,
onTap: () {},
),
),
Container(
width: 50,
height: 50,
child: InkWell(
key: rightKey,
onTap: () {},
),
),
],
),
),
),
),
),
),
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(leftKey)));
final Offset parentPosition = tester.getTopLeft(find.byKey(parentKey)) + const Offset(1, 1);
// Press left child
final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byKey(leftKey)), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Press right child
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byKey(rightKey)), pointer: 2);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 2));
// Press parent
final TestGesture gesture3 = await tester.startGesture(parentPosition, pointer: 3);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 2));
await gesture3.up();
// Release left child
await gesture1.up();
await tester.pumpAndSettle();
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Press parent
await gesture3.down(parentPosition);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
await gesture3.up();
// Release right child
await gesture2.up();
await tester.pumpAndSettle();
expect(material, paintsExactlyCountTimes(#drawCircle, 0));
// Press parent
await gesture3.down(parentPosition);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
await gesture3.up();
});
testWidgets('When ink wells are reparented, the old parent can display splash while the new parent can not', (WidgetTester tester) async {
final GlobalKey innerKey = GlobalKey();
final GlobalKey leftKey = GlobalKey();
final GlobalKey rightKey = GlobalKey();
Widget doubleInkWellRow({
double leftWidth,
double rightWidth,
Widget leftChild,
Widget rightChild,
}) {
return Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: Container(
width: leftWidth+rightWidth,
height: 100,
child: Row(
children: <Widget>[
Container(
width: leftWidth,
height: 100,
child: InkWell(
key: leftKey,
onTap: () {},
child: Center(
child: Container(
width: leftWidth,
height: 50,
child: leftChild,
),
),
),
),
Container(
width: rightWidth,
height: 100,
child: InkWell(
key: rightKey,
onTap: () {},
child: Center(
child: Container(
width: leftWidth,
height: 50,
child: rightChild,
),
),
)
),
],
),
),
),
),
);
}
await tester.pumpWidget(
doubleInkWellRow(
leftWidth: 110,
rightWidth: 90,
leftChild: InkWell(
key: innerKey,
onTap: () {},
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)));
// Press inner
final TestGesture gesture = await tester.startGesture(const Offset(100, 50), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Switch side
await tester.pumpWidget(
doubleInkWellRow(
leftWidth: 90,
rightWidth: 110,
rightChild: InkWell(
key: innerKey,
onTap: () {},
),
),
);
expect(material, paintsExactlyCountTimes(#drawCircle, 0));
// A second pointer presses inner
final TestGesture gesture2 = await tester.startGesture(const Offset(100, 50), pointer: 2);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
await gesture.up();
await gesture2.up();
await tester.pumpAndSettle();
// Press inner
await gesture.down(const Offset(100, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Press left
await gesture2.down(const Offset(50, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 2));
await gesture.up();
await gesture2.up();
});
testWidgets("Ink wells's splash starts before tap is confirmed and disappear after tap is canceled", (WidgetTester tester) async {
final GlobalKey innerKey = GlobalKey();
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: GestureDetector(
onHorizontalDragStart: (_) {},
child: Center(
child: Container(
width: 100,
height: 100,
child: InkWell(
onTap: () {},
child: Center(
child: Container(
width: 50,
height: 50,
child: InkWell(
key: innerKey,
onTap: () {},
),
),
),
),
),
),
),
),
),
);
final MaterialInkController material = Material.of(tester.element(find.byKey(innerKey)));
// Press
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(innerKey)), pointer: 1);
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
// Scroll upward
await gesture.moveBy(const Offset(0, -100));
await tester.pumpAndSettle();
expect(material, paintsNothing);
// Up
await gesture.up();
await tester.pumpAndSettle();
expect(material, paintsNothing);
// Press again
await gesture.down(tester.getCenter(find.byKey(innerKey)));
await tester.pump(const Duration(milliseconds: 200));
expect(material, paintsExactlyCountTimes(#drawCircle, 1));
});
}