From 8929f279c2aced42bac8dc00534f92a421358ad8 Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Sat, 10 Aug 2024 19:22:22 +0800 Subject: [PATCH 1/8] Add slider thumb --- forui/lib/forui.dart | 1 + forui/lib/src/widgets/slider/slider.dart | 143 +++++++++++++++++++++++ forui/lib/src/widgets/slider/thumb.dart | 121 +++++++++++++++++++ forui/lib/widgets/slider.dart | 3 + 4 files changed, 268 insertions(+) create mode 100644 forui/lib/src/widgets/slider/slider.dart create mode 100644 forui/lib/src/widgets/slider/thumb.dart create mode 100644 forui/lib/widgets/slider.dart diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index 349b69422..347d781d1 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -18,6 +18,7 @@ export 'widgets/header.dart'; export 'widgets/progress.dart'; export 'widgets/resizable.dart'; export 'widgets/scaffold.dart'; +export 'widgets/slider.dart'; export 'widgets/switch.dart'; export 'widgets/tabs.dart'; export 'widgets/text_field.dart'; diff --git a/forui/lib/src/widgets/slider/slider.dart b/forui/lib/src/widgets/slider/slider.dart new file mode 100644 index 000000000..599f4a51e --- /dev/null +++ b/forui/lib/src/widgets/slider/slider.dart @@ -0,0 +1,143 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:meta/meta.dart'; + +/// A slider's styles. +final class FSliderStyles with Diagnosticable { + /// The enabled slider's style. + final FSliderStyle enabledStyle; + + /// The disabled slider's style. + final FSliderStyle disabledStyle; + + /// Creates a [FSliderStyles]. + FSliderStyles({ + required this.enabledStyle, + required this.disabledStyle, + }); + + /// Creates a [FSliderStyles] that inherits its properties from the given [FColorScheme]. + FSliderStyles.inherit({required FColorScheme colorScheme}) + : enabledStyle = FSliderStyle( + activeColor: colorScheme.primary, + inactiveColor: colorScheme.secondary, + thumbStyle: FSliderThumbStyle( + color: colorScheme.foreground, + borderColor: colorScheme.primary, + ), + ), + disabledStyle = FSliderStyle( + activeColor: colorScheme.primary.withOpacity(0.7), + inactiveColor: colorScheme.secondary, + thumbStyle: FSliderThumbStyle( + color: colorScheme.primary.withOpacity(0.7), + borderColor: colorScheme.primary.withOpacity(0.7), + ), + ); + + /// Returns a copy of this [FSliderStyles] but with the given fields replaced with the new values. + /// + /// ```dart + /// final styles = FSliderStyles( + /// enabledStyle: ..., + /// disabledStyle: ..., + /// ); + /// + /// final copy = styles.copyWith(disabledStyle: ...); + /// + /// print(styles.enabledStyle == copy.enabledStyle); // true + /// print(styles.disabledStyle == copy.disabledStyle); // false + /// ``` + @useResult + FSliderStyles copyWith({ + FSliderStyle? enabledStyle, + FSliderStyle? disabledStyle, + }) => + FSliderStyles( + enabledStyle: enabledStyle ?? this.enabledStyle, + disabledStyle: disabledStyle ?? this.disabledStyle, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('enabledStyle', enabledStyle)) + ..add(DiagnosticsProperty('disabledStyle', disabledStyle)); + } +} + +/// A slider's style. +final class FSliderStyle with Diagnosticable { + /// The slider's active color. + final Color activeColor; + + /// The slider inactive color. + final Color inactiveColor; + + /// The slider's border radius. + final BorderRadius borderRadius; + + /// The slider thumb's style. + final FSliderThumbStyle thumbStyle; + + /// Creates a [FSliderStyle]. + FSliderStyle({ + required this.activeColor, + required this.inactiveColor, + required this.thumbStyle, + BorderRadius? borderRadius, + }) : borderRadius = borderRadius ?? BorderRadius.circular(4); + + /// Returns a copy of this [FSliderStyle] but with the given fields replaced with the new values. + /// + /// ```dart + /// final style = FSliderStyle( + /// activeColor: Colors.red, + /// inactiveColor: Colors.blue, + /// ..., // Other properties + /// ); + /// + /// final copy = style.copyWith(inactiveColor: Colors.green); + /// + /// print(copy.activeColor); // Colors.red + /// print(copy.inactiveColor); // Colors.green + /// ``` + @useResult + FSliderStyle copyWith({ + Color? activeColor, + Color? inactiveColor, + BorderRadius? borderRadius, + FSliderThumbStyle? thumbStyle, + }) => + FSliderStyle( + activeColor: activeColor ?? this.activeColor, + inactiveColor: inactiveColor ?? this.inactiveColor, + borderRadius: borderRadius ?? this.borderRadius, + thumbStyle: thumbStyle ?? this.thumbStyle, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('activeColor', activeColor)) + ..add(ColorProperty('inactiveColor', inactiveColor)) + ..add(DiagnosticsProperty('borderRadius', borderRadius)) + ..add(DiagnosticsProperty('thumbStyle', thumbStyle)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSliderStyle && + runtimeType == other.runtimeType && + activeColor == other.activeColor && + inactiveColor == other.inactiveColor && + borderRadius == other.borderRadius && + thumbStyle == other.thumbStyle; + + @override + int get hashCode => activeColor.hashCode ^ inactiveColor.hashCode ^ borderRadius.hashCode ^ thumbStyle.hashCode; +} diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart new file mode 100644 index 000000000..9be6197fc --- /dev/null +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -0,0 +1,121 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class Thumb extends StatelessWidget { + final FSliderThumbStyle style; + final bool enabled; + + const Thumb({required this.style, required this.enabled, super.key}); + + @override + Widget build(BuildContext context) => MouseRegion( + cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, + child: SizedBox( + height: style.diameter, + width: style.diameter, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: style.color, + border: Border.all( + color: style.borderColor, + width: style.borderWidth, + ), + ), + ), + ), + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('style', style)) + ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled')); + } +} + +/// The slider thumb's style. +final class FSliderThumbStyle with Diagnosticable { + /// The thumb's color. + final Color color; + + /// The thumb's diameter, inclusive of the border. Defaults to `20`. + /// + /// ## Contract + /// Throws [AssertionError] if [diameter] is not positive. + final double diameter; + + /// The border's color. + final Color borderColor; + + /// The border's width. Defaults to `2`. + /// + /// ## Contract + /// Throws [AssertionError] if [borderWidth] is not positive. + final double borderWidth; + + /// Creates a [FSliderThumbStyle]. + FSliderThumbStyle({ + required this.color, + required this.borderColor, + this.diameter = 20, + this.borderWidth = 2, + }): + assert(0 < diameter, 'The diameter must be positive'), + assert(0 < borderWidth, 'The border width must be positive'); + + /// Returns a copy of this [FSliderThumbStyle] but with the given fields replaced with the new values. + /// + /// ```dart + /// final style = FSliderThumbStyle( + /// color: Colors.red, + /// diameter: 16, + /// borderColor: Colors.black, + /// borderWidth: 2, + /// ); + /// + /// final copy = style.copyWith(color: Colors.blue); + /// + /// print(copy.color); // Colors.blue + /// print(copy.diameter); // 16 + /// ``` + @useResult + FSliderThumbStyle copyWith({ + Color? color, + double? diameter, + Color? borderColor, + double? borderWidth, + }) => + FSliderThumbStyle( + color: color ?? this.color, + diameter: diameter ?? this.diameter, + borderColor: borderColor ?? this.borderColor, + borderWidth: borderWidth ?? this.borderWidth, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(DoubleProperty('diameter', diameter)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(DoubleProperty('borderWidth', borderWidth)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSliderThumbStyle && + runtimeType == other.runtimeType && + color == other.color && + diameter == other.diameter && + borderColor == other.borderColor && + borderWidth == other.borderWidth; + + @override + int get hashCode => color.hashCode ^ diameter.hashCode ^ borderColor.hashCode ^ borderWidth.hashCode; +} diff --git a/forui/lib/widgets/slider.dart b/forui/lib/widgets/slider.dart new file mode 100644 index 000000000..d3caacf97 --- /dev/null +++ b/forui/lib/widgets/slider.dart @@ -0,0 +1,3 @@ +library forui.widgets.slider; + +export 'package:forui/src/widgets/slider/thumb.dart'; \ No newline at end of file From 3b0a311b6cdce225459b53a010828e74dbcbaaf2 Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Sat, 10 Aug 2024 19:48:03 +0800 Subject: [PATCH 2/8] Tweak resizable region --- .../widgets/resizable/resizable_region_data.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/forui/lib/src/widgets/resizable/resizable_region_data.dart b/forui/lib/src/widgets/resizable/resizable_region_data.dart index affb94e0d..84385251b 100644 --- a/forui/lib/src/widgets/resizable/resizable_region_data.dart +++ b/forui/lib/src/widgets/resizable/resizable_region_data.dart @@ -23,7 +23,7 @@ final class FResizableRegionData with Diagnosticable { /// * max <= min final ({double min, double current, double max, double total}) extent; - /// This region's current minimum and maximum offset along the main resizable axis., in logical pixels + /// This region's current minimum and maximum offset along the main resizable axis, in logical pixels /// /// Both offsets are relative to the top/left side of the parent [FResizable], or, in other words, relative to 0. /// @@ -65,14 +65,14 @@ final class FResizableRegionData with Diagnosticable { /// /// ```dart /// final data = FResizableData( - /// index: 1, - /// selected: false, - /// // Other arguments omitted for brevity + /// index: ..., + /// extent: ..., + /// // Other properties /// ); /// - /// final copy = data.copyWith(selected: true); - /// print(copy.index); // 1 - /// print(copy.selected); // true + /// final copy = data.copyWith(extent: ...); + /// print(data.index == copy.index); // true + /// print(data.extent == copy.extent); // false /// ``` @useResult FResizableRegionData copyWith({ From a21b70dcd8c9950579b3b7e1a64805135ce2aabb Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Tue, 13 Aug 2024 18:12:09 +0800 Subject: [PATCH 3/8] WIP bar without padding --- forui/example/lib/example.dart | 40 +++--- forui/example/lib/main.dart | 2 +- forui/lib/forui.dart | 1 + forui/lib/foundation.dart | 4 + .../lib/src/foundation/layout_direction.dart | 17 +++ forui/lib/src/widgets/slider/bar.dart | 124 ++++++++++++++++ forui/lib/src/widgets/slider/slider.dart | 66 +++++---- .../src/widgets/slider/slider_controller.dart | 33 +++++ forui/lib/src/widgets/slider/slider_data.dart | 134 ++++++++++++++++++ forui/lib/src/widgets/slider/slider_mark.dart | 102 +++++++++++++ forui/lib/src/widgets/slider/thumb.dart | 47 +++--- forui/lib/widgets/slider.dart | 5 +- 12 files changed, 500 insertions(+), 75 deletions(-) create mode 100644 forui/lib/foundation.dart create mode 100644 forui/lib/src/foundation/layout_direction.dart create mode 100644 forui/lib/src/widgets/slider/bar.dart create mode 100644 forui/lib/src/widgets/slider/slider_controller.dart create mode 100644 forui/lib/src/widgets/slider/slider_data.dart create mode 100644 forui/lib/src/widgets/slider/slider_mark.dart diff --git a/forui/example/lib/example.dart b/forui/example/lib/example.dart index 9a49c0e36..99f0a51af 100644 --- a/forui/example/lib/example.dart +++ b/forui/example/lib/example.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Thumb; import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/slider/bar.dart'; import 'package:intl/intl.dart'; class Example extends StatefulWidget { @@ -17,27 +18,22 @@ class _ExampleState extends State { } @override - Widget build(BuildContext context) => const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 100, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Positioned(top: 1, left: 10, child: Align(child: Text('Hiqdqdqdqd'))), - SizedBox( - width: 10, - height: 100, - child: ColoredBox( - color: Colors.white30, - ), - ), - ], - ), - ) - ], - ); + Widget build(BuildContext context) { + final scheme = context.theme.colorScheme; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Bar( + controller: FSliderController(FSliderData( + extent: (min: 1, max: 100, total: 300), + offset: (min: 0, max: 50), + )), + style: FSliderStyles.inherit(colorScheme: scheme).enabledStyle, + direction: LayoutDirection.ltr, + ) + ], + ); + } } class Label extends StatelessWidget { diff --git a/forui/example/lib/main.dart b/forui/example/lib/main.dart index 149fd800d..f663e6166 100644 --- a/forui/example/lib/main.dart +++ b/forui/example/lib/main.dart @@ -21,7 +21,7 @@ class _ApplicationState extends State { @override Widget build(BuildContext context) => MaterialApp( builder: (context, child) => FTheme( - data: FThemes.zinc.dark, + data: FThemes.zinc.light, child: FScaffold( header: FHeader( title: const Text('Example'), diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index 347d781d1..74635263c 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -2,6 +2,7 @@ library forui; export 'assets.dart'; +export 'foundation.dart'; export 'theme.dart'; export 'widgets/alert.dart'; diff --git a/forui/lib/foundation.dart b/forui/lib/foundation.dart new file mode 100644 index 000000000..35c0b2c77 --- /dev/null +++ b/forui/lib/foundation.dart @@ -0,0 +1,4 @@ +/// Low-level utilities and services. +library forui.foundation; + +export 'src/foundation/layout_direction.dart'; diff --git a/forui/lib/src/foundation/layout_direction.dart b/forui/lib/src/foundation/layout_direction.dart new file mode 100644 index 000000000..38cb1d03e --- /dev/null +++ b/forui/lib/src/foundation/layout_direction.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// The layout direction. +enum LayoutDirection { + /// Signifies that the direction is from left to right. + ltr, + + /// Signifies that the direction is from right to left. + rtl, + + /// Signifies that the direction is from bottom to top. + btt, + + /// Signifies that the direction is from top to bottom. + ttb, +} diff --git a/forui/lib/src/widgets/slider/bar.dart b/forui/lib/src/widgets/slider/bar.dart new file mode 100644 index 000000000..f810f0aa3 --- /dev/null +++ b/forui/lib/src/widgets/slider/bar.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:meta/meta.dart'; + +@internal +class Bar extends StatelessWidget { + final FSliderController controller; + final FSliderStyle style; + final LayoutDirection direction; + + const Bar({ + required this.controller, + required this.style, + required this.direction, + super.key, + }); + + @override + Widget build(BuildContext context) { + late final double height; + late final double width; + late final double Function(TapDownDetails) translate; + late final Widget Function(double, Widget) mark; + late final ValueWidgetBuilder active; + + switch (direction) { + case LayoutDirection.ltr: + height = style.crossAxisExtent; + width = controller.value.extent.total; + translate = (details) => details.localPosition.dx; + mark = (offset, marker) => Positioned(left: offset, child: marker); + active = (context, active, child) => Positioned( + left: active.offset.min, + child: SizedBox( + height: style.crossAxisExtent, + width: active.extent.current, + child: child!, + ), + ); + + case LayoutDirection.rtl: + height = style.crossAxisExtent; + width = controller.value.extent.total; + translate = (details) => controller.value.extent.total - details.localPosition.dx; + mark = (offset, marker) => Positioned(right: offset, child: marker); + active = (context, active, child) => Positioned( + right: active.offset.min, + child: SizedBox( + height: style.crossAxisExtent, + width: active.extent.current, + child: child!, + ), + ); + + case LayoutDirection.ttb: + height = controller.value.extent.total; + width = style.crossAxisExtent; + translate = (details) => details.localPosition.dy; + mark = (offset, marker) => Positioned(top: offset, child: marker); + active = (context, active, child) => Positioned( + top: active.offset.min, + child: SizedBox( + height: active.extent.current, + width: style.crossAxisExtent, + child: child!, + ), + ); + + case LayoutDirection.btt: + height = controller.value.extent.total; + width = style.crossAxisExtent; + translate = (details) => controller.value.extent.total - details.localPosition.dy; + mark = (offset, marker) => Positioned(bottom: offset, child: marker); + active = (context, active, child) => Positioned( + bottom: active.offset.min, + child: SizedBox( + height: active.extent.current, + width: style.crossAxisExtent, + child: child!, + ), + ); + } + + final marker = DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: style.markStyle.color, + ), + child: SizedBox.square( + dimension: style.markStyle.dimension, + ), + ); + + return GestureDetector( + onTapDown: (details) => controller.tap(translate(details)), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: style.borderRadius, + color: style.inactiveColor, + ), + child: SizedBox( + height: height, + width: width, + child: Stack( + alignment: Alignment.center, + children: [ + // TODO: marks + ValueListenableBuilder( + valueListenable: controller, + builder: active, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: style.borderRadius, + color: style.activeColor, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/forui/lib/src/widgets/slider/slider.dart b/forui/lib/src/widgets/slider/slider.dart index 599f4a51e..a8ca1b26d 100644 --- a/forui/lib/src/widgets/slider/slider.dart +++ b/forui/lib/src/widgets/slider/slider.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/slider/slider_mark.dart'; import 'package:meta/meta.dart'; /// A slider's styles. @@ -22,6 +23,7 @@ final class FSliderStyles with Diagnosticable { : enabledStyle = FSliderStyle( activeColor: colorScheme.primary, inactiveColor: colorScheme.secondary, + markStyle: FSliderMarkStyle(color: colorScheme.mutedForeground), thumbStyle: FSliderThumbStyle( color: colorScheme.foreground, borderColor: colorScheme.primary, @@ -30,6 +32,7 @@ final class FSliderStyles with Diagnosticable { disabledStyle = FSliderStyle( activeColor: colorScheme.primary.withOpacity(0.7), inactiveColor: colorScheme.secondary, + markStyle: FSliderMarkStyle(color: colorScheme.mutedForeground.withOpacity(0.7)), thumbStyle: FSliderThumbStyle( color: colorScheme.primary.withOpacity(0.7), borderColor: colorScheme.primary.withOpacity(0.7), @@ -37,18 +40,6 @@ final class FSliderStyles with Diagnosticable { ); /// Returns a copy of this [FSliderStyles] but with the given fields replaced with the new values. - /// - /// ```dart - /// final styles = FSliderStyles( - /// enabledStyle: ..., - /// disabledStyle: ..., - /// ); - /// - /// final copy = styles.copyWith(disabledStyle: ...); - /// - /// print(styles.enabledStyle == copy.enabledStyle); // true - /// print(styles.disabledStyle == copy.disabledStyle); // false - /// ``` @useResult FSliderStyles copyWith({ FSliderStyle? enabledStyle, @@ -76,9 +67,24 @@ final class FSliderStyle with Diagnosticable { /// The slider inactive color. final Color inactiveColor; + /// The padding on each side of the slider's main axis. Defaults to 5. + /// + /// ## Contract: + /// Throws [AssertionError] if it is negative. + final double mainAxisPadding; + + /// The slider's cross-axis extent. Defaults to 8. + /// + /// ## Contract: + /// Throws [AssertionError] if it is not positive. + final double crossAxisExtent; + /// The slider's border radius. final BorderRadius borderRadius; + /// The slider marks' style. + final FSliderMarkStyle markStyle; + /// The slider thumb's style. final FSliderThumbStyle thumbStyle; @@ -86,35 +92,32 @@ final class FSliderStyle with Diagnosticable { FSliderStyle({ required this.activeColor, required this.inactiveColor, + required this.markStyle, required this.thumbStyle, + this.mainAxisPadding = 5, + this.crossAxisExtent = 8, BorderRadius? borderRadius, }) : borderRadius = borderRadius ?? BorderRadius.circular(4); /// Returns a copy of this [FSliderStyle] but with the given fields replaced with the new values. - /// - /// ```dart - /// final style = FSliderStyle( - /// activeColor: Colors.red, - /// inactiveColor: Colors.blue, - /// ..., // Other properties - /// ); - /// - /// final copy = style.copyWith(inactiveColor: Colors.green); - /// - /// print(copy.activeColor); // Colors.red - /// print(copy.inactiveColor); // Colors.green /// ``` @useResult FSliderStyle copyWith({ Color? activeColor, Color? inactiveColor, + double? mainAxisPadding, + double? crossAxisExtent, BorderRadius? borderRadius, + FSliderMarkStyle? markStyle, FSliderThumbStyle? thumbStyle, }) => FSliderStyle( activeColor: activeColor ?? this.activeColor, inactiveColor: inactiveColor ?? this.inactiveColor, + mainAxisPadding: mainAxisPadding ?? this.mainAxisPadding, + crossAxisExtent: crossAxisExtent ?? this.crossAxisExtent, borderRadius: borderRadius ?? this.borderRadius, + markStyle: markStyle ?? this.markStyle, thumbStyle: thumbStyle ?? this.thumbStyle, ); @@ -124,7 +127,10 @@ final class FSliderStyle with Diagnosticable { properties ..add(ColorProperty('activeColor', activeColor)) ..add(ColorProperty('inactiveColor', inactiveColor)) + ..add(DoubleProperty('mainAxisPadding', mainAxisPadding)) + ..add(DoubleProperty('crossAxisExtent', crossAxisExtent)) ..add(DiagnosticsProperty('borderRadius', borderRadius)) + ..add(DiagnosticsProperty('markStyle', markStyle)) ..add(DiagnosticsProperty('thumbStyle', thumbStyle)); } @@ -135,9 +141,19 @@ final class FSliderStyle with Diagnosticable { runtimeType == other.runtimeType && activeColor == other.activeColor && inactiveColor == other.inactiveColor && + mainAxisPadding == other.mainAxisPadding && + crossAxisExtent == other.crossAxisExtent && borderRadius == other.borderRadius && + markStyle == other.markStyle && thumbStyle == other.thumbStyle; @override - int get hashCode => activeColor.hashCode ^ inactiveColor.hashCode ^ borderRadius.hashCode ^ thumbStyle.hashCode; + int get hashCode => + activeColor.hashCode ^ + inactiveColor.hashCode ^ + mainAxisPadding.hashCode ^ + crossAxisExtent.hashCode ^ + borderRadius.hashCode ^ + markStyle.hashCode ^ + thumbStyle.hashCode; } diff --git a/forui/lib/src/widgets/slider/slider_controller.dart b/forui/lib/src/widgets/slider/slider_controller.dart new file mode 100644 index 000000000..3b9d0268f --- /dev/null +++ b/forui/lib/src/widgets/slider/slider_controller.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; + +abstract class FSliderController extends ValueNotifier { + factory FSliderController(FSliderData value) = _Stub; + + FSliderController.value(super.value); + + void drag(double delta); + + void tap(double offset); + + ({bool min, bool max}) get extendable; +} + +// TODO: remove +final class _Stub extends FSliderController { + _Stub(super.value) : super.value(); + + @override + void drag(double delta) { + // TODO: implement drag + } + + @override + // TODO: implement extendable + ({bool max, bool min}) get extendable => throw UnimplementedError(); + + @override + void tap(double offset) { + // TODO: implement tap + } +} diff --git a/forui/lib/src/widgets/slider/slider_data.dart b/forui/lib/src/widgets/slider/slider_data.dart new file mode 100644 index 000000000..6a1145317 --- /dev/null +++ b/forui/lib/src/widgets/slider/slider_data.dart @@ -0,0 +1,134 @@ +import 'package:flutter/foundation.dart'; + +import 'package:meta/meta.dart'; +import 'dart:math' as math; + +import 'package:forui/forui.dart'; + +/// A [FSlider] active region's data. +final class FSliderData with Diagnosticable { + /// This active region's minimum and maximum extent along the main resizable axis, in logical pixels. + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * min <= 0 + /// * max <= min + final ({double min, double current, double max, double total}) extent; + + /// This active region's current minimum and maximum offset along the main resizable axis, in logical pixels. + /// + /// Both offsets are relative to the bottom/left side of the slider, or, in other words, relative to 0, regardless of + /// [FSlider.direction]. + /// + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * min < 0 + /// * max <= min + /// * `extent.total` <= max + final ({double min, double max}) offset; + + /// Creates a [FSliderData]. + FSliderData({ + required ({double min, double max, double total}) extent, + required this.offset, + }) : assert(0 < extent.min, 'Min extent should be positive, but is ${extent.min}'), + assert( + extent.min < extent.max, + 'Min extent should be less than the max extent, but min is ${extent.min} and max is ${extent.max}', + ), + assert( + extent.max <= extent.total, + 'Max extent should be less than or equal to the total extent, but max is ${extent.max} and total is ${extent.total}', + ), + assert(0 <= offset.min, 'Min offset should be non-negative, but is ${offset.min}'), + assert( + offset.min < offset.max, + 'Min offset should be less than the max offset, but min is ${offset.min} and max is ${offset.max}', + ), + assert( + 0 <= offset.max - offset.min && offset.max - offset.min <= extent.max, + 'Current extent should be non-negative and less than or equal to the max extent, but current is ' + '${offset.max - offset.min} and max is ${extent.max}.', + ), + extent = (min: extent.min, current: offset.max - offset.min, max: extent.max, total: extent.total); + + /// Returns a copy of this [FSliderData] with the given fields replaced by the new values. + /// + /// ```dart + /// final data = FSliderData( + /// // Other arguments omitted for brevity + /// ); + /// + /// final copy = data.copyWith(selected: true); + /// print(copy.index); // 1 + /// print(copy.selected); // true + /// ``` + @useResult + FSliderData copyWith({ + double? minExtent, + double? maxExtent, + double? totalExtent, + double? minOffset, + double? maxOffset, + }) => + FSliderData( + extent: (min: minExtent ?? extent.min, max: maxExtent ?? extent.max, total: totalExtent ?? extent.total), + offset: (min: minOffset ?? offset.min, max: maxOffset ?? offset.max), + ); + + /// The offsets as a percentage of the parent [FResizable]'s size. + /// + /// For example, if the offsets are `(200, 400)`, and the [FResizable]'s size is 500, [offsetPercentage] will be + /// `(0.4, 0.8)`. + ({double min, double max}) get offsetPercentage => (min: offset.min / extent.total, max: offset.max / extent.total); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSliderData && runtimeType == other.runtimeType && extent == other.extent && offset == other.offset; + + @override + int get hashCode => extent.hashCode ^ offset.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('extent.min', extent.min)) + ..add(DoubleProperty('extent.current', extent.current)) + ..add(DoubleProperty('extent.max', extent.max)) + ..add(DoubleProperty('extent.total', extent.total)) + ..add(DoubleProperty('offset.min', offset.min)) + ..add(DoubleProperty('offset.max', offset.max)) + ..add(DoubleProperty('offsetPercentage.min', offsetPercentage.min)) + ..add(DoubleProperty('offsetPercentage.max', offsetPercentage.max)); + } +} + +@internal +extension UpdatableSliderData on FSliderData { + /// Returns a copy of this data with an updated extent, and an offset with any shrinkage beyond the minimum + /// height/width removed. + /// + /// This method assumes that shrinking regions are computed before expanding regions. + @useResult + FSliderData update(double delta, {required bool lhs}) { + var (:min, :max) = offset; + lhs ? min += delta : max += delta; + final newExtent = max - min; + + assert(0 <= min, '$min should be non-negative.'); + assert(newExtent <= extent.max, '$newExtent should be less than ${extent.max}.'); + + if (extent.min <= newExtent) { + return copyWith(minOffset: math.max(min, 0), maxOffset: math.min(max, extent.total)); + } + + if (lhs) { + return extent.min == extent.current ? this : copyWith(minOffset: max - extent.min); + } else { + return extent.min == extent.current ? this : copyWith(maxOffset: min + extent.min); + } + } +} diff --git a/forui/lib/src/widgets/slider/slider_mark.dart b/forui/lib/src/widgets/slider/slider_mark.dart new file mode 100644 index 000000000..02d366a73 --- /dev/null +++ b/forui/lib/src/widgets/slider/slider_mark.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; +import 'package:meta/meta.dart'; + +/// A mark in a [FSlider]. +class FSliderMark with Diagnosticable { + /// The mark's style. + final FSliderMarkStyle? style; + + /// The mark's percentage in the slider's bar. + /// + /// ## Contract + /// Throws [AssertionError] if it is not between `0` and `1`, inclusive. + final double percentage; + + /// Whether the mark is visible in the slider's bar. Defaults to true. + final bool visible; + + /// The mark's label. + final Widget? label; + + // TODO: Figure out how to align label. + + /// Creates a [FSliderMark] at the given percentage in a slider. + const FSliderMark({ + required this.percentage, + this.style, + this.visible = true, + this.label, + }) : assert(0 <= percentage && percentage <= 1, 'Percentage must be between 0 and 1, but is $percentage.'); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('style', style)) + ..add(DoubleProperty('percentage', percentage)) + ..add(FlagProperty('visible', value: visible, ifTrue: 'visible')); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSliderMark && + runtimeType == other.runtimeType && + style == other.style && + percentage == other.percentage && + visible == other.visible && + label == other.label; + + @override + int get hashCode => style.hashCode ^ percentage.hashCode ^ visible.hashCode ^ label.hashCode; +} + +/// The style of a mark in a [FSlider]. +final class FSliderMarkStyle with Diagnosticable { + /// The color. + final Color color; + + /// The dimension. Defaults to 3. + /// + /// ## Contract + /// Throws [AssertionError] if it is not positive. + final double dimension; + + /// Creates a [FSliderMarkStyle]. + const FSliderMarkStyle({ + required this.color, + this.dimension = 3, + }) : assert(0 < dimension, 'Dimension must be positive, but is $dimension.'); + + /// Returns a copy of this [FSliderMarkStyle] but with the given fields replaced with the new values. + @useResult + FSliderMarkStyle copyWith({ + Color? color, + double? dimension, + }) => + FSliderMarkStyle( + color: color ?? this.color, + dimension: dimension ?? this.dimension, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(ColorProperty('color', color)) + ..add(DoubleProperty('dimension', dimension)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FSliderMarkStyle && + runtimeType == other.runtimeType && + color == other.color && + dimension == other.dimension; + + @override + int get hashCode => color.hashCode ^ dimension.hashCode; +} diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index 9be6197fc..143425207 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -10,19 +10,15 @@ class Thumb extends StatelessWidget { const Thumb({required this.style, required this.enabled, super.key}); @override - Widget build(BuildContext context) => MouseRegion( - cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, - child: SizedBox( - height: style.diameter, - width: style.diameter, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: style.color, - border: Border.all( - color: style.borderColor, - width: style.borderWidth, - ), + Widget build(BuildContext context) => SizedBox.square( + dimension: style.dimension, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: style.color, + border: Border.all( + color: style.borderColor, + width: style.borderWidth, ), ), ), @@ -37,16 +33,16 @@ class Thumb extends StatelessWidget { } } -/// The slider thumb's style. +/// A slider thumb's style. final class FSliderThumbStyle with Diagnosticable { /// The thumb's color. final Color color; - /// The thumb's diameter, inclusive of the border. Defaults to `20`. + /// The thumb's dimension, inclusive of the border. Defaults to `20`. /// /// ## Contract - /// Throws [AssertionError] if [diameter] is not positive. - final double diameter; + /// Throws [AssertionError] if [dimension] is not positive. + final double dimension; /// The border's color. final Color borderColor; @@ -61,11 +57,10 @@ final class FSliderThumbStyle with Diagnosticable { FSliderThumbStyle({ required this.color, required this.borderColor, - this.diameter = 20, + this.dimension = 20, this.borderWidth = 2, - }): - assert(0 < diameter, 'The diameter must be positive'), - assert(0 < borderWidth, 'The border width must be positive'); + }) : assert(0 < dimension, 'The diameter must be positive'), + assert(0 < borderWidth, 'The border width must be positive'); /// Returns a copy of this [FSliderThumbStyle] but with the given fields replaced with the new values. /// @@ -85,13 +80,13 @@ final class FSliderThumbStyle with Diagnosticable { @useResult FSliderThumbStyle copyWith({ Color? color, - double? diameter, + double? dimension, Color? borderColor, double? borderWidth, }) => FSliderThumbStyle( color: color ?? this.color, - diameter: diameter ?? this.diameter, + dimension: dimension ?? this.dimension, borderColor: borderColor ?? this.borderColor, borderWidth: borderWidth ?? this.borderWidth, ); @@ -101,7 +96,7 @@ final class FSliderThumbStyle with Diagnosticable { super.debugFillProperties(properties); properties ..add(ColorProperty('color', color)) - ..add(DoubleProperty('diameter', diameter)) + ..add(DoubleProperty('dimension', dimension)) ..add(ColorProperty('borderColor', borderColor)) ..add(DoubleProperty('borderWidth', borderWidth)); } @@ -112,10 +107,10 @@ final class FSliderThumbStyle with Diagnosticable { other is FSliderThumbStyle && runtimeType == other.runtimeType && color == other.color && - diameter == other.diameter && + dimension == other.dimension && borderColor == other.borderColor && borderWidth == other.borderWidth; @override - int get hashCode => color.hashCode ^ diameter.hashCode ^ borderColor.hashCode ^ borderWidth.hashCode; + int get hashCode => color.hashCode ^ dimension.hashCode ^ borderColor.hashCode ^ borderWidth.hashCode; } diff --git a/forui/lib/widgets/slider.dart b/forui/lib/widgets/slider.dart index d3caacf97..d79d292b1 100644 --- a/forui/lib/widgets/slider.dart +++ b/forui/lib/widgets/slider.dart @@ -1,3 +1,6 @@ library forui.widgets.slider; -export 'package:forui/src/widgets/slider/thumb.dart'; \ No newline at end of file +export 'package:forui/src/widgets/slider/slider.dart'; +export 'package:forui/src/widgets/slider/slider_controller.dart'; +export 'package:forui/src/widgets/slider/slider_data.dart' hide UpdatableSliderData; +export 'package:forui/src/widgets/slider/thumb.dart'; From 467bb9f293aa8d9fae33304620a8a24482166ada Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Tue, 13 Aug 2024 19:31:10 +0800 Subject: [PATCH 4/8] Add marks in slider bar --- forui/example/lib/example.dart | 9 ++- forui/lib/src/widgets/slider/bar.dart | 63 +++++++++++++------ forui/lib/src/widgets/slider/slider.dart | 11 ---- .../src/widgets/slider/slider_controller.dart | 2 +- forui/lib/src/widgets/slider/slider_mark.dart | 1 - forui/lib/src/widgets/slider/thumb.dart | 20 +++--- forui/lib/widgets/slider.dart | 1 + 7 files changed, 64 insertions(+), 43 deletions(-) diff --git a/forui/example/lib/example.dart b/forui/example/lib/example.dart index 99f0a51af..414707c21 100644 --- a/forui/example/lib/example.dart +++ b/forui/example/lib/example.dart @@ -29,7 +29,14 @@ class _ExampleState extends State { offset: (min: 0, max: 50), )), style: FSliderStyles.inherit(colorScheme: scheme).enabledStyle, - direction: LayoutDirection.ltr, + direction: LayoutDirection.btt, + marks: [ + FSliderMark(percentage: 0), + FSliderMark(percentage: 0.25), + FSliderMark(percentage: 0.5), + FSliderMark(percentage: 0.75), + FSliderMark(percentage: 1), + ], ) ], ); diff --git a/forui/lib/src/widgets/slider/bar.dart b/forui/lib/src/widgets/slider/bar.dart index f810f0aa3..b487f97df 100644 --- a/forui/lib/src/widgets/slider/bar.dart +++ b/forui/lib/src/widgets/slider/bar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; import 'package:meta/meta.dart'; @@ -7,11 +8,13 @@ class Bar extends StatelessWidget { final FSliderController controller; final FSliderStyle style; final LayoutDirection direction; + final List marks; const Bar({ required this.controller, required this.style, required this.direction, + required this.marks, super.key, }); @@ -20,61 +23,62 @@ class Bar extends StatelessWidget { late final double height; late final double width; late final double Function(TapDownDetails) translate; - late final Widget Function(double, Widget) mark; + late final Widget Function(double, Widget) position; late final ValueWidgetBuilder active; + final half = style.thumbStyle.dimension / 2; switch (direction) { case LayoutDirection.ltr: height = style.crossAxisExtent; - width = controller.value.extent.total; - translate = (details) => details.localPosition.dx; - mark = (offset, marker) => Positioned(left: offset, child: marker); + width = controller.value.extent.total + style.thumbStyle.dimension; + translate = (details) => details.localPosition.dx - half; + position = (offset, marker) => Positioned(left: offset, child: marker); active = (context, active, child) => Positioned( left: active.offset.min, child: SizedBox( height: style.crossAxisExtent, - width: active.extent.current, + width: active.extent.current + half, child: child!, ), ); case LayoutDirection.rtl: height = style.crossAxisExtent; - width = controller.value.extent.total; - translate = (details) => controller.value.extent.total - details.localPosition.dx; - mark = (offset, marker) => Positioned(right: offset, child: marker); + width = controller.value.extent.total + style.thumbStyle.dimension; + translate = (details) => controller.value.extent.total + half - details.localPosition.dx; + position = (offset, marker) => Positioned(right: offset, child: marker); active = (context, active, child) => Positioned( right: active.offset.min, child: SizedBox( height: style.crossAxisExtent, - width: active.extent.current, + width: active.extent.current + half, child: child!, ), ); case LayoutDirection.ttb: - height = controller.value.extent.total; + height = controller.value.extent.total + style.thumbStyle.dimension; width = style.crossAxisExtent; - translate = (details) => details.localPosition.dy; - mark = (offset, marker) => Positioned(top: offset, child: marker); + translate = (details) => details.localPosition.dy - half; + position = (offset, marker) => Positioned(top: offset, child: marker); active = (context, active, child) => Positioned( top: active.offset.min, child: SizedBox( - height: active.extent.current, + height: active.extent.current + half, width: style.crossAxisExtent, child: child!, ), ); case LayoutDirection.btt: - height = controller.value.extent.total; + height = controller.value.extent.total + style.thumbStyle.dimension; width = style.crossAxisExtent; - translate = (details) => controller.value.extent.total - details.localPosition.dy; - mark = (offset, marker) => Positioned(bottom: offset, child: marker); + translate = (details) => controller.value.extent.total + half - details.localPosition.dy; + position = (offset, marker) => Positioned(bottom: offset, child: marker); active = (context, active, child) => Positioned( bottom: active.offset.min, child: SizedBox( - height: active.extent.current, + height: active.extent.current + half, width: style.crossAxisExtent, child: child!, ), @@ -92,7 +96,13 @@ class Bar extends StatelessWidget { ); return GestureDetector( - onTapDown: (details) => controller.tap(translate(details)), + onTapDown: (details) { + controller.tap(switch (translate(details)) { + < 0 => 0, + final translated when controller.value.extent.total < translated => controller.value.extent.total, + final translated => translated, + }); + }, child: DecoratedBox( decoration: BoxDecoration( borderRadius: style.borderRadius, @@ -104,7 +114,12 @@ class Bar extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - // TODO: marks + for (final mark in marks) + if (mark.visible) + position( + mark.percentage * controller.value.extent.total + half - (style.markStyle.dimension / 2), + marker, + ), ValueListenableBuilder( valueListenable: controller, builder: active, @@ -121,4 +136,14 @@ class Bar extends StatelessWidget { ), ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('style', style)) + ..add(EnumProperty('direction', direction)) + ..add(IterableProperty('marks', marks)); + } } diff --git a/forui/lib/src/widgets/slider/slider.dart b/forui/lib/src/widgets/slider/slider.dart index a8ca1b26d..c832e6085 100644 --- a/forui/lib/src/widgets/slider/slider.dart +++ b/forui/lib/src/widgets/slider/slider.dart @@ -67,12 +67,6 @@ final class FSliderStyle with Diagnosticable { /// The slider inactive color. final Color inactiveColor; - /// The padding on each side of the slider's main axis. Defaults to 5. - /// - /// ## Contract: - /// Throws [AssertionError] if it is negative. - final double mainAxisPadding; - /// The slider's cross-axis extent. Defaults to 8. /// /// ## Contract: @@ -94,7 +88,6 @@ final class FSliderStyle with Diagnosticable { required this.inactiveColor, required this.markStyle, required this.thumbStyle, - this.mainAxisPadding = 5, this.crossAxisExtent = 8, BorderRadius? borderRadius, }) : borderRadius = borderRadius ?? BorderRadius.circular(4); @@ -114,7 +107,6 @@ final class FSliderStyle with Diagnosticable { FSliderStyle( activeColor: activeColor ?? this.activeColor, inactiveColor: inactiveColor ?? this.inactiveColor, - mainAxisPadding: mainAxisPadding ?? this.mainAxisPadding, crossAxisExtent: crossAxisExtent ?? this.crossAxisExtent, borderRadius: borderRadius ?? this.borderRadius, markStyle: markStyle ?? this.markStyle, @@ -127,7 +119,6 @@ final class FSliderStyle with Diagnosticable { properties ..add(ColorProperty('activeColor', activeColor)) ..add(ColorProperty('inactiveColor', inactiveColor)) - ..add(DoubleProperty('mainAxisPadding', mainAxisPadding)) ..add(DoubleProperty('crossAxisExtent', crossAxisExtent)) ..add(DiagnosticsProperty('borderRadius', borderRadius)) ..add(DiagnosticsProperty('markStyle', markStyle)) @@ -141,7 +132,6 @@ final class FSliderStyle with Diagnosticable { runtimeType == other.runtimeType && activeColor == other.activeColor && inactiveColor == other.inactiveColor && - mainAxisPadding == other.mainAxisPadding && crossAxisExtent == other.crossAxisExtent && borderRadius == other.borderRadius && markStyle == other.markStyle && @@ -151,7 +141,6 @@ final class FSliderStyle with Diagnosticable { int get hashCode => activeColor.hashCode ^ inactiveColor.hashCode ^ - mainAxisPadding.hashCode ^ crossAxisExtent.hashCode ^ borderRadius.hashCode ^ markStyle.hashCode ^ diff --git a/forui/lib/src/widgets/slider/slider_controller.dart b/forui/lib/src/widgets/slider/slider_controller.dart index 3b9d0268f..fdef465d9 100644 --- a/forui/lib/src/widgets/slider/slider_controller.dart +++ b/forui/lib/src/widgets/slider/slider_controller.dart @@ -28,6 +28,6 @@ final class _Stub extends FSliderController { @override void tap(double offset) { - // TODO: implement tap + print(offset); } } diff --git a/forui/lib/src/widgets/slider/slider_mark.dart b/forui/lib/src/widgets/slider/slider_mark.dart index 02d366a73..ac3fab3ed 100644 --- a/forui/lib/src/widgets/slider/slider_mark.dart +++ b/forui/lib/src/widgets/slider/slider_mark.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:forui/forui.dart'; import 'package:meta/meta.dart'; /// A mark in a [FSlider]. diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index 143425207..5735946bc 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -10,18 +10,18 @@ class Thumb extends StatelessWidget { const Thumb({required this.style, required this.enabled, super.key}); @override - Widget build(BuildContext context) => SizedBox.square( - dimension: style.dimension, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: style.color, - border: Border.all( - color: style.borderColor, - width: style.borderWidth, - ), + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: style.color, + border: Border.all( + color: style.borderColor, + width: style.borderWidth, ), ), + child: SizedBox.square( + dimension: style.dimension, + ), ); @override diff --git a/forui/lib/widgets/slider.dart b/forui/lib/widgets/slider.dart index d79d292b1..c8027c894 100644 --- a/forui/lib/widgets/slider.dart +++ b/forui/lib/widgets/slider.dart @@ -3,4 +3,5 @@ library forui.widgets.slider; export 'package:forui/src/widgets/slider/slider.dart'; export 'package:forui/src/widgets/slider/slider_controller.dart'; export 'package:forui/src/widgets/slider/slider_data.dart' hide UpdatableSliderData; +export 'package:forui/src/widgets/slider/slider_mark.dart'; export 'package:forui/src/widgets/slider/thumb.dart'; From 9d50fa5083d6135a834c8fdbc0117dcf85714cbd Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Tue, 13 Aug 2024 22:59:01 +0800 Subject: [PATCH 5/8] WIP --- forui/example/lib/example.dart | 15 +++- forui/lib/src/widgets/slider/bar.dart | 46 ++++++----- forui/lib/src/widgets/slider/slider.dart | 4 +- forui/lib/src/widgets/slider/thumb.dart | 77 +++++++++++-------- .../widgets/text_field/text_form_field.dart | 6 +- samples/pubspec.lock | 24 +++--- 6 files changed, 98 insertions(+), 74 deletions(-) diff --git a/forui/example/lib/example.dart b/forui/example/lib/example.dart index 414707c21..c09490924 100644 --- a/forui/example/lib/example.dart +++ b/forui/example/lib/example.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Thumb; import 'package:forui/forui.dart'; import 'package:forui/src/widgets/slider/bar.dart'; +import 'package:forui/src/widgets/slider/thumb.dart'; import 'package:intl/intl.dart'; class Example extends StatefulWidget { @@ -29,15 +30,21 @@ class _ExampleState extends State { offset: (min: 0, max: 50), )), style: FSliderStyles.inherit(colorScheme: scheme).enabledStyle, - direction: LayoutDirection.btt, + direction: LayoutDirection.ltr, marks: [ FSliderMark(percentage: 0), - FSliderMark(percentage: 0.25), + FSliderMark(percentage: 0.25, visible: false), FSliderMark(percentage: 0.5), - FSliderMark(percentage: 0.75), + FSliderMark(percentage: 0.75, visible: false), FSliderMark(percentage: 1), ], - ) + ), + const SizedBox(height: 10), + Thumb( + style: FSliderStyles.inherit(colorScheme: scheme).enabledStyle.thumbStyle, + enabled: true, + hitRegionExtent: 20, + ), ], ); } diff --git a/forui/lib/src/widgets/slider/bar.dart b/forui/lib/src/widgets/slider/bar.dart index b487f97df..bc1816fc7 100644 --- a/forui/lib/src/widgets/slider/bar.dart +++ b/forui/lib/src/widgets/slider/bar.dart @@ -23,7 +23,7 @@ class Bar extends StatelessWidget { late final double height; late final double width; late final double Function(TapDownDetails) translate; - late final Widget Function(double, Widget) position; + late final Widget Function(double, Widget) marker; late final ValueWidgetBuilder active; final half = style.thumbStyle.dimension / 2; @@ -32,7 +32,7 @@ class Bar extends StatelessWidget { height = style.crossAxisExtent; width = controller.value.extent.total + style.thumbStyle.dimension; translate = (details) => details.localPosition.dx - half; - position = (offset, marker) => Positioned(left: offset, child: marker); + marker = (offset, marker) => Positioned(left: offset, child: marker); active = (context, active, child) => Positioned( left: active.offset.min, child: SizedBox( @@ -46,7 +46,7 @@ class Bar extends StatelessWidget { height = style.crossAxisExtent; width = controller.value.extent.total + style.thumbStyle.dimension; translate = (details) => controller.value.extent.total + half - details.localPosition.dx; - position = (offset, marker) => Positioned(right: offset, child: marker); + marker = (offset, marker) => Positioned(right: offset, child: marker); active = (context, active, child) => Positioned( right: active.offset.min, child: SizedBox( @@ -60,7 +60,7 @@ class Bar extends StatelessWidget { height = controller.value.extent.total + style.thumbStyle.dimension; width = style.crossAxisExtent; translate = (details) => details.localPosition.dy - half; - position = (offset, marker) => Positioned(top: offset, child: marker); + marker = (offset, marker) => Positioned(top: offset, child: marker); active = (context, active, child) => Positioned( top: active.offset.min, child: SizedBox( @@ -74,7 +74,7 @@ class Bar extends StatelessWidget { height = controller.value.extent.total + style.thumbStyle.dimension; width = style.crossAxisExtent; translate = (details) => controller.value.extent.total + half - details.localPosition.dy; - position = (offset, marker) => Positioned(bottom: offset, child: marker); + marker = (offset, marker) => Positioned(bottom: offset, child: marker); active = (context, active, child) => Positioned( bottom: active.offset.min, child: SizedBox( @@ -85,24 +85,14 @@ class Bar extends StatelessWidget { ); } - final marker = DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: style.markStyle.color, - ), - child: SizedBox.square( - dimension: style.markStyle.dimension, - ), - ); - return GestureDetector( - onTapDown: (details) { - controller.tap(switch (translate(details)) { + onTapDown: (details) => controller.tap( + switch (translate(details)) { < 0 => 0, final translated when controller.value.extent.total < translated => controller.value.extent.total, final translated => translated, - }); - }, + }, + ), child: DecoratedBox( decoration: BoxDecoration( borderRadius: style.borderRadius, @@ -114,11 +104,19 @@ class Bar extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - for (final mark in marks) - if (mark.visible) - position( - mark.percentage * controller.value.extent.total + half - (style.markStyle.dimension / 2), - marker, + for (final FSliderMark(:style, :percentage, :visible) in marks) + if (visible) + marker( + percentage * controller.value.extent.total + half - ((style ?? this.style.markStyle).dimension / 2), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (style ?? this.style.markStyle).color, + ), + child: SizedBox.square( + dimension: (style ?? this.style.markStyle).dimension, + ), + ), ), ValueListenableBuilder( valueListenable: controller, diff --git a/forui/lib/src/widgets/slider/slider.dart b/forui/lib/src/widgets/slider/slider.dart index c832e6085..31919d198 100644 --- a/forui/lib/src/widgets/slider/slider.dart +++ b/forui/lib/src/widgets/slider/slider.dart @@ -25,7 +25,7 @@ final class FSliderStyles with Diagnosticable { inactiveColor: colorScheme.secondary, markStyle: FSliderMarkStyle(color: colorScheme.mutedForeground), thumbStyle: FSliderThumbStyle( - color: colorScheme.foreground, + color: colorScheme.primaryForeground, borderColor: colorScheme.primary, ), ), @@ -34,7 +34,7 @@ final class FSliderStyles with Diagnosticable { inactiveColor: colorScheme.secondary, markStyle: FSliderMarkStyle(color: colorScheme.mutedForeground.withOpacity(0.7)), thumbStyle: FSliderThumbStyle( - color: colorScheme.primary.withOpacity(0.7), + color: colorScheme.primaryForeground.withOpacity(0.7), borderColor: colorScheme.primary.withOpacity(0.7), ), ); diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index 5735946bc..a70fa1998 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -2,27 +2,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -@internal -class Thumb extends StatelessWidget { +class Thumb extends StatefulWidget { final FSliderThumbStyle style; + final double hitRegionExtent; final bool enabled; - const Thumb({required this.style, required this.enabled, super.key}); + const Thumb({required this.style, required this.hitRegionExtent, required this.enabled, super.key}); @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: style.color, - border: Border.all( - color: style.borderColor, - width: style.borderWidth, - ), - ), - child: SizedBox.square( - dimension: style.dimension, - ), - ); + State createState() => _ThumbState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -33,6 +21,48 @@ class Thumb extends StatelessWidget { } } +class _ThumbState extends State { + MouseCursor _cursor = SystemMouseCursors.grab; + + @override + Widget build(BuildContext context) { + // TODO: arrow keys & focus node & draggable + + Widget thumb = SizedBox.square( + dimension: widget.style.dimension + widget.hitRegionExtent, + child: Align( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.style.color, + border: Border.all( + color: widget.style.borderColor, + width: widget.style.borderWidth, + ), + ), + child: SizedBox.square( + dimension: widget.style.dimension, + ), + ), + ), + ); + + if (widget.enabled) { + thumb = GestureDetector( + onTapDown: (_) => setState(() => _cursor = SystemMouseCursors.grabbing), + onTapUp: (_) => setState(() => _cursor = SystemMouseCursors.grab), + onTapCancel: () => setState(() => _cursor = SystemMouseCursors.grab), + child: MouseRegion( + cursor: widget.enabled ? _cursor : MouseCursor.defer, + child: thumb, + ), + ); + } + + return thumb; + } +} + /// A slider thumb's style. final class FSliderThumbStyle with Diagnosticable { /// The thumb's color. @@ -63,21 +93,6 @@ final class FSliderThumbStyle with Diagnosticable { assert(0 < borderWidth, 'The border width must be positive'); /// Returns a copy of this [FSliderThumbStyle] but with the given fields replaced with the new values. - /// - /// ```dart - /// final style = FSliderThumbStyle( - /// color: Colors.red, - /// diameter: 16, - /// borderColor: Colors.black, - /// borderWidth: 2, - /// ); - /// - /// final copy = style.copyWith(color: Colors.blue); - /// - /// print(copy.color); // Colors.blue - /// print(copy.diameter); // 16 - /// ``` - @useResult FSliderThumbStyle copyWith({ Color? color, double? dimension, diff --git a/forui/lib/src/widgets/text_field/text_form_field.dart b/forui/lib/src/widgets/text_field/text_form_field.dart index 9644854c1..8b934405a 100644 --- a/forui/lib/src/widgets/text_field/text_form_field.dart +++ b/forui/lib/src/widgets/text_field/text_form_field.dart @@ -18,7 +18,11 @@ class _Field extends FormField { ? null : DefaultTextStyle.merge(style: stateStyle.descriptionTextStyle, child: parent.description!), helperStyle: stateStyle.descriptionTextStyle, - error: state.errorText == null ? null : const SizedBox(), + error: switch ((state.errorText, parent.description)) { + (null, _) => null, + (_, null) => const SizedBox(), + (_, final description?) => DefaultTextStyle.merge(style: stateStyle.descriptionTextStyle, child: description), + }, disabledBorder: OutlineInputBorder( borderSide: BorderSide( color: style.disabledStyle.unfocusedStyle.color, diff --git a/samples/pubspec.lock b/samples/pubspec.lock index c5a1244da..0c55bb373 100644 --- a/samples/pubspec.lock +++ b/samples/pubspec.lock @@ -367,18 +367,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: @@ -407,18 +407,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.12.0" mime: dependency: transitive description: @@ -652,10 +652,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.0" timing: dependency: transitive description: @@ -708,10 +708,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.1" watcher: dependency: transitive description: From 00934473b37907b11d4d7b814f0cfd4e376010bd Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Wed, 14 Aug 2024 18:33:49 +0800 Subject: [PATCH 6/8] Thumb WIP --- forui/lib/src/widgets/slider/thumb.dart | 74 ++++++++++++++++++------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index a70fa1998..d97109b83 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -1,13 +1,34 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:forui/forui.dart'; import 'package:meta/meta.dart'; +class _ShrinkIntent extends Intent { + const _ShrinkIntent(); +} + +class _ExpandIntent extends Intent { + const _ExpandIntent(); +} + +@internal class Thumb extends StatefulWidget { + final FSliderController controller; final FSliderThumbStyle style; final double hitRegionExtent; + final FocusNode? focusNode; + final bool autofocus; final bool enabled; - const Thumb({required this.style, required this.hitRegionExtent, required this.enabled, super.key}); + const Thumb({ + required this.controller, + required this.style, + required this.hitRegionExtent, + required this.focusNode, + required this.autofocus, + required this.enabled, + super.key, + }); @override State createState() => _ThumbState(); @@ -26,34 +47,47 @@ class _ThumbState extends State { @override Widget build(BuildContext context) { - // TODO: arrow keys & focus node & draggable - - Widget thumb = SizedBox.square( - dimension: widget.style.dimension + widget.hitRegionExtent, - child: Align( - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.style.color, - border: Border.all( - color: widget.style.borderColor, - width: widget.style.borderWidth, - ), - ), - child: SizedBox.square( - dimension: widget.style.dimension, - ), + Widget thumb = DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.style.color, + border: Border.all( + color: widget.style.borderColor, + width: widget.style.borderWidth, ), ), + child: SizedBox.square( + dimension: widget.style.dimension, + ), ); + if (widget.hitRegionExtent != 0) { + thumb = SizedBox.square( + dimension: widget.style.dimension + widget.hitRegionExtent, + child: Align( + child: thumb, + ), + ); + } + if (widget.enabled) { thumb = GestureDetector( onTapDown: (_) => setState(() => _cursor = SystemMouseCursors.grabbing), onTapUp: (_) => setState(() => _cursor = SystemMouseCursors.grab), onTapCancel: () => setState(() => _cursor = SystemMouseCursors.grab), - child: MouseRegion( - cursor: widget.enabled ? _cursor : MouseCursor.defer, + child: FocusableActionDetector( + actions: { + _ShrinkIntent: CallbackAction<_ShrinkIntent>( + onInvoke: (_) {}, + ), + _ExpandIntent: CallbackAction<_ExpandIntent>( + onInvoke: (_) {}, + ), + }, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + enabled: widget.enabled, + mouseCursor: widget.enabled ? _cursor : MouseCursor.defer, child: thumb, ), ); From 3c2991b3933ea5f400898ff9093cf643e542dabe Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Wed, 14 Aug 2024 18:33:56 +0800 Subject: [PATCH 7/8] whops --- forui/lib/src/widgets/slider/thumb.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index d97109b83..7c9cab341 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -76,12 +76,13 @@ class _ThumbState extends State { onTapUp: (_) => setState(() => _cursor = SystemMouseCursors.grab), onTapCancel: () => setState(() => _cursor = SystemMouseCursors.grab), child: FocusableActionDetector( + // TODO: shorts actions: { _ShrinkIntent: CallbackAction<_ShrinkIntent>( - onInvoke: (_) {}, + onInvoke: (_) {}, // TODO: how to work with snappable ), _ExpandIntent: CallbackAction<_ExpandIntent>( - onInvoke: (_) {}, + onInvoke: (_) {}, // TODO: how to work with snappable ), }, focusNode: widget.focusNode, From 12e4bfc9dc1a955fb0813948d30f9079a858d9ed Mon Sep 17 00:00:00 2001 From: Matthias Ngeo Date: Wed, 14 Aug 2024 22:03:02 +0800 Subject: [PATCH 8/8] Finish thumb --- forui/lib/src/widgets/slider/bar.dart | 144 +++++----- forui/lib/src/widgets/slider/slider.dart | 44 ++++ .../src/widgets/slider/slider_controller.dart | 12 +- forui/lib/src/widgets/slider/thumb.dart | 249 ++++++++++++++---- forui/lib/widgets/slider.dart | 2 +- 5 files changed, 325 insertions(+), 126 deletions(-) diff --git a/forui/lib/src/widgets/slider/bar.dart b/forui/lib/src/widgets/slider/bar.dart index bc1816fc7..c7a3fd673 100644 --- a/forui/lib/src/widgets/slider/bar.dart +++ b/forui/lib/src/widgets/slider/bar.dart @@ -1,147 +1,143 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/slider/slider.dart'; import 'package:meta/meta.dart'; @internal class Bar extends StatelessWidget { - final FSliderController controller; - final FSliderStyle style; - final LayoutDirection direction; - final List marks; - - const Bar({ - required this.controller, - required this.style, - required this.direction, - required this.marks, - super.key, - }); + const Bar({super.key}); @override Widget build(BuildContext context) { + final InheritedData( + :controller, + style: FSliderStyle(:activeColor, :inactiveColor, :borderRadius, :crossAxisExtent, :markStyle, :thumbStyle), + :direction, + :marks, + :enabled, + ) = InheritedData.of(context); + late final double height; late final double width; late final double Function(TapDownDetails) translate; late final Widget Function(double, Widget) marker; late final ValueWidgetBuilder active; - final half = style.thumbStyle.dimension / 2; + // We use the thumb style's dimension as the bar's padding. + final half = thumbStyle.dimension / 2; + switch (direction) { case LayoutDirection.ltr: - height = style.crossAxisExtent; - width = controller.value.extent.total + style.thumbStyle.dimension; + height = crossAxisExtent; + width = controller.value.extent.total + thumbStyle.dimension; translate = (details) => details.localPosition.dx - half; marker = (offset, marker) => Positioned(left: offset, child: marker); active = (context, active, child) => Positioned( left: active.offset.min, child: SizedBox( - height: style.crossAxisExtent, + height: crossAxisExtent, width: active.extent.current + half, child: child!, ), ); case LayoutDirection.rtl: - height = style.crossAxisExtent; - width = controller.value.extent.total + style.thumbStyle.dimension; + height = crossAxisExtent; + width = controller.value.extent.total + thumbStyle.dimension; translate = (details) => controller.value.extent.total + half - details.localPosition.dx; marker = (offset, marker) => Positioned(right: offset, child: marker); active = (context, active, child) => Positioned( right: active.offset.min, child: SizedBox( - height: style.crossAxisExtent, + height: crossAxisExtent, width: active.extent.current + half, child: child!, ), ); case LayoutDirection.ttb: - height = controller.value.extent.total + style.thumbStyle.dimension; - width = style.crossAxisExtent; + height = controller.value.extent.total + thumbStyle.dimension; + width = crossAxisExtent; translate = (details) => details.localPosition.dy - half; marker = (offset, marker) => Positioned(top: offset, child: marker); active = (context, active, child) => Positioned( top: active.offset.min, child: SizedBox( height: active.extent.current + half, - width: style.crossAxisExtent, + width: crossAxisExtent, child: child!, ), ); case LayoutDirection.btt: - height = controller.value.extent.total + style.thumbStyle.dimension; - width = style.crossAxisExtent; + height = controller.value.extent.total + thumbStyle.dimension; + width = crossAxisExtent; translate = (details) => controller.value.extent.total + half - details.localPosition.dy; marker = (offset, marker) => Positioned(bottom: offset, child: marker); active = (context, active, child) => Positioned( bottom: active.offset.min, child: SizedBox( height: active.extent.current + half, - width: style.crossAxisExtent, + width: crossAxisExtent, child: child!, ), ); } - return GestureDetector( - onTapDown: (details) => controller.tap( - switch (translate(details)) { - < 0 => 0, - final translated when controller.value.extent.total < translated => controller.value.extent.total, - final translated => translated, - }, + Widget bar = DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: inactiveColor, ), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: style.borderRadius, - color: style.inactiveColor, - ), - child: SizedBox( - height: height, - width: width, - child: Stack( - alignment: Alignment.center, - children: [ - for (final FSliderMark(:style, :percentage, :visible) in marks) - if (visible) - marker( - percentage * controller.value.extent.total + half - ((style ?? this.style.markStyle).dimension / 2), - DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: (style ?? this.style.markStyle).color, - ), - child: SizedBox.square( - dimension: (style ?? this.style.markStyle).dimension, - ), + child: SizedBox( + height: height, + width: width, + child: Stack( + alignment: Alignment.center, + children: [ + for (final FSliderMark(:style, :percentage, :visible) in marks) + if (visible) + marker( + percentage * controller.value.extent.total + half - ((style ?? markStyle).dimension / 2), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (style ?? markStyle).color, + ), + child: SizedBox.square( + dimension: (style ?? markStyle).dimension, ), - ), - ValueListenableBuilder( - valueListenable: controller, - builder: active, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: style.borderRadius, - color: style.activeColor, ), ), + ValueListenableBuilder( + valueListenable: controller, + builder: active, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: activeColor, + ), ), - ], - ), + ), + ], ), ), ); - } - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('controller', controller)) - ..add(DiagnosticsProperty('style', style)) - ..add(EnumProperty('direction', direction)) - ..add(IterableProperty('marks', marks)); + if (enabled) { + bar = GestureDetector( + onTapDown: (details) => controller.tap( + switch (translate(details)) { + < 0 => 0, + final translated when controller.value.extent.total < translated => controller.value.extent.total, + final translated => translated, + }, + ), + child: bar, + ); + } + + return bar; } } diff --git a/forui/lib/src/widgets/slider/slider.dart b/forui/lib/src/widgets/slider/slider.dart index 31919d198..7d0dd28e8 100644 --- a/forui/lib/src/widgets/slider/slider.dart +++ b/forui/lib/src/widgets/slider/slider.dart @@ -4,6 +4,50 @@ import 'package:forui/forui.dart'; import 'package:forui/src/widgets/slider/slider_mark.dart'; import 'package:meta/meta.dart'; +@internal +class InheritedData extends InheritedWidget { + final FSliderController controller; + final FSliderStyle style; + final LayoutDirection direction; + final List marks; + final bool enabled; + + static InheritedData of(BuildContext context) { + final InheritedData? result = context.dependOnInheritedWidgetOfExactType(); + assert(result != null, 'No InheritedData found in context'); + return result!; + } + + const InheritedData({ + required this.controller, + required this.style, + required this.direction, + required this.marks, + required this.enabled, + required super.child, + super.key, + }); + + @override + bool updateShouldNotify(InheritedData old) => + controller != old.controller || + style != old.style || + direction != old.direction || + marks != old.marks || + enabled != old.enabled; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('style', style)) + ..add(EnumProperty('direction', direction)) + ..add(IterableProperty('marks', marks)) + ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled')); + } +} + /// A slider's styles. final class FSliderStyles with Diagnosticable { /// The enabled slider's style. diff --git a/forui/lib/src/widgets/slider/slider_controller.dart b/forui/lib/src/widgets/slider/slider_controller.dart index fdef465d9..de5da9eed 100644 --- a/forui/lib/src/widgets/slider/slider_controller.dart +++ b/forui/lib/src/widgets/slider/slider_controller.dart @@ -2,20 +2,24 @@ import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; abstract class FSliderController extends ValueNotifier { + /// Whether the slider is extendable at the min and max sides. + final ({bool min, bool max}) extendable; + + /// True if the slider has continuous values, and false if it has discrete values. + final bool continuous; + factory FSliderController(FSliderData value) = _Stub; - FSliderController.value(super.value); + FSliderController.value(super._value, {required this.extendable, required this.continuous}); void drag(double delta); void tap(double offset); - - ({bool min, bool max}) get extendable; } // TODO: remove final class _Stub extends FSliderController { - _Stub(super.value) : super.value(); + _Stub(super.value) : super.value(extendable: (min: true, max: true), continuous: true); @override void drag(double delta) { diff --git a/forui/lib/src/widgets/slider/thumb.dart b/forui/lib/src/widgets/slider/thumb.dart index 7c9cab341..b03cf1291 100644 --- a/forui/lib/src/widgets/slider/thumb.dart +++ b/forui/lib/src/widgets/slider/thumb.dart @@ -1,7 +1,11 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/slider/slider.dart'; import 'package:meta/meta.dart'; +import 'package:sugar/collection.dart'; +import 'package:sugar/collection_aggregate.dart'; class _ShrinkIntent extends Intent { const _ShrinkIntent(); @@ -13,89 +17,240 @@ class _ExpandIntent extends Intent { @internal class Thumb extends StatefulWidget { - final FSliderController controller; - final FSliderThumbStyle style; final double hitRegionExtent; final FocusNode? focusNode; final bool autofocus; - final bool enabled; + final double adjustment; + final bool min; const Thumb({ - required this.controller, - required this.style, required this.hitRegionExtent, required this.focusNode, required this.autofocus, - required this.enabled, + required this.adjustment, + required this.min, super.key, }); @override - State createState() => _ThumbState(); + State createState() => ThumbState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty('style', style)) - ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled')); + ..add(DoubleProperty('hitRegionExtent', hitRegionExtent)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')) + ..add(DoubleProperty('adjustment', adjustment)) + ..add(FlagProperty('min', value: min, ifTrue: 'min', ifFalse: 'max')); } } -class _ThumbState extends State { +@internal +class ThumbState extends State { MouseCursor _cursor = SystemMouseCursors.grab; @override Widget build(BuildContext context) { - Widget thumb = DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.style.color, - border: Border.all( - color: widget.style.borderColor, - width: widget.style.borderWidth, - ), - ), - child: SizedBox.square( - dimension: widget.style.dimension, - ), + final InheritedData(:controller, :style, :direction, :enabled) = InheritedData.of(context); + + Widget thumb = _Thumb( + shortcuts: _shortcuts(direction), + cursor: _cursor, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + adjustment: widget.adjustment, + min: widget.min, ); - if (widget.hitRegionExtent != 0) { - thumb = SizedBox.square( - dimension: widget.style.dimension + widget.hitRegionExtent, - child: Align( - child: thumb, - ), - ); - } + if (enabled) { + final (horizontal, vertical) = _gestures(controller, direction); - if (widget.enabled) { thumb = GestureDetector( onTapDown: (_) => setState(() => _cursor = SystemMouseCursors.grabbing), onTapUp: (_) => setState(() => _cursor = SystemMouseCursors.grab), onTapCancel: () => setState(() => _cursor = SystemMouseCursors.grab), - child: FocusableActionDetector( - // TODO: shorts - actions: { - _ShrinkIntent: CallbackAction<_ShrinkIntent>( - onInvoke: (_) {}, // TODO: how to work with snappable - ), - _ExpandIntent: CallbackAction<_ExpandIntent>( - onInvoke: (_) {}, // TODO: how to work with snappable - ), - }, - focusNode: widget.focusNode, - autofocus: widget.autofocus, - enabled: widget.enabled, - mouseCursor: widget.enabled ? _cursor : MouseCursor.defer, - child: thumb, - ), + onHorizontalDragUpdate: horizontal, + onVerticalDragUpdate: vertical, + child: widget.hitRegionExtent == 0 + ? thumb + : SizedBox.square( + dimension: style.thumbStyle.dimension + widget.hitRegionExtent, + child: Align( + child: thumb, + ), + ), ); } return thumb; } + + (GestureDragUpdateCallback?, GestureDragUpdateCallback?) _gestures( + FSliderController controller, + LayoutDirection direction, + ) => + switch (direction) { + LayoutDirection.ltr => ((details) => controller.drag(details.delta.dx), null), + LayoutDirection.rtl => ((details) => controller.drag(-details.delta.dx), null), + LayoutDirection.ttb => (null, (details) => controller.drag(details.delta.dy)), + LayoutDirection.btt => (null, (details) => controller.drag(-details.delta.dy)), + }; + + Map _shortcuts(LayoutDirection direction) => switch ((direction, widget.min)) { + (LayoutDirection.ltr, true) => const { + SingleActivator(LogicalKeyboardKey.arrowLeft): _ExpandIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight): _ShrinkIntent(), + }, + (LayoutDirection.ltr, false) => const { + SingleActivator(LogicalKeyboardKey.arrowLeft): _ShrinkIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight): _ExpandIntent(), + }, + (LayoutDirection.rtl, true) => const { + SingleActivator(LogicalKeyboardKey.arrowLeft): _ShrinkIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight): _ExpandIntent(), + }, + (LayoutDirection.rtl, false) => const { + SingleActivator(LogicalKeyboardKey.arrowLeft): _ExpandIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight): _ShrinkIntent(), + }, + (LayoutDirection.ttb, true) => const { + SingleActivator(LogicalKeyboardKey.arrowUp): _ExpandIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ShrinkIntent(), + }, + (LayoutDirection.ttb, false) => const { + SingleActivator(LogicalKeyboardKey.arrowUp): _ShrinkIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ExpandIntent(), + }, + (LayoutDirection.btt, true) => const { + SingleActivator(LogicalKeyboardKey.arrowUp): _ShrinkIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ExpandIntent(), + }, + (LayoutDirection.btt, false) => const { + SingleActivator(LogicalKeyboardKey.arrowUp): _ExpandIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ShrinkIntent(), + }, + }; +} + +class _Thumb extends StatelessWidget { + final Map shortcuts; + final MouseCursor cursor; + final FocusNode? focusNode; + final bool autofocus; + final double adjustment; + final bool min; + + const _Thumb({ + required this.shortcuts, + required this.cursor, + required this.focusNode, + required this.autofocus, + required this.adjustment, + required this.min, + }); + + @override + Widget build(BuildContext context) { + final InheritedData(:controller, style: FSliderStyle(:thumbStyle), :marks, :enabled) = InheritedData.of(context); + return FocusableActionDetector( + shortcuts: shortcuts, + actions: { + _ExpandIntent: CallbackAction<_ExpandIntent>(onInvoke: _expand(controller, marks)), + _ShrinkIntent: CallbackAction<_ShrinkIntent>(onInvoke: _shrink(controller, marks)), + }, + focusNode: focusNode, + autofocus: autofocus, + enabled: enabled, + mouseCursor: enabled ? cursor : MouseCursor.defer, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: thumbStyle.color, + border: Border.all( + color: thumbStyle.borderColor, + width: thumbStyle.borderWidth, + ), + ), + child: SizedBox.square( + dimension: thumbStyle.dimension, + ), + ), + ); + } + + void Function(_ExpandIntent) _expand(FSliderController controller, List marks) { + switch ((min, controller.continuous)) { + case (true, true): + return (_) => controller.drag(-adjustment * controller.value.extent.total); + + case (true, false): + return (_) { + final previous = marks + .where((mark) => mark.percentage * controller.value.extent.total < controller.value.offset.min) + .order(by: (mark) => mark.percentage) + .max; + + controller.drag((previous?.percentage ?? 0) * controller.value.extent.total - controller.value.offset.min); + }; + + case (false, true): + return (_) => controller.drag(adjustment * controller.value.extent.total); + + case (false, false): + return (_) { + final next = marks + .where((mark) => controller.value.offset.max < mark.percentage * controller.value.extent.total) + .order(by: (mark) => mark.percentage) + .min; + + controller.drag((next?.percentage ?? 1) * controller.value.extent.total - controller.value.offset.max); + }; + } + } + + void Function(_ShrinkIntent) _shrink(FSliderController controller, List marks) { + switch ((min, controller.continuous)) { + case (true, true): + return (_) => controller.drag(adjustment * controller.value.extent.total); + + case (true, false): + return (_) { + final next = marks + .where((mark) => controller.value.offset.min < mark.percentage * controller.value.extent.total) + .order(by: (mark) => mark.percentage) + .min; + + controller.drag((next?.percentage ?? 1) * controller.value.extent.total - controller.value.offset.min); + }; + + case (false, true): + return (_) => controller.drag(-adjustment * controller.value.extent.total); + + case (false, false): + return (_) { + final previous = marks + .where((mark) => mark.percentage * controller.value.extent.total < controller.value.offset.max) + .order(by: (mark) => mark.percentage) + .max; + + controller.drag((previous?.percentage ?? 0) * controller.value.extent.total - controller.value.offset.max); + }; + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('shortcuts', shortcuts)) + ..add(DiagnosticsProperty('cursor', cursor)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')) + ..add(DoubleProperty('adjustment', adjustment)) + ..add(FlagProperty('min', value: min, ifTrue: 'min', ifFalse: 'max')); + } } /// A slider thumb's style. diff --git a/forui/lib/widgets/slider.dart b/forui/lib/widgets/slider.dart index c8027c894..6c7482619 100644 --- a/forui/lib/widgets/slider.dart +++ b/forui/lib/widgets/slider.dart @@ -1,6 +1,6 @@ library forui.widgets.slider; -export 'package:forui/src/widgets/slider/slider.dart'; +export 'package:forui/src/widgets/slider/slider.dart' hide InheritedData; export 'package:forui/src/widgets/slider/slider_controller.dart'; export 'package:forui/src/widgets/slider/slider_data.dart' hide UpdatableSliderData; export 'package:forui/src/widgets/slider/slider_mark.dart';