diff --git a/packages/flutter/lib/src/cupertino/button.dart b/packages/flutter/lib/src/cupertino/button.dart index bac428a653da..5debf4bd8308 100644 --- a/packages/flutter/lib/src/cupertino/button.dart +++ b/packages/flutter/lib/src/cupertino/button.dart @@ -16,6 +16,12 @@ const EdgeInsets _kBackgroundButtonPadding = EdgeInsets.symmetric( horizontal: 64.0, ); +// The relative values needed to transform a color to it's equivalent focus +// outline color. +const double _kCupertinoFocusColorOpacity = 0.80; +const double _kCupertinoFocusColorBrightness = 0.69; +const double _kCupertinoFocusColorSaturation = 0.835; + /// An iOS-style button. /// /// Takes in a text or an icon that fades out and in on touch. May optionally have a @@ -48,6 +54,10 @@ class CupertinoButton extends StatefulWidget { this.pressedOpacity = 0.4, this.borderRadius = const BorderRadius.all(Radius.circular(8.0)), this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, required this.onPressed, }) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)), _filled = false; @@ -67,6 +77,10 @@ class CupertinoButton extends StatefulWidget { this.pressedOpacity = 0.4, this.borderRadius = const BorderRadius.all(Radius.circular(8.0)), this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, required this.onPressed, }) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)), color = null, @@ -131,6 +145,26 @@ class CupertinoButton extends StatefulWidget { /// Always defaults to [Alignment.center]. final AlignmentGeometry alignment; + /// The color to use for the focus highlight for keyboard interactions. + /// + /// Defaults to a slightly transparent [color]. If [color] is null, defaults + /// to a slightly transparent [CupertinoColors.activeBlue]. Slightly + /// transparent in this context means the color is used with an opacity of + /// 0.80, a brightness of 0.69 and a saturation of 0.835. + final Color? focusColor; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + final bool _filled; /// Whether the button is enabled or disabled. Buttons are disabled by default. To @@ -156,9 +190,12 @@ class _CupertinoButtonState extends State with SingleTickerProv late AnimationController _animationController; late Animation _opacityAnimation; + late bool isFocused; + @override void initState() { super.initState(); + isFocused = false; _animationController = AnimationController( duration: const Duration(milliseconds: 200), value: 0.0, @@ -224,6 +261,12 @@ class _CupertinoButtonState extends State with SingleTickerProv }); } + void _onShowFocusHighlight(bool showHighlight) { + setState(() { + isFocused = showHighlight; + }); + } + @override Widget build(BuildContext context) { final bool enabled = widget.enabled; @@ -239,47 +282,71 @@ class _CupertinoButtonState extends State with SingleTickerProv ? primaryColor : CupertinoDynamicColor.resolve(CupertinoColors.placeholderText, context); + final Color effectiveFocusOutlineColor = widget.focusColor ?? + HSLColor + .fromColor((backgroundColor ?? CupertinoColors.activeBlue) + .withOpacity(_kCupertinoFocusColorOpacity)) + .withLightness(_kCupertinoFocusColorBrightness) + .withSaturation(_kCupertinoFocusColorSaturation) + .toColor(); + final TextStyle textStyle = themeData.textTheme.textStyle.copyWith(color: foregroundColor); return MouseRegion( cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: enabled ? _handleTapDown : null, - onTapUp: enabled ? _handleTapUp : null, - onTapCancel: enabled ? _handleTapCancel : null, - onTap: widget.onPressed, - child: Semantics( - button: true, - child: ConstrainedBox( - constraints: widget.minSize == null - ? const BoxConstraints() - : BoxConstraints( - minWidth: widget.minSize!, - minHeight: widget.minSize!, - ), - child: FadeTransition( - opacity: _opacityAnimation, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: widget.borderRadius, - color: backgroundColor != null && !enabled - ? CupertinoDynamicColor.resolve(widget.disabledColor, context) - : backgroundColor, - ), - child: Padding( - padding: widget.padding ?? (backgroundColor != null - ? _kBackgroundButtonPadding - : _kButtonPadding), - child: Align( - alignment: widget.alignment, - widthFactor: 1.0, - heightFactor: 1.0, - child: DefaultTextStyle( - style: textStyle, - child: IconTheme( - data: IconThemeData(color: foregroundColor), - child: widget.child, + child: FocusableActionDetector( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: widget.onFocusChange, + onShowFocusHighlight: _onShowFocusHighlight, + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: enabled ? _handleTapDown : null, + onTapUp: enabled ? _handleTapUp : null, + onTapCancel: enabled ? _handleTapCancel : null, + onTap: widget.onPressed, + child: Semantics( + button: true, + child: ConstrainedBox( + constraints: widget.minSize == null + ? const BoxConstraints() + : BoxConstraints( + minWidth: widget.minSize!, + minHeight: widget.minSize!, + ), + child: FadeTransition( + opacity: _opacityAnimation, + child: DecoratedBox( + decoration: BoxDecoration( + border: enabled && isFocused + ? Border.fromBorderSide( + BorderSide( + color: effectiveFocusOutlineColor, + width: 3.5, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ) + : null, + borderRadius: widget.borderRadius, + color: backgroundColor != null && !enabled + ? CupertinoDynamicColor.resolve(widget.disabledColor, context) + : backgroundColor, + ), + child: Padding( + padding: widget.padding ?? (backgroundColor != null + ? _kBackgroundButtonPadding + : _kButtonPadding), + child: Align( + alignment: widget.alignment, + widthFactor: 1.0, + heightFactor: 1.0, + child: DefaultTextStyle( + style: textStyle, + child: IconTheme( + data: IconThemeData(color: foregroundColor), + child: widget.child, + ), ), ), ), diff --git a/packages/flutter/test/cupertino/button_test.dart b/packages/flutter/test/cupertino/button_test.dart index c05f8ca8d8ee..39b22e5359a6 100644 --- a/packages/flutter/test/cupertino/button_test.dart +++ b/packages/flutter/test/cupertino/button_test.dart @@ -298,7 +298,10 @@ void main() { TestSemantics.rootChild( actions: SemanticsAction.tap.index, label: 'ABC', - flags: SemanticsFlag.isButton.index, + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isFocusable, + ] ), ], ), @@ -486,6 +489,119 @@ void main() { kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic, ); }); + + testWidgets('Button can be focused and has default colors', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Button'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Border defaultFocusBorder = Border.fromBorderSide( + BorderSide( + color: Color(0xcc6eadf2), + width: 3.5, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () { }, + focusNode: focusNode, + autofocus: true, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(focusNode.hasPrimaryFocus, isTrue); + + // The button has no border. + final BoxDecoration unfocusedDecoration = tester.widget( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ).decoration as BoxDecoration; + await tester.pump(); + expect(unfocusedDecoration.border, null); + + // When focused, the button has a light blue border outline by default. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + final BoxDecoration decoration = tester.widget( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ).decoration as BoxDecoration; + expect(decoration.border, defaultFocusBorder); + }); + + testWidgets('Button configures focus color', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Button'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color focusColor = CupertinoColors.systemGreen; + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () { }, + focusNode: focusNode, + autofocus: true, + focusColor: focusColor, + child: const Text('Tap me'), + ), + ), + ), + ); + + expect(focusNode.hasPrimaryFocus, isTrue); + focusNode.requestFocus(); + await tester.pump(); + final BoxDecoration decoration = tester.widget( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ).decoration as BoxDecoration; + final Border border = decoration.border! as Border; + await tester.pumpAndSettle(); + expect(border.top.color, focusColor); + expect(border.left.color, focusColor); + expect(border.right.color, focusColor); + expect(border.bottom.color, focusColor); + }); + + testWidgets('CupertinoButton.onFocusChange callback', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'CupertinoButton'); + bool focused = false; + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoButton( + onPressed: () { }, + focusNode: focusNode, + onFocusChange: (bool value) { + focused = value; + }, + child: const Text('Tap me'), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(focused, isTrue); + expect(focusNode.hasFocus, isTrue); + + focusNode.unfocus(); + await tester.pump(); + expect(focused, isFalse); + expect(focusNode.hasFocus, isFalse); + }); } Widget boilerplate({ required Widget child }) {