Skip to content

Commit f89c7c7

Browse files
Add focusNode, focusColor, onFocusChange, autofocus to CupertinoButton (flutter#150721)
Before: https://github.com/flutter/flutter/assets/77553258/e7ed7af0-03ab-4a7d-98dd-be1ce4e9c7da After: https://github.com/flutter/flutter/assets/77553258/ca93fc67-1816-4e18-b0c5-130975c7f06b Fixes flutter#144385 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Taha Tesser <[email protected]>
1 parent e5a35f4 commit f89c7c7

File tree

2 files changed

+221
-38
lines changed

2 files changed

+221
-38
lines changed

packages/flutter/lib/src/cupertino/button.dart

Lines changed: 104 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const EdgeInsets _kBackgroundButtonPadding = EdgeInsets.symmetric(
1616
horizontal: 64.0,
1717
);
1818

19+
// The relative values needed to transform a color to it's equivalent focus
20+
// outline color.
21+
const double _kCupertinoFocusColorOpacity = 0.80;
22+
const double _kCupertinoFocusColorBrightness = 0.69;
23+
const double _kCupertinoFocusColorSaturation = 0.835;
24+
1925
/// An iOS-style button.
2026
///
2127
/// 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 {
4854
this.pressedOpacity = 0.4,
4955
this.borderRadius = const BorderRadius.all(Radius.circular(8.0)),
5056
this.alignment = Alignment.center,
57+
this.focusColor,
58+
this.focusNode,
59+
this.onFocusChange,
60+
this.autofocus = false,
5161
required this.onPressed,
5262
}) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)),
5363
_filled = false;
@@ -67,6 +77,10 @@ class CupertinoButton extends StatefulWidget {
6777
this.pressedOpacity = 0.4,
6878
this.borderRadius = const BorderRadius.all(Radius.circular(8.0)),
6979
this.alignment = Alignment.center,
80+
this.focusColor,
81+
this.focusNode,
82+
this.onFocusChange,
83+
this.autofocus = false,
7084
required this.onPressed,
7185
}) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)),
7286
color = null,
@@ -131,6 +145,26 @@ class CupertinoButton extends StatefulWidget {
131145
/// Always defaults to [Alignment.center].
132146
final AlignmentGeometry alignment;
133147

148+
/// The color to use for the focus highlight for keyboard interactions.
149+
///
150+
/// Defaults to a slightly transparent [color]. If [color] is null, defaults
151+
/// to a slightly transparent [CupertinoColors.activeBlue]. Slightly
152+
/// transparent in this context means the color is used with an opacity of
153+
/// 0.80, a brightness of 0.69 and a saturation of 0.835.
154+
final Color? focusColor;
155+
156+
/// {@macro flutter.widgets.Focus.focusNode}
157+
final FocusNode? focusNode;
158+
159+
/// Handler called when the focus changes.
160+
///
161+
/// Called with true if this widget's node gains focus, and false if it loses
162+
/// focus.
163+
final ValueChanged<bool>? onFocusChange;
164+
165+
/// {@macro flutter.widgets.Focus.autofocus}
166+
final bool autofocus;
167+
134168
final bool _filled;
135169

136170
/// Whether the button is enabled or disabled. Buttons are disabled by default. To
@@ -156,9 +190,12 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
156190
late AnimationController _animationController;
157191
late Animation<double> _opacityAnimation;
158192

193+
late bool isFocused;
194+
159195
@override
160196
void initState() {
161197
super.initState();
198+
isFocused = false;
162199
_animationController = AnimationController(
163200
duration: const Duration(milliseconds: 200),
164201
value: 0.0,
@@ -224,6 +261,12 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
224261
});
225262
}
226263

