Unverified Commit ce150971 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Re-land keyboard traversal PRs (#42278)

This attempts to reland #40186 and #41220, that were reverted in #41945.

The main modifications from the original PRs are that I predefine the shortcuts and actions maps instead of defining them inline in the build function, and I use a new mapEquals to do a deep comparison so that we don't rebuild modified things if the contents of the map haven't changed.

I also eliminated an operator== and hashCode that were defined on the Actions widget, since widgets shouldn't have those. (it's too bad though: I get an 85% speedup if we leave this in! Too bad it prevents rebuilding of the children...)

Fixes #40101
parent a95a84eb
This diff is collapsed.
......@@ -45,7 +45,7 @@ void main() {
await tester.pumpWidget(MaterialApp(home: ChipDemo()));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
handle.dispose();
});
}, skip: true); // TODO(gspencergoog): Stop skipping when issue is fixed. https://github.com/flutter/flutter/issues/42455
testWidgets('data_table_demo', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
......
......@@ -512,7 +512,7 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> {
tabFocusNodes.addAll(
List<FocusScopeNode>.generate(
widget.tabCount - tabFocusNodes.length,
(int index) => FocusScopeNode(debugLabel: '${describeIdentity(widget)} Tab ${index + tabFocusNodes.length}'),
(int index) => FocusScopeNode(debugLabel: '$CupertinoTabScaffold Tab ${index + tabFocusNodes.length}'),
),
);
}
......
......@@ -10,14 +10,22 @@
/// the same length, and contain the same members. Returns false otherwise.
/// Order is not compared.
///
/// The term "deep" above refers to the first level of equality: if the elements
/// are maps, lists, sets, or other collections/composite objects, then the
/// values of those elements are not compared element by element unless their
/// equality operators ([Object.operator==]) do so.
///
/// See also:
///
/// * [listEquals], which does something similar for lists.
/// * [mapEquals], which does something similar for maps.
bool setEquals<T>(Set<T> a, Set<T> b) {
if (a == null)
return b == null;
if (b == null || a.length != b.length)
return false;
if (identical(a, b))
return true;
for (T value in a) {
if (!b.contains(value))
return false;
......@@ -31,14 +39,22 @@ bool setEquals<T>(Set<T> a, Set<T> b) {
/// the same length, and contain the same members in the same order. Returns
/// false otherwise.
///
/// The term "deep" above refers to the first level of equality: if the elements
/// are maps, lists, sets, or other collections/composite objects, then the
/// values of those elements are not compared element by element unless their
/// equality operators ([Object.operator==]) do so.
///
/// See also:
///
/// * [setEquals], which does something similar for sets.
/// * [mapEquals], which does something similar for maps.
bool listEquals<T>(List<T> a, List<T> b) {
if (a == null)
return b == null;
if (b == null || a.length != b.length)
return false;
if (identical(a, b))
return true;
for (int index = 0; index < a.length; index += 1) {
if (a[index] != b[index])
return false;
......@@ -46,6 +62,37 @@ bool listEquals<T>(List<T> a, List<T> b) {
return true;
}
/// Compares two maps for deep equality.
///
/// Returns true if the maps are both null, or if they are both non-null, have
/// the same length, and contain the same keys associated with the same values.
/// Returns false otherwise.
///
/// The term "deep" above refers to the first level of equality: if the elements
/// are maps, lists, sets, or other collections/composite objects, then the
/// values of those elements are not compared element by element unless their
/// equality operators ([Object.operator==]) do so.
///
/// See also:
///
/// * [setEquals], which does something similar for sets.
/// * [listEquals], which does something similar for lists.
bool mapEquals<T, U>(Map<T, U> a, Map<T, U> b) {
if (a == null)
return b == null;
if (b == null || a.length != b.length)
return false;
if (identical(a, b))
return true;
for (T key in a.keys) {
if (!b.containsKey(key) || b[key] != a[key]) {
return false;
}
}
return true;
}
/// Returns the position of `value` in the `sortedList`, if it exists.
///
/// Returns `-1` if the `value` is not in the list. Requires the list items
......
......@@ -474,45 +474,43 @@ class _BottomNavigationTile extends StatelessWidget {
child: Semantics(
container: true,
selected: selected,
child: Focus(
child: Stack(
children: <Widget>[
InkResponse(
onTap: onTap,
child: Padding(
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_TileIcon(
colorTween: colorTween,
animation: animation,
iconSize: iconSize,
selected: selected,
item: item,
selectedIconTheme: selectedIconTheme,
unselectedIconTheme: unselectedIconTheme,
),
_Label(
colorTween: colorTween,
animation: animation,
item: item,
selectedLabelStyle: selectedLabelStyle,
unselectedLabelStyle: unselectedLabelStyle,
showSelectedLabels: showSelectedLabels,
showUnselectedLabels: showUnselectedLabels,
),
],
),
child: Stack(
children: <Widget>[
InkResponse(
onTap: onTap,
child: Padding(
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_TileIcon(
colorTween: colorTween,
animation: animation,
iconSize: iconSize,
selected: selected,
item: item,
selectedIconTheme: selectedIconTheme,
unselectedIconTheme: unselectedIconTheme,
),
_Label(
colorTween: colorTween,
animation: animation,
item: item,
selectedLabelStyle: selectedLabelStyle,
unselectedLabelStyle: unselectedLabelStyle,
showSelectedLabels: showSelectedLabels,
showUnselectedLabels: showUnselectedLabels,
),
],
),
),
Semantics(
label: indexLabel,
),
],
),
),
Semantics(
label: indexLabel,
),
],
),
),
);
......
......@@ -330,39 +330,37 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>(widget.textStyle?.color, _states);
final ShapeBorder effectiveShape = MaterialStateProperty.resolveAs<ShapeBorder>(widget.shape, _states);
final Widget result = Focus(
focusNode: widget.focusNode,
canRequestFocus: widget.enabled,
onFocusChange: _handleFocusedChanged,
autofocus: widget.autofocus,
child: ConstrainedBox(
constraints: widget.constraints,
child: Material(
elevation: _effectiveElevation,
textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
shape: effectiveShape,
color: widget.fillColor,
type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
animationDuration: widget.animationDuration,
clipBehavior: widget.clipBehavior,
child: InkWell(
onHighlightChanged: _handleHighlightChanged,
splashColor: widget.splashColor,
highlightColor: widget.highlightColor,
focusColor: widget.focusColor,
hoverColor: widget.hoverColor,
onHover: _handleHoveredChanged,
onTap: widget.onPressed,
customBorder: effectiveShape,
child: IconTheme.merge(
data: IconThemeData(color: effectiveTextColor),
child: Container(
padding: widget.padding,
child: Center(
widthFactor: 1.0,
heightFactor: 1.0,
child: widget.child,
),
final Widget result = ConstrainedBox(
constraints: widget.constraints,
child: Material(
elevation: _effectiveElevation,
textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
shape: effectiveShape,
color: widget.fillColor,
type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
animationDuration: widget.animationDuration,
clipBehavior: widget.clipBehavior,
child: InkWell(
focusNode: widget.focusNode,
canRequestFocus: widget.enabled,
onFocusChange: _handleFocusedChanged,
autofocus: widget.autofocus,
onHighlightChanged: _handleHighlightChanged,
splashColor: widget.splashColor,
highlightColor: widget.highlightColor,
focusColor: widget.focusColor,
hoverColor: widget.hoverColor,
onHover: _handleHoveredChanged,
onTap: widget.onPressed,
customBorder: effectiveShape,
child: IconTheme.merge(
data: IconThemeData(color: effectiveTextColor),
child: Container(
padding: widget.padding,
child: Center(
widthFactor: 1.0,
heightFactor: 1.0,
child: widget.child,
),
),
),
......
......@@ -1774,73 +1774,71 @@ class _RawChipState extends State<RawChip> with TickerProviderStateMixin<RawChip
final Color resolvedLabelColor = MaterialStateProperty.resolveAs<Color>(effectiveLabelStyle?.color, _states);
final TextStyle resolvedLabelStyle = effectiveLabelStyle?.copyWith(color: resolvedLabelColor);
Widget result = Focus(
onFocusChange: _handleFocus,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: widget.isEnabled,
child: Material(
elevation: isTapping ? pressElevation : elevation,
shadowColor: widget.selected ? selectedShadowColor : shadowColor,
animationDuration: pressedAnimationDuration,
shape: shape,
clipBehavior: widget.clipBehavior,
child: InkWell(
onTap: canTap ? _handleTap : null,
onTapDown: canTap ? _handleTapDown : null,
onTapCancel: canTap ? _handleTapCancel : null,
onHover: canTap ? _handleHover : null,
customBorder: shape,
child: AnimatedBuilder(
animation: Listenable.merge(<Listenable>[selectController, enableController]),
builder: (BuildContext context, Widget child) {
return Container(
decoration: ShapeDecoration(
shape: shape,
color: getBackgroundColor(chipTheme),
Widget result = Material(
elevation: isTapping ? pressElevation : elevation,
shadowColor: widget.selected ? selectedShadowColor : shadowColor,
animationDuration: pressedAnimationDuration,
shape: shape,
clipBehavior: widget.clipBehavior,
child: InkWell(
onFocusChange: _handleFocus,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
canRequestFocus: widget.isEnabled,
onTap: canTap ? _handleTap : null,
onTapDown: canTap ? _handleTapDown : null,
onTapCancel: canTap ? _handleTapCancel : null,
onHover: canTap ? _handleHover : null,
customBorder: shape,
child: AnimatedBuilder(
animation: Listenable.merge(<Listenable>[selectController, enableController]),
builder: (BuildContext context, Widget child) {
return Container(
decoration: ShapeDecoration(
shape: shape,
color: getBackgroundColor(chipTheme),
),
child: child,
);
},
child: _wrapWithTooltip(
widget.tooltip,
widget.onPressed,
_ChipRenderWidget(
theme: _ChipRenderTheme(
label: DefaultTextStyle(
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
maxLines: 1,
softWrap: false,
style: resolvedLabelStyle,
child: widget.label,
),
avatar: AnimatedSwitcher(
child: widget.avatar,
duration: _kDrawerDuration,
switchInCurve: Curves.fastOutSlowIn,
),
child: child,
);
},
child: _wrapWithTooltip(
widget.tooltip,
widget.onPressed,
_ChipRenderWidget(
theme: _ChipRenderTheme(
label: DefaultTextStyle(
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
maxLines: 1,
softWrap: false,
style: resolvedLabelStyle,
child: widget.label,
),
avatar: AnimatedSwitcher(
child: widget.avatar,
duration: _kDrawerDuration,
switchInCurve: Curves.fastOutSlowIn,
),
deleteIcon: AnimatedSwitcher(
child: _buildDeleteIcon(context, theme, chipTheme),
duration: _kDrawerDuration,
switchInCurve: Curves.fastOutSlowIn,
),
brightness: chipTheme.brightness,
padding: (widget.padding ?? chipTheme.padding).resolve(textDirection),
labelPadding: (widget.labelPadding ?? chipTheme.labelPadding).resolve(textDirection),
showAvatar: hasAvatar,
showCheckmark: showCheckmark,
checkmarkColor: checkmarkColor,
canTapBody: canTap,
deleteIcon: AnimatedSwitcher(
child: _buildDeleteIcon(context, theme, chipTheme),
duration: _kDrawerDuration,
switchInCurve: Curves.fastOutSlowIn,
),
value: widget.selected,
checkmarkAnimation: checkmarkAnimation,
enableAnimation: enableAnimation,
avatarDrawerAnimation: avatarDrawerAnimation,
deleteDrawerAnimation: deleteDrawerAnimation,
isEnabled: widget.isEnabled,
avatarBorder: widget.avatarBorder,
brightness: chipTheme.brightness,
padding: (widget.padding ?? chipTheme.padding).resolve(textDirection),
labelPadding: (widget.labelPadding ?? chipTheme.labelPadding).resolve(textDirection),
showAvatar: hasAvatar,
showCheckmark: showCheckmark,
checkmarkColor: checkmarkColor,
canTapBody: canTap,
),
value: widget.selected,
checkmarkAnimation: checkmarkAnimation,
enableAnimation: enableAnimation,
avatarDrawerAnimation: avatarDrawerAnimation,
deleteDrawerAnimation: deleteDrawerAnimation,
isEnabled: widget.isEnabled,
avatarBorder: widget.avatarBorder,
),
),
),
......
......@@ -309,22 +309,20 @@ class IconButton extends StatelessWidget {
return Semantics(
button: true,
enabled: onPressed != null,
child: Focus(
child: InkResponse(
focusNode: focusNode,
autofocus: autofocus,
canRequestFocus: onPressed != null,
child: InkResponse(
onTap: onPressed,
child: result,
focusColor: focusColor ?? Theme.of(context).focusColor,
hoverColor: hoverColor ?? Theme.of(context).hoverColor,
highlightColor: highlightColor ?? Theme.of(context).highlightColor,
splashColor: splashColor ?? Theme.of(context).splashColor,
radius: math.max(
Material.defaultSplashRadius,
(iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7,
// x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps.
),
onTap: onPressed,
child: result,
focusColor: focusColor ?? Theme.of(context).focusColor,
hoverColor: hoverColor ?? Theme.of(context).hoverColor,
highlightColor: highlightColor ?? Theme.of(context).highlightColor,
splashColor: splashColor ?? Theme.of(context).splashColor,
radius: math.max(
Material.defaultSplashRadius,
(iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7,
// x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps.
),
),
);
......
......@@ -210,10 +210,16 @@ class InkResponse extends StatefulWidget {
this.splashFactory,
this.enableFeedback = true,
this.excludeFromSemantics = false,
this.focusNode,
this.canRequestFocus = true,
this.onFocusChange,
this.autofocus = false,
}) : assert(containedInkWell != null),
assert(highlightShape != null),
assert(enableFeedback != null),
assert(excludeFromSemantics != null),
assert(autofocus != null),
assert(canRequestFocus != null),
super(key: key);
/// The widget below this widget in the tree.
......@@ -400,6 +406,21 @@ class InkResponse extends StatefulWidget {
/// duplication of information.
final bool excludeFromSemantics;
/// Handler called when the focus changes.
///
/// Called with true if this widget's node gains focus, and false if it loses
/// focus.
final ValueChanged<bool> onFocusChange;
/// {@macro flutter.widgets.Focus.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode focusNode;
/// {@template flutter.widgets.Focus.canRequestFocus}
final bool canRequestFocus;
/// The rectangle to use for the highlight effect and for clipping
/// the splash effects if [containedInkWell] is true.
///
......@@ -462,39 +483,41 @@ enum _HighlightType {
class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> {
Set<InteractiveInkFeature> _splashes;
InteractiveInkFeature _currentSplash;
FocusNode _focusNode;
bool _hovering = false;
final Map<_HighlightType, InkHighlight> _highlights = <_HighlightType, InkHighlight>{};
Map<LocalKey, ActionFactory> _actionMap;
bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty;
@override
void initState() {
super.initState();
_actionMap = <LocalKey, ActionFactory>{
ActivateAction.key: () {
return CallbackAction(
ActivateAction.key,
onInvoke: (FocusNode node, Intent intent) {
_startSplash(context: node.context);
_handleTap(node.context);
},
);
},
};
WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_focusNode?.removeListener(_handleFocusUpdate);
_focusNode = Focus.of(context, nullOk: true);
_focusNode?.addListener(_handleFocusUpdate);
}
@override
void didUpdateWidget(InkResponse oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) {
_handleHoverChange(_hovering);
_handleFocusUpdate();
_updateFocusHighlights();
}
}
@override
void dispose() {
WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
_focusNode?.removeListener(_handleFocusUpdate);
super.dispose();
}
......@@ -560,7 +583,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
}
assert(value == (_highlights[type] != null && _highlights[type].active));
switch(type) {
switch (type) {
case _HighlightType.pressed:
if (widget.onHighlightChanged != null)
widget.onHighlightChanged(value);
......@@ -574,10 +597,10 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
}
}
InteractiveInkFeature _createInkFeature(TapDownDetails details) {
InteractiveInkFeature _createInkFeature(Offset globalPosition) {
final MaterialInkController inkController = Material.of(context);
final RenderBox referenceBox = context.findRenderObject();
final Offset position = referenceBox.globalToLocal(details.globalPosition);
final Offset position = referenceBox.globalToLocal(globalPosition);
final Color color = widget.splashColor ?? Theme.of(context).splashColor;
final RectCallback rectCallback = widget.containedInkWell ? widget.getRectCallback(referenceBox) : null;
final BorderRadius borderRadius = widget.borderRadius;
......@@ -616,31 +639,54 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
return;
}
setState(() {
_handleFocusUpdate();
_updateFocusHighlights();
});
}
void _handleFocusUpdate() {
void _updateFocusHighlights() {
bool showFocus;
switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch:
showFocus = false;
break;
case FocusHighlightMode.traditional:
showFocus = enabled && (Focus.of(context, nullOk: true)?.hasPrimaryFocus ?? false);
showFocus = enabled && _hasFocus;
break;
}
updateHighlight(_HighlightType.focus, value: showFocus);
}
bool _hasFocus = false;
void _handleFocusUpdate(bool hasFocus) {
_hasFocus = hasFocus;
_updateFocusHighlights();
if (widget.onFocusChange != null) {
widget.onFocusChange(hasFocus);
}
}
void _handleTapDown(TapDownDetails details) {
final InteractiveInkFeature splash = _createInkFeature(details);
_splashes ??= HashSet<InteractiveInkFeature>();
_splashes.add(splash);
_currentSplash = splash;
_startSplash(details: details);
if (widget.onTapDown != null) {
widget.onTapDown(details);
}
}
void _startSplash({TapDownDetails details, BuildContext context}) {
assert(details != null || context != null);
Offset globalPosition;
if (context != null) {
final RenderBox referenceBox = context.findRenderObject();
assert(referenceBox.hasSize, 'InkResponse must be done with layout before starting a splash.');
globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center);
} else {
globalPosition = details.globalPosition;
}
final InteractiveInkFeature splash = _createInkFeature(globalPosition);
_splashes ??= HashSet<InteractiveInkFeature>();
_splashes.add(splash);
_currentSplash = splash;
updateKeepAlive();
updateHighlight(_HighlightType.pressed, value: true);
}
......@@ -722,18 +768,27 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
_highlights[type]?.color = getHighlightColorForType(type);
}
_currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor;
return 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,
child: widget.child,
excludeFromSemantics: widget.excludeFromSemantics,
return Actions(
actions: _actionMap,
child: Focus(
focusNode: widget.focusNode,
canRequestFocus: widget.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,
),
),
),
);
}
......@@ -854,6 +909,10 @@ class InkWell extends InkResponse {
ShapeBorder customBorder,
bool enableFeedback = true,
bool excludeFromSemantics = false,
FocusNode focusNode,
bool canRequestFocus = true,
ValueChanged<bool> onFocusChange,
bool autofocus = false,
}) : super(
key: key,
child: child,
......@@ -876,5 +935,9 @@ class InkWell extends InkResponse {
customBorder: customBorder,
enableFeedback: enableFeedback ?? true,
excludeFromSemantics: excludeFromSemantics ?? false,
focusNode: focusNode,
canRequestFocus: canRequestFocus ?? true,
onFocusChange: onFocusChange,
autofocus: autofocus ?? false,
);
}
......@@ -200,6 +200,10 @@ class Actions extends InheritedWidget {
/// A map of [Intent] keys to [ActionFactory] factory methods that defines
/// which actions this widget knows about.
///
/// For performance reasons, it is recommended that a pre-built map is
/// passed in here (e.g. a final variable from your widget class) instead of
/// defining it inline in the build function.
final Map<LocalKey, ActionFactory> actions;
// Finds the nearest valid ActionDispatcher, or creates a new one if it
......@@ -341,7 +345,7 @@ class Actions extends InheritedWidget {
@override
bool updateShouldNotify(Actions oldWidget) {
return oldWidget.dispatcher != dispatcher || oldWidget.actions != actions;
return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
}
@override
......@@ -368,3 +372,16 @@ class DoNothingAction extends Action {
@override
void invoke(FocusNode node, Intent intent) { }
}
/// An action that invokes the currently focused control.
///
/// This is an abstract class that serves as a base class for actions that
/// activate a control. It is bound to [LogicalKeyboardKey.enter] in the default
/// keyboard map in [WidgetsApp].
abstract class ActivateAction extends Action {
/// Creates a [ActivateAction] with a fixed [key];
const ActivateAction() : super(key);
/// The [LocalKey] that uniquely identifies this action.
static const LocalKey key = ValueKey<Type>(ActivateAction);
}
......@@ -7,6 +7,7 @@ import 'dart:collection' show HashMap;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'banner.dart';
......@@ -20,6 +21,7 @@ import 'navigator.dart';
import 'pages.dart';
import 'performance_overlay.dart';
import 'semantics_debugger.dart';
import 'shortcuts.dart';
import 'text.dart';
import 'title.dart';
import 'widget_inspector.dart';
......@@ -1036,6 +1038,24 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
return true;
}
final Map<LogicalKeySet, Intent> _keyMap = <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.tab): const Intent(NextFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): const Intent(PreviousFocusAction.key),
LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left),
LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up),
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
};
final Map<LocalKey, ActionFactory> _actionMap = <LocalKey, ActionFactory>{
DoNothingAction.key: () => const DoNothingAction(),
RequestFocusAction.key: () => RequestFocusAction(),
NextFocusAction.key: () => NextFocusAction(),
PreviousFocusAction.key: () => PreviousFocusAction(),
DirectionalFocusAction.key: () => DirectionalFocusAction(),
};
@override
Widget build(BuildContext context) {
Widget navigator;
......@@ -1147,17 +1167,18 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
assert(_debugCheckLocalizations(appLocale));
return Actions(
actions: <LocalKey, ActionFactory>{
DoNothingAction.key: () => const DoNothingAction(),
},
child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: _MediaQueryFromWindow(
child: Localizations(
locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
return Shortcuts(
shortcuts: _keyMap,
child: Actions(
actions: _actionMap,
child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: _MediaQueryFromWindow(
child: Localizations(
locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
),
),
),
),
......
......@@ -186,7 +186,7 @@ class Focus extends StatefulWidget {
/// Handler called when the focus changes.
///
/// Called with true if this node gains focus, and false if it loses
/// Called with true if this widget's node gains focus, and false if it loses
/// focus.
final ValueChanged<bool> onFocusChange;
......@@ -230,6 +230,7 @@ class Focus extends StatefulWidget {
/// still be focused explicitly.
final bool skipTraversal;
/// {@template flutter.widgets.Focus.canRequestFocus}
/// If true, this widget may request the primary focus.
///
/// Defaults to true. Set to false if you want the [FocusNode] this widget
......@@ -249,6 +250,7 @@ class Focus extends StatefulWidget {
/// its descendants.
/// - [FocusTraversalPolicy], a class that can be extended to describe a
/// traversal policy.
/// {@endtemplate}
final bool canRequestFocus;
/// Returns the [focusNode] of the [Focus] that most tightly encloses the
......
......@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'actions.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_manager.dart';
......@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget {
@override
bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy;
}
// A base class for all of the default actions that request focus for a node.
class _RequestFocusActionBase extends Action {
_RequestFocusActionBase(LocalKey name) : super(name);
FocusNode _previousFocus;
@override
void invoke(FocusNode node, Intent tag) {
_previousFocus = WidgetsBinding.instance.focusManager.primaryFocus;
node.requestFocus();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('previous', _previousFocus));
}
}
/// An [Action] that requests the focus on the node it is invoked on.
///
/// This action can be used to request focus for a particular node, by calling
/// [Action.invoke] like so:
///
/// ```dart
/// Actions.invoke(context, const Intent(RequestFocusAction.key), focusNode: _focusNode);
/// ```
///
/// Where the `_focusNode` is the node for which the focus will be requested.
///
/// The difference between requesting focus in this way versus calling
/// [_focusNode.requestFocus] directly is that it will use the [Action]
/// registered in the nearest [Actions] widget associated with [key] to make the
/// request, rather than just requesting focus directly. This allows the action
/// to have additional side effects, like logging, or undo and redo
/// functionality.
///
/// However, this [RequestFocusAction] is the default action associated with the
/// [key] in the [WidgetsApp], and it simply requests focus and has no side
/// effects.
class RequestFocusAction extends _RequestFocusActionBase {
/// Creates a [RequestFocusAction] with a fixed [key].
RequestFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(RequestFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.requestFocus();
}
}
/// An [Action] that moves the focus to the next focusable node in the focus
/// order.
///
/// This action is the default action registered for the [key], and by default
/// is bound to the [LogicalKeyboardKey.tab] key in the [WidgetsApp].
class NextFocusAction extends _RequestFocusActionBase {
/// Creates a [NextFocusAction] with a fixed [key];
NextFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.nextFocus();
}
}
/// An [Action] that moves the focus to the previous focusable node in the focus
/// order.
///
/// This action is the default action registered for the [key], and by default
/// is bound to a combination of the [LogicalKeyboardKey.tab] key and the
/// [LogicalKeyboardKey.shift] key in the [WidgetsApp].
class PreviousFocusAction extends _RequestFocusActionBase {
/// Creates a [PreviousFocusAction] with a fixed [key];
PreviousFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to an [Intent].
static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override
void invoke(FocusNode node, Intent tag) {
super.invoke(node, tag);
node.previousFocus();
}
}
/// An [Intent] that represents moving to the next focusable node in the given
/// [direction].
///
/// This is the [Intent] bound by default to the [LogicalKeyboardKey.arrowUp],
/// [LogicalKeyboardKey.arrowDown], [LogicalKeyboardKey.arrowLeft], and
/// [LogicalKeyboardKey.arrowRight] keys in the [WidgetsApp], with the
/// appropriate associated directions.
class DirectionalFocusIntent extends Intent {
/// Creates a [DirectionalFocusIntent] with a fixed [key], and the given
/// [direction].
const DirectionalFocusIntent(this.direction) : super(DirectionalFocusAction.key);
/// The direction in which to look for the next focusable node when the
/// associated [DirectionalFocusAction] is invoked.
final TraversalDirection direction;
}
/// An [Action] that moves the focus to the focusable node in the given
/// [direction] configured by the associated [DirectionalFocusIntent].
///
/// This is the [Action] associated with the [key] and bound by default to the
/// [LogicalKeyboardKey.arrowUp], [LogicalKeyboardKey.arrowDown],
/// [LogicalKeyboardKey.arrowLeft], and [LogicalKeyboardKey.arrowRight] keys in
/// the [WidgetsApp], with the appropriate associated directions.
class DirectionalFocusAction extends _RequestFocusActionBase {
/// Creates a [DirectionalFocusAction] with a fixed [key];
DirectionalFocusAction() : super(key);
/// The [LocalKey] that uniquely identifies this action to [DirectionalFocusIntent].
static const LocalKey key = ValueKey<Type>(DirectionalFocusAction);
/// The direction in which to look for the next focusable node when invoked.
TraversalDirection direction;
@override
void invoke(FocusNode node, DirectionalFocusIntent tag) {
super.invoke(node, tag);
final DirectionalFocusIntent args = tag;
node.focusInDirection(args.direction);
}
}
......@@ -91,7 +91,7 @@ class KeySet<T extends KeyboardKey> extends Diagnosticable {
return false;
}
final KeySet<T> typedOther = other;
return _keys.length == typedOther._keys.length && _keys.containsAll(typedOther._keys);
return setEquals<T>(_keys, typedOther._keys);
}
@override
......@@ -169,10 +169,7 @@ class ShortcutManager extends ChangeNotifier with DiagnosticableMixin {
Map<LogicalKeySet, Intent> get shortcuts => _shortcuts;
Map<LogicalKeySet, Intent> _shortcuts;
set shortcuts(Map<LogicalKeySet, Intent> value) {
if (_shortcuts == value) {
return;
}
if (_shortcuts != value) {
if (!mapEquals<LogicalKeySet, Intent>(_shortcuts, value)) {
_shortcuts = value;
notifyListeners();
}
......@@ -259,6 +256,10 @@ class Shortcuts extends StatefulWidget {
final ShortcutManager manager;
/// The map of shortcuts that the [manager] will be given to manage.
///
/// For performance reasons, it is recommended that a pre-built map is passed
/// in here (e.g. a final variable from your widget class) instead of defining
/// it inline in the build function.
final Map<LogicalKeySet, Intent> shortcuts;
/// The child widget for this [Shortcuts] widget.
......@@ -324,15 +325,15 @@ class _ShortcutsState extends State<Shortcuts> {
@override
void didUpdateWidget(Shortcuts oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.manager != oldWidget.manager || widget.shortcuts != oldWidget.shortcuts) {
if (widget.manager != oldWidget.manager) {
if (widget.manager != null) {
_internalManager?.dispose();
_internalManager = null;
} else {
_internalManager ??= ShortcutManager();
}
manager.shortcuts = widget.shortcuts;
}
manager.shortcuts = widget.shortcuts;
}
bool _handleOnKey(FocusNode node, RawKeyEvent event) {
......@@ -345,7 +346,7 @@ class _ShortcutsState extends State<Shortcuts> {
@override
Widget build(BuildContext context) {
return Focus(
debugLabel: describeIdentity(widget),
debugLabel: '$Shortcuts',
canRequestFocus: false,
onKey: _handleOnKey,
child: _ShortcutsMarker(
......
// Copyright 2019 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/src/foundation/collections.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('binarySearch', () {
final List<int> items = <int>[1, 2, 3];
expect(binarySearch(items, 1), 0);
expect(binarySearch(items, 2), 1);
expect(binarySearch(items, 3), 2);
expect(binarySearch(items, 12), -1);
});
}
// Copyright 2019 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/src/foundation/collections.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('listEquals', () {
final List<int> listA = <int>[1, 2, 3];
final List<int> listB = <int>[1, 2, 3];
final List<int> listC = <int>[1, 2];
final List<int> listD = <int>[3, 2, 1];
expect(listEquals<void>(null, null), isTrue);
expect(listEquals(listA, null), isFalse);
expect(listEquals(null, listB), isFalse);
expect(listEquals(listA, listA), isTrue);
expect(listEquals(listA, listB), isTrue);
expect(listEquals(listA, listC), isFalse);
expect(listEquals(listA, listD), isFalse);
});
test('setEquals', () {
final Set<int> setA = <int>{1, 2, 3};
final Set<int> setB = <int>{1, 2, 3};
final Set<int> setC = <int>{1, 2};
final Set<int> setD = <int>{3, 2, 1};
expect(setEquals<void>(null, null), isTrue);
expect(setEquals(setA, null), isFalse);
expect(setEquals(null, setB), isFalse);
expect(setEquals(setA, setA), isTrue);
expect(setEquals(setA, setB), isTrue);
expect(setEquals(setA, setC), isFalse);
expect(setEquals(setA, setD), isTrue);
});
test('mapEquals', () {
final Map<int, int> mapA = <int, int>{1:1, 2:2, 3:3};
final Map<int, int> mapB = <int, int>{1:1, 2:2, 3:3};
final Map<int, int> mapC = <int, int>{1:1, 2:2};
final Map<int, int> mapD = <int, int>{3:3, 2:2, 1:1};
final Map<int, int> mapE = <int, int>{3:1, 2:2, 1:3};
expect(mapEquals<void, void>(null, null), isTrue);
expect(mapEquals(mapA, null), isFalse);
expect(mapEquals(null, mapB), isFalse);
expect(mapEquals(mapA, mapA), isTrue);
expect(mapEquals(mapA, mapB), isTrue);
expect(mapEquals(mapA, mapC), isFalse);
expect(mapEquals(mapA, mapD), isTrue);
expect(mapEquals(mapA, mapE), isFalse);
});
test('binarySearch', () {
final List<int> items = <int>[1, 2, 3];
expect(binarySearch(items, 1), 0);
expect(binarySearch(items, 2), 1);
expect(binarySearch(items, 3), 2);
expect(binarySearch(items, 12), -1);
});
}
......@@ -241,10 +241,11 @@ void main() {
rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0),
transform: null,
flags: <SemanticsFlag>[
SemanticsFlag.hasToggledState,
SemanticsFlag.isToggled,
SemanticsFlag.hasEnabledState,
SemanticsFlag.hasToggledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isToggled,
],
actions: SemanticsAction.tap.index,
label: 'aaa\nAAA',
......@@ -255,9 +256,10 @@ void main() {
transform: Matrix4.translationValues(0.0, 56.0, 0.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isChecked,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: SemanticsAction.tap.index,
label: 'bbb\nBBB',
......@@ -270,6 +272,7 @@ void main() {
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isInMutuallyExclusiveGroup,
],
actions: SemanticsAction.tap.index,
......
......@@ -432,12 +432,16 @@ void _tests() {
thickness: 0.0,
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '2016',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isSelected],
flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Fri, Jan 15',
textDirection: TextDirection.ltr,
......
......@@ -1028,24 +1028,28 @@ void main() {
TestSemantics(
label: 'one',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap],
),
TestSemantics(
label: 'two',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap],
),
TestSemantics(
label: 'three',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap],
),
TestSemantics(
label: 'four',
textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap],
),
......
......@@ -1014,6 +1014,7 @@ void main() {
expect(tester.getSemantics(find.byKey(expandedKey)), matchesSemantics(
label: 'Expanded',
isButton: true,
isFocusable: true,
hasEnabledState: true,
hasTapAction: true,
));
......@@ -1021,6 +1022,7 @@ void main() {
expect(tester.getSemantics(find.byKey(collapsedKey)), matchesSemantics(
label: 'Collapsed',
isButton: true,
isFocusable: true,
hasEnabledState: true,
hasTapAction: true,
));
......
......@@ -4,6 +4,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
......@@ -251,6 +252,102 @@ void main() {
await gesture.up();
}, skip: isBrowser);
testWidgets('The InkWell widget renders an ActivateAction-induced ink ripple', (WidgetTester tester) async {
const Color highlightColor = Color(0xAAFF0000);
const Color splashColor = Color(0xB40000FF);
final BorderRadius borderRadius = BorderRadius.circular(6.0);
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
await tester.pumpWidget(
Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
},
child: Directionality(
textDirection: TextDirection.ltr,
child: Material(
child: Center(
child: Container(
width: 100.0,
height: 100.0,
child: InkWell(
borderRadius: borderRadius,
highlightColor: highlightColor,
splashColor: splashColor,
focusNode: focusNode,
onTap: () { },
radius: 100.0,
splashFactory: InkRipple.splashFactory,
),
),
),
),
),
),
);
final Offset topLeft = tester.getTopLeft(find.byType(InkWell));
final Offset inkWellCenter = tester.getCenter(find.byType(InkWell)) - topLeft;
// Now activate it with a keypress.
focusNode.requestFocus();
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
final RenderBox box = Material.of(tester.element(find.byType(InkWell))) as dynamic;
bool offsetsAreClose(Offset a, Offset b) => (a - b).distance < 1.0;
bool radiiAreClose(double a, double b) => (a - b).abs() < 1.0;
PaintPattern ripplePattern(double expectedRadius, int expectedAlpha) {
return paints
..translate(x: 0.0, y: 0.0)
..translate(x: topLeft.dx, y: topLeft.dy)
..something((Symbol method, List<dynamic> arguments) {
if (method != #drawCircle) {
return false;
}
final Offset center = arguments[0];
final double radius = arguments[1];
final Paint paint = arguments[2];
if (offsetsAreClose(center, inkWellCenter) &&
radiiAreClose(radius, expectedRadius) &&
paint.color.alpha == expectedAlpha) {
return true;
}
throw '''
Expected: center == $inkWellCenter, radius == $expectedRadius, alpha == $expectedAlpha
Found: center == $center radius == $radius alpha == ${paint.color.alpha}''';
},
);
}
// ripplePattern always add a translation of topLeft.
expect(box, ripplePattern(30.0, 0));
// The ripple fades in for 75ms. During that time its alpha is eased from
// 0 to the splashColor's alpha value.
await tester.pump(const Duration(milliseconds: 50));
expect(box, ripplePattern(56.0, 120));
// At 75ms the ripple has faded in: it's alpha matches the splashColor's
// alpha.
await tester.pump(const Duration(milliseconds: 25));
expect(box, ripplePattern(73.0, 180));
// At this point the splash radius has expanded to its limit: 5 past the
// ink well's radius parameter. The fade-out is about to start.
// The fade-out begins at 225ms = 50ms + 25ms + 150ms.
await tester.pump(const Duration(milliseconds: 150));
expect(box, ripplePattern(105.0, 180));
// After another 150ms the fade-out is complete.
await tester.pump(const Duration(milliseconds: 150));
expect(box, ripplePattern(105.0, 0));
});
testWidgets('Cancel an InkRipple that was disposed when its animation ended', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/14391
await tester.pumpWidget(
......@@ -331,5 +428,4 @@ void main() {
throw 'Expected: paint.color.alpha == 0, found: ${paint.color.alpha}';
}));
});
}
......@@ -103,9 +103,9 @@ void main() {
splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff),
onTap: () {},
onLongPress: () {},
onHover: (bool hover) {},
onTap: () { },
onLongPress: () { },
onHover: (bool hover) { },
),
),
),
......@@ -123,29 +123,29 @@ void main() {
testWidgets('ink response changes color on focus', (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
await tester.pumpWidget(Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Focus(
focusNode: focusNode,
await tester.pumpWidget(
Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Container(
width: 100,
height: 100,
child: InkWell(
focusNode: focusNode,
hoverColor: const Color(0xff00ff00),
splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff),
onTap: () {},
onLongPress: () {},
onHover: (bool hover) {},
onTap: () { },
onLongPress: () { },
onHover: (bool hover) { },
),
),
),
),
),
));
);
await tester.pumpAndSettle();
final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures');
expect(inkFeatures, paintsExactlyCountTimes(#rect, 0));
......@@ -172,9 +172,9 @@ void main() {
splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff),
onTap: () {},
onLongPress: () {},
onHover: (bool hover) {},
onTap: () { },
onLongPress: () { },
onHover: (bool hover) { },
),
),
),
......@@ -206,8 +206,8 @@ void main() {
textDirection: TextDirection.ltr,
child: Center(
child: InkWell(
onTap: () {},
onLongPress: () {},
onTap: () { },
onLongPress: () { },
),
),
),
......@@ -234,8 +234,8 @@ void main() {
textDirection: TextDirection.ltr,
child: Center(
child: InkWell(
onTap: () {},
onLongPress: () {},
onTap: () { },
onLongPress: () { },
enableFeedback: false,
),
),
......@@ -301,7 +301,7 @@ void main() {
textDirection: TextDirection.ltr,
child: Material(
child: InkWell(
onTap: () {},
onTap: () { },
child: const Text('Button'),
),
),
......@@ -312,7 +312,7 @@ void main() {
textDirection: TextDirection.ltr,
child: Material(
child: InkWell(
onTap: () {},
onTap: () { },
child: const Text('Button'),
excludeFromSemantics: true,
),
......
......@@ -368,33 +368,41 @@ void main() {
),
);
expect(semantics, hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
label: 'one',
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
),
TestSemantics.rootChild(
label: 'two',
flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
],
),
TestSemantics.rootChild(
label: 'three',
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
],
),
],
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
label: 'one',
),
TestSemantics.rootChild(
flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
label: 'two',
),
TestSemantics.rootChild(
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isFocusable,
],
label: 'three',
),
],
),
ignoreTransform: true,
ignoreId: true,
ignoreRect: true,
),
ignoreTransform: true, ignoreId: true, ignoreRect: true),
);
semantics.dispose();
......
......@@ -520,26 +520,28 @@ void main() {
testWidgets('open PopupMenu has correct semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(MaterialApp(
home: Material(
child: PopupMenuButton<int>(
itemBuilder: (BuildContext context) {
return <PopupMenuItem<int>>[
const PopupMenuItem<int>(value: 1, child: Text('1')),
const PopupMenuItem<int>(value: 2, child: Text('2')),
const PopupMenuItem<int>(value: 3, child: Text('3')),
const PopupMenuItem<int>(value: 4, child: Text('4')),
const PopupMenuItem<int>(value: 5, child: Text('5')),
];
},
child: const SizedBox(
height: 100.0,
width: 100.0,
child: Text('XXX'),
await tester.pumpWidget(
MaterialApp(
home: Material(
child: PopupMenuButton<int>(
itemBuilder: (BuildContext context) {
return <PopupMenuItem<int>>[
const PopupMenuItem<int>(value: 1, child: Text('1')),
const PopupMenuItem<int>(value: 2, child: Text('2')),
const PopupMenuItem<int>(value: 3, child: Text('3')),
const PopupMenuItem<int>(value: 4, child: Text('4')),
const PopupMenuItem<int>(value: 5, child: Text('5')),
];
},
child: const SizedBox(
height: 100.0,
width: 100.0,
child: Text('XXX'),
),
),
),
),
));
);
await tester.tap(find.text('XXX'));
await tester.pumpAndSettle();
......@@ -563,26 +565,31 @@ void main() {
],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '1',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '2',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '3',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '4',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: '5',
textDirection: TextDirection.ltr,
......
......@@ -5,12 +5,77 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/src/services/keyboard_key.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
void main() {
testWidgets('RawMaterialButton responds when tapped', (WidgetTester tester) async {
bool pressed = false;
const Color splashColor = Color(0xff00ff00);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: RawMaterialButton(
splashColor: splashColor,
onPressed: () { pressed = true; },
child: const Text('BUTTON'),
),
),
),
);
await tester.tap(find.text('BUTTON'));
await tester.pump(const Duration(milliseconds: 10));
final RenderBox splash = Material.of(tester.element(find.byType(InkWell))) as dynamic;
expect(splash, paints..circle(color: splashColor));
await tester.pumpAndSettle();
expect(pressed, isTrue);
});
testWidgets('RawMaterialButton responds to shortcut when activated', (WidgetTester tester) async {
bool pressed = false;
final FocusNode focusNode = FocusNode(debugLabel: 'Test Button');
const Color splashColor = Color(0xff00ff00);
await tester.pumpWidget(
Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.enter): const Intent(ActivateAction.key),
},
child: Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: RawMaterialButton(
splashColor: splashColor,
focusNode: focusNode,
onPressed: () { pressed = true; },
child: const Text('BUTTON'),
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump(const Duration(milliseconds: 10));
final RenderBox splash = Material.of(tester.element(find.byType(InkWell))) as dynamic;
expect(splash, paints..circle(color: splashColor));
await tester.pumpAndSettle();
expect(pressed, isTrue);
});
testWidgets('materialTapTargetSize.padded expands hit test area', (WidgetTester tester) async {
int pressed = 0;
......
......@@ -469,6 +469,7 @@ void main() {
hasToggledState: true,
isToggled: true,
isEnabled: true,
isFocusable: true,
hasEnabledState: true,
label: 'Switch tile',
hasTapAction: true,
......
......@@ -1614,15 +1614,19 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
actions: SemanticsAction.tap.index,
flags: SemanticsFlag.isSelected.index,
actions: <SemanticsAction>[SemanticsAction.tap],
flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
label: 'TAB #0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 5,
actions: SemanticsAction.tap.index,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'TAB #1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
......@@ -1878,15 +1882,19 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
actions: SemanticsAction.tap.index,
flags: SemanticsFlag.isSelected.index,
flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Semantics override 0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 5,
actions: SemanticsAction.tap.index,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Semantics override 1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
......
......@@ -481,6 +481,7 @@ void main() {
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
label: 'Signed in\nname\nemail',
textDirection: TextDirection.ltr,
children: <TestSemantics>[
......
......@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
......@@ -914,5 +916,112 @@ void main() {
expect(focusCenter.hasFocus, isFalse);
expect(focusTop.hasFocus, isTrue);
});
testWidgets('Focus traversal actions are invoked when shortcuts are used.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
final GlobalKey lowerLeftKey = GlobalKey(debugLabel: 'lowerLeftKey');
final GlobalKey lowerRightKey = GlobalKey(debugLabel: 'lowerRightKey');
await tester.pumpWidget(
WidgetsApp(
color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) {
return TestRoute(
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
debugLabel: 'scope',
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Focus(
autofocus: true,
debugLabel: 'upperLeft',
child: Container(width: 100, height: 100, key: upperLeftKey),
),
Focus(
debugLabel: 'upperRight',
child: Container(width: 100, height: 100, key: upperRightKey),
),
],
),
Row(
children: <Widget>[
Focus(
debugLabel: 'lowerLeft',
child: Container(width: 100, height: 100, key: lowerLeftKey),
),
Focus(
debugLabel: 'lowerRight',
child: Container(width: 100, height: 100, key: lowerRightKey),
),
],
),
],
),
),
),
);
},
),
);
// Initial focus happens.
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Traverse in a direction
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(Focus.of(upperRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(Focus.of(lowerRightKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(Focus.of(lowerLeftKey.currentContext).hasPrimaryFocus, isTrue);
// Initial focus happens.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
});
});
}
class TestRoute extends PageRouteBuilder<void> {
TestRoute({Widget child})
: super(
pageBuilder: (BuildContext _, Animation<double> __, Animation<double> ___) {
return child;
},
);
}
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