Skip to content

Commit

Permalink
Add focusNode, focusColor, onFocusChange, autofocus to `Cuper…
Browse files Browse the repository at this point in the history
…tinoButton` (#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 #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]>
  • Loading branch information
victorsanni and TahaTesser authored Jun 25, 2024
1 parent e5a35f4 commit f89c7c7
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 38 deletions.
141 changes: 104 additions & 37 deletions packages/flutter/lib/src/cupertino/button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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<bool>? 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
Expand All @@ -156,9 +190,12 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
late AnimationController _animationController;
late Animation<double> _opacityAnimation;

late bool isFocused;

@override
void initState() {
super.initState();
isFocused = false;
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
value: 0.0,
Expand Down Expand Up @@ -224,6 +261,12 @@ class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProv
});
}

void _onShowFocusHighlight(bool showHighlight) {
setState(() {
isFocused = showHighlight;
});
}

@override
Widget build(BuildContext context) {
final bool enabled = widget.enabled;
Expand All @@ -239,47 +282,71 @@ class _CupertinoButtonState extends State<CupertinoButton> 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,
),
),
),
),
Expand Down
118 changes: 117 additions & 1 deletion packages/flutter/test/cupertino/button_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,10 @@ void main() {
TestSemantics.rootChild(
actions: SemanticsAction.tap.index,
label: 'ABC',
flags: SemanticsFlag.isButton.index,
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isFocusable,
]
),
],
),
Expand Down Expand Up @@ -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<DecoratedBox>(
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<DecoratedBox>(
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<DecoratedBox>(
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 }) {
Expand Down

0 comments on commit f89c7c7

Please sign in to comment.