From c2c64a5a4cd984670ca8ac09d6342b767a10e4e4 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 10 May 2018 17:29:43 -0700 Subject: [PATCH] Add onChangeStart and onChangeEnd to slider. (#17298) This fixes #17169 by adding onChangeStart and onChangeEnd to the slider. These will be called when the user starts a change, and when they end a change, regardless of whether that change is a tap or a drag. These differ from onChanged, in that they only report when the user starts and ends an interaction, not at every slight change. --- packages/flutter/lib/src/material/slider.dart | 128 +++++++++++++++++- .../flutter/test/material/slider_test.dart | 30 ++++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index e8cbd5e452..10c8348d26 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -16,6 +16,9 @@ import 'material.dart'; import 'slider_theme.dart'; import 'theme.dart'; +// Examples can assume: +// int _duelCommandment = 1; + /// A Material Design slider. /// /// Used to select from a range of values. @@ -46,7 +49,8 @@ import 'theme.dart'; /// of the slider changes, the widget calls the [onChanged] callback. Most /// widgets that use a slider will listen for the [onChanged] callback and /// rebuild the slider with a new [value] to update the visual appearance of the -/// slider. +/// slider. To know when the value starts to change, or when it is done +/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. /// /// By default, a slider will be as wide as possible, centered vertically. When /// given unbounded constraints, it will attempt to make the track 144 pixels @@ -83,7 +87,12 @@ class Slider extends StatefulWidget { /// the slider. /// /// * [value] determines currently selected value for this slider. - /// * [onChanged] is called when the user selects a new value for the slider. + /// * [onChanged] is called while the user is selecting a new value for the + /// slider. + /// * [onChangeStart] is called when the user starts to select a new value for + /// the slider. + /// * [onChangeEnd] is called when the user is done selecting a new value for + /// the slider. /// /// You can override some of the colors with the [activeColor] and /// [inactiveColor] properties, although more fine-grained control of the @@ -92,6 +101,8 @@ class Slider extends StatefulWidget { Key key, @required this.value, @required this.onChanged, + this.onChangeStart, + this.onChangeEnd, this.min: 0.0, this.max: 1.0, this.divisions, @@ -111,7 +122,8 @@ class Slider extends StatefulWidget { /// The slider's thumb is drawn at a position that corresponds to this value. final double value; - /// Called when the user selects a new value for the slider. + /// 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 @@ -123,6 +135,8 @@ class Slider extends StatefulWidget { /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// + /// ## Sample code + /// /// ```dart /// new Slider( /// value: _duelCommandment.toDouble(), @@ -137,8 +151,82 @@ class Slider extends StatefulWidget { /// }, /// ) /// ``` + /// + /// 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. + /// + /// ## Sample code + /// + /// ```dart + /// new 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'); + /// }, + /// ) + /// ``` + /// + /// 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. + /// + /// ## Sample code + /// + /// ```dart + /// new 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'); + /// }, + /// ) + /// ``` + /// + /// See also: + /// + /// * [onChangeStart] for a callback that is called when a value change + /// begins. + final ValueChanged onChangeEnd; + /// The minimum value the user can select. /// /// Defaults to 0.0. Must be less than or equal to [max]. @@ -269,6 +357,16 @@ class _SliderState extends State with TickerProviderStateMixin { } } + void _handleDragStart(double value) { + assert(widget.onChangeStart != null); + widget.onChangeStart(_lerp(value)); + } + + void _handleDragEnd(double value) { + assert(widget.onChangeEnd != null); + widget.onChangeEnd(_lerp(value)); + } + // Returns a number between min and max, proportional to value, which must // be between 0.0 and 1.0. double _lerp(double value) { @@ -313,6 +411,8 @@ class _SliderState extends State with TickerProviderStateMixin { sliderTheme: sliderTheme, mediaQueryData: MediaQuery.of(context), onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, + onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, + onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, state: this, ); } @@ -327,6 +427,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { this.sliderTheme, this.mediaQueryData, this.onChanged, + this.onChangeStart, + this.onChangeEnd, this.state, }) : super(key: key); @@ -336,6 +438,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { final SliderThemeData sliderTheme; final MediaQueryData mediaQueryData; final ValueChanged onChanged; + final ValueChanged onChangeStart; + final ValueChanged onChangeEnd; final _SliderState state; @override @@ -348,6 +452,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { theme: Theme.of(context), mediaQueryData: mediaQueryData, onChanged: onChanged, + onChangeStart: onChangeStart, + onChangeEnd: onChangeEnd, state: state, textDirection: Directionality.of(context), ); @@ -363,6 +469,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ..theme = Theme.of(context) ..mediaQueryData = mediaQueryData ..onChanged = onChanged + ..onChangeStart = onChangeStart + ..onChangeEnd = onChangeEnd ..textDirection = Directionality.of(context); // Ticker provider cannot change since there's a 1:1 relationship between // the _SliderRenderObjectWidget object and the _SliderState object. @@ -378,6 +486,8 @@ class _RenderSlider extends RenderBox { ThemeData theme, MediaQueryData mediaQueryData, ValueChanged onChanged, + this.onChangeStart, + this.onChangeEnd, @required _SliderState state, @required TextDirection textDirection, }) : assert(value != null && value >= 0.0 && value <= 1.0), @@ -540,6 +650,9 @@ class _RenderSlider extends RenderBox { } } + ValueChanged onChangeStart; + ValueChanged onChangeEnd; + TextDirection get textDirection => _textDirection; TextDirection _textDirection; set textDirection(TextDirection value) { @@ -633,6 +746,12 @@ class _RenderSlider extends RenderBox { void _startInteraction(Offset globalPosition) { if (isInteractive) { _active = true; + // We supply the *current* value as the start location, so that if we have + // a tap, it consists of a call to onChangeStart with the previous value and + // a call to onChangeEnd with the new value. + if (onChangeStart != null) { + onChangeStart(_discretize(value)); + } _currentDragValue = _getValueFromGlobalPosition(globalPosition); onChanged(_discretize(_currentDragValue)); _state.overlayController.forward(); @@ -652,6 +771,9 @@ class _RenderSlider extends RenderBox { void _endInteraction() { if (_active && _state.mounted) { + if (onChangeEnd != null) { + onChangeEnd(_discretize(_currentDragValue)); + } _active = false; _currentDragValue = 0.0; _state.overlayController.reverse(); diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index bc70eed6e7..498aa98375 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -46,6 +46,8 @@ void main() { testWidgets('Slider can move when tapped (LTR)', (WidgetTester tester) async { final Key sliderKey = new UniqueKey(); double value = 0.0; + double startValue; + double endValue; await tester.pumpWidget( new Directionality( @@ -64,6 +66,12 @@ void main() { value = newValue; }); }, + onChangeStart: (double value) { + startValue = value; + }, + onChangeEnd: (double value) { + endValue = value; + }, ), ), ), @@ -76,6 +84,10 @@ void main() { expect(value, equals(0.0)); await tester.tap(find.byKey(sliderKey)); expect(value, equals(0.5)); + expect(startValue, equals(0.0)); + expect(endValue, equals(0.5)); + startValue = null; + endValue = null; await tester.pump(); // No animation should start. expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); @@ -85,6 +97,8 @@ void main() { final Offset target = topLeft + (bottomRight - topLeft) / 4.0; await tester.tapAt(target); expect(value, closeTo(0.25, 0.05)); + expect(startValue, equals(0.5)); + expect(endValue, closeTo(0.25, 0.05)); await tester.pump(); // No animation should start. expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); }); @@ -138,7 +152,11 @@ void main() { testWidgets("Slider doesn't send duplicate change events if tapped on the same value", (WidgetTester tester) async { final Key sliderKey = new UniqueKey(); double value = 0.0; + double startValue; + double endValue; int updates = 0; + int startValueUpdates = 0; + int endValueUpdates = 0; await tester.pumpWidget( new Directionality( @@ -158,6 +176,14 @@ void main() { value = newValue; }); }, + onChangeStart: (double value) { + startValueUpdates++; + startValue = value; + }, + onChangeEnd: (double value) { + endValueUpdates++; + endValue = value; + }, ), ), ), @@ -170,11 +196,15 @@ void main() { expect(value, equals(0.0)); await tester.tap(find.byKey(sliderKey)); expect(value, equals(0.5)); + expect(startValue, equals(0.0)); + expect(endValue, equals(0.5)); await tester.pump(); await tester.tap(find.byKey(sliderKey)); expect(value, equals(0.5)); await tester.pump(); expect(updates, equals(1)); + expect(startValueUpdates, equals(2)); + expect(endValueUpdates, equals(2)); }); testWidgets('Value indicator shows for a bit after being tapped', (WidgetTester tester) async {