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() { ...@@ -45,7 +45,7 @@ void main() {
await tester.pumpWidget(MaterialApp(home: ChipDemo())); await tester.pumpWidget(MaterialApp(home: ChipDemo()));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
handle.dispose(); 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 { testWidgets('data_table_demo', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics(); final SemanticsHandle handle = tester.ensureSemantics();
......
...@@ -512,7 +512,7 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> { ...@@ -512,7 +512,7 @@ class _TabSwitchingViewState extends State<_TabSwitchingView> {
tabFocusNodes.addAll( tabFocusNodes.addAll(
List<FocusScopeNode>.generate( List<FocusScopeNode>.generate(
widget.tabCount - tabFocusNodes.length, 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 @@ ...@@ -10,14 +10,22 @@
/// the same length, and contain the same members. Returns false otherwise. /// the same length, and contain the same members. Returns false otherwise.
/// Order is not compared. /// 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: /// See also:
/// ///
/// * [listEquals], which does something similar for lists. /// * [listEquals], which does something similar for lists.
/// * [mapEquals], which does something similar for maps.
bool setEquals<T>(Set<T> a, Set<T> b) { bool setEquals<T>(Set<T> a, Set<T> b) {
if (a == null) if (a == null)
return b == null; return b == null;
if (b == null || a.length != b.length) if (b == null || a.length != b.length)
return false; return false;
if (identical(a, b))
return true;
for (T value in a) { for (T value in a) {
if (!b.contains(value)) if (!b.contains(value))
return false; return false;
...@@ -31,14 +39,22 @@ bool setEquals<T>(Set<T> a, Set<T> b) { ...@@ -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 /// the same length, and contain the same members in the same order. Returns
/// false otherwise. /// 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: /// See also:
/// ///
/// * [setEquals], which does something similar for sets. /// * [setEquals], which does something similar for sets.
/// * [mapEquals], which does something similar for maps.
bool listEquals<T>(List<T> a, List<T> b) { bool listEquals<T>(List<T> a, List<T> b) {
if (a == null) if (a == null)
return b == null; return b == null;
if (b == null || a.length != b.length) if (b == null || a.length != b.length)
return false; return false;
if (identical(a, b))
return true;
for (int index = 0; index < a.length; index += 1) { for (int index = 0; index < a.length; index += 1) {
if (a[index] != b[index]) if (a[index] != b[index])
return false; return false;
...@@ -46,6 +62,37 @@ bool listEquals<T>(List<T> a, List<T> b) { ...@@ -46,6 +62,37 @@ bool listEquals<T>(List<T> a, List<T> b) {
return true; 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 the position of `value` in the `sortedList`, if it exists.
/// ///
/// Returns `-1` if the `value` is not in the list. Requires the list items /// Returns `-1` if the `value` is not in the list. Requires the list items
......
...@@ -474,45 +474,43 @@ class _BottomNavigationTile extends StatelessWidget { ...@@ -474,45 +474,43 @@ class _BottomNavigationTile extends StatelessWidget {
child: Semantics( child: Semantics(
container: true, container: true,
selected: selected, selected: selected,
child: Focus( child: Stack(
child: Stack( children: <Widget>[
children: <Widget>[ InkResponse(
InkResponse( onTap: onTap,
onTap: onTap, child: Padding(
child: Padding( padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: <Widget>[
children: <Widget>[ _TileIcon(
_TileIcon( colorTween: colorTween,
colorTween: colorTween, animation: animation,
animation: animation, iconSize: iconSize,
iconSize: iconSize, selected: selected,
selected: selected, item: item,
item: item, selectedIconTheme: selectedIconTheme,
selectedIconTheme: selectedIconTheme, unselectedIconTheme: unselectedIconTheme,
unselectedIconTheme: unselectedIconTheme, ),
), _Label(
_Label( colorTween: colorTween,
colorTween: colorTween, animation: animation,
animation: animation, item: item,
item: item, selectedLabelStyle: selectedLabelStyle,
selectedLabelStyle: selectedLabelStyle, unselectedLabelStyle: unselectedLabelStyle,
unselectedLabelStyle: unselectedLabelStyle, showSelectedLabels: showSelectedLabels,
showSelectedLabels: showSelectedLabels, showUnselectedLabels: showUnselectedLabels,
showUnselectedLabels: showUnselectedLabels, ),
), ],
],
),
), ),
), ),
Semantics( ),
label: indexLabel, Semantics(
), label: indexLabel,
], ),
), ],
), ),
), ),
); );
......
...@@ -330,39 +330,37 @@ class _RawMaterialButtonState extends State<RawMaterialButton> { ...@@ -330,39 +330,37 @@ class _RawMaterialButtonState extends State<RawMaterialButton> {
final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>(widget.textStyle?.color, _states); final Color effectiveTextColor = MaterialStateProperty.resolveAs<Color>(widget.textStyle?.color, _states);
final ShapeBorder effectiveShape = MaterialStateProperty.resolveAs<ShapeBorder>(widget.shape, _states); final ShapeBorder effectiveShape = MaterialStateProperty.resolveAs<ShapeBorder>(widget.shape, _states);
final Widget result = Focus( final Widget result = ConstrainedBox(
focusNode: widget.focusNode, constraints: widget.constraints,
canRequestFocus: widget.enabled, child: Material(
onFocusChange: _handleFocusedChanged, elevation: _effectiveElevation,
autofocus: widget.autofocus, textStyle: widget.textStyle?.copyWith(color: effectiveTextColor),
child: ConstrainedBox( shape: effectiveShape,
constraints: widget.constraints, color: widget.fillColor,
child: Material( type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button,
elevation: _effectiveElevation, animationDuration: widget.animationDuration,
textStyle: widget.textStyle?.copyWith(color: effectiveTextColor), clipBehavior: widget.clipBehavior,
shape: effectiveShape, child: InkWell(
color: widget.fillColor, focusNode: widget.focusNode,
type: widget.fillColor == null ? MaterialType.transparency : MaterialType.button, canRequestFocus: widget.enabled,
animationDuration: widget.animationDuration, onFocusChange: _handleFocusedChanged,
clipBehavior: widget.clipBehavior, autofocus: widget.autofocus,
child: InkWell( onHighlightChanged: _handleHighlightChanged,
onHighlightChanged: _handleHighlightChanged, splashColor: widget.splashColor,
splashColor: widget.splashColor, highlightColor: widget.highlightColor,
highlightColor: widget.highlightColor, focusColor: widget.focusColor,
focusColor: widget.focusColor, hoverColor: widget.hoverColor,
hoverColor: widget.hoverColor, onHover: _handleHoveredChanged,
onHover: _handleHoveredChanged, onTap: widget.onPressed,
onTap: widget.onPressed, customBorder: effectiveShape,
customBorder: effectiveShape, child: IconTheme.merge(
child: IconTheme.merge( data: IconThemeData(color: effectiveTextColor),
data: IconThemeData(color: effectiveTextColor), child: Container(
child: Container( padding: widget.padding,
padding: widget.padding, child: Center(
child: Center( widthFactor: 1.0,
widthFactor: 1.0, heightFactor: 1.0,
heightFactor: 1.0, child: widget.child,
child: widget.child,
),
), ),
), ),
), ),
......
...@@ -1774,73 +1774,71 @@ class _RawChipState extends State<RawChip> with TickerProviderStateMixin<RawChip ...@@ -1774,73 +1774,71 @@ class _RawChipState extends State<RawChip> with TickerProviderStateMixin<RawChip
final Color resolvedLabelColor = MaterialStateProperty.resolveAs<Color>(effectiveLabelStyle?.color, _states); final Color resolvedLabelColor = MaterialStateProperty.resolveAs<Color>(effectiveLabelStyle?.color, _states);
final TextStyle resolvedLabelStyle = effectiveLabelStyle?.copyWith(color: resolvedLabelColor); final TextStyle resolvedLabelStyle = effectiveLabelStyle?.copyWith(color: resolvedLabelColor);
Widget result = Focus( Widget result = Material(
onFocusChange: _handleFocus, elevation: isTapping ? pressElevation : elevation,
focusNode: widget.focusNode, shadowColor: widget.selected ? selectedShadowColor : shadowColor,
autofocus: widget.autofocus, animationDuration: pressedAnimationDuration,
canRequestFocus: widget.isEnabled, shape: shape,
child: Material( clipBehavior: widget.clipBehavior,
elevation: isTapping ? pressElevation : elevation, child: InkWell(
shadowColor: widget.selected ? selectedShadowColor : shadowColor, onFocusChange: _handleFocus,
animationDuration: pressedAnimationDuration, focusNode: widget.focusNode,
shape: shape, autofocus: widget.autofocus,
clipBehavior: widget.clipBehavior, canRequestFocus: widget.isEnabled,
child: InkWell( onTap: canTap ? _handleTap : null,
onTap: canTap ? _handleTap : null, onTapDown: canTap ? _handleTapDown : null,
onTapDown: canTap ? _handleTapDown : null, onTapCancel: canTap ? _handleTapCancel : null,
onTapCancel: canTap ? _handleTapCancel : null, onHover: canTap ? _handleHover : null,
onHover: canTap ? _handleHover : null, customBorder: shape,
customBorder: shape, child: AnimatedBuilder(
child: AnimatedBuilder( animation: Listenable.merge(<Listenable>[selectController, enableController]),
animation: Listenable.merge(<Listenable>[selectController, enableController]), builder: (BuildContext context, Widget child) {
builder: (BuildContext context, Widget child) { return Container(
return Container( decoration: ShapeDecoration(
decoration: ShapeDecoration( shape: shape,
shape: shape, color: getBackgroundColor(chipTheme),
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, deleteIcon: AnimatedSwitcher(
); child: _buildDeleteIcon(context, theme, chipTheme),
}, duration: _kDrawerDuration,
child: _wrapWithTooltip( switchInCurve: Curves.fastOutSlowIn,
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,
), ),
value: widget.selected, brightness: chipTheme.brightness,
checkmarkAnimation: checkmarkAnimation, padding: (widget.padding ?? chipTheme.padding).resolve(textDirection),
enableAnimation: enableAnimation, labelPadding: (widget.labelPadding ?? chipTheme.labelPadding).resolve(textDirection),
avatarDrawerAnimation: avatarDrawerAnimation, showAvatar: hasAvatar,
deleteDrawerAnimation: deleteDrawerAnimation, showCheckmark: showCheckmark,
isEnabled: widget.isEnabled, checkmarkColor: checkmarkColor,
avatarBorder: widget.avatarBorder, 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 { ...@@ -309,22 +309,20 @@ class IconButton extends StatelessWidget {
return Semantics( return Semantics(
button: true, button: true,
enabled: onPressed != null, enabled: onPressed != null,
child: Focus( child: InkResponse(
focusNode: focusNode, focusNode: focusNode,
autofocus: autofocus, autofocus: autofocus,
canRequestFocus: onPressed != null, canRequestFocus: onPressed != null,
child: InkResponse( onTap: onPressed,
onTap: onPressed, child: result,
child: result, focusColor: focusColor ?? Theme.of(context).focusColor,
focusColor: focusColor ?? Theme.of(context).focusColor, hoverColor: hoverColor ?? Theme.of(context).hoverColor,
hoverColor: hoverColor ?? Theme.of(context).hoverColor, highlightColor: highlightColor ?? Theme.of(context).highlightColor,
highlightColor: highlightColor ?? Theme.of(context).highlightColor, splashColor: splashColor ?? Theme.of(context).splashColor,
splashColor: splashColor ?? Theme.of(context).splashColor, radius: math.max(
radius: math.max( Material.defaultSplashRadius,
Material.defaultSplashRadius, (iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7,
(iconSize + math.min(padding.horizontal, padding.vertical)) * 0.7, // x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps.
// x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps.
),
), ),
), ),
); );
......
...@@ -210,10 +210,16 @@ class InkResponse extends StatefulWidget { ...@@ -210,10 +210,16 @@ class InkResponse extends StatefulWidget {
this.splashFactory, this.splashFactory,
this.enableFeedback = true, this.enableFeedback = true,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.focusNode,
this.canRequestFocus = true,
this.onFocusChange,
this.autofocus = false,
}) : assert(containedInkWell != null), }) : assert(containedInkWell != null),
assert(highlightShape != null), assert(highlightShape != null),
assert(enableFeedback != null), assert(enableFeedback != null),
assert(excludeFromSemantics != null), assert(excludeFromSemantics != null),
assert(autofocus != null),
assert(canRequestFocus != null),
super(key: key); super(key: key);
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
...@@ -400,6 +406,21 @@ class InkResponse extends StatefulWidget { ...@@ -400,6 +406,21 @@ class InkResponse extends StatefulWidget {
/// duplication of information. /// duplication of information.
final bool excludeFromSemantics; 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 rectangle to use for the highlight effect and for clipping
/// the splash effects if [containedInkWell] is true. /// the splash effects if [containedInkWell] is true.
/// ///
...@@ -462,39 +483,41 @@ enum _HighlightType { ...@@ -462,39 +483,41 @@ enum _HighlightType {
class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> { class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> {
Set<InteractiveInkFeature> _splashes; Set<InteractiveInkFeature> _splashes;
InteractiveInkFeature _currentSplash; InteractiveInkFeature _currentSplash;
FocusNode _focusNode;
bool _hovering = false; bool _hovering = false;
final Map<_HighlightType, InkHighlight> _highlights = <_HighlightType, InkHighlight>{}; final Map<_HighlightType, InkHighlight> _highlights = <_HighlightType, InkHighlight>{};
Map<LocalKey, ActionFactory> _actionMap;
bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty; bool get highlightsExist => _highlights.values.where((InkHighlight highlight) => highlight != null).isNotEmpty;
@override @override
void initState() { void initState() {
super.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); WidgetsBinding.instance.focusManager.addHighlightModeListener(_handleFocusHighlightModeChange);
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_focusNode?.removeListener(_handleFocusUpdate);
_focusNode = Focus.of(context, nullOk: true);
_focusNode?.addListener(_handleFocusUpdate);
}
@override @override
void didUpdateWidget(InkResponse oldWidget) { void didUpdateWidget(InkResponse oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) { if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) {
_handleHoverChange(_hovering); _handleHoverChange(_hovering);
_handleFocusUpdate(); _updateFocusHighlights();
} }
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange); WidgetsBinding.instance.focusManager.removeHighlightModeListener(_handleFocusHighlightModeChange);
_focusNode?.removeListener(_handleFocusUpdate);
super.dispose(); super.dispose();
} }
...@@ -560,7 +583,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -560,7 +583,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
} }
assert(value == (_highlights[type] != null && _highlights[type].active)); assert(value == (_highlights[type] != null && _highlights[type].active));
switch(type) { switch (type) {
case _HighlightType.pressed: case _HighlightType.pressed:
if (widget.onHighlightChanged != null) if (widget.onHighlightChanged != null)
widget.onHighlightChanged(value); widget.onHighlightChanged(value);
...@@ -574,10 +597,10 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -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 MaterialInkController inkController = Material.of(context);
final RenderBox referenceBox = context.findRenderObject(); 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 Color color = widget.splashColor ?? Theme.of(context).splashColor;
final RectCallback rectCallback = widget.containedInkWell ? widget.getRectCallback(referenceBox) : null; final RectCallback rectCallback = widget.containedInkWell ? widget.getRectCallback(referenceBox) : null;
final BorderRadius borderRadius = widget.borderRadius; final BorderRadius borderRadius = widget.borderRadius;
...@@ -616,31 +639,54 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -616,31 +639,54 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
return; return;
} }
setState(() { setState(() {
_handleFocusUpdate(); _updateFocusHighlights();
}); });
} }
void _handleFocusUpdate() { void _updateFocusHighlights() {
bool showFocus; bool showFocus;
switch (WidgetsBinding.instance.focusManager.highlightMode) { switch (WidgetsBinding.instance.focusManager.highlightMode) {
case FocusHighlightMode.touch: case FocusHighlightMode.touch:
showFocus = false; showFocus = false;
break; break;
case FocusHighlightMode.traditional: case FocusHighlightMode.traditional:
showFocus = enabled && (Focus.of(context, nullOk: true)?.hasPrimaryFocus ?? false); showFocus = enabled && _hasFocus;
break; break;
} }
updateHighlight(_HighlightType.focus, value: showFocus); 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) { void _handleTapDown(TapDownDetails details) {
final InteractiveInkFeature splash = _createInkFeature(details); _startSplash(details: details);
_splashes ??= HashSet<InteractiveInkFeature>();
_splashes.add(splash);
_currentSplash = splash;
if (widget.onTapDown != null) { if (widget.onTapDown != null) {
widget.onTapDown(details); 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(); updateKeepAlive();
updateHighlight(_HighlightType.pressed, value: true); updateHighlight(_HighlightType.pressed, value: true);
} }
...@@ -722,18 +768,27 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe ...@@ -722,18 +768,27 @@ class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKe
_highlights[type]?.color = getHighlightColorForType(type); _highlights[type]?.color = getHighlightColorForType(type);
} }
_currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor; _currentSplash?.color = widget.splashColor ?? Theme.of(context).splashColor;
return MouseRegion( return Actions(
onEnter: enabled ? _handleMouseEnter : null, actions: _actionMap,
onExit: enabled ? _handleMouseExit : null, child: Focus(
child: GestureDetector( focusNode: widget.focusNode,
onTapDown: enabled ? _handleTapDown : null, canRequestFocus: widget.canRequestFocus,
onTap: enabled ? () => _handleTap(context) : null, onFocusChange: _handleFocusUpdate,
onTapCancel: enabled ? _handleTapCancel : null, autofocus: widget.autofocus,
onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null, child: MouseRegion(
onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null, onEnter: enabled ? _handleMouseEnter : null,
behavior: HitTestBehavior.opaque, onExit: enabled ? _handleMouseExit : null,
child: widget.child, child: GestureDetector(
excludeFromSemantics: widget.excludeFromSemantics, 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 { ...@@ -854,6 +909,10 @@ class InkWell extends InkResponse {
ShapeBorder customBorder, ShapeBorder customBorder,
bool enableFeedback = true, bool enableFeedback = true,
bool excludeFromSemantics = false, bool excludeFromSemantics = false,
FocusNode focusNode,
bool canRequestFocus = true,
ValueChanged<bool> onFocusChange,
bool autofocus = false,
}) : super( }) : super(
key: key, key: key,
child: child, child: child,
...@@ -876,5 +935,9 @@ class InkWell extends InkResponse { ...@@ -876,5 +935,9 @@ class InkWell extends InkResponse {
customBorder: customBorder, customBorder: customBorder,
enableFeedback: enableFeedback ?? true, enableFeedback: enableFeedback ?? true,
excludeFromSemantics: excludeFromSemantics ?? false, excludeFromSemantics: excludeFromSemantics ?? false,
focusNode: focusNode,
canRequestFocus: canRequestFocus ?? true,
onFocusChange: onFocusChange,
autofocus: autofocus ?? false,
); );
} }
...@@ -200,6 +200,10 @@ class Actions extends InheritedWidget { ...@@ -200,6 +200,10 @@ class Actions extends InheritedWidget {
/// A map of [Intent] keys to [ActionFactory] factory methods that defines /// A map of [Intent] keys to [ActionFactory] factory methods that defines
/// which actions this widget knows about. /// 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; final Map<LocalKey, ActionFactory> actions;
// Finds the nearest valid ActionDispatcher, or creates a new one if it // Finds the nearest valid ActionDispatcher, or creates a new one if it
...@@ -341,7 +345,7 @@ class Actions extends InheritedWidget { ...@@ -341,7 +345,7 @@ class Actions extends InheritedWidget {
@override @override
bool updateShouldNotify(Actions oldWidget) { bool updateShouldNotify(Actions oldWidget) {
return oldWidget.dispatcher != dispatcher || oldWidget.actions != actions; return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
} }
@override @override
...@@ -368,3 +372,16 @@ class DoNothingAction extends Action { ...@@ -368,3 +372,16 @@ class DoNothingAction extends Action {
@override @override
void invoke(FocusNode node, Intent intent) { } 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; ...@@ -7,6 +7,7 @@ import 'dart:collection' show HashMap;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'banner.dart'; import 'banner.dart';
...@@ -20,6 +21,7 @@ import 'navigator.dart'; ...@@ -20,6 +21,7 @@ import 'navigator.dart';
import 'pages.dart'; import 'pages.dart';
import 'performance_overlay.dart'; import 'performance_overlay.dart';
import 'semantics_debugger.dart'; import 'semantics_debugger.dart';
import 'shortcuts.dart';
import 'text.dart'; import 'text.dart';
import 'title.dart'; import 'title.dart';
import 'widget_inspector.dart'; import 'widget_inspector.dart';
...@@ -1036,6 +1038,24 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1036,6 +1038,24 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
return true; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget navigator; Widget navigator;
...@@ -1147,17 +1167,18 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1147,17 +1167,18 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
assert(_debugCheckLocalizations(appLocale)); assert(_debugCheckLocalizations(appLocale));
return Actions( return Shortcuts(
actions: <LocalKey, ActionFactory>{ shortcuts: _keyMap,
DoNothingAction.key: () => const DoNothingAction(), child: Actions(
}, actions: _actionMap,
child: DefaultFocusTraversal( child: DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
child: _MediaQueryFromWindow( child: _MediaQueryFromWindow(
child: Localizations( child: Localizations(
locale: appLocale, locale: appLocale,
delegates: _localizationsDelegates.toList(), delegates: _localizationsDelegates.toList(),
child: title, child: title,
),
), ),
), ),
), ),
......
...@@ -186,7 +186,7 @@ class Focus extends StatefulWidget { ...@@ -186,7 +186,7 @@ class Focus extends StatefulWidget {
/// Handler called when the focus changes. /// 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. /// focus.
final ValueChanged<bool> onFocusChange; final ValueChanged<bool> onFocusChange;
...@@ -230,6 +230,7 @@ class Focus extends StatefulWidget { ...@@ -230,6 +230,7 @@ class Focus extends StatefulWidget {
/// still be focused explicitly. /// still be focused explicitly.
final bool skipTraversal; final bool skipTraversal;
/// {@template flutter.widgets.Focus.canRequestFocus}
/// If true, this widget may request the primary focus. /// If true, this widget may request the primary focus.
/// ///
/// Defaults to true. Set to false if you want the [FocusNode] this widget /// Defaults to true. Set to false if you want the [FocusNode] this widget
...@@ -249,6 +250,7 @@ class Focus extends StatefulWidget { ...@@ -249,6 +250,7 @@ class Focus extends StatefulWidget {
/// its descendants. /// its descendants.
/// - [FocusTraversalPolicy], a class that can be extended to describe a /// - [FocusTraversalPolicy], a class that can be extended to describe a
/// traversal policy. /// traversal policy.
/// {@endtemplate}
final bool canRequestFocus; final bool canRequestFocus;
/// Returns the [focusNode] of the [Focus] that most tightly encloses the /// Returns the [focusNode] of the [Focus] that most tightly encloses the
......
...@@ -2,9 +2,12 @@ ...@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'actions.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
...@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget { ...@@ -790,3 +793,138 @@ class DefaultFocusTraversal extends InheritedWidget {
@override @override
bool updateShouldNotify(DefaultFocusTraversal oldWidget) => policy != oldWidget.policy; 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 { ...@@ -91,7 +91,7 @@ class KeySet<T extends KeyboardKey> extends Diagnosticable {
return false; return false;
} }
final KeySet<T> typedOther = other; final KeySet<T> typedOther = other;
return _keys.length == typedOther._keys.length && _keys.containsAll(typedOther._keys); return setEquals<T>(_keys, typedOther._keys);
} }
@override @override
...@@ -169,10 +169,7 @@ class ShortcutManager extends ChangeNotifier with DiagnosticableMixin { ...@@ -169,10 +169,7 @@ class ShortcutManager extends ChangeNotifier with DiagnosticableMixin {
Map<LogicalKeySet, Intent> get shortcuts => _shortcuts; Map<LogicalKeySet, Intent> get shortcuts => _shortcuts;
Map<LogicalKeySet, Intent> _shortcuts; Map<LogicalKeySet, Intent> _shortcuts;
set shortcuts(Map<LogicalKeySet, Intent> value) { set shortcuts(Map<LogicalKeySet, Intent> value) {
if (_shortcuts == value) { if (!mapEquals<LogicalKeySet, Intent>(_shortcuts, value)) {
return;
}
if (_shortcuts != value) {
_shortcuts = value; _shortcuts = value;
notifyListeners(); notifyListeners();
} }
...@@ -259,6 +256,10 @@ class Shortcuts extends StatefulWidget { ...@@ -259,6 +256,10 @@ class Shortcuts extends StatefulWidget {
final ShortcutManager manager; final ShortcutManager manager;
/// The map of shortcuts that the [manager] will be given to manage. /// 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; final Map<LogicalKeySet, Intent> shortcuts;
/// The child widget for this [Shortcuts] widget. /// The child widget for this [Shortcuts] widget.
...@@ -324,15 +325,15 @@ class _ShortcutsState extends State<Shortcuts> { ...@@ -324,15 +325,15 @@ class _ShortcutsState extends State<Shortcuts> {
@override @override
void didUpdateWidget(Shortcuts oldWidget) { void didUpdateWidget(Shortcuts oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.manager != oldWidget.manager || widget.shortcuts != oldWidget.shortcuts) { if (widget.manager != oldWidget.manager) {
if (widget.manager != null) { if (widget.manager != null) {
_internalManager?.dispose(); _internalManager?.dispose();
_internalManager = null; _internalManager = null;
} else { } else {
_internalManager ??= ShortcutManager(); _internalManager ??= ShortcutManager();
} }
manager.shortcuts = widget.shortcuts;
} }
manager.shortcuts = widget.shortcuts;
} }
bool _handleOnKey(FocusNode node, RawKeyEvent event) { bool _handleOnKey(FocusNode node, RawKeyEvent event) {
...@@ -345,7 +346,7 @@ class _ShortcutsState extends State<Shortcuts> { ...@@ -345,7 +346,7 @@ class _ShortcutsState extends State<Shortcuts> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return Focus(
debugLabel: describeIdentity(widget), debugLabel: '$Shortcuts',
canRequestFocus: false, canRequestFocus: false,
onKey: _handleOnKey, onKey: _handleOnKey,
child: _ShortcutsMarker( 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() { ...@@ -241,10 +241,11 @@ void main() {
rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0),
transform: null, transform: null,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasToggledState,
SemanticsFlag.isToggled,
SemanticsFlag.hasEnabledState, SemanticsFlag.hasEnabledState,
SemanticsFlag.hasToggledState,
SemanticsFlag.isEnabled, SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isToggled,
], ],
actions: SemanticsAction.tap.index, actions: SemanticsAction.tap.index,
label: 'aaa\nAAA', label: 'aaa\nAAA',
...@@ -255,9 +256,10 @@ void main() { ...@@ -255,9 +256,10 @@ void main() {
transform: Matrix4.translationValues(0.0, 56.0, 0.0), transform: Matrix4.translationValues(0.0, 56.0, 0.0),
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState, SemanticsFlag.hasCheckedState,
SemanticsFlag.isChecked,
SemanticsFlag.hasEnabledState, SemanticsFlag.hasEnabledState,
SemanticsFlag.isChecked,
SemanticsFlag.isEnabled, SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
], ],
actions: SemanticsAction.tap.index, actions: SemanticsAction.tap.index,
label: 'bbb\nBBB', label: 'bbb\nBBB',
...@@ -270,6 +272,7 @@ void main() { ...@@ -270,6 +272,7 @@ void main() {
SemanticsFlag.hasCheckedState, SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState, SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled, SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isInMutuallyExclusiveGroup, SemanticsFlag.isInMutuallyExclusiveGroup,
], ],
actions: SemanticsAction.tap.index, actions: SemanticsAction.tap.index,
......
...@@ -432,12 +432,16 @@ void _tests() { ...@@ -432,12 +432,16 @@ void _tests() {
thickness: 0.0, thickness: 0.0,
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '2016', label: '2016',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
), ),
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isSelected], flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Fri, Jan 15', label: 'Fri, Jan 15',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
......
...@@ -1028,24 +1028,28 @@ void main() { ...@@ -1028,24 +1028,28 @@ void main() {
TestSemantics( TestSemantics(
label: 'one', label: 'one',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')], tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
), ),
TestSemantics( TestSemantics(
label: 'two', label: 'two',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')], tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
), ),
TestSemantics( TestSemantics(
label: 'three', label: 'three',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')], tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
), ),
TestSemantics( TestSemantics(
label: 'four', label: 'four',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')], tags: <SemanticsTag>[const SemanticsTag('RenderViewport.twoPane')],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
), ),
......
...@@ -1014,6 +1014,7 @@ void main() { ...@@ -1014,6 +1014,7 @@ void main() {
expect(tester.getSemantics(find.byKey(expandedKey)), matchesSemantics( expect(tester.getSemantics(find.byKey(expandedKey)), matchesSemantics(
label: 'Expanded', label: 'Expanded',
isButton: true, isButton: true,
isFocusable: true,
hasEnabledState: true, hasEnabledState: true,
hasTapAction: true, hasTapAction: true,
)); ));
...@@ -1021,6 +1022,7 @@ void main() { ...@@ -1021,6 +1022,7 @@ void main() {
expect(tester.getSemantics(find.byKey(collapsedKey)), matchesSemantics( expect(tester.getSemantics(find.byKey(collapsedKey)), matchesSemantics(
label: 'Collapsed', label: 'Collapsed',
isButton: true, isButton: true,
isFocusable: true,
hasEnabledState: true, hasEnabledState: true,
hasTapAction: true, hasTapAction: true,
)); ));
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
...@@ -251,6 +252,102 @@ void main() { ...@@ -251,6 +252,102 @@ void main() {
await gesture.up(); await gesture.up();
}, skip: isBrowser); }, 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 { testWidgets('Cancel an InkRipple that was disposed when its animation ended', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/14391 // Regression test for https://github.com/flutter/flutter/issues/14391
await tester.pumpWidget( await tester.pumpWidget(
...@@ -331,5 +428,4 @@ void main() { ...@@ -331,5 +428,4 @@ void main() {
throw 'Expected: paint.color.alpha == 0, found: ${paint.color.alpha}'; throw 'Expected: paint.color.alpha == 0, found: ${paint.color.alpha}';
})); }));
}); });
} }
...@@ -103,9 +103,9 @@ void main() { ...@@ -103,9 +103,9 @@ void main() {
splashColor: const Color(0xffff0000), splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff), focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff), highlightColor: const Color(0xf00fffff),
onTap: () {}, onTap: () { },
onLongPress: () {}, onLongPress: () { },
onHover: (bool hover) {}, onHover: (bool hover) { },
), ),
), ),
), ),
...@@ -123,29 +123,29 @@ void main() { ...@@ -123,29 +123,29 @@ void main() {
testWidgets('ink response changes color on focus', (WidgetTester tester) async { testWidgets('ink response changes color on focus', (WidgetTester tester) async {
WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; WidgetsBinding.instance.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus'); final FocusNode focusNode = FocusNode(debugLabel: 'Ink Focus');
await tester.pumpWidget(Material( await tester.pumpWidget(
child: Directionality( Material(
textDirection: TextDirection.ltr, child: Directionality(
child: Center( textDirection: TextDirection.ltr,
child: Focus( child: Center(
focusNode: focusNode,
child: Container( child: Container(
width: 100, width: 100,
height: 100, height: 100,
child: InkWell( child: InkWell(
focusNode: focusNode,
hoverColor: const Color(0xff00ff00), hoverColor: const Color(0xff00ff00),
splashColor: const Color(0xffff0000), splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff), focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff), highlightColor: const Color(0xf00fffff),
onTap: () {}, onTap: () { },
onLongPress: () {}, onLongPress: () { },
onHover: (bool hover) {}, onHover: (bool hover) { },
), ),
), ),
), ),
), ),
), ),
)); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures');
expect(inkFeatures, paintsExactlyCountTimes(#rect, 0)); expect(inkFeatures, paintsExactlyCountTimes(#rect, 0));
...@@ -172,9 +172,9 @@ void main() { ...@@ -172,9 +172,9 @@ void main() {
splashColor: const Color(0xffff0000), splashColor: const Color(0xffff0000),
focusColor: const Color(0xff0000ff), focusColor: const Color(0xff0000ff),
highlightColor: const Color(0xf00fffff), highlightColor: const Color(0xf00fffff),
onTap: () {}, onTap: () { },
onLongPress: () {}, onLongPress: () { },
onHover: (bool hover) {}, onHover: (bool hover) { },
), ),
), ),
), ),
...@@ -206,8 +206,8 @@ void main() { ...@@ -206,8 +206,8 @@ void main() {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: Center( child: Center(
child: InkWell( child: InkWell(
onTap: () {}, onTap: () { },
onLongPress: () {}, onLongPress: () { },
), ),
), ),
), ),
...@@ -234,8 +234,8 @@ void main() { ...@@ -234,8 +234,8 @@ void main() {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: Center( child: Center(
child: InkWell( child: InkWell(
onTap: () {}, onTap: () { },
onLongPress: () {}, onLongPress: () { },
enableFeedback: false, enableFeedback: false,
), ),
), ),
...@@ -301,7 +301,7 @@ void main() { ...@@ -301,7 +301,7 @@ void main() {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: Material( child: Material(
child: InkWell( child: InkWell(
onTap: () {}, onTap: () { },
child: const Text('Button'), child: const Text('Button'),
), ),
), ),
...@@ -312,7 +312,7 @@ void main() { ...@@ -312,7 +312,7 @@ void main() {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: Material( child: Material(
child: InkWell( child: InkWell(
onTap: () {}, onTap: () { },
child: const Text('Button'), child: const Text('Button'),
excludeFromSemantics: true, excludeFromSemantics: true,
), ),
......
...@@ -368,33 +368,41 @@ void main() { ...@@ -368,33 +368,41 @@ void main() {
), ),
); );
expect(semantics, hasSemantics( expect(
TestSemantics.root( semantics,
children: <TestSemantics>[ hasSemantics(
TestSemantics.rootChild( TestSemantics.root(
label: 'one', children: <TestSemantics>[
flags: <SemanticsFlag>[ TestSemantics.rootChild(
SemanticsFlag.hasEnabledState, flags: <SemanticsFlag>[
SemanticsFlag.isEnabled, SemanticsFlag.hasEnabledState,
], SemanticsFlag.isEnabled,
), SemanticsFlag.isFocusable,
TestSemantics.rootChild( ],
label: 'two', label: 'one',
flags: <SemanticsFlag>[ ),
SemanticsFlag.isSelected, TestSemantics.rootChild(
SemanticsFlag.hasEnabledState, flags: <SemanticsFlag>[
SemanticsFlag.isEnabled, SemanticsFlag.isSelected,
], SemanticsFlag.hasEnabledState,
), SemanticsFlag.isEnabled,
TestSemantics.rootChild( SemanticsFlag.isFocusable,
label: 'three', ],
flags: <SemanticsFlag>[ label: 'two',
SemanticsFlag.hasEnabledState, ),
], TestSemantics.rootChild(
), flags: <SemanticsFlag>[
], SemanticsFlag.hasEnabledState,
SemanticsFlag.isFocusable,
],
label: 'three',
),
],
),
ignoreTransform: true,
ignoreId: true,
ignoreRect: true,
), ),
ignoreTransform: true, ignoreId: true, ignoreRect: true),
); );
semantics.dispose(); semantics.dispose();
......
...@@ -520,26 +520,28 @@ void main() { ...@@ -520,26 +520,28 @@ void main() {
testWidgets('open PopupMenu has correct semantics', (WidgetTester tester) async { testWidgets('open PopupMenu has correct semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(
home: Material( MaterialApp(
child: PopupMenuButton<int>( home: Material(
itemBuilder: (BuildContext context) { child: PopupMenuButton<int>(
return <PopupMenuItem<int>>[ itemBuilder: (BuildContext context) {
const PopupMenuItem<int>(value: 1, child: Text('1')), return <PopupMenuItem<int>>[
const PopupMenuItem<int>(value: 2, child: Text('2')), const PopupMenuItem<int>(value: 1, child: Text('1')),
const PopupMenuItem<int>(value: 3, child: Text('3')), const PopupMenuItem<int>(value: 2, child: Text('2')),
const PopupMenuItem<int>(value: 4, child: Text('4')), const PopupMenuItem<int>(value: 3, child: Text('3')),
const PopupMenuItem<int>(value: 5, child: Text('5')), const PopupMenuItem<int>(value: 4, child: Text('4')),
]; const PopupMenuItem<int>(value: 5, child: Text('5')),
}, ];
child: const SizedBox( },
height: 100.0, child: const SizedBox(
width: 100.0, height: 100.0,
child: Text('XXX'), width: 100.0,
child: Text('XXX'),
),
), ),
), ),
), ),
)); );
await tester.tap(find.text('XXX')); await tester.tap(find.text('XXX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
...@@ -563,26 +565,31 @@ void main() { ...@@ -563,26 +565,31 @@ void main() {
], ],
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '1', label: '1',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
), ),
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '2', label: '2',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
), ),
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '3', label: '3',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
), ),
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '4', label: '4',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
), ),
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap], actions: <SemanticsAction>[SemanticsAction.tap],
label: '5', label: '5',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
......
...@@ -5,12 +5,77 @@ ...@@ -5,12 +5,77 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/src/services/keyboard_key.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
void main() { 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 { testWidgets('materialTapTargetSize.padded expands hit test area', (WidgetTester tester) async {
int pressed = 0; int pressed = 0;
......
...@@ -469,6 +469,7 @@ void main() { ...@@ -469,6 +469,7 @@ void main() {
hasToggledState: true, hasToggledState: true,
isToggled: true, isToggled: true,
isEnabled: true, isEnabled: true,
isFocusable: true,
hasEnabledState: true, hasEnabledState: true,
label: 'Switch tile', label: 'Switch tile',
hasTapAction: true, hasTapAction: true,
......
...@@ -1614,15 +1614,19 @@ void main() { ...@@ -1614,15 +1614,19 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
actions: SemanticsAction.tap.index, actions: <SemanticsAction>[SemanticsAction.tap],
flags: SemanticsFlag.isSelected.index, flags: <SemanticsFlag>[
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
label: 'TAB #0\nTab 1 of 2', label: 'TAB #0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0), transform: Matrix4.translationValues(0.0, 276.0, 0.0),
), ),
TestSemantics( TestSemantics(
id: 5, id: 5,
actions: SemanticsAction.tap.index, flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'TAB #1\nTab 2 of 2', label: 'TAB #1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0), transform: Matrix4.translationValues(116.0, 276.0, 0.0),
...@@ -1878,15 +1882,19 @@ void main() { ...@@ -1878,15 +1882,19 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
actions: SemanticsAction.tap.index, flags: <SemanticsFlag>[
flags: SemanticsFlag.isSelected.index, SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Semantics override 0\nTab 1 of 2', label: 'Semantics override 0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0), transform: Matrix4.translationValues(0.0, 276.0, 0.0),
), ),
TestSemantics( TestSemantics(
id: 5, id: 5,
actions: SemanticsAction.tap.index, flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
actions: <SemanticsAction>[SemanticsAction.tap],
label: 'Semantics override 1\nTab 2 of 2', label: 'Semantics override 1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0), transform: Matrix4.translationValues(116.0, 276.0, 0.0),
......
...@@ -481,6 +481,7 @@ void main() { ...@@ -481,6 +481,7 @@ void main() {
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute], flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.isFocusable],
label: 'Signed in\nname\nemail', label: 'Signed in\nname\nemail',
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
children: <TestSemantics>[ children: <TestSemantics>[
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -914,5 +916,112 @@ void main() { ...@@ -914,5 +916,112 @@ void main() {
expect(focusCenter.hasFocus, isFalse); expect(focusCenter.hasFocus, isFalse);
expect(focusTop.hasFocus, isTrue); 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