diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart index 138c14d468..96dd54fa78 100644 --- a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart +++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart @@ -63,7 +63,6 @@ class _EditableTextToolbarBuilderExampleAppState extends State= 0.0 && pressedOpacity <= 1.0)), - _filled = false; + _style = _CupertinoButtonStyle.plain; + + /// Creates an iOS-style button with a tinted background. + /// + /// The background color is derived from the [CupertinoTheme]'s `primaryColor` + transparency. + /// The foreground color is the [CupertinoTheme]'s `primaryColor`. + /// + /// To specify a custom background color, use the [color] argument of the + /// default constructor. + /// + /// To match the iOS "grey" button style, set [color] to [CupertinoColors.systemGrey]. + const CupertinoButton.tinted({ + super.key, + required this.child, + this.sizeStyle = CupertinoButtonSize.large, + this.padding, + this.color, + this.disabledColor = CupertinoColors.tertiarySystemFill, + this.minSize, + this.pressedOpacity = 0.4, + this.borderRadius, + this.alignment = Alignment.center, + this.focusColor, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + required this.onPressed, + }) : _style = _CupertinoButtonStyle.tinted; /// Creates an iOS-style button with a filled background. /// @@ -72,11 +119,12 @@ class CupertinoButton extends StatefulWidget { const CupertinoButton.filled({ super.key, required this.child, + this.sizeStyle = CupertinoButtonSize.large, this.padding, - this.disabledColor = CupertinoColors.quaternarySystemFill, - this.minSize = kMinInteractiveDimensionCupertino, + this.disabledColor = CupertinoColors.tertiarySystemFill, + this.minSize, this.pressedOpacity = 0.4, - this.borderRadius = const BorderRadius.all(Radius.circular(8.0)), + this.borderRadius, this.alignment = Alignment.center, this.focusColor, this.focusNode, @@ -85,7 +133,7 @@ class CupertinoButton extends StatefulWidget { required this.onPressed, }) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)), color = null, - _filled = true; + _style = _CupertinoButtonStyle.filled; /// The widget below this widget in the tree. /// @@ -133,9 +181,14 @@ class CupertinoButton extends StatefulWidget { /// The radius of the button's corners when it has a background color. /// - /// Defaults to round corners of 8 logical pixels. + /// Defaults to [kCupertinoButtonSizeBorderRadius], based on [sizeStyle]. final BorderRadius? borderRadius; + /// The size of the button. + /// + /// Defaults to [CupertinoButtonSize.large]. + final CupertinoButtonSize sizeStyle; + /// The alignment of the button's [child]. /// /// Typically buttons are sized to be just big enough to contain the child and its @@ -166,7 +219,7 @@ class CupertinoButton extends StatefulWidget { /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; - final bool _filled; + final _CupertinoButtonStyle _style; /// Whether the button is enabled or disabled. Buttons are disabled by default. To /// enable a button, set its [onPressed] property to a non-null value. @@ -273,15 +326,24 @@ class _CupertinoButtonState extends State with SingleTickerProv final bool enabled = widget.enabled; final CupertinoThemeData themeData = CupertinoTheme.of(context); final Color primaryColor = themeData.primaryColor; - final Color? backgroundColor = widget.color == null - ? (widget._filled ? primaryColor : null) - : CupertinoDynamicColor.maybeResolve(widget.color, context); - - final Color foregroundColor = backgroundColor != null + final Color? backgroundColor = ( + widget.color == null + ? widget._style != _CupertinoButtonStyle.plain + ? primaryColor + : null + : CupertinoDynamicColor.maybeResolve(widget.color, context) + )?.withOpacity( + widget._style == _CupertinoButtonStyle.tinted + ? CupertinoTheme.brightnessOf(context) == Brightness.light + ? kCupertinoButtonTintedOpacityLight + : kCupertinoButtonTintedOpacityDark + : widget.color?.opacity ?? 1.0, + ); + final Color foregroundColor = widget._style == _CupertinoButtonStyle.filled ? themeData.primaryContrastingColor : enabled ? primaryColor - : CupertinoDynamicColor.resolve(CupertinoColors.placeholderText, context); + : CupertinoDynamicColor.resolve(CupertinoColors.tertiaryLabel, context); final Color effectiveFocusOutlineColor = widget.focusColor ?? HSLColor @@ -291,8 +353,17 @@ class _CupertinoButtonState extends State with SingleTickerProv .withSaturation(kCupertinoFocusColorSaturation) .toColor(); - final TextStyle textStyle = themeData.textTheme.textStyle.copyWith(color: foregroundColor); - final IconThemeData iconTheme = IconTheme.of(context).copyWith(color: foregroundColor); + final TextStyle textStyle = ( + widget.sizeStyle == CupertinoButtonSize.small + ? themeData.textTheme.actionSmallTextStyle + : themeData.textTheme.actionTextStyle + ).copyWith(color: foregroundColor); + final IconThemeData iconTheme = IconTheme.of(context).copyWith( + color: foregroundColor, + size: textStyle.fontSize != null + ? textStyle.fontSize! * 1.2 + : kCupertinoButtonDefaultIconSize, + ); return MouseRegion( cursor: enabled && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, @@ -311,12 +382,10 @@ class _CupertinoButtonState extends State with SingleTickerProv child: Semantics( button: true, child: ConstrainedBox( - constraints: widget.minSize == null - ? const BoxConstraints() - : BoxConstraints( - minWidth: widget.minSize!, - minHeight: widget.minSize!, - ), + constraints: BoxConstraints( + minWidth: widget.minSize ?? kCupertinoButtonMinSize[widget.sizeStyle] ?? kMinInteractiveDimensionCupertino, + minHeight: widget.minSize ?? kCupertinoButtonMinSize[widget.sizeStyle] ?? kMinInteractiveDimensionCupertino, + ), child: FadeTransition( opacity: _opacityAnimation, child: DecoratedBox( @@ -330,15 +399,13 @@ class _CupertinoButtonState extends State with SingleTickerProv ), ) : null, - borderRadius: widget.borderRadius, + borderRadius: widget.borderRadius ?? kCupertinoButtonSizeBorderRadius[widget.sizeStyle], color: backgroundColor != null && !enabled ? CupertinoDynamicColor.resolve(widget.disabledColor, context) : backgroundColor, ), child: Padding( - padding: widget.padding ?? (backgroundColor != null - ? _kBackgroundButtonPadding - : _kButtonPadding), + padding: widget.padding ?? kCupertinoButtonPadding[widget.sizeStyle]!, child: Align( alignment: widget.alignment, widthFactor: 1.0, diff --git a/packages/flutter/lib/src/cupertino/constants.dart b/packages/flutter/lib/src/cupertino/constants.dart index c7d1071683..2ed80d1866 100644 --- a/packages/flutter/lib/src/cupertino/constants.dart +++ b/packages/flutter/lib/src/cupertino/constants.dart @@ -5,6 +5,10 @@ /// @docImport 'package:flutter/material.dart'; library; +import 'package:flutter/widgets.dart'; + +import 'button.dart'; + /// The minimum dimension of any interactive region according to the iOS Human /// Interface Guidelines. /// @@ -31,3 +35,55 @@ const double kMinInteractiveDimensionCupertino = 44.0; const double kCupertinoFocusColorOpacity = 0.80, kCupertinoFocusColorBrightness = 0.69, kCupertinoFocusColorSaturation = 0.835; + +/// Opacity values for the background of a [CupertinoButton.tinted]. +/// +/// See also: +/// +/// * +const double kCupertinoButtonTintedOpacityLight = 0.12, + kCupertinoButtonTintedOpacityDark = 0.26; + +/// The default value for [IconThemeData.size] of [CupertinoButton.child]. +/// +/// Set to match the most-frequent size of icons in iOS (matches md/lg). +/// +/// Used only when the [CupertinoTextThemeData.actionTextStyle] or [CupertinoTextThemeData.actionSmallTextStyle] +/// has a null [TextStyle.fontSize]. +const double kCupertinoButtonDefaultIconSize = 20.0; + +/// The padding values for the different [CupertinoButtonSize]s. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +const Map kCupertinoButtonPadding = { + CupertinoButtonSize.small: EdgeInsets.symmetric( + vertical: 6, + horizontal: 12, + ), + CupertinoButtonSize.medium: EdgeInsets.symmetric( + vertical: 10, + horizontal: 15, + ), + CupertinoButtonSize.large: EdgeInsets.symmetric( + vertical: 16, + horizontal: 20, + ), +}; + +/// The border radius values for the different [CupertinoButtonSize]s. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +final Map kCupertinoButtonSizeBorderRadius = { + CupertinoButtonSize.small: BorderRadius.circular(40), + CupertinoButtonSize.medium: BorderRadius.circular(40), + CupertinoButtonSize.large: BorderRadius.circular(12), +}; + +/// The minimum size of a [CupertinoButton] based on the [CupertinoButtonSize]. +/// +/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS). +const Map kCupertinoButtonMinSize = { + CupertinoButtonSize.small: 28, + CupertinoButtonSize.medium: 32, + CupertinoButtonSize.large: 44, +}; diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart index 1507f3073e..f817333454 100644 --- a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart +++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart @@ -133,7 +133,6 @@ class _CupertinoTextSelectionToolbarButtonState extends State _tabLabelTextStyle ?? _defaults.tabLabelTextStyle; @@ -216,6 +241,7 @@ class CupertinoTextThemeData with Diagnosticable { CupertinoDynamicColor.maybeResolve(_primaryColor, context), _resolveTextStyle(_textStyle, context), _resolveTextStyle(_actionTextStyle, context), + _resolveTextStyle(_actionSmallTextStyle, context), _resolveTextStyle(_tabLabelTextStyle, context), _resolveTextStyle(_navTitleTextStyle, context), _resolveTextStyle(_navLargeTitleTextStyle, context), @@ -231,6 +257,7 @@ class CupertinoTextThemeData with Diagnosticable { Color? primaryColor, TextStyle? textStyle, TextStyle? actionTextStyle, + TextStyle? actionSmallTextStyle, TextStyle? tabLabelTextStyle, TextStyle? navTitleTextStyle, TextStyle? navLargeTitleTextStyle, @@ -243,6 +270,7 @@ class CupertinoTextThemeData with Diagnosticable { primaryColor ?? _primaryColor, textStyle ?? _textStyle, actionTextStyle ?? _actionTextStyle, + actionSmallTextStyle ?? _actionSmallTextStyle, tabLabelTextStyle ?? _tabLabelTextStyle, navTitleTextStyle ?? _navTitleTextStyle, navLargeTitleTextStyle ?? _navLargeTitleTextStyle, @@ -258,6 +286,7 @@ class CupertinoTextThemeData with Diagnosticable { const CupertinoTextThemeData defaultData = CupertinoTextThemeData(); properties.add(DiagnosticsProperty('textStyle', textStyle, defaultValue: defaultData.textStyle)); properties.add(DiagnosticsProperty('actionTextStyle', actionTextStyle, defaultValue: defaultData.actionTextStyle)); + properties.add(DiagnosticsProperty('actionSmallTextStyle', actionSmallTextStyle, defaultValue: defaultData.actionSmallTextStyle)); properties.add(DiagnosticsProperty('tabLabelTextStyle', tabLabelTextStyle, defaultValue: defaultData.tabLabelTextStyle)); properties.add(DiagnosticsProperty('navTitleTextStyle', navTitleTextStyle, defaultValue: defaultData.navTitleTextStyle)); properties.add(DiagnosticsProperty('navLargeTitleTextStyle', navLargeTitleTextStyle, defaultValue: defaultData.navLargeTitleTextStyle)); @@ -279,6 +308,7 @@ class CupertinoTextThemeData with Diagnosticable { && other._primaryColor == _primaryColor && other._textStyle == _textStyle && other._actionTextStyle == _actionTextStyle + && other._actionSmallTextStyle == _actionSmallTextStyle && other._tabLabelTextStyle == _tabLabelTextStyle && other._navTitleTextStyle == _navTitleTextStyle && other._navLargeTitleTextStyle == _navLargeTitleTextStyle @@ -293,6 +323,7 @@ class CupertinoTextThemeData with Diagnosticable { _primaryColor, _textStyle, _actionTextStyle, + _actionSmallTextStyle, _tabLabelTextStyle, _navTitleTextStyle, _navLargeTitleTextStyle, @@ -327,6 +358,7 @@ class _TextThemeDefaultsBuilder { TextStyle get dateTimePickerTextStyle => _applyLabelColor(_kDefaultDateTimePickerTextStyle, labelColor); TextStyle actionTextStyle({ Color? primaryColor }) => _kDefaultActionTextStyle.copyWith(color: primaryColor); + TextStyle actionSmallTextStyle({ Color? primaryColor }) => _kDefaultActionSmallTextStyle.copyWith(color: primaryColor); TextStyle navActionTextStyle({ Color? primaryColor }) => actionTextStyle(primaryColor: primaryColor); _TextThemeDefaultsBuilder resolveFrom(BuildContext context) { diff --git a/packages/flutter/test/cupertino/button_test.dart b/packages/flutter/test/cupertino/button_test.dart index 8cc9887d8f..d2e3a4d5e9 100644 --- a/packages/flutter/test/cupertino/button_test.dart +++ b/packages/flutter/test/cupertino/button_test.dart @@ -27,8 +27,8 @@ void main() { final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); expect( buttonBox.size, - // 1 10px character + 16px * 2 is smaller than the default 44px minimum. - const Size.square(44.0), + // 1 10px character + 20px * 2 = 50.0 + const Size(50.0, 44.0), ); }); @@ -44,7 +44,7 @@ void main() { final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); expect( buttonBox.size, - // 1 10px character + 16px * 2 is smaller than defined 60.0px minimum + // 1 10px character + 20px * 2 = 50.0 (is smaller than minSize: 60.0) const Size.square(minSize), ); }); @@ -59,8 +59,8 @@ void main() { final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); expect( buttonBox.size.width, - // 4 10px character + 16px * 2 = 72. - 72.0, + // 4 10px character + 20px * 2 = 80.0 + 80.0, ); }); @@ -129,17 +129,37 @@ void main() { expect(align.alignment, Alignment.centerLeft); }); - testWidgets('Button with background is wider', (WidgetTester tester) async { + testWidgets('Button size changes depending on size property', (WidgetTester tester) async { + const Widget child = Text('X', style: testStyle); + await tester.pumpWidget(boilerplate(child: const CupertinoButton( onPressed: null, - color: Color(0xFFFFFFFF), - child: Text('X', style: testStyle), + sizeStyle: CupertinoButtonSize.small, + child: child, ))); final RenderBox buttonBox = tester.renderObject(find.byType(CupertinoButton)); expect( - buttonBox.size.width, - // 1 10px character + 64 * 2 = 138 for buttons with background. - 138.0, + buttonBox.size, + const Size(34.0, 28.0) + ); + + await tester.pumpWidget(boilerplate(child: const CupertinoButton( + onPressed: null, + sizeStyle: CupertinoButtonSize.medium, + child: child, + ))); + expect( + buttonBox.size, + const Size(40.0, 32.0), + ); + + await tester.pumpWidget(boilerplate(child: const CupertinoButton( + onPressed: null, + child: child, + ))); + expect( + buttonBox.size, + const Size(50.0, 44.0), ); }); @@ -404,8 +424,27 @@ void main() { ), ), ); + expect(textStyle.color, isSameColorAs(CupertinoColors.activeBlue)); + await tester.pumpWidget( + CupertinoApp( + home: CupertinoButton.tinted( + onPressed: () { }, + child: Builder(builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }), + ), + ), + ); expect(textStyle.color, CupertinoColors.activeBlue); + BoxDecoration decoration = tester.widget( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ).decoration as BoxDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue.withOpacity(0.12))); await tester.pumpWidget( CupertinoApp( @@ -418,15 +457,14 @@ void main() { ), ), ); - expect(textStyle.color, isSameColorAs(CupertinoColors.white)); - BoxDecoration decoration = tester.widget( + decoration = tester.widget( find.descendant( of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox), ), ).decoration as BoxDecoration; - expect(decoration.color, CupertinoColors.activeBlue); + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue)); await tester.pumpWidget( CupertinoApp( @@ -442,6 +480,27 @@ void main() { ); expect(textStyle.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: CupertinoButton.tinted( + onPressed: () { }, + child: Builder(builder: (BuildContext context) { + textStyle = DefaultTextStyle.of(context).style; + return const Placeholder(); + }), + ), + ), + ); + expect(textStyle.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); + decoration = tester.widget( + find.descendant( + of: find.byType(CupertinoButton), + matching: find.byType(DecoratedBox), + ), + ).decoration as BoxDecoration; + expect(decoration.color, isSameColorAs(CupertinoColors.activeBlue.darkColor.withOpacity(0.26))); + await tester.pumpWidget( CupertinoApp( theme: const CupertinoThemeData(brightness: Brightness.dark), @@ -464,6 +523,14 @@ void main() { expect(decoration.color, isSameColorAs(CupertinoColors.systemBlue.darkColor)); }); + testWidgets("All CupertinoButton const maps keys' match the available style sizes", (WidgetTester tester) async { + for (final CupertinoButtonSize size in CupertinoButtonSize.values) { + expect(kCupertinoButtonPadding[size], isNotNull); + expect(kCupertinoButtonSizeBorderRadius[size], isNotNull); + expect(kCupertinoButtonMinSize[size], isNotNull); + } + }); + testWidgets('Hovering over Cupertino button updates cursor to clickable on Web', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( @@ -612,32 +679,57 @@ void main() { expect(focusNode.hasFocus, isFalse); }); - testWidgets('IconThemeData is not replaced by CupertinoButton', (WidgetTester tester) async { - const IconThemeData givenIconTheme = IconThemeData(size: 12.0); + testWidgets('IconThemeData falls back to default value when the TextStyle has a null size', (WidgetTester tester) async { + const IconThemeData defaultIconTheme = IconThemeData(size: kCupertinoButtonDefaultIconSize); IconThemeData? actualIconTheme; + // Large size. await tester.pumpWidget( CupertinoApp( + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData( + actionTextStyle: TextStyle(), + ), + ), home: Center( - child: IconTheme( - data: givenIconTheme, - child: CupertinoButton( - onPressed: () {}, - child: Builder( - builder: (BuildContext context) { - actualIconTheme = IconTheme.of(context); + child: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + actualIconTheme = IconTheme.of(context); - return const Placeholder(); - } - ), + return const Placeholder(); + } ), ), ), ), ); + expect(actualIconTheme?.size, defaultIconTheme.size); - expect(actualIconTheme?.size, givenIconTheme.size); + // Small size. + await tester.pumpWidget( + CupertinoApp( + theme: const CupertinoThemeData( + textTheme: CupertinoTextThemeData( + actionSmallTextStyle: TextStyle(), + ), + ), + home: Center( + child: CupertinoButton( + onPressed: () {}, + child: Builder( + builder: (BuildContext context) { + actualIconTheme = IconTheme.of(context); + + return const Placeholder(); + } + ), + ), + ), + ), + ); }); } diff --git a/packages/flutter/test/cupertino/text_theme_test.dart b/packages/flutter/test/cupertino/text_theme_test.dart index 7993d37441..54ec5c84a1 100644 --- a/packages/flutter/test/cupertino/text_theme_test.dart +++ b/packages/flutter/test/cupertino/text_theme_test.dart @@ -29,6 +29,12 @@ void main() { expect(theme.actionTextStyle.letterSpacing, -0.41); expect(theme.actionTextStyle.fontWeight, null); + // ActionSmallTextStyle 15 -0.23 (aka "Subheadline/Regular") + expect(theme.actionSmallTextStyle.fontSize, 15); + expect(theme.actionSmallTextStyle.fontFamily, 'CupertinoSystemText'); + expect(theme.actionSmallTextStyle.letterSpacing, -0.23); + expect(theme.actionSmallTextStyle.fontWeight, null); + // TextStyle 17 -0.41 expect(theme.tabLabelTextStyle.fontSize, 10); expect(theme.tabLabelTextStyle.fontFamily, 'CupertinoSystemText'); diff --git a/packages/flutter/test/cupertino/theme_test.dart b/packages/flutter/test/cupertino/theme_test.dart index bc68425cad..dd715c0fcb 100644 --- a/packages/flutter/test/cupertino/theme_test.dart +++ b/packages/flutter/test/cupertino/theme_test.dart @@ -189,6 +189,7 @@ void main() { 'applyThemeToAll', 'textStyle', 'actionTextStyle', + 'actionSmallTextStyle', 'tabLabelTextStyle', 'navTitleTextStyle', 'navLargeTitleTextStyle',