264+
void _onShowFocusHighlight(bool showHighlight) {
265+
setState(() {
266+
isFocused = showHighlight;
267+
});
268+
}
269+
227270
@override
228271
Widget build(BuildContext context) {
229272
final bool enabled = widget.enabled;
@@ -239,47 +282,71 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
239282
? primaryColor
240283
: CupertinoDynamicColor.resolve(CupertinoColors.placeholderText, context);
241284

285+
final Color effectiveFocusOutlineColor = widget.focusColor ??
286+
HSLColor
287+
.fromColor((backgroundColor ?? CupertinoColors.activeBlue)
288+
.withOpacity(_kCupertinoFocusColorOpacity))
289+
.withLightness(_kCupertinoFocusColorBrightness)
290+
.withSaturation(_kCupertinoFocusColorSaturation)
291+
.toColor();
292+
242293
final TextStyle textStyle = themeData.textTheme.textStyle.copyWith(color: foregroundColor);
243294

244295
return MouseRegion(
245296
cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
246-
child: GestureDetector(
247-
behavior: HitTestBehavior.opaque,
248-
onTapDown: enabled ? _handleTapDown : null,
249-
onTapUp: enabled ? _handleTapUp : null,
250-
onTapCancel: enabled ? _handleTapCancel : null,
251-
onTap: widget.onPressed,
252-
child: Semantics(
253-
button: true,
254-
child: ConstrainedBox(
255-
constraints: widget.minSize == null
256-
? const BoxConstraints()
257-
: BoxConstraints(
258-
minWidth: widget.minSize!,
259-
minHeight: widget.minSize!,
260-
),
261-
child: FadeTransition(
262-
opacity: _opacityAnimation,
263-
child: DecoratedBox(
264-
decoration: BoxDecoration(
265-
borderRadius: widget.borderRadius,
266-
color: backgroundColor != null && !enabled
267-
? CupertinoDynamicColor.resolve(widget.disabledColor, context)
268-
: backgroundColor,
269-
),
270-
child: Padding(
271-
padding: widget.padding ?? (backgroundColor != null
272-
? _kBackgroundButtonPadding
273-
: _kButtonPadding),
274-
child: Align(
275-
alignment: widget.alignment,
276-
widthFactor: 1.0,
277-
heightFactor: 1.0,
278-
child: DefaultTextStyle(
279-
style: textStyle,
280-
child: IconTheme(
281-
data: IconThemeData(color: foregroundColor),
282-
child: widget.child,
297+
child: FocusableActionDetector(
298+
focusNode: widget.focusNode,
299+
autofocus: widget.autofocus,
300+
onFocusChange: widget.onFocusChange,
301+
onShowFocusHighlight: _onShowFocusHighlight,
302+
enabled: enabled,
303+
child: GestureDetector(
304+
behavior: HitTestBehavior.opaque,
305+
onTapDown: enabled ? _handleTapDown : null,
306+
onTapUp: enabled ? _handleTapUp : null,
307+
onTapCancel: enabled ? _handleTapCancel : null,
308+
onTap: widget.onPressed,
309+
child: Semantics(
310+
button: true,
311+
child: ConstrainedBox(
312+
constraints: widget.minSize == null
313+
? const BoxConstraints()
314+
: BoxConstraints(
315+
minWidth: widget.minSize!,
316+
minHeight: widget.minSize!,
317+
),
318+
child: FadeTransition(
319+
opacity: _opacityAnimation,
320+
child: DecoratedBox(
321+
decoration: BoxDecoration(
322+
border: enabled && isFocused
323+
? Border.fromBorderSide(
324+
BorderSide(
325+
color: effectiveFocusOutlineColor,
326+
width: 3.5,
327+
strokeAlign: BorderSide.strokeAlignOutside,
328+
),
329+
)
330+
: null,
331+
borderRadius: widget.borderRadius,
332+
color: backgroundColor != null && !enabled
333+
? CupertinoDynamicColor.resolve(widget.disabledColor, context)
334+
: backgroundColor,
335+
),
336+
child: Padding(
337+
padding: widget.padding ?? (backgroundColor != null
338+
? _kBackgroundButtonPadding
339+
: _kButtonPadding),
340+
child: Align(
341+
alignment: widget.alignment,
342+
widthFactor: 1.0,
343+
heightFactor: 1.0,
344+
child: DefaultTextStyle(
345+
style: textStyle,
346+
child: IconTheme(
347+
data: IconThemeData(color: foregroundColor),
348+
child: widget.child,
349+
),
283350
),
284351
),
285352
),

packages/flutter/test/cupertino/button_test.dart

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,10 @@ void main() {
298298
TestSemantics.rootChild(
299299
actions: SemanticsAction.tap.index,
300300
label: 'ABC',
301-
flags: SemanticsFlag.isButton.index,
301+
flags: <SemanticsFlag>[
302+
SemanticsFlag.isButton,
303+
SemanticsFlag.isFocusable,
304+
]
302305
),
303306
],
304307
),
@@ -486,6 +489,119 @@ void main() {
486489
kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic,
487490
);
488491
});
492+
493+
testWidgets('Button can be focused and has default colors', (WidgetTester tester) async {
494+
final FocusNode focusNode = FocusNode(debugLabel: 'Button');
495+
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
496+
const Border defaultFocusBorder = Border.fromBorderSide(
497+
BorderSide(
498+
color: Color(0xcc6eadf2),
499+
width: 3.5,
500+
strokeAlign: BorderSide.strokeAlignOutside,
501+
),
502+
);
503+
504+
await tester.pumpWidget(
505+
CupertinoApp(
506+
home: Center(
507+
child: CupertinoButton(
508+
onPressed: () { },
509+
focusNode: focusNode,
510+
autofocus: true,
511+
child: const Text('Tap me'),
512+
),
513+
),
514+
),
515+
);
516+
517+
expect(focusNode.hasPrimaryFocus, isTrue);
518+
519+
// The button has no border.
520+
final BoxDecoration unfocusedDecoration = tester.widget<DecoratedBox>(
521+
find.descendant(
522+
of: find.byType(CupertinoButton),
523+
matching: find.byType(DecoratedBox),
524+
),
525+
).decoration as BoxDecoration;
526+
await tester.pump();
527+
expect(unfocusedDecoration.border, null);
528+
529+
// When focused, the button has a light blue border outline by default.
530+
focusNode.requestFocus();
531+
await tester.pumpAndSettle();
532+
final BoxDecoration decoration = tester.widget<DecoratedBox>(
533+
find.descendant(
534+
of: find.byType(CupertinoButton),
535+
matching: find.byType(DecoratedBox),
536+
),
537+
).decoration as BoxDecoration;
538+
expect(decoration.border, defaultFocusBorder);
539+
});
540+
541+
testWidgets('Button configures focus color', (WidgetTester tester) async {
542+
final FocusNode focusNode = FocusNode(debugLabel: 'Button');
543+
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
544+
const Color focusColor = CupertinoColors.systemGreen;
545+
546+
await tester.pumpWidget(
547+
CupertinoApp(
548+
home: Center(
549+
child: CupertinoButton(
550+
onPressed: () { },
551+
focusNode: focusNode,
552+
autofocus: true,
553+
focusColor: focusColor,
554+
child: const Text('Tap me'),
555+
),
556+
),
557+
),
558+
);
559+
560+
expect(focusNode.hasPrimaryFocus, isTrue);
561+
focusNode.requestFocus();
562+
await tester.pump();
563+
final BoxDecoration decoration = tester.widget<DecoratedBox>(
564+
find.descendant(
565+
of: find.byType(CupertinoButton),
566+
matching: find.byType(DecoratedBox),
567+
),
568+
).decoration as BoxDecoration;
569+
final Border border = decoration.border! as Border;
570+
await tester.pumpAndSettle();
571+
expect(border.top.color, focusColor);
572+
expect(border.left.color, focusColor);
573+
expect(border.right.color, focusColor);
574+
expect(border.bottom.color, focusColor);
575+
});
576+
577+
testWidgets('CupertinoButton.onFocusChange callback', (WidgetTester tester) async {
578+
final FocusNode focusNode = FocusNode(debugLabel: 'CupertinoButton');
579+
bool focused = false;
580+
await tester.pumpWidget(
581+
CupertinoApp(
582+
home: Center(
583+
child: CupertinoButton(
584+
onPressed: () { },
585+
focusNode: focusNode,
586+
onFocusChange: (bool value) {
587+
focused = value;
588+
},
589+
child: const Text('Tap me'),
590+
),
591+
),
592+
),
593+
);
594+
595+
focusNode.requestFocus();
596+
await tester.pump();
597+
expect(focused, isTrue);
598+
expect(focusNode.hasFocus, isTrue);
599+
600+
focusNode.unfocus();
601+
await tester.pump();
602+
expect(focused, isFalse);
603+
expect(focusNode.hasFocus, isFalse);
604+
});
489605
}
490606

491607
Widget boilerplate({ required Widget child }) {

0 commit comments

Comments
 (0)