diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index f5668a5066..d317891916 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -373,17 +373,26 @@ class Slider extends StatefulWidget { /// (like the native default iOS slider). final Color? thumbColor; + /// {@template flutter.material.slider.mouseCursor} /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// /// If [mouseCursor] is a [MaterialStateProperty], /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: /// + /// * [MaterialState.dragged]. /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// * [MaterialState.disabled]. + /// {@endtemplate} /// - /// If this property is null, [MaterialStateMouseCursor.clickable] will be used. + /// If null, then the value of [SliderThemeData.mouseCursor] is used. If that + /// is also null, then [MaterialStateMouseCursor.clickable] is used. + /// + /// See also: + /// + /// * [MaterialStateMouseCursor], which can be used to create a [MouseCursor] + /// that is also a [MaterialStateProperty]. final MouseCursor? mouseCursor; /// The callback used to create a semantic value from a slider value. @@ -481,6 +490,8 @@ class _SliderState extends State with TickerProviderStateMixin { // Value Indicator Animation that appears on the Overlay. PaintValueIndicator? paintValueIndicator; + bool _dragging = false; + FocusNode? _focusNode; FocusNode get focusNode => widget.focusNode ?? _focusNode!; @@ -540,13 +551,13 @@ class _SliderState extends State with TickerProviderStateMixin { } void _handleDragStart(double value) { - assert(widget.onChangeStart != null); - widget.onChangeStart!(_lerp(value)); + _dragging = true; + widget.onChangeStart?.call(_lerp(value)); } void _handleDragEnd(double value) { - assert(widget.onChangeEnd != null); - widget.onChangeEnd!(_lerp(value)); + _dragging = false; + widget.onChangeEnd?.call(_lerp(value)); } void _actionHandler(_AdjustSliderIntent intent) { @@ -692,14 +703,15 @@ class _SliderState extends State with TickerProviderStateMixin { color: theme.colorScheme.onPrimary, ), ); - final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs( - widget.mouseCursor ?? MaterialStateMouseCursor.clickable, - { - if (!_enabled) MaterialState.disabled, - if (_hovering) MaterialState.hovered, - if (_focused) MaterialState.focused, - }, - ); + final Set states = { + if (!_enabled) MaterialState.disabled, + if (_hovering) MaterialState.hovered, + if (_focused) MaterialState.focused, + if (_dragging) MaterialState.dragged, + }; + final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(widget.mouseCursor, states) + ?? sliderTheme.mouseCursor?.resolve(states) + ?? MaterialStateMouseCursor.clickable.resolve(states); // This size is used as the max bounds for the painting of the value // indicators It must be kept in sync with the function with the same name @@ -748,8 +760,8 @@ class _SliderState extends State with TickerProviderStateMixin { textScaleFactor: MediaQuery.of(context).textScaleFactor, screenSize: _screenSize(), onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, - onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, - onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, + onChangeStart: _handleDragStart, + onChangeEnd: _handleDragEnd, state: this, semanticFormatterCallback: widget.semanticFormatterCallback, hasFocus: _focused, diff --git a/packages/flutter/lib/src/material/slider_theme.dart b/packages/flutter/lib/src/material/slider_theme.dart index 6def4b672a..9b42d09532 100644 --- a/packages/flutter/lib/src/material/slider_theme.dart +++ b/packages/flutter/lib/src/material/slider_theme.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; +import 'material_state.dart'; import 'theme.dart'; /// Applies a slider theme to descendant [Slider] widgets. @@ -290,6 +291,7 @@ class SliderThemeData with Diagnosticable { this.valueIndicatorTextStyle, this.minThumbSeparation, this.thumbSelector, + this.mouseCursor, }); /// Generates a SliderThemeData from three main colors. @@ -561,6 +563,11 @@ class SliderThemeData with Diagnosticable { /// Override this for custom thumb selection. final RangeThumbSelector? thumbSelector; + /// {@macro flutter.material.slider.mouseCursor} + /// + /// If specified, overrides the default value of [Slider.mouseCursor]. + final MaterialStateProperty? mouseCursor; + /// Creates a copy of this object but with the given fields replaced with the /// new values. SliderThemeData copyWith({ @@ -591,6 +598,7 @@ class SliderThemeData with Diagnosticable { TextStyle? valueIndicatorTextStyle, double? minThumbSeparation, RangeThumbSelector? thumbSelector, + MaterialStateProperty? mouseCursor, }) { return SliderThemeData( trackHeight: trackHeight ?? this.trackHeight, @@ -620,6 +628,7 @@ class SliderThemeData with Diagnosticable { valueIndicatorTextStyle: valueIndicatorTextStyle ?? this.valueIndicatorTextStyle, minThumbSeparation: minThumbSeparation ?? this.minThumbSeparation, thumbSelector: thumbSelector ?? this.thumbSelector, + mouseCursor: mouseCursor ?? this.mouseCursor, ); } @@ -660,6 +669,7 @@ class SliderThemeData with Diagnosticable { valueIndicatorTextStyle: TextStyle.lerp(a.valueIndicatorTextStyle, b.valueIndicatorTextStyle, t), minThumbSeparation: lerpDouble(a.minThumbSeparation, b.minThumbSeparation, t), thumbSelector: t < 0.5 ? a.thumbSelector : b.thumbSelector, + mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, ); } @@ -693,6 +703,7 @@ class SliderThemeData with Diagnosticable { valueIndicatorTextStyle, minThumbSeparation, thumbSelector, + mouseCursor, ]); } @@ -731,7 +742,8 @@ class SliderThemeData with Diagnosticable { && other.showValueIndicator == showValueIndicator && other.valueIndicatorTextStyle == valueIndicatorTextStyle && other.minThumbSeparation == minThumbSeparation - && other.thumbSelector == thumbSelector; + && other.thumbSelector == thumbSelector + && other.mouseCursor == mouseCursor; } @override @@ -765,6 +777,7 @@ class SliderThemeData with Diagnosticable { properties.add(DiagnosticsProperty('valueIndicatorTextStyle', valueIndicatorTextStyle, defaultValue: defaultData.valueIndicatorTextStyle)); properties.add(DoubleProperty('minThumbSeparation', minThumbSeparation, defaultValue: defaultData.minThumbSeparation)); properties.add(DiagnosticsProperty('thumbSelector', thumbSelector, defaultValue: defaultData.thumbSelector)); + properties.add(DiagnosticsProperty>('mouseCursor', mouseCursor, defaultValue: defaultData.mouseCursor)); } } diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index 2476c0aa2d..fc08542100 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -70,6 +70,35 @@ class TallSliderTickMarkShape extends SliderTickMarkShape { } } +class _StateDependentMouseCursor extends MaterialStateMouseCursor { + const _StateDependentMouseCursor({ + this.disabled = SystemMouseCursors.none, + this.dragged = SystemMouseCursors.none, + this.hovered = SystemMouseCursors.none, + }); + + final MouseCursor disabled; + final MouseCursor hovered; + final MouseCursor dragged; + + @override + MouseCursor resolve(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabled; + } + if (states.contains(MaterialState.dragged)) { + return dragged; + } + if (states.contains(MaterialState.hovered)) { + return hovered; + } + return SystemMouseCursors.none; + } + + @override + String get debugDescription => '_StateDependentMouseCursor'; +} + void main() { testWidgets('Slider can move when tapped (LTR)', (WidgetTester tester) async { final Key sliderKey = UniqueKey(); @@ -2521,6 +2550,57 @@ void main() { expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click); }); + testWidgets('Slider MaterialStateMouseCursor resolves correctly', (WidgetTester tester) async { + const MouseCursor disabledCursor = SystemMouseCursors.basic; + const MouseCursor hoveredCursor = SystemMouseCursors.grab; + const MouseCursor draggedCursor = SystemMouseCursors.move; + + Widget buildFrame({ required bool enabled }) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: Slider( + mouseCursor: const _StateDependentMouseCursor( + disabled: disabledCursor, + hovered: hoveredCursor, + dragged: draggedCursor, + ), + value: 0.5, + onChanged: enabled ? (double newValue) { } : null, + ), + ), + ), + ), + ), + ); + } + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await tester.pumpWidget(buildFrame(enabled: false)); + expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), disabledCursor); + + await tester.pumpWidget(buildFrame(enabled: true)); + expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.none); + + await gesture.moveTo(tester.getCenter(find.byType(Slider))); // start hover + await tester.pumpAndSettle(); + expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), hoveredCursor); + + await tester.timedDrag( + find.byType(Slider), + const Offset(20.0, 0.0), + const Duration(milliseconds: 100), + ); + expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.move); + }); + testWidgets('Slider implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); diff --git a/packages/flutter/test/material/slider_theme_test.dart b/packages/flutter/test/material/slider_theme_test.dart index 1d00f37a61..38e8d3337d 100644 --- a/packages/flutter/test/material/slider_theme_test.dart +++ b/packages/flutter/test/material/slider_theme_test.dart @@ -4,6 +4,7 @@ import 'dart:ui' show window; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -57,6 +58,7 @@ void main() { rangeValueIndicatorShape: PaddleRangeSliderValueIndicatorShape(), showValueIndicator: ShowValueIndicator.always, valueIndicatorTextStyle: TextStyle(color: Colors.black), + mouseCursor: MaterialStateMouseCursor.clickable, ).debugFillProperties(builder); final List description = builder.properties @@ -90,6 +92,7 @@ void main() { "rangeValueIndicatorShape: Instance of 'PaddleRangeSliderValueIndicatorShape'", 'showValueIndicator: always', 'valueIndicatorTextStyle: TextStyle(inherit: true, color: Color(0xff000000))', + 'mouseCursor: MaterialStateMouseCursor(clickable)' ]); }); @@ -1242,6 +1245,21 @@ void main() { ); }); + testWidgets('The mouse cursor is themeable', (WidgetTester tester) async { + await tester.pumpWidget(_buildApp( + ThemeData().sliderTheme.copyWith( + mouseCursor: MaterialStateProperty.all(SystemMouseCursors.text), + ) + )); + + await tester.pumpAndSettle(); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(Slider))); + await tester.pumpAndSettle(); + expect(RendererBinding.instance!.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); + }); } class RoundedRectSliderTrackShapeWithCustomAdditionalActiveTrackHeight extends RoundedRectSliderTrackShape {