From ced51936e5bd2066aa84765e47401821fe6ec7c3 Mon Sep 17 00:00:00 2001 From: Norbert Kozsir Date: Fri, 3 Feb 2023 17:30:28 +0100 Subject: [PATCH] Macos slider (#337) * chore: run flutter format . * chore: fix analysis * chore: Bump version and update CHANGELOG.md * chore: Update images to self taken ones as MacOS images are outdated * fix: fix position offset by a small value * fix: PR review feedback * Update lib/src/indicators/slider.dart --------- Co-authored-by: Reuben Turner --- CHANGELOG.md | 3 + README.md | 23 ++ example/lib/pages/indicators_page.dart | 14 +- example/pubspec.lock | 4 +- lib/macos_ui.dart | 1 + lib/src/indicators/slider.dart | 373 +++++++++++++++++++++++++ lib/src/theme/macos_colors.dart | 19 ++ pubspec.lock | 6 +- pubspec.yaml | 2 +- test/indicators/slider_test.dart | 114 ++++++++ 10 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 lib/src/indicators/slider.dart create mode 100644 test/indicators/slider_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0360a5df..97e63f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [1.9.0] +* Implement `MacosSlider` + ## [1.8.0] 🚨 Breaking Changes 🚨 * `ContentArea.builder` has been changed from a `ScrollableWidgetBuilder` to a `WidgetBuilder` due to diff --git a/README.md b/README.md index f03b6da9..a697ca0c 100644 --- a/README.md +++ b/README.md @@ -858,6 +858,29 @@ CapacityIndicator( You can set `discrete` to `true` to make it a discrete capacity indicator. +### MacosSlider + +A slider is a control that lets people select a value from a continuous or discrete range of values by moving the slider thumb. + + Continuous | Discrete | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ![Continuous Slider Example](https://i.imgur.com/dc4YjoX.png) | ![Discrete Slider Example](https://i.imgur.com/KckOTUf.png) | +| A horizontal slider where any value continuous value between a min and max can be selected | A horizontal slider where only discrete values between a min and max can be selected. Tick marks are often displayed to provide context. | + + +Here's an example of how to create an interactive continuous slider: + +```dart +double value = 0.5; + +MacosSlider( + value: value, + onChanged: (v) { + setState(() => value = v); + }, +), +``` + ### RatingIndicator A rating indicator uses a series of horizontally arranged graphical symbols to communicate a ranking level. The default diff --git a/example/lib/pages/indicators_page.dart b/example/lib/pages/indicators_page.dart index 757a936c..58a6d391 100644 --- a/example/lib/pages/indicators_page.dart +++ b/example/lib/pages/indicators_page.dart @@ -11,7 +11,8 @@ class IndicatorsPage extends StatefulWidget { class _IndicatorsPageState extends State { double ratingValue = 0; - double sliderValue = 0; + double capacitorValue = 0; + double sliderValue = 0.3; @override Widget build(BuildContext context) { @@ -48,6 +49,17 @@ class _IndicatorsPageState extends State { onChanged: (v) => setState(() => sliderValue = v), ), const SizedBox(height: 20), + MacosSlider( + value: sliderValue, + onChanged: (v) => setState(() => sliderValue = v), + ), + const SizedBox(height: 20), + MacosSlider( + value: sliderValue, + discrete: true, + onChanged: (v) => setState(() => sliderValue = v), + ), + const SizedBox(height: 20), RatingIndicator( value: ratingValue, onChanged: (v) => setState(() => ratingValue = v), diff --git a/example/pubspec.lock b/example/pubspec.lock index 6ab7a682..47da932b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -97,7 +97,7 @@ packages: path: ".." relative: true source: path - version: "1.8.0" + version: "1.9.0" matcher: dependency: transitive description: @@ -208,5 +208,5 @@ packages: source: hosted version: "2.1.4" sdks: - dart: ">=2.18.0 <4.0.0" + dart: ">=2.18.0 <3.0.0" flutter: ">=1.20.0" diff --git a/lib/macos_ui.dart b/lib/macos_ui.dart index b783a572..619f0956 100644 --- a/lib/macos_ui.dart +++ b/lib/macos_ui.dart @@ -37,6 +37,7 @@ export 'src/indicators/progress_indicators.dart'; export 'src/indicators/rating_indicator.dart'; export 'src/indicators/relevance_indicator.dart'; export 'src/indicators/scrollbar.dart'; +export 'src/indicators/slider.dart'; export 'src/labels/label.dart'; export 'src/labels/tooltip.dart'; export 'src/layout/content_area.dart'; diff --git a/lib/src/indicators/slider.dart b/lib/src/indicators/slider.dart new file mode 100644 index 00000000..8a172c2e --- /dev/null +++ b/lib/src/indicators/slider.dart @@ -0,0 +1,373 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:macos_ui/macos_ui.dart'; + +const double _kSliderMinWidth = 100.0; +const double _kSliderBorderRadius = 16.0; +const double _kSliderHeight = 4.0; +const double _kContinuousThumbSize = 20; +const double _kOverallHeight = 20; +const double _kTickWidth = 2.0; +const double _kTickHeight = 8.0; + +const double _kDiscreteThumbWidth = 6.0; +const double _kDiscreteThumbBorderRadius = 8; + +/// {@template macosSlider} +/// A slider is a horizontal track with a control, called a thumb, +/// that people can adjust between a minimum and maximum value. +/// +/// The slider doesn't maintain any state itself, instead the user is expected to +/// update this widget with a new [value] whenever the slider changes. +/// +/// {@image } +/// {@endtemplate} +class MacosSlider extends StatelessWidget { + /// {@macro macosSlider} + const MacosSlider({ + super.key, + required this.value, + required this.onChanged, + this.discrete = false, + this.splits = 15, + this.min = 0.0, + this.max = 1.0, + this.color = CupertinoColors.systemBlue, + this.backgroundColor = MacosColors.sliderBackgroundColor, + this.tickBackgroundColor = MacosColors.tickBackgroundColor, + this.thumbColor = MacosColors.sliderThumbColor, + this.semanticLabel, + }) : assert(value >= min && value <= max), + assert(min < max), + assert(splits >= 2); + + /// The value of this slider. + /// + /// This value must be between [min] and [max], inclusive. + final double value; + + /// Called whenever the value of the slider changes + final ValueChanged onChanged; + + /// Whether the slider is discrete or continuous. + /// + /// Continuous sliders have a thumb that can be dragged anywhere along the track. + /// Discrete sliders have a thumb that can only be dragged to the tick marks. + /// + /// [splits] will only be considered if this is true. + final bool discrete; + + /// The minimum value of this slider + final double min; + + /// The maximum value of this slider + final double max; + + /// The number of discrete splits when using [discrete] mode. + /// + /// This includes the split at [min] and [max] + final int splits; + + /// The color of the slider (the part where the thumb is sliding on) that is not + /// considered selected. + /// + /// Defaults to [CupertinoColors.quaternaryLabel] + final Color backgroundColor; + + /// The color of background ticks when using [discrete] mode. + final Color tickBackgroundColor; + + /// The color of the slider (the part where the thumb is sliding on) that is + /// considered selected. + /// + /// Defaults to [CupertinoColors.systemBlue] + final Color color; + + /// The color of the thumb. + final Color thumbColor; + + /// The semantic label used by screen readers. + final String? semanticLabel; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('value', value)); + properties.add(ObjectFlagProperty.has('onChanged', onChanged)); + properties.add(DoubleProperty('min', min)); + properties.add(DoubleProperty('max', max)); + properties.add(ColorProperty('color', color)); + properties.add(ColorProperty('backgroundColor', backgroundColor)); + properties.add(ColorProperty('tickBackgroundColor', tickBackgroundColor)); + properties.add(ColorProperty('thumbColor', thumbColor)); + properties + .add(FlagProperty('discrete', value: discrete, ifTrue: 'discrete')); + properties.add(IntProperty('splits', splits)); + properties.add(StringProperty('semanticLabel', semanticLabel)); + } + + double get _percentage { + if (discrete) { + final double splitPercentage = 1 / (splits - 1); + final int splitIndex = (value / splitPercentage).round(); + return splitIndex * splitPercentage; + } else { + return (value - min) / (max - min); + } + } + + void _update(double sliderWidth, double localPosition) { + if (discrete) { + final double splitPercentage = 1 / (splits - 1); + final int splitIndex = (localPosition / sliderWidth / splitPercentage) + .round() + .clamp(0, splits - 1); + onChanged(splitIndex * splitPercentage); + } else { + final double newValue = (localPosition / sliderWidth) * (max - min) + min; + onChanged(newValue.clamp(min, max)); + } + } + + @override + Widget build(BuildContext context) { + return Semantics( + slider: true, + label: semanticLabel, + value: value.toStringAsFixed(2), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: _kSliderMinWidth), + child: LayoutBuilder( + builder: (context, constraints) { + double width = constraints.maxWidth; + if (width.isInfinite) width = _kSliderMinWidth; + + // Padding around every element so the thumb is clickable as it never + // leaves the edge of the stack + double horizontalPadding; + if (discrete) { + horizontalPadding = _kDiscreteThumbWidth / 2; + } else { + horizontalPadding = _kContinuousThumbSize / 2; + } + width -= horizontalPadding * 2; + + return SizedBox( + height: _kOverallHeight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (details) { + _update(width, details.localPosition.dx - horizontalPadding); + }, + onHorizontalDragUpdate: (details) { + _update(width, details.localPosition.dx - horizontalPadding); + }, + onTapDown: (details) { + _update(width, details.localPosition.dx - horizontalPadding); + }, + child: Stack( + children: [ + Center( + child: Container( + margin: + EdgeInsets.symmetric(horizontal: horizontalPadding), + height: _kSliderHeight, + width: width, + decoration: BoxDecoration( + color: MacosDynamicColor.resolve( + backgroundColor, + context, + ), + borderRadius: + BorderRadius.circular(_kSliderBorderRadius), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: Container( + margin: + EdgeInsets.symmetric(horizontal: horizontalPadding), + height: _kSliderHeight, + width: width * _percentage, + decoration: BoxDecoration( + color: MacosDynamicColor.resolve(color, context), + borderRadius: + BorderRadius.circular(_kSliderBorderRadius), + ), + ), + ), + if (discrete) + Padding( + padding: + EdgeInsets.symmetric(horizontal: horizontalPadding), + child: SizedBox( + height: _kOverallHeight, + width: width, + child: CustomPaint( + size: Size(width, _kOverallHeight), + painter: _DiscreteTickPainter( + color: MacosDynamicColor.resolve(color, context), + backgroundColor: MacosDynamicColor.resolve( + backgroundColor, + context, + ), + selectedPercentage: _percentage, + ticks: splits, + ), + ), + ), + ), + if (!discrete) + Positioned( + left: width * _percentage - _kContinuousThumbSize / 2, + width: _kContinuousThumbSize * 2, + height: _kContinuousThumbSize, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: _ContinuousThumb( + color: + MacosDynamicColor.resolve(thumbColor, context), + ), + ), + ), + if (discrete) + Positioned( + left: width * _percentage - _kDiscreteThumbWidth / 2, + width: _kDiscreteThumbWidth * 2, + height: _kContinuousThumbSize, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: _DiscreteThumb( + color: + MacosDynamicColor.resolve(thumbColor, context), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class _ContinuousThumb extends StatelessWidget { + const _ContinuousThumb({ + required this.color, + }); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + height: _kContinuousThumbSize, + width: _kContinuousThumbSize, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(_kContinuousThumbSize), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.1), + blurRadius: 1, + spreadRadius: 1, + offset: Offset(0, 1), + ), + ], + ), + ); + } +} + +class _DiscreteThumb extends StatelessWidget { + const _DiscreteThumb({ + required this.color, + }); + + final Color color; + @override + Widget build(BuildContext context) { + return Container( + height: _kContinuousThumbSize, + width: _kDiscreteThumbWidth, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(_kDiscreteThumbBorderRadius), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.1), + blurRadius: 1, + spreadRadius: 1, + offset: Offset(0, 1), + ), + ], + ), + ); + } +} + +class _DiscreteTickPainter extends CustomPainter { + _DiscreteTickPainter({ + required this.ticks, + required this.selectedPercentage, + required this.backgroundColor, + required this.color, + }); + + final int ticks; + final double selectedPercentage; + final Color backgroundColor; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + var width = size.width; + + var spaceBetween = width / (ticks - 1); + + var paint = Paint() + ..color = color + ..strokeWidth = 1 + ..strokeCap = StrokeCap.round; + + var backgroundPaint = Paint() + ..color = backgroundColor + ..strokeWidth = 1 + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < ticks; i++) { + var x = spaceBetween * i; + + var isPastSelectedPercentage = x / width > selectedPercentage; + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + x - 1, + (size.height / 2) - (_kTickHeight / 2), + _kTickWidth, + _kTickHeight, + ), + const Radius.circular(8), + ), + isPastSelectedPercentage ? backgroundPaint : paint, + ); + } + } + + @override + bool shouldRepaint(_DiscreteTickPainter oldDelegate) { + return oldDelegate.ticks != ticks || + oldDelegate.selectedPercentage != selectedPercentage || + oldDelegate.backgroundColor != backgroundColor || + oldDelegate.color != color; + } +} diff --git a/lib/src/theme/macos_colors.dart b/lib/src/theme/macos_colors.dart index 59bad194..f5d92d80 100644 --- a/lib/src/theme/macos_colors.dart +++ b/lib/src/theme/macos_colors.dart @@ -291,6 +291,25 @@ class MacosColors { darkColor: Color.fromRGBO(26, 169, 255, 0.3), ); + /// The color of the thumb of [MacosSlider]. + static const sliderThumbColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(255, 255, 255, 1), + darkColor: Color.fromRGBO(152, 152, 157, 1), + ); + + /// The color of the tick marks which are not selected (the portion to the right of the thumb) of [MacosSlider]. + static const tickBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(220, 220, 220, 1), + darkColor: Color.fromRGBO(70, 70, 70, 1), + ); + + /// The color of the slider in [MacosSlider] which is not selected (the portion + /// to the right of the thumb). + static const sliderBackgroundColor = CupertinoDynamicColor.withBrightness( + color: Color.fromRGBO(0, 0, 0, 0.1), + darkColor: Color.fromRGBO(255, 255, 255, 0.1), + ); + /// The accent color selected by the user in system preferences. /// /// No dark variant. diff --git a/pubspec.lock b/pubspec.lock index e512103a..eb16ba9a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -247,10 +247,10 @@ packages: dependency: transitive description: name: logging - sha256: c0bbfe94d46aedf9b8b3e695cf3bd48c8e14b35e3b2c639e0aa7755d589ba946 + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" matcher: dependency: transitive description: @@ -545,5 +545,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.18.0 <4.0.0" + dart: ">=2.18.0 <3.0.0" flutter: ">=1.20.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7c6bea93..db2162f8 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: 1.8.0 +version: 1.9.0 homepage: "https://macosui.dev" repository: "https://github.com/GroovinChip/macos_ui" diff --git a/test/indicators/slider_test.dart b/test/indicators/slider_test.dart new file mode 100644 index 00000000..23bb0b35 --- /dev/null +++ b/test/indicators/slider_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:macos_ui/macos_ui.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = + TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('debugFillProperties', (tester) async { + final builder = DiagnosticPropertiesBuilder(); + MacosSlider( + value: 0.5, + onChanged: (newValue) {}, + ).debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toString()) + .toList(); + + expect( + description, + [ + 'value: 0.5', + 'has onChanged', + 'min: 0.0', + 'max: 1.0', + 'color: systemBlue(*color = Color(0xff007aff)*, darkColor = Color(0xff0a84ff), highContrastColor = Color(0xff0040dd), darkHighContrastColor = Color(0xff409cff), resolved by: UNRESOLVED)', + 'backgroundColor: CupertinoDynamicColor(*color = Color(0x19000000)*, darkColor = Color(0x19ffffff), resolved by: UNRESOLVED)', + 'tickBackgroundColor: CupertinoDynamicColor(*color = Color(0xffdcdcdc)*, darkColor = Color(0xff464646), resolved by: UNRESOLVED)', + 'thumbColor: CupertinoDynamicColor(*color = Color(0xffffffff)*, darkColor = Color(0xff98989d), resolved by: UNRESOLVED)', + 'splits: 15', + 'semanticLabel: null', + ], + ); + }); + + testWidgets('Continuous slider can move when tapped', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(100, 50); + binding.window.devicePixelRatioTestValue = 1.0; + + final value = ValueNotifier(0.25); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: MacosSlider( + value: value.value, + onChanged: (newValue) { + value.value = newValue; + }, + ), + ), + ), + ); + + expect(value.value, 0.25); + + // Tap on the right half of the slider. + await tester.tapAt(const Offset(50, 25)); + await tester.pumpAndSettle(); + + expect(value.value, greaterThan(0.25)); + + await tester.tapAt(const Offset(0, 25)); + await tester.pumpAndSettle(); + + expect(value.value, 0.0); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + }); + + testWidgets('Discrete slider snaps to correct values', (widgetTester) async { + widgetTester.binding.window.physicalSizeTestValue = const Size(100, 50); + binding.window.devicePixelRatioTestValue = 1.0; + + final value = ValueNotifier(0.25); + await widgetTester.pumpWidget( + CupertinoApp( + home: Center( + child: MacosSlider( + value: value.value, + onChanged: (newValue) { + value.value = newValue; + }, + min: 0.0, + max: 1.0, + discrete: true, + splits: 3, + ), + ), + ), + ); + + expect(value.value, 0.25); + + // Tap on the right half of the slider. + await widgetTester.tapAt(const Offset(50, 25)); + await widgetTester.pumpAndSettle(); + + expect(value.value, 0.5); + + await widgetTester.tapAt(const Offset(0, 25)); + await widgetTester.pumpAndSettle(); + + expect(value.value, 0.0); + + // Tap slightly to the right of the 0.5 mark. + await widgetTester.tapAt(const Offset(55, 25)); + await widgetTester.pumpAndSettle(); + + expect(value.value, 0.5); + + addTearDown(widgetTester.binding.window.clearPhysicalSizeTestValue); + }); +}