import 'dart:math' as math; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/material.dart' as m; /// A slider is a control that lets the user select from a /// range of values by moving a thumb control along a track. /// /// A slider is a good choice when you know that users think /// of the value as a relative quantity, not a numeric value. /// For example, users think about setting their audio volume /// to low or medium—not about setting the value to 2 or 5. /// /// ![Slider Preview](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/images/controls/slider.png) /// /// See also: /// - [RatingBar] class Slider extends StatefulWidget { const Slider({ Key? key, required this.value, required this.onChanged, this.onChangeStart, this.onChangeEnd, this.min = 0.0, this.max = 100.0, this.divisions, this.style, this.label, this.focusNode, this.vertical = false, this.autofocus = false, this.mouseCursor = MouseCursor.defer, }) : assert(value >= min && value <= max), assert(divisions == null || divisions > 0), super(key: key); /// The currently selected value for this slider. /// /// The slider's thumb is drawn at a position that corresponds to this value. final double value; /// Called during a drag when the user is selecting a new value for the slider /// by dragging. /// /// The slider passes the new value to the callback but does not actually /// change state until the parent widget rebuilds the slider with the new /// value. /// /// If null, the slider will be displayed as disabled. /// /// The callback provided to onChanged should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// /// {@tool snippet} /// /// ```dart /// Slider( /// value: _duelCommandment.toDouble(), /// min: 1.0, /// max: 10.0, /// divisions: 10, /// label: '$_duelCommandment', /// onChanged: (double newValue) { /// setState(() { /// _duelCommandment = newValue.round(); /// }); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeStart] for a callback that is called when the user starts /// changing the value. /// * [onChangeEnd] for a callback that is called when the user stops /// changing the value. final ValueChanged? onChanged; /// Called when the user starts selecting a new value for the slider. /// /// This callback shouldn't be used to update the slider [value] (use /// [onChanged] for that), but rather to be notified when the user has started /// selecting a new value by starting a drag or with a tap. /// /// The value passed will be the last [value] that the slider had before the /// change began. /// /// {@tool snippet} /// /// ```dart /// Slider( /// value: _duelCommandment.toDouble(), /// min: 1.0, /// max: 10.0, /// divisions: 10, /// label: '$_duelCommandment', /// onChanged: (double newValue) { /// setState(() { /// _duelCommandment = newValue.round(); /// }); /// }, /// onChangeStart: (double startValue) { /// print('Started change at $startValue'); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeEnd] for a callback that is called when the value change is /// complete. final ValueChanged? onChangeStart; /// Called when the user is done selecting a new value for the slider. /// /// This callback shouldn't be used to update the slider [value] (use /// [onChanged] for that), but rather to know when the user has completed /// selecting a new [value] by ending a drag or a click. /// /// {@tool snippet} /// /// ```dart /// Slider( /// value: _duelCommandment.toDouble(), /// min: 1.0, /// max: 10.0, /// divisions: 10, /// label: '$_duelCommandment', /// onChanged: (double newValue) { /// setState(() { /// _duelCommandment = newValue.round(); /// }); /// }, /// onChangeEnd: (double newValue) { /// print('Ended change on $newValue'); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeStart] for a callback that is called when a value change /// begins. final ValueChanged? onChangeEnd; /// The maximum value the user can select. /// /// Defaults to 1.0. Must be greater than or equal to [min]. /// /// If the [max] is equal to the [min], then the slider is disabled. final double min; /// The minimum value the user can select. /// /// Defaults to 0.0. Must be less than or equal to [max]. /// /// If the [max] is equal to the [min], then the slider is disabled. final double max; /// The number of discrete divisions. /// /// Typically used with [label] to show the current discrete value. /// /// If null, the slider is continuous. final int? divisions; /// The style used in this slider. It's mescled with [ThemeData.sliderThemeData] final SliderThemeData? style; /// A label to show above the slider, or at the left /// of the slider if [vertical] is `true` when the slider is active. final String? label; /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; /// Whether the slider is vertical or not /// /// Use a vertical slider if the slider represents a /// real-world value that is normally shown vertically /// (such as temperature). final bool vertical; /// {@macro fluent_ui.controls.inputs.HoverButton.mouseCursor} final MouseCursor mouseCursor; @override _SliderState createState() => _SliderState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add(DoubleProperty('value', value)) ..add(DoubleProperty('min', min)) ..add(DoubleProperty('max', max)) ..add(IntProperty('divisions', divisions)) ..add(StringProperty('label', label)) ..add(ObjectFlagProperty.has('focusNode', focusNode)) ..add(DiagnosticsProperty('style', style)) ..add(FlagProperty('vertical', value: vertical, ifFalse: 'horizontal')); } } class _SliderState extends m.State { bool _showFocusHighlight = false; late FocusNode _focusNode; bool _sliding = false; @override void initState() { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_handleFocusChanged); } void _handleFocusChanged() { setState(() {}); } @override void dispose() { _focusNode.removeListener(_handleFocusChanged); // Only dispose the focus node manually created if (widget.focusNode == null) _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { assert(debugCheckHasFluentTheme(context)); assert(debugCheckHasDirectionality(context)); final style = SliderTheme.of(context).merge(widget.style); final direction = Directionality.of(context); Widget child = HoverButton( onPressed: widget.onChanged == null ? null : () {}, margin: style.margin ?? EdgeInsets.zero, cursor: widget.mouseCursor, builder: (context, states) => m.Material( type: m.MaterialType.transparency, child: TweenAnimationBuilder( duration: FluentTheme.of(context).fastAnimationDuration, tween: Tween( begin: 1.0, end: states.isPressing || _sliding ? 0.45 : states.isHovering ? 0.66 : 0.5, ), builder: (context, innerFactor, child) => m.SliderTheme( data: m.SliderThemeData( showValueIndicator: m.ShowValueIndicator.always, thumbColor: style.thumbColor ?? style.activeColor, overlayShape: const m.RoundSliderOverlayShape(overlayRadius: 0), thumbShape: SliderThumbShape( elevation: 0, pressedElevation: 0, useBall: style.useThumbBall ?? true, innerFactor: innerFactor, brightness: FluentTheme.of(context).brightness, ), valueIndicatorShape: _RectangularSliderValueIndicatorShape( backgroundColor: style.labelBackgroundColor, vertical: widget.vertical, ltr: direction == TextDirection.ltr, ), trackHeight: 1.75, trackShape: _CustomTrackShape(), disabledThumbColor: style.disabledThumbColor, disabledInactiveTrackColor: style.disabledInactiveColor, disabledActiveTrackColor: style.disabledActiveColor, ), child: child!, ), child: m.Slider( value: widget.value, max: widget.max, min: widget.min, onChanged: widget.onChanged, onChangeEnd: (v) { widget.onChangeEnd?.call(v); setState(() => _sliding = false); }, onChangeStart: (v) { widget.onChangeStart?.call(v); setState(() => _sliding = true); }, activeColor: style.activeColor, inactiveColor: style.inactiveColor, divisions: widget.divisions, label: widget.label, focusNode: _focusNode, autofocus: widget.autofocus, mouseCursor: MouseCursor.defer, ), ), ), ); child = FocusableActionDetector( onShowFocusHighlight: (v) => setState(() => _showFocusHighlight = v), child: FocusBorder( focused: _showFocusHighlight && (_focusNode.hasPrimaryFocus), useStackApproach: true, child: child, ), ); if (widget.vertical) { return RotatedBox( quarterTurns: direction == TextDirection.ltr ? 3 : 5, child: child, ); } return child; } } /// This is used to remove the padding the Material Slider adds automatically class _CustomTrackShape extends m.RoundedRectSliderTrackShape { @override Rect getPreferredRect({ required RenderBox parentBox, Offset offset = Offset.zero, required m.SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, }) { final double trackHeight = sliderTheme.trackHeight!; final double trackLeft = offset.dx; final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; final double trackWidth = parentBox.size.width; return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); } } /// The default shape of a [Slider]'s thumb. /// /// There is a shadow for the resting, pressed, hovered, and focused state. class SliderThumbShape extends m.SliderComponentShape { /// Create a fluent-styled slider thumb; const SliderThumbShape({ this.enabledThumbRadius = 10.0, this.disabledThumbRadius, this.elevation = 1.0, this.pressedElevation = 6.0, this.useBall = true, this.innerFactor = 1.0, this.brightness = Brightness.light, }); final double innerFactor; final Brightness brightness; /// Whether to draw a ball instead of a line final bool useBall; /// The preferred radius of the round thumb shape when the slider is enabled. /// /// If it is not provided, then the material default of 10 is used. final double enabledThumbRadius; /// The preferred radius of the round thumb shape when the slider is disabled. /// /// If no disabledRadius is provided, then it is equal to the /// [enabledThumbRadius] final double? disabledThumbRadius; double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; /// The resting elevation adds shadow to the unpressed thumb. /// /// The default is 1. /// /// Use 0 for no shadow. The higher the value, the larger the shadow. For /// example, a value of 12 will create a very large shadow. /// final double elevation; /// The pressed elevation adds shadow to the pressed thumb. /// /// The default is 6. /// /// Use 0 for no shadow. The higher the value, the larger the shadow. For /// example, a value of 12 will create a very large shadow. final double pressedElevation; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { return Size.fromRadius( isEnabled == true ? enabledThumbRadius : _disabledThumbRadius); } @override void paint( PaintingContext context, Offset center, { required Animation activationAnimation, required Animation enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required m.SliderThemeData sliderTheme, required TextDirection textDirection, required double value, required double textScaleFactor, required Size sizeWithOverflow, }) { assert(sliderTheme.disabledThumbColor != null); assert(sliderTheme.thumbColor != null); final Canvas canvas = context.canvas; final Tween radiusTween = Tween( begin: _disabledThumbRadius, end: enabledThumbRadius, ); final ColorTween colorTween = ColorTween( begin: sliderTheme.disabledThumbColor, end: sliderTheme.thumbColor, ); final Color color = colorTween.evaluate(enableAnimation)!; final double radius = radiusTween.evaluate(enableAnimation); if (!useBall) { canvas.drawLine( center - const Offset(0, 6), center + const Offset(0, 6), Paint() ..color = color ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round ..strokeWidth = 8.0, ); } else { final Tween elevationTween = Tween( begin: elevation, end: pressedElevation, ); final double evaluatedElevation = elevationTween.evaluate(activationAnimation); final Path path = Path() ..addArc( Rect.fromCenter( center: center, width: 2 * radius, height: 2 * radius), 0, math.pi * 2); canvas.drawShadow(path, Colors.black, evaluatedElevation, true); canvas.drawCircle( center, radius, Paint() ..color = brightness == Brightness.light ? Colors.white : const Color(0xFF454545), ); canvas.drawCircle( center, radius * innerFactor, Paint()..color = color, ); } } } /// An inherited widget that defines the configuration for /// [Slider]s in this widget's subtree. /// /// Values specified here are used for [Slider] properties that are not /// given an explicit non-null value. class SliderTheme extends InheritedTheme { /// Creates a slider theme that controls the configurations for /// [Slider]. const SliderTheme({ Key? key, required this.data, required Widget child, }) : super(key: key, child: child); /// The properties for descendant [Slider] widgets. final SliderThemeData data; /// Creates a button theme that controls how descendant [Slider]s should /// look like, and merges in the current slider theme, if any. static Widget merge({ Key? key, required SliderThemeData data, required Widget child, }) { return Builder(builder: (BuildContext context) { return SliderTheme( key: key, data: _getInheritedThemeData(context).merge(data), child: child, ); }); } static SliderThemeData _getInheritedThemeData(BuildContext context) { final theme = context.dependOnInheritedWidgetOfExactType(); return theme?.data ?? FluentTheme.of(context).sliderTheme; } /// Returns the [data] from the closest [SliderTheme] ancestor. If there is /// no ancestor, it returns [ThemeData.sliderTheme]. Applications can assume /// that the returned value will not be null. /// /// Typical usage is as follows: /// /// ```dart /// SliderThemeData theme = SliderTheme.of(context); /// ``` static SliderThemeData of(BuildContext context) { return SliderThemeData.standard(FluentTheme.of(context)).merge( _getInheritedThemeData(context), ); } @override Widget wrap(BuildContext context, Widget child) { return SliderTheme(data: data, child: child); } @override bool updateShouldNotify(SliderTheme oldWidget) => data != oldWidget.data; } @immutable class SliderThemeData with Diagnosticable { final Color? thumbColor; final Color? disabledThumbColor; final Color? labelBackgroundColor; final bool? useThumbBall; final Color? activeColor; final Color? inactiveColor; final Color? disabledActiveColor; final Color? disabledInactiveColor; final EdgeInsetsGeometry? margin; const SliderThemeData({ this.margin, this.thumbColor, this.disabledThumbColor, this.activeColor, this.disabledActiveColor, this.inactiveColor, this.disabledInactiveColor, this.labelBackgroundColor, this.useThumbBall, }); factory SliderThemeData.standard(ThemeData? style) { final def = SliderThemeData( thumbColor: style?.accentColor, activeColor: style?.accentColor, inactiveColor: style?.disabledColor.withOpacity(1), margin: EdgeInsets.zero, disabledActiveColor: style?.disabledColor.withOpacity(1), disabledThumbColor: style?.disabledColor.withOpacity(1), disabledInactiveColor: style?.disabledColor, useThumbBall: true, ); return def; } static SliderThemeData lerp( SliderThemeData? a, SliderThemeData? b, double t) { return SliderThemeData( margin: EdgeInsetsGeometry.lerp(a?.margin, b?.margin, t), thumbColor: Color.lerp(a?.thumbColor, b?.thumbColor, t), activeColor: Color.lerp(a?.activeColor, b?.activeColor, t), inactiveColor: Color.lerp(a?.inactiveColor, b?.inactiveColor, t), disabledActiveColor: Color.lerp(a?.disabledActiveColor, b?.disabledActiveColor, t), disabledInactiveColor: Color.lerp(a?.disabledInactiveColor, b?.disabledInactiveColor, t), disabledThumbColor: Color.lerp(a?.disabledThumbColor, b?.disabledThumbColor, t), labelBackgroundColor: Color.lerp(a?.labelBackgroundColor, b?.labelBackgroundColor, t), useThumbBall: t < 0.5 ? a?.useThumbBall : b?.useThumbBall, ); } SliderThemeData merge(SliderThemeData? style) { return SliderThemeData( margin: style?.margin ?? margin, thumbColor: style?.thumbColor ?? thumbColor, activeColor: style?.activeColor ?? activeColor, inactiveColor: style?.inactiveColor ?? inactiveColor, disabledActiveColor: style?.disabledActiveColor ?? disabledActiveColor, disabledInactiveColor: style?.disabledInactiveColor ?? disabledInactiveColor, disabledThumbColor: style?.disabledThumbColor ?? disabledThumbColor, labelBackgroundColor: style?.labelBackgroundColor ?? labelBackgroundColor, useThumbBall: style?.useThumbBall ?? useThumbBall, ); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('margin', margin)); properties.add(ColorProperty('thumbColor', thumbColor)); properties.add(ColorProperty('activeColor', activeColor)); properties.add(ColorProperty('inactiveColor', inactiveColor)); properties.add(ColorProperty('disabledActiveColor', disabledActiveColor)); properties .add(ColorProperty('disabledInactiveColor', disabledInactiveColor)); properties.add(ColorProperty('disabledThumbColor', disabledThumbColor)); properties.add(ColorProperty('labelBackgroundColor', labelBackgroundColor)); } } class _RectangularSliderValueIndicatorShape extends m.SliderComponentShape { final Color? backgroundColor; final bool vertical; final bool ltr; /// Create a slider value indicator that resembles a rectangular tooltip. const _RectangularSliderValueIndicatorShape({ this.backgroundColor, this.vertical = false, this.ltr = false, }); get _pathPainter => _RectangularSliderValueIndicatorPathPainter( vertical, ltr, ); @override Size getPreferredSize( bool isEnabled, bool isDiscrete, { TextPainter? labelPainter, double? textScaleFactor, }) { assert(labelPainter != null); assert(textScaleFactor != null && textScaleFactor >= 0); return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!); } @override void paint( PaintingContext context, Offset center, { required Animation activationAnimation, required Animation enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required m.SliderThemeData sliderTheme, required TextDirection textDirection, required double value, required double textScaleFactor, required Size sizeWithOverflow, }) { final Canvas canvas = context.canvas; final double scale = activationAnimation.value; _pathPainter.paint( parentBox: parentBox, canvas: canvas, center: center, scale: scale, labelPainter: labelPainter, textScaleFactor: textScaleFactor, sizeWithOverflow: sizeWithOverflow, backgroundPaintColor: backgroundColor ?? sliderTheme.valueIndicatorColor!, ); } } class _RectangularSliderValueIndicatorPathPainter { final bool vertical; /// Whether the current [Directionality] is [TextDirection.ltr] final bool ltr; const _RectangularSliderValueIndicatorPathPainter([ this.vertical = false, this.ltr = false, ]); static const double _triangleHeight = 8.0; static const double _labelPadding = 8.0; static const double _preferredHeight = 32.0; static const double _minLabelWidth = 16.0; static const double _bottomTipYOffset = 14.0; static const double _preferredHalfHeight = _preferredHeight / 2; static const double _upperRectRadius = 4; Size getPreferredSize(TextPainter labelPainter, double textScaleFactor) { return Size( _upperRectangleWidth(labelPainter, 1, textScaleFactor), labelPainter.height + _labelPadding, ); } double getHorizontalShift({ required RenderBox parentBox, required Offset center, required TextPainter labelPainter, required double textScaleFactor, required Size sizeWithOverflow, required double scale, }) { assert(!sizeWithOverflow.isEmpty); const double edgePadding = 8.0; final double rectangleWidth = _upperRectangleWidth(labelPainter, scale, textScaleFactor); /// Value indicator draws on the Overlay and by using the global Offset /// we are making sure we use the bounds of the Overlay instead of the Slider. final Offset globalCenter = parentBox.localToGlobal(center); // The rectangle must be shifted towards the center so that it minimizes the // chance of it rendering outside the bounds of the render box. If the shift // is negative, then the lobe is shifted from right to left, and if it is // positive, then the lobe is shifted from left to right. final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding); final double overflowRight = math.max( 0, rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding)); if (rectangleWidth < sizeWithOverflow.width) { return overflowLeft - overflowRight; } else if (overflowLeft - overflowRight > 0) { return overflowLeft - (edgePadding * textScaleFactor); } else { return -overflowRight + (edgePadding * textScaleFactor); } } double _upperRectangleWidth( TextPainter labelPainter, double scale, double textScaleFactor, ) { final double unscaledWidth = math.max(_minLabelWidth * textScaleFactor, labelPainter.width) + _labelPadding; return unscaledWidth * scale; } void paint({ required RenderBox parentBox, required Canvas canvas, required Offset center, required double scale, required TextPainter labelPainter, required double textScaleFactor, required Size sizeWithOverflow, required Color backgroundPaintColor, Color? strokePaintColor, }) { if (scale == 0.0) { // Zero scale essentially means "do not draw anything", so it's safe to just return. return; } assert(!sizeWithOverflow.isEmpty); final double rectangleWidth = _upperRectangleWidth( labelPainter, scale, textScaleFactor, ); final double horizontalShift = getHorizontalShift( parentBox: parentBox, center: center, labelPainter: labelPainter, textScaleFactor: textScaleFactor, sizeWithOverflow: sizeWithOverflow, scale: scale, ); final double rectHeight = labelPainter.height + _labelPadding; final Rect upperRect = Rect.fromLTWH( -rectangleWidth / 2 + horizontalShift, -_triangleHeight - rectHeight, rectangleWidth, rectHeight, ); final Path trianglePath = Path()..close(); final Paint fillPaint = Paint()..color = backgroundPaintColor; final RRect upperRRect = RRect.fromRectAndRadius( upperRect, const Radius.circular(_upperRectRadius), ); trianglePath.addRRect(upperRRect); canvas.save(); // Prepare the canvas for the base of the tooltip, which is relative to the // center of the thumb. final double verticalFactor = ltr ? 20.0 : 10.0; canvas.translate( center.dx + (vertical ? ltr ? -verticalFactor : verticalFactor * 2 : 0), center.dy - _bottomTipYOffset + (vertical ? ltr ? -verticalFactor : -verticalFactor * 2 : 0), ); canvas.scale(scale, scale); // Rotate the label if it's vertical if (vertical) canvas.rotate((ltr ? 1 : -1) * math.pi / 2); if (strokePaintColor != null) { final Paint strokePaint = Paint() ..color = strokePaintColor ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; canvas.drawPath(trianglePath, strokePaint); } canvas.drawPath(trianglePath, fillPaint); // The label text is centered within the value indicator. final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height; canvas.translate(0, bottomTipToUpperRectTranslateY); final Offset boxCenter = Offset(horizontalShift, upperRect.height / 2); final Offset halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); final Offset labelOffset = boxCenter - halfLabelPainterOffset; labelPainter.paint(canvas, labelOffset); canvas.restore(); } }