diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c6bd4af..4a426fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ +## [2.0.0-beta.2] +✨New ✨ +* `MacosSwitch` has been completely rewritten and now matches the native macOS switch in appearance and behavior. +* A `ControlSize` enum has been introduced, which will allow widgets to more closely match their native counterparts. + +πŸ”„ Updated πŸ”„ +* Some previously missing elements of the `MacosColor` class have been added. + ## [2.0.0-beta.1] 🚨 Breaking Changes 🚨 -* Migrate macos_ui to [macos_window_utils](https://pub.dev/packages/macos_window_utils), which provides the following benefits: +* Migrate `macos_ui` to [macos_window_utils](https://pub.dev/packages/macos_window_utils), which provides the following benefits: * Window animation smoothness is drastically improved, particularly when miniaturizing and deminiaturizing the application window. * Some visual artifacts that occurred while the window was being (de)miniaturized (such as the application's shadow going missing) no longer occur. * The sidebar remains transparent when the app's brightness setting mismatches the OS setting. diff --git a/README.md b/README.md index 4c8f9447..8164314d 100644 --- a/README.md +++ b/README.md @@ -657,26 +657,34 @@ PushButton( ## MacosSwitch -A switch is a visual toggle between two mutually exclusive states β€” on and off. A switch shows that it's on when the -accent color is visible and off when the switch appears colorless. [Learn more](https://developer.apple.com/design/human-interface-guidelines/macos/buttons/switches/) +A switch (also known as a toggle) is a control that offers a binary choice between two mutually exclusive states β€” on and off. A switch shows that it's on when the +accent color is visible and off when the switch appears colorless. -| On | Off | -| ------------------------------------------ | ------------------------------------------ | -| | | +The `ContolSize` enum can be passed to the `size` property to control the size of the switch. `MacosSwitch` supports the following +control sizes: +* `mini` +* `small` +* `regular` + +| Off | On | +|--------------------------------------------|--------------------------------------------| +| | | Here's an example of how to create a basic toggle switch: ```dart -bool selected = false; +bool switchValue = false; MacosSwitch( - value: selected, + value: switchValue, onChanged: (value) { - setState(() => selected = value); + setState(() => switchValue = value); }, ), ``` +Learn more about switches [here](https://developer.apple.com/design/human-interface-guidelines/toggles). + ## MacosSegmentedControl Displays one or more navigational tabs in a single horizontal group. Used by `MacosTabView` to navigate between the diff --git a/example/lib/pages/buttons_page.dart b/example/lib/pages/buttons_page.dart index 6c373f5f..befbaef1 100644 --- a/example/lib/pages/buttons_page.dart +++ b/example/lib/pages/buttons_page.dart @@ -217,11 +217,32 @@ class _ButtonsPageState extends State { const SizedBox(height: 20), const Text('MacosSwitch'), const SizedBox(height: 8), - MacosSwitch( - value: switchValue, - onChanged: (value) { - setState(() => switchValue = value); - }, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MacosSwitch( + value: switchValue, + size: ControlSize.mini, + onChanged: (value) { + setState(() => switchValue = value); + }, + ), + const SizedBox(width: 16.0), + MacosSwitch( + value: switchValue, + size: ControlSize.small, + onChanged: (value) { + setState(() => switchValue = value); + }, + ), + const SizedBox(width: 16.0), + MacosSwitch( + value: switchValue, + onChanged: (value) { + setState(() => switchValue = value); + }, + ), + ], ), const SizedBox(height: 20), const Text('MacosPulldownButton'), diff --git a/example/pubspec.lock b/example/pubspec.lock index bf64e601..931e90c9 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -97,7 +97,7 @@ packages: path: ".." relative: true source: path - version: "2.0.0-beta.1" + version: "2.0.0-beta.2" macos_window_utils: dependency: transitive description: diff --git a/lib/macos_ui.dart b/lib/macos_ui.dart index 4d3ac251..62dcc602 100644 --- a/lib/macos_ui.dart +++ b/lib/macos_ui.dart @@ -14,6 +14,9 @@ library macos_ui; +export 'package:macos_window_utils/macos/ns_window_delegate.dart'; +export 'package:macos_window_utils/macos_window_utils.dart'; + export 'src/buttons/back_button.dart'; export 'src/buttons/checkbox.dart'; export 'src/buttons/disclosure_button.dart'; @@ -29,6 +32,7 @@ export 'src/buttons/toolbar/toolbar_icon_button.dart'; export 'src/buttons/toolbar/toolbar_overflow_button.dart'; export 'src/buttons/toolbar/toolbar_pulldown_button.dart'; export 'src/dialogs/macos_alert_dialog.dart'; +export 'src/enums/control_size.dart'; export 'src/fields/search_field.dart'; export 'src/fields/text_field.dart'; export 'src/icon/image_icon.dart'; @@ -37,7 +41,6 @@ export 'src/indicators/capacity_indicators.dart'; export 'src/indicators/progress_indicators.dart'; export 'src/indicators/rating_indicator.dart'; export 'src/indicators/relevance_indicator.dart'; -export 'src/layout/scrollbar.dart'; export 'src/indicators/slider.dart'; export 'src/labels/label.dart'; export 'src/labels/tooltip.dart'; @@ -45,6 +48,7 @@ export 'src/layout/content_area.dart'; export 'src/layout/macos_list_tile.dart'; export 'src/layout/resizable_pane.dart'; export 'src/layout/scaffold.dart'; +export 'src/layout/scrollbar.dart'; export 'src/layout/sidebar/sidebar.dart'; export 'src/layout/sidebar/sidebar_item.dart'; export 'src/layout/sidebar/sidebar_items.dart'; @@ -60,8 +64,10 @@ export 'src/layout/toolbar/toolbar_overflow_menu.dart'; export 'src/layout/toolbar/toolbar_overflow_menu_item.dart'; export 'src/layout/toolbar/toolbar_popup.dart'; export 'src/layout/toolbar/toolbar_spacer.dart'; +export 'src/layout/wallpaper_tinted_area.dart'; export 'src/layout/window.dart'; export 'src/macos_app.dart'; +export 'src/macos_window_utils_config.dart'; export 'src/selectors/color_well.dart'; export 'src/selectors/date_picker.dart'; export 'src/selectors/time_picker.dart'; @@ -82,8 +88,3 @@ export 'src/theme/search_field_theme.dart'; export 'src/theme/time_picker_theme.dart'; export 'src/theme/tooltip_theme.dart'; export 'src/theme/typography.dart'; -export 'src/layout/wallpaper_tinted_area.dart'; -export 'src/macos_window_utils_config.dart'; - -export 'package:macos_window_utils/macos_window_utils.dart'; -export 'package:macos_window_utils/macos/ns_window_delegate.dart'; diff --git a/lib/src/buttons/switch.dart b/lib/src/buttons/switch.dart index 6dd4375e..25d7f637 100644 --- a/lib/src/buttons/switch.dart +++ b/lib/src/buttons/switch.dart @@ -1,23 +1,48 @@ -import 'package:flutter/cupertino.dart' as c; -import 'package:flutter/foundation.dart'; +import 'dart:ui'; + import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; import 'package:macos_ui/macos_ui.dart'; import 'package:macos_ui/src/library.dart'; +const _kDefaultBorderColor = CupertinoDynamicColor.withBrightness( + color: MacosColor.fromRGBO(215, 215, 215, 1.0), + darkColor: MacosColor.fromRGBO(101, 101, 101, 1.0), +); + +const _kDefaultTrackColor = CupertinoDynamicColor.withBrightness( + color: MacosColor.fromRGBO(228, 226, 228, 1.0), + darkColor: MacosColor.fromRGBO(66, 66, 66, 1.0), +); + +// Dark color might be Color.fromRGBO(255, 255, 255, 0.721)?? +const _kDefaultKnobColor = CupertinoDynamicColor.withBrightness( + color: MacosColors.white, + darkColor: MacosColor.fromRGBO(207, 207, 207, 1.0), +); + /// {@template macosSwitch} -/// A switch is a visual toggle between two mutually exclusive -/// states β€” on and off. A switch shows that it's on when the -/// accent color is visible and off when the switch appears colorless. +/// A switch is a control that offers a binary choice between two mutually +/// exclusive states β€” on and off. +/// +/// A switch shows that it's on when the [activeColor] is visible and off when +/// the [trackColor] is visible. +/// +/// Additional Reference: +/// * [Toggles (Human Interface Guidelines)](https://developer.apple.com/design/human-interface-guidelines/components/selection-and-input/toggles) +/// * [Toggles (Apple Developer)](https://developer.apple.com/documentation/swiftui/toggle) /// {@endtemplate} -class MacosSwitch extends StatelessWidget { +class MacosSwitch extends StatefulWidget { /// {@macro macosSwitch} const MacosSwitch({ super.key, required this.value, + this.size = ControlSize.regular, required this.onChanged, this.dragStartBehavior = DragStartBehavior.start, this.activeColor, this.trackColor, + this.knobColor, this.semanticLabel, }); @@ -26,6 +51,13 @@ class MacosSwitch extends StatelessWidget { /// Must not be null. final bool value; + /// The size of the switch, which is [ControlSize.regular] by default. + /// + /// Allowable sizes are [ControlSize.mini], [ControlSize.small], and + /// [ControlSize.regular]. If [ControlSize.large] is used, the switch will + /// size itself as a [ControlSize.regular] switch. + final ControlSize size; + /// Called when the user toggles with switch on or off. /// /// The switch passes the new value to the callback but does not actually @@ -39,7 +71,7 @@ class MacosSwitch extends StatelessWidget { /// gets rebuilt; for example: /// /// ```dart - /// Switch( + /// MacosSwitch( /// value: _giveVerse, /// onChanged: (bool newValue) { /// setState(() { @@ -53,19 +85,25 @@ class MacosSwitch extends StatelessWidget { /// {@macro flutter.cupertino.CupertinoSwitch.dragStartBehavior} final DragStartBehavior dragStartBehavior; - /// The color to use when this switch is on. + /// The color to use for the track when this switch is on. /// /// Defaults to [MacosThemeData.primaryColor] when null. - final Color? activeColor; + final MacosColor? activeColor; - /// The color to use for the background when the switch is off. + /// The color to use for track when this switch is off. /// - /// Defaults to [CupertinoColors.secondarySystemFill] when null. - final Color? trackColor; + /// Defaults to [MacosTheme.primaryColor] when null. + final MacosColor? trackColor; + + /// The color to use for the switch's knob. + final MacosColor? knobColor; /// The semantic label used by screen readers. final String? semanticLabel; + @override + State createState() => _MacosSwitchState(); + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -74,6 +112,7 @@ class MacosSwitch extends StatelessWidget { value: value, ifFalse: 'unchecked', )); + properties.add(EnumProperty('size', size)); properties.add(EnumProperty('dragStartBehavior', dragStartBehavior)); properties.add(FlagProperty( 'enabled', @@ -82,26 +121,606 @@ class MacosSwitch extends StatelessWidget { )); properties.add(ColorProperty('activeColor', activeColor)); properties.add(ColorProperty('trackColor', trackColor)); + properties.add(ColorProperty('knobColor', knobColor)); properties.add(StringProperty('semanticLabel', semanticLabel)); } +} + +class _MacosSwitchState extends State + with TickerProviderStateMixin { + late TapGestureRecognizer _tap; + late HorizontalDragGestureRecognizer _drag; + + late AnimationController _positionController; + late CurvedAnimation position; + + late AnimationController _reactionController; + late Animation _reaction; + + bool get isInteractive => widget.onChanged != null; + + // A non-null boolean value that changes to true at the end of a drag if the + // switch must be animated to the position indicated by the widget's value. + bool needsPositionAnimation = false; + + @override + void initState() { + super.initState(); + + _tap = TapGestureRecognizer() + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTap = _handleTap + ..onTapCancel = _handleTapCancel; + _drag = HorizontalDragGestureRecognizer() + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..dragStartBehavior = widget.dragStartBehavior; + + _positionController = AnimationController( + duration: _kToggleDuration, + value: widget.value ? 1.0 : 0.0, + vsync: this, + ); + position = CurvedAnimation( + parent: _positionController, + curve: Curves.linear, + ); + _reactionController = AnimationController( + duration: _kReactionDuration, + vsync: this, + ); + _reaction = CurvedAnimation( + parent: _reactionController, + curve: Curves.ease, + ); + } + + @override + void didUpdateWidget(MacosSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + _drag.dragStartBehavior = widget.dragStartBehavior; + + if (needsPositionAnimation || oldWidget.value != widget.value) { + _resumePositionAnimation(isLinear: needsPositionAnimation); + } + } + + // `isLinear` must be true if the position animation is trying to move the + // knob to the closest end after the most recent drag animation, so the curve + // does not change when the controller's value is not 0 or 1. + // + // It can be set to false when it's an implicit animation triggered by + // widget.value changes. + void _resumePositionAnimation({bool isLinear = true}) { + needsPositionAnimation = false; + position + ..curve = isLinear ? Curves.linear : Curves.ease + ..reverseCurve = isLinear ? Curves.linear : Curves.ease.flipped; + if (widget.value) { + _positionController.forward(); + } else { + _positionController.reverse(); + } + } + + void _handleTapDown(TapDownDetails details) { + if (isInteractive) { + needsPositionAnimation = false; + } + _reactionController.forward(); + } + + void _handleTap() { + if (isInteractive) { + widget.onChanged!(!widget.value); + } + } + + void _handleTapUp(TapUpDetails details) { + if (isInteractive) { + needsPositionAnimation = false; + _reactionController.reverse(); + } + } + + void _handleTapCancel() { + if (isInteractive) { + _reactionController.reverse(); + } + } + + void _handleDragStart(DragStartDetails details) { + if (isInteractive) { + needsPositionAnimation = false; + _reactionController.forward(); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (isInteractive) { + position + ..curve = Curves.linear + ..reverseCurve = Curves.linear; + final double delta = details.primaryDelta! / widget.size.trackInnerLength; + switch (Directionality.of(context)) { + case TextDirection.rtl: + _positionController.value -= delta; + break; + case TextDirection.ltr: + _positionController.value += delta; + break; + } + } + } + + void _handleDragEnd(DragEndDetails details) { + // Deferring the animation to the next build phase. + setState(() => needsPositionAnimation = true); + // Call onChanged when the user's intent to change value is clear. + if (position.value >= 0.5 != widget.value) { + widget.onChanged!(!widget.value); + } + _reactionController.reverse(); + } + + @override + void dispose() { + _tap.dispose(); + _drag.dispose(); + + _positionController.dispose(); + _reactionController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { assert(debugCheckHasMacosTheme(context)); final MacosThemeData theme = MacosTheme.of(context); + MacosColor borderColor = + MacosDynamicColor.resolve(_kDefaultBorderColor, context).toMacosColor(); + MacosColor activeColor = MacosColor(MacosDynamicColor.resolve( + widget.activeColor ?? theme.primaryColor, + context, + ).value); + MacosColor trackColor = widget.trackColor ?? + MacosDynamicColor.resolve(_kDefaultTrackColor, context).toMacosColor(); + MacosColor knobColor = widget.knobColor ?? + MacosDynamicColor.resolve(_kDefaultKnobColor, context).toMacosColor(); + + // Shot in the dark to try and get the border color correct for each + // possible color + if (widget.value) { + if (theme.brightness.isDark) { + borderColor.computeLuminance() > 0.5 + ? borderColor = MacosColor.darken(activeColor, 20) + : borderColor = MacosColor.lighten(activeColor, 20); + } else { + borderColor.computeLuminance() > 0.5 + ? borderColor = MacosColor.darken(activeColor, 20) + : borderColor = MacosColor.lighten(activeColor, 20); + } + } + return Semantics( - label: semanticLabel, - checked: value, - child: c.CupertinoSwitch( - value: value, - onChanged: onChanged, - dragStartBehavior: dragStartBehavior, - activeColor: MacosDynamicColor.resolve( - activeColor ?? theme.primaryColor, - context, - ), + label: widget.semanticLabel, + checked: widget.value, + child: _MacosSwitchRenderObjectWidget( + value: widget.value, + size: widget.size, + activeColor: activeColor, trackColor: trackColor, + knobColor: knobColor, + borderColor: borderColor, + onChanged: widget.onChanged, + textDirection: Directionality.of(context), + state: this, ), ); } } + +class _MacosSwitchRenderObjectWidget extends LeafRenderObjectWidget { + const _MacosSwitchRenderObjectWidget({ + required this.value, + required this.size, + required this.activeColor, + required this.trackColor, + required this.knobColor, + required this.borderColor, + required this.onChanged, + required this.textDirection, + required this.state, + }); + final bool value; + final ControlSize size; + final MacosColor activeColor; + final MacosColor trackColor; + final MacosColor knobColor; + final MacosColor borderColor; + final ValueChanged? onChanged; + final TextDirection textDirection; + final _MacosSwitchState state; + + @override + _RenderMacosSwitch createRenderObject(BuildContext context) { + return _RenderMacosSwitch( + value: value, + size: size, + activeColor: activeColor, + trackColor: trackColor, + knobColor: knobColor, + borderColor: borderColor, + onChanged: onChanged, + textDirection: textDirection, + state: state, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderMacosSwitch renderObject, + ) { + assert(renderObject._state == state); + renderObject + ..value = value + ..controlSize = size + ..activeColor = activeColor + ..trackColor = trackColor + ..knobColor = knobColor + ..borderColor = borderColor + ..onChanged = onChanged + ..textDirection = textDirection; + } +} + +const Size _kMiniTrackSize = Size(26.0, 15.0); +const Size _kSmallTrackSize = Size(32.0, 18.0); +const Size _kRegularTrackSize = Size(38.0, 22.0); + +const double _kMiniKnobSize = 13.0; +const double _kSmallKnobSize = 16.0; +const double _kRegularKnobSize = 20.0; + +// Shortcuts for details about how to create the switch, based on the control +// size. +extension _ControlSizeX on ControlSize { + Size get trackSize { + switch (this) { + case ControlSize.mini: + return _kMiniTrackSize; + case ControlSize.small: + return _kSmallTrackSize; + default: + return _kRegularTrackSize; + } + } + + double get knobSize { + switch (this) { + case ControlSize.mini: + return _kMiniKnobSize; + case ControlSize.small: + return _kSmallKnobSize; + default: + return _kRegularKnobSize; + } + } + + double get knobRadius => knobSize / 2.0; + double get trackInnerStart => trackSize.height / 2.0; + double get trackInnerEnd => trackSize.width - trackInnerStart; + double get trackInnerLength => trackInnerEnd - trackInnerStart; +} + +const Duration _kReactionDuration = Duration(milliseconds: 400); +const Duration _kToggleDuration = Duration(milliseconds: 300); + +class _RenderMacosSwitch extends RenderConstrainedBox { + _RenderMacosSwitch({ + required bool value, + required ControlSize size, + required MacosColor activeColor, + required MacosColor trackColor, + required MacosColor knobColor, + required MacosColor borderColor, + required ValueChanged? onChanged, + required TextDirection textDirection, + required _MacosSwitchState state, + }) : _value = value, + _size = size, + _activeColor = activeColor, + _trackColor = trackColor, + _knobPainter = MacosSwitchKnobPainter(color: knobColor), + _borderColor = borderColor, + _onChanged = onChanged, + _textDirection = textDirection, + _state = state, + super( + additionalConstraints: BoxConstraints.tightFor( + width: size.trackSize.width, + height: size.trackSize.height, + ), + ) { + state.position.addListener(markNeedsPaint); + state._reaction.addListener(markNeedsPaint); + } + + final _MacosSwitchState _state; + + bool get value => _value; + bool _value; + set value(bool newValue) { + if (newValue == _value) { + return; + } + _value = newValue; + markNeedsSemanticsUpdate(); + } + + ControlSize get controlSize => _size; + ControlSize _size; + set controlSize(ControlSize value) { + if (value == _size) { + return; + } + _size = value; + markNeedsPaint(); + } + + MacosColor get activeColor => _activeColor; + MacosColor _activeColor; + set activeColor(MacosColor value) { + if (value == _activeColor) { + return; + } + _activeColor = value; + markNeedsPaint(); + } + + MacosColor get trackColor => _trackColor; + MacosColor _trackColor; + set trackColor(MacosColor value) { + if (value == _trackColor) { + return; + } + _trackColor = value; + markNeedsPaint(); + } + + MacosColor get knobColor => _knobPainter.color; + MacosSwitchKnobPainter _knobPainter; + set knobColor(MacosColor value) { + if (value == knobColor) { + return; + } + _knobPainter = MacosSwitchKnobPainter(color: value); + markNeedsPaint(); + } + + MacosColor get borderColor => _borderColor; + MacosColor _borderColor; + set borderColor(MacosColor value) { + if (value == borderColor) { + return; + } + _borderColor = value; + markNeedsPaint(); + } + + ValueChanged? get onChanged => _onChanged; + ValueChanged? _onChanged; + set onChanged(ValueChanged? value) { + if (value == _onChanged) { + return; + } + final bool wasInteractive = isInteractive; + _onChanged = value; + if (wasInteractive != isInteractive) { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (value == _textDirection) { + return; + } + _textDirection = value; + markNeedsPaint(); + } + + bool get isInteractive => onChanged != null; + + @override + bool hitTestSelf(Offset position) => true; + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + if (event is PointerDownEvent && isInteractive) { + _state._drag.addPointer(event); + _state._tap.addPointer(event); + } + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + + if (isInteractive) { + config.onTap = _state._handleTap; + } + + config.isEnabled = isInteractive; + config.isToggled = _value; + } + + @override + void paint(PaintingContext context, Offset offset) { + final Canvas canvas = context.canvas; + final double currentValue = _state.position.value; + final trackSize = controlSize.trackSize; + final innerStart = controlSize.trackInnerStart; + final innerEnd = controlSize.trackInnerEnd; + + final double visualPosition; + switch (textDirection) { + case TextDirection.rtl: + visualPosition = 1.0 - currentValue; + break; + case TextDirection.ltr: + visualPosition = currentValue; + break; + } + + final Paint paint = Paint() + ..color = MacosColor.lerp(trackColor, activeColor, currentValue); + + final Rect trackRect = Rect.fromLTWH( + offset.dx + (size.width - trackSize.width) / 2.0, + offset.dy + (size.height - trackSize.height) / 2.0, + trackSize.width, + trackSize.height, + ); + final RRect trackRRect = RRect.fromRectAndRadius( + trackRect, + Radius.circular(trackSize.height / 2.0), + ); + canvas.drawRRect(trackRRect, paint); + canvas.drawRRect( + trackRRect, + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke, + ); + + final double knobLeft = lerpDouble( + trackRect.left + innerStart - controlSize.knobRadius, + trackRect.left + innerEnd - controlSize.knobRadius, + visualPosition, + )!; + final double knobRight = lerpDouble( + trackRect.left + innerStart + controlSize.knobRadius, + trackRect.left + innerEnd + controlSize.knobRadius, + visualPosition, + )!; + final double knobCenterY = offset.dy + size.height / 2.0; + final Rect knobBounds = Rect.fromLTRB( + knobLeft, + knobCenterY - controlSize.knobRadius, + knobRight, + knobCenterY + controlSize.knobRadius, + ); + + _clipRRectLayer.layer = context.pushClipRRect( + needsCompositing, + Offset.zero, + knobBounds, + trackRRect, + (PaintingContext innerContext, Offset offset) { + _knobPainter.paint( + innerContext.canvas, + knobBounds, + visualPosition == 1.0, + ); + }, + oldLayer: _clipRRectLayer.layer, + ); + } + + final LayerHandle _clipRRectLayer = + LayerHandle(); + + @override + void dispose() { + _clipRRectLayer.layer = null; + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description.add(FlagProperty( + 'value', + value: value, + ifTrue: 'checked', + ifFalse: 'unchecked', + showName: true, + )); + description.add(FlagProperty( + 'isInteractive', + value: isInteractive, + ifTrue: 'enabled', + ifFalse: 'disabled', + showName: true, + defaultValue: true, + )); + } +} + +const List _kSwitchOffBoxShadows = [ + BoxShadow( + color: Color(0x26000000), + // offset: Offset(1, 1), + blurRadius: 8.0, + blurStyle: BlurStyle.inner, + ), + BoxShadow( + color: Color(0x0F000000), + // offset: Offset(1, 1), + blurRadius: 1.0, + blurStyle: BlurStyle.inner, + ), +]; + +const List _kSwitchOnBoxShadows = [ + BoxShadow( + color: Color(0x26000000), + // offset: Offset(-3, 1), + blurRadius: 8.0, + blurStyle: BlurStyle.inner, + ), + BoxShadow( + color: Color(0x0F000000), + // offset: Offset(-1, 1), + blurRadius: 1.0, + blurStyle: BlurStyle.inner, + ), +]; + +/// Paints a macOS-style switch knob. +/// +/// Used by [MacosSwitch]. +class MacosSwitchKnobPainter { + /// Creates an object that paints a macOS-style switch knob. + const MacosSwitchKnobPainter({required this.color}); + + /// The color of the interior of the knob. + final MacosColor color; + + /// Paints the knob onto the given canvas in the given rectangle. + void paint(Canvas canvas, Rect rect, bool isOn) { + final RRect rrect = RRect.fromRectAndRadius( + rect, + Radius.circular(rect.shortestSide / 2.0), + ); + + if (isOn) { + for (final BoxShadow shadow in _kSwitchOnBoxShadows) { + canvas.drawRRect(rrect.shift(shadow.offset), shadow.toPaint()); + } + } else { + for (final BoxShadow shadow in _kSwitchOffBoxShadows) { + canvas.drawRRect(rrect.shift(shadow.offset), shadow.toPaint()); + } + } + + canvas.drawRRect(rrect, Paint()..color = color); + } +} diff --git a/lib/src/enums/control_size.dart b/lib/src/enums/control_size.dart new file mode 100644 index 00000000..835812bf --- /dev/null +++ b/lib/src/enums/control_size.dart @@ -0,0 +1,25 @@ +/// The out-of-the-box sizes that certain "control" widgets can be. +/// +/// +/// +/// Not all controls support all sizes. For example, a [PushButton] can be any +/// size, but a [MacosSwitch] can be all but large. In cases where a control +/// doesn't support a certain size, the control will automatically fall back to +/// the nearest supported size. +/// +/// Reference: +/// * https://developer.apple.com/documentation/swiftui/controlsize +/// * https://developer.apple.com/documentation/swiftui/view/controlsize(_:) +enum ControlSize { + /// A control that is minimally sized. + mini, + + /// A control that is proportionally smaller size for space-constrained views. + small, + + /// A control that is the default size. + regular, + + /// A control that is prominently sized. + large, +} diff --git a/lib/src/theme/icon_theme.dart b/lib/src/theme/icon_theme.dart index 9e7377c8..f3b801f7 100644 --- a/lib/src/theme/icon_theme.dart +++ b/lib/src/theme/icon_theme.dart @@ -210,7 +210,7 @@ class MacosIconThemeData with Diagnosticable { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(ColorProperty('MacosColor', color, defaultValue: null)); properties.add(DoubleProperty('opacity', opacity, defaultValue: null)); properties.add(DoubleProperty('size', size, defaultValue: null)); } diff --git a/lib/src/theme/macos_colors.dart b/lib/src/theme/macos_colors.dart index f5d92d80..73985d8f 100644 --- a/lib/src/theme/macos_colors.dart +++ b/lib/src/theme/macos_colors.dart @@ -85,6 +85,77 @@ class MacosColor extends Color { static int getAlphaFromOpacity(double opacity) { return (opacity.clamp(0.0, 1.0) * 255).round(); } + + /// Returns a new color that matches this color with the alpha channel + /// replaced with the given `opacity` (which ranges from 0.0 to 1.0). + /// + /// Out of range values will have unexpected effects. + @override + MacosColor withOpacity(double opacity) { + assert(opacity >= 0.0 && opacity <= 1.0); + return withAlpha((255.0 * opacity).round()); + } + + /// Returns a new color that matches this color with the alpha channel + /// replaced with `a` (which ranges from 0 to 255). + /// + /// Out of range values will have unexpected effects. + @override + MacosColor withAlpha(int a) { + return MacosColor.fromARGB(a, red, green, blue); + } + + /// Darkens a [MacosColor] by a [percent] amount (100 = black) without + /// changing the tint of the color. + static MacosColor darken(MacosColor c, [int percent = 10]) { + assert(1 <= percent && percent <= 100); + var f = 1 - percent / 100; + return MacosColor.fromARGB( + c.alpha, + (c.red * f).round(), + (c.green * f).round(), + (c.blue * f).round(), + ); + } + + /// Lightens a [MacosColor] by a [percent] amount (100 = white) without + /// changing the tint of the color + static MacosColor lighten(MacosColor c, [int percent = 10]) { + assert(1 <= percent && percent <= 100); + var p = percent / 100; + return MacosColor.fromARGB( + c.alpha, + c.red + ((255 - c.red) * p).round(), + c.green + ((255 - c.green) * p).round(), + c.blue + ((255 - c.blue) * p).round(), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is MacosColor && other.value == value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'MacosColor(0x${value.toRadixString(16).padLeft(8, '0')})'; + } +} + +extension ColorX on Color { + /// Returns a [MacosColor] with the same color values as this [Color]. + MacosColor toMacosColor() { + return MacosColor(value); + } } /// A collection of color values lifted from the macOS system color picker. diff --git a/pubspec.yaml b/pubspec.yaml index 02ae5581..c52118c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: macos_ui description: Flutter widgets and themes implementing the current macOS design language. -version: 2.0.0-beta.1 +version: 2.0.0-beta.2 homepage: "https://macosui.dev" repository: "https://github.com/GroovinChip/macos_ui" diff --git a/test/buttons/switch_test.dart b/test/buttons/switch_test.dart index 642f0f11..a02bc40b 100644 --- a/test/buttons/switch_test.dart +++ b/test/buttons/switch_test.dart @@ -52,10 +52,12 @@ void main() { description, [ 'unchecked', + 'size: regular', 'dragStartBehavior: start', 'disabled', 'activeColor: null', 'trackColor: null', + 'knobColor: null', 'semanticLabel: null', ], ); diff --git a/test/theme/help_button_theme_test.dart b/test/theme/help_button_theme_test.dart index 98556548..1e127f80 100644 --- a/test/theme/help_button_theme_test.dart +++ b/test/theme/help_button_theme_test.dart @@ -45,8 +45,8 @@ void main() { expect( description, [ - 'color: Color(0xff0433ff)', - 'disabledColor: Color(0xff8e8e93)', + 'color: MacosColor(0xff0433ff)', + 'disabledColor: MacosColor(0xff8e8e93)', ], ); }); diff --git a/test/theme/icon_theme_test.dart b/test/theme/icon_theme_test.dart index b45e4de6..79076b18 100644 --- a/test/theme/icon_theme_test.dart +++ b/test/theme/icon_theme_test.dart @@ -51,7 +51,7 @@ void main() { expect( description, [ - 'color: Color(0xffffffff)', + 'MacosColor: MacosColor(0xffffffff)', 'opacity: 0.0', 'size: 20.0', ], diff --git a/test/theme/popup_button_theme_test.dart b/test/theme/popup_button_theme_test.dart index 730d86a6..7cbe6b7a 100644 --- a/test/theme/popup_button_theme_test.dart +++ b/test/theme/popup_button_theme_test.dart @@ -54,8 +54,8 @@ void main() { expect( description, [ - 'highlightColor: Color(0xff8e8e93)', - 'backgroundColor: Color(0xff0433ff)', + 'highlightColor: MacosColor(0xff8e8e93)', + 'backgroundColor: MacosColor(0xff0433ff)', 'popupColor: Color(0x19000000)', ], ); diff --git a/test/theme/pulldown_button_theme_test.dart b/test/theme/pulldown_button_theme_test.dart index 0ef34561..87fc9666 100644 --- a/test/theme/pulldown_button_theme_test.dart +++ b/test/theme/pulldown_button_theme_test.dart @@ -53,10 +53,10 @@ void main() { expect( description, [ - 'highlightColor: Color(0xff8e8e93)', - 'backgroundColor: Color(0xff0433ff)', + 'highlightColor: MacosColor(0xff8e8e93)', + 'backgroundColor: MacosColor(0xff0433ff)', 'pulldownColor: Color(0x19000000)', - 'iconColor: Color(0xff00f900)', + 'iconColor: MacosColor(0xff00f900)', ], ); }); diff --git a/test/theme/push_button_theme_test.dart b/test/theme/push_button_theme_test.dart index a910d981..a26619f8 100644 --- a/test/theme/push_button_theme_test.dart +++ b/test/theme/push_button_theme_test.dart @@ -46,8 +46,8 @@ void main() { expect( description, [ - 'color: Color(0xff0433ff)', - 'disabledColor: Color(0xff8e8e93)', + 'color: MacosColor(0xff0433ff)', + 'disabledColor: MacosColor(0xff8e8e93)', 'secondaryColor: Color(0x19000000)', ], );