blob: 919a0e8512c1d4a6bf4c6233e34c87a8f8ec807a [file] [log] [blame] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// @docImport 'nav_bar.dart';
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'text_theme.dart';
import 'theme.dart';
// Measured against iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS).
/// The size of a [CupertinoButton].
/// Based on the iOS (17) [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS).
enum CupertinoButtonSize {
/// Displays a smaller button with round sides and smaller text (uses [CupertinoTextThemeData.actionSmallTextStyle]).
small,
/// Displays a medium sized button with round sides and regular-sized text.
medium,
/// Displays a (classic) large button with rounded edges and regular-sized text.
large,
}
/// The style of a [CupertinoButton] that changes the style of the button's background.
///
/// Based on the iOS Human Interface Guidelines (https://developer.apple.com/design/human-interface-guidelines/buttons#iOS-iPadOS).
enum _CupertinoButtonStyle {
/// No background or border, primary foreground color.
plain,
/// Translucent background, primary foreground color.
tinted,
/// Solid background, contrasting foreground color.
filled,
}
/// An iOS-style button.
///
/// Takes in a text or an icon that fades out and in on touch. May optionally have a
/// background.
///
/// The [padding] defaults to 16.0 pixels. When using a [CupertinoButton] within
/// a fixed height parent, like a [CupertinoNavigationBar], a smaller, or even
/// [EdgeInsets.zero], should be used to prevent clipping larger [child]
/// widgets.
///
/// Preserves any parent [IconThemeData] but overwrites its [IconThemeData.color]
/// with the [CupertinoThemeData.primaryColor] (or
/// [CupertinoThemeData.primaryContrastingColor] if the button is disabled).
///
/// {@tool dartpad}
/// This sample shows produces an enabled and disabled [CupertinoButton] and
/// [CupertinoButton.filled].
///
/// ** See code in examples/api/lib/cupertino/button/cupertino_button.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * <https://developer.apple.com/design/human-interface-guidelines/buttons/>
class CupertinoButton extends StatefulWidget {
/// Creates an iOS-style button.
const CupertinoButton({
super.key,
required this.child,
this.sizeStyle = CupertinoButtonSize.large,
this.padding,
this.color,
this.foregroundColor,
this.disabledColor = CupertinoColors.quaternarySystemFill,
@Deprecated(
'Use minimumSize instead. '
'This feature was deprecated after v3.28.0-3.0.pre.',
)
this.minSize,
this.minimumSize,
this.pressedOpacity = 0.4,
this.borderRadius,
this.alignment = Alignment.center,
this.focusColor,
this.focusNode,
this.onFocusChange,
this.autofocus = false,
this.mouseCursor,
this.onLongPress,
required this.onPressed,
}) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)),
assert(minimumSize == null || minSize == null),
_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.foregroundColor,
this.disabledColor = CupertinoColors.tertiarySystemFill,
@Deprecated(
'Use minimumSize instead. '
'This feature was deprecated after v3.28.0-3.0.pre.',
)
this.minSize,
this.minimumSize,
this.pressedOpacity = 0.4,
this.borderRadius,
this.alignment = Alignment.center,
this.focusColor,
this.focusNode,
this.onFocusChange,
this.autofocus = false,
this.mouseCursor,
this.onLongPress,
required this.onPressed,
}) : assert(minimumSize == null || minSize == null),
_style = _CupertinoButtonStyle.tinted;
/// Creates an iOS-style button with a filled background.
///
/// The background color is derived from the [color] argument.
/// The foreground color is the [CupertinoTheme]'s `primaryContrastingColor`.
const CupertinoButton.filled({
super.key,
required this.child,
this.sizeStyle = CupertinoButtonSize.large,
this.padding,
this.color,
this.disabledColor = CupertinoColors.tertiarySystemFill,
this.foregroundColor,
@Deprecated(
'Use minimumSize instead. '
'This feature was deprecated after v3.28.0-3.0.pre.',
)
this.minSize,
this.minimumSize,
this.pressedOpacity = 0.4,
this.borderRadius,
this.alignment = Alignment.center,
this.focusColor,
this.focusNode,
this.onFocusChange,
this.autofocus = false,
this.mouseCursor,
this.onLongPress,
required this.onPressed,
}) : assert(pressedOpacity == null || (pressedOpacity >= 0.0 && pressedOpacity <= 1.0)),
assert(minimumSize == null || minSize == null),
_style = _CupertinoButtonStyle.filled;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
/// The amount of space to surround the child inside the bounds of the button.
///
/// Defaults to 16.0 pixels.
final EdgeInsetsGeometry? padding;
/// The color of the button's background.
///
/// Defaults to null which produces a button with no background or border.
///
/// Defaults to the [CupertinoTheme]'s `primaryColor` when the
/// [CupertinoButton.filled] constructor is used.
final Color? color;
/// The color of the button's background when the button is disabled.
///
/// Ignored if the [CupertinoButton] doesn't also have a [color].
///
/// Defaults to [CupertinoColors.quaternarySystemFill] when [color] is
/// specified.
final Color disabledColor;
/// The color of the button's text and icons.
///
/// Defaults to the [CupertinoTheme]'s `primaryColor` when the
/// [CupertinoButton.filled] constructor is used.
final Color? foregroundColor;
/// The callback that is called when the button is tapped or otherwise activated.
///
/// If [onPressed] and [onLongPress] callbacks are null, then the button will be disabled.
final VoidCallback? onPressed;
/// If [onPressed] and [onLongPress] callbacks are null, then the button will be disabled.
final VoidCallback? onLongPress;
/// Minimum size of the button.
///
/// Defaults to kMinInteractiveDimensionCupertino which the iOS Human
/// Interface Guidelines recommends as the minimum tappable area.
@Deprecated(
'Use minimumSize instead. '
'This feature was deprecated after v3.28.0-3.0.pre.',
)
final double? minSize;
/// The minimum size of the button.
///
/// Defaults to a button with a height and a width of
/// [kMinInteractiveDimensionCupertino], which the iOS Human
/// Interface Guidelines recommends as the minimum tappable area.
final Size? minimumSize;
/// The opacity that the button will fade to when it is pressed.
/// The button will have an opacity of 1.0 when it is not pressed.
///
/// This defaults to 0.4. If null, opacity will not change on pressed if using
/// your own custom effects is desired.
final double? pressedOpacity;
/// The radius of the button's corners when it has a background color.
///
/// 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
/// [padding]. If the button's size is constrained to a fixed size, for example by
/// enclosing it with a [SizedBox], this property defines how the child is aligned
/// within the available space.
///
/// 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;
/// The cursor for a mouse pointer when it enters or is hovering over the widget.
///
/// If [mouseCursor] is a [WidgetStateMouseCursor],
/// [WidgetStateProperty.resolve] is used for the following [WidgetState]:
/// * [WidgetState.disabled].
/// * [WidgetState.pressed].
/// * [WidgetState.focused].
///
/// If null, then [MouseCursor.defer] is used when the button is disabled.
/// When the button is enabled, [SystemMouseCursors.click] is used on Web
/// and [MouseCursor.defer] is used on other platforms.
///
/// See also:
///
/// * [WidgetStateMouseCursor], a [MouseCursor] that implements
/// [WidgetStateProperty] which is used in APIs that need to accept
/// either a [MouseCursor] or a [WidgetStateProperty].
final MouseCursor? mouseCursor;
final _CupertinoButtonStyle _style;
/// Whether the button is enabled or disabled. Buttons are disabled by default. To
/// enable a button, set [onPressed] or [onLongPress] to a non-null value.
bool get enabled => onPressed != null || onLongPress != null;
/// The distance a button needs to be moved after being pressed for its opacity to change.
///
/// The opacity changes when the position moved is this distance away from the button.
static double tapMoveSlop() {
return switch (defaultTargetPlatform) {
TargetPlatform.iOS ||
TargetPlatform.android ||
TargetPlatform.fuchsia => kCupertinoButtonTapMoveSlop,
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 0.0,
};
}
@override
State<CupertinoButton> createState() => _CupertinoButtonState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'disabled'));
}
}
class _CupertinoButtonState extends State<CupertinoButton> with SingleTickerProviderStateMixin {
// Eyeballed values. Feel free to tweak.
static const Duration kFadeOutDuration = Duration(milliseconds: 120);
static const Duration kFadeInDuration = Duration(milliseconds: 180);
final Tween<double> _opacityTween = Tween<double>(begin: 1.0);
late AnimationController _animationController;
late Animation<double> _opacityAnimation;
late bool isFocused;
static final WidgetStateProperty<MouseCursor> _defaultCursor =
WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) {
return !states.contains(WidgetState.disabled) && kIsWeb
? SystemMouseCursors.click
: MouseCursor.defer;
});
@override
void initState() {
super.initState();
isFocused = false;
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
value: 0.0,
vsync: this,
);
_opacityAnimation = _animationController
.drive(CurveTween(curve: Curves.decelerate))
.drive(_opacityTween);
_setTween();
}
@override
void didUpdateWidget(CupertinoButton old) {
super.didUpdateWidget(old);
_setTween();
}
void _setTween() {
_opacityTween.end = widget.pressedOpacity ?? 1.0;
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
bool _buttonHeldDown = false;
bool _tapInProgress = false;
void _handleTapDown(TapDownDetails event) {
setState(() {
_tapInProgress = true;
});
if (!_buttonHeldDown) {
_buttonHeldDown = true;
_animate();
}
}
void _handleTapUp(TapUpDetails event) {
setState(() {
_tapInProgress = false;
});
if (_buttonHeldDown) {
_buttonHeldDown = false;
_animate();
}
final renderObject = context.findRenderObject()! as RenderBox;
final Offset localPosition = renderObject.globalToLocal(event.globalPosition);
if (renderObject.paintBounds.inflate(CupertinoButton.tapMoveSlop()).contains(localPosition)) {
_handleTap();
}
}
void _handleTapCancel() {
setState(() {
_tapInProgress = false;
});
if (_buttonHeldDown) {
_buttonHeldDown = false;
_animate();
}
}
void _handleTapMove(TapMoveDetails event) {
final renderObject = context.findRenderObject()! as RenderBox;
final Offset localPosition = renderObject.globalToLocal(event.globalPosition);
final bool buttonShouldHeldDown = renderObject.paintBounds
.inflate(CupertinoButton.tapMoveSlop())
.contains(localPosition);
if (_tapInProgress && buttonShouldHeldDown != _buttonHeldDown) {
_buttonHeldDown = buttonShouldHeldDown;
_animate();
}
}
void _handleTap([Intent? _]) {
if (widget.onPressed != null) {
widget.onPressed!();
context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
}
}
void _animate() {
if (_animationController.isAnimating) {
return;
}
final bool wasHeldDown = _buttonHeldDown;
final TickerFuture ticker = _buttonHeldDown
? _animationController.animateTo(
1.0,
duration: kFadeOutDuration,
curve: Curves.easeInOutCubicEmphasized,
)
: _animationController.animateTo(
0.0,
duration: kFadeInDuration,
curve: Curves.easeOutCubic,
);
ticker.then<void>((void value) {
if (mounted && wasHeldDown != _buttonHeldDown) {
_animate();
}
});
}
void _onShowFocusHighlight(bool showHighlight) {
setState(() {
isFocused = showHighlight;
});
}
late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleTap),
};
@override
Widget build(BuildContext context) {
final bool enabled = widget.enabled;
final Size? minimumSize = widget.minimumSize == null
? widget.minSize == null
? null
: Size(widget.minSize!, widget.minSize!)
: widget.minimumSize!;
final CupertinoThemeData themeData = CupertinoTheme.of(context);
final Color primaryColor = themeData.primaryColor;
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 effectiveForegroundColor =
widget.foregroundColor ??
switch ((widget._style, enabled)) {
(_CupertinoButtonStyle.filled, _) => themeData.primaryContrastingColor,
(_, true) => primaryColor,
(_, false) => CupertinoDynamicColor.resolve(CupertinoColors.tertiaryLabel, context),
};
final Color effectiveFocusOutlineColor =
widget.focusColor ??
HSLColor.fromColor(
(backgroundColor ?? CupertinoColors.activeBlue).withOpacity(
kCupertinoFocusColorOpacity,
),
)
.withLightness(kCupertinoFocusColorBrightness)
.withSaturation(kCupertinoFocusColorSaturation)
.toColor();
final TextStyle textStyle =
(widget.sizeStyle == CupertinoButtonSize.small
? themeData.textTheme.actionSmallTextStyle
: themeData.textTheme.actionTextStyle)
.copyWith(color: effectiveForegroundColor);
final IconThemeData iconTheme = IconTheme.of(context).copyWith(
color: effectiveForegroundColor,
size: textStyle.fontSize != null
? textStyle.fontSize! * 1.2
: kCupertinoButtonDefaultIconSize,
);
final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
final states = <WidgetState>{
if (!enabled) WidgetState.disabled,
if (_tapInProgress) WidgetState.pressed,
if (isFocused) WidgetState.focused,
};
final MouseCursor effectiveMouseCursor =
WidgetStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ??
_defaultCursor.resolve(states);
final shapeDecoration = ShapeDecoration(
shape: RoundedSuperellipseBorder(
side: enabled && isFocused
? BorderSide(
color: effectiveFocusOutlineColor,
width: 3.5,
strokeAlign: BorderSide.strokeAlignOutside,
)
: BorderSide.none,
borderRadius: widget.borderRadius ?? kCupertinoButtonSizeBorderRadius[widget.sizeStyle],
),
color: backgroundColor != null && !enabled
? CupertinoDynamicColor.resolve(widget.disabledColor, context)
: backgroundColor,
);
return MouseRegion(
cursor: effectiveMouseCursor,
child: FocusableActionDetector(
actions: _actionMap,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
onFocusChange: widget.onFocusChange,
onShowFocusHighlight: _onShowFocusHighlight,
enabled: enabled,
child: RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(postAcceptSlopTolerance: null),
(TapGestureRecognizer instance) {
instance.onTapDown = enabled ? _handleTapDown : null;
instance.onTapUp = enabled ? _handleTapUp : null;
instance.onTapCancel = enabled ? _handleTapCancel : null;
instance.onTapMove = enabled ? _handleTapMove : null;
instance.gestureSettings = gestureSettings;
},
),
if (widget.onLongPress != null)
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer instance) {
instance.onLongPress = widget.onLongPress;
instance.gestureSettings = gestureSettings;
},
),
},
child: Semantics(
button: true,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth:
minimumSize?.width ??
kCupertinoButtonMinSize[widget.sizeStyle] ??
kMinInteractiveDimensionCupertino,
minHeight:
minimumSize?.height ??
kCupertinoButtonMinSize[widget.sizeStyle] ??
kMinInteractiveDimensionCupertino,
),
child: FadeTransition(
opacity: _opacityAnimation,
child: DecoratedBox(
decoration: shapeDecoration,
child: Padding(
padding: widget.padding ?? kCupertinoButtonPadding[widget.sizeStyle]!,
child: Align(
alignment: widget.alignment,
widthFactor: 1.0,
heightFactor: 1.0,
child: DefaultTextStyle(
style: textStyle,
child: IconTheme(data: iconTheme, child: widget.child),
),
),
),
),
),
),
),
),
),
);
}
}