Added onChangeStart and onChangeEnd to CupertinoSlider (#17535)

This is a follow up on issue #17169 and the pull request #17298

This pull request adds the onChangeStart and onChangeEnd callbacks for CupertinoSlider. These are called when a user starts and ends a change respectively.

Pushing for @dcaraujo0872, the PR author.
diff --git a/packages/flutter/lib/src/cupertino/slider.dart b/packages/flutter/lib/src/cupertino/slider.dart
index 29c743f..bbcb198 100644
--- a/packages/flutter/lib/src/cupertino/slider.dart
+++ b/packages/flutter/lib/src/cupertino/slider.dart
@@ -12,6 +12,9 @@
 import 'colors.dart';
 import 'thumb_painter.dart';
 
+// Examples can assume:
+// int _cupertinoSliderValue = 1;
+
 /// An iOS-style slider.
 ///
 /// Used to select from a range of values.
@@ -41,10 +44,16 @@
   ///
   /// * [value] determines currently selected value for this slider.
   /// * [onChanged] is called when the user selects 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.
   const CupertinoSlider({
     Key key,
     @required this.value,
     @required this.onChanged,
+    this.onChangeStart,
+    this.onChangeEnd,
     this.min: 0.0,
     this.max: 1.0,
     this.divisions,
@@ -75,19 +84,91 @@
   ///
   /// ```dart
   /// new CupertinoSlider(
-  ///   value: _duelCommandment.toDouble(),
+  ///   value: _cupertinoSliderValue.toDouble(),
   ///   min: 1.0,
   ///   max: 10.0,
   ///   divisions: 10,
   ///   onChanged: (double newValue) {
   ///     setState(() {
-  ///       _duelCommandment = newValue.round();
+  ///       _cupertinoSliderValue = newValue.round();
   ///     });
   ///   },
   /// )
   /// ```
+  /// 
+  /// 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<double> 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.
+  ///
+  /// The value passed will be the last [value] that the slider had before the
+  /// change began.
+  ///
+  /// ## Sample code
+  ///
+  /// ```dart
+  /// new CupertinoSlider(
+  ///   value: _cupertinoSliderValue.toDouble(),
+  ///   min: 1.0,
+  ///   max: 10.0,
+  ///   divisions: 10,
+  ///   onChanged: (double newValue) {
+  ///     setState(() {
+  ///       _cupertinoSliderValue = 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<double> 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.
+  ///
+  /// ## Sample code
+  ///
+  /// ```dart
+  /// new CupertinoSlider(
+  ///   value: _cupertinoSliderValue.toDouble(),
+  ///   min: 1.0,
+  ///   max: 10.0,
+  ///   divisions: 10,
+  ///   onChanged: (double newValue) {
+  ///     setState(() {
+  ///       _cupertinoSliderValue = 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<double> onChangeEnd;
+
   /// The minimum value the user can select.
   ///
   /// Defaults to 0.0.
@@ -121,7 +202,20 @@
 class _CupertinoSliderState extends State<CupertinoSlider> with TickerProviderStateMixin {
   void _handleChanged(double value) {
     assert(widget.onChanged != null);
-    widget.onChanged(value * (widget.max - widget.min) + widget.min);
+    final double lerpValue = lerpDouble(widget.min, widget.max, value);
+    if (lerpValue != widget.value) {
+      widget.onChanged(lerpValue);
+    }
+  }
+
+  void _handleDragStart(double value) {
+    assert(widget.onChangeStart != null);
+    widget.onChangeStart(lerpDouble(widget.min, widget.max, value));
+  }
+
+  void _handleDragEnd(double value) {
+    assert(widget.onChangeEnd != null);
+    widget.onChangeEnd(lerpDouble(widget.min, widget.max, value));
   }
 
   @override
@@ -131,6 +225,8 @@
       divisions: widget.divisions,
       activeColor: widget.activeColor,
       onChanged: widget.onChanged != null ? _handleChanged : null,
+      onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
+      onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
       vsync: this,
     );
   }
@@ -143,6 +239,8 @@
     this.divisions,
     this.activeColor,
     this.onChanged,
+    this.onChangeStart,
+    this.onChangeEnd,
     this.vsync,
   }) : super(key: key);
 
@@ -150,6 +248,8 @@
   final int divisions;
   final Color activeColor;
   final ValueChanged<double> onChanged;
+  final ValueChanged<double> onChangeStart;
+  final ValueChanged<double> onChangeEnd;
   final TickerProvider vsync;
 
   @override
@@ -159,6 +259,8 @@
       divisions: divisions,
       activeColor: activeColor,
       onChanged: onChanged,
+      onChangeStart: onChangeStart,
+      onChangeEnd: onChangeEnd,
       vsync: vsync,
       textDirection: Directionality.of(context),
     );
@@ -171,6 +273,8 @@
       ..divisions = divisions
       ..activeColor = activeColor
       ..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.
@@ -191,6 +295,8 @@
     int divisions,
     Color activeColor,
     ValueChanged<double> onChanged,
+    this.onChangeStart,
+    this.onChangeEnd,
     TickerProvider vsync,
     @required TextDirection textDirection,
   }) : assert(value != null && value >= 0.0 && value <= 1.0),
@@ -254,6 +360,9 @@
       markNeedsSemanticsUpdate();
   }
 
+  ValueChanged<double> onChangeStart;
+  ValueChanged<double> onChangeEnd;
+
   TextDirection get textDirection => _textDirection;
   TextDirection _textDirection;
   set textDirection(TextDirection value) {
@@ -293,12 +402,7 @@
 
   bool get isInteractive => onChanged != null;
 
-  void _handleDragStart(DragStartDetails details) {
-    if (isInteractive) {
-      _currentDragValue = _value;
-      onChanged(_discretizedCurrentDragValue);
-    }
-  }
+  void _handleDragStart(DragStartDetails details) => _startInteraction(details.globalPosition);
 
   void _handleDragUpdate(DragUpdateDetails details) {
     if (isInteractive) {
@@ -316,7 +420,22 @@
     }
   }
 
-  void _handleDragEnd(DragEndDetails details) {
+  void _handleDragEnd(DragEndDetails details) => _endInteraction();
+
+  void _startInteraction(Offset globalPosition) {
+    if (isInteractive) {
+      if (onChangeStart != null) {
+        onChangeStart(_discretizedCurrentDragValue);
+      }
+      _currentDragValue = _value;
+      onChanged(_discretizedCurrentDragValue);
+    }
+  }
+
+  void _endInteraction() {
+    if (onChangeEnd != null) {
+      onChangeEnd(_discretizedCurrentDragValue);
+    }
     _currentDragValue = 0.0;
   }
 
diff --git a/packages/flutter/test/cupertino/slider_test.dart b/packages/flutter/test/cupertino/slider_test.dart
index 595c7e2..d2fa176 100644
--- a/packages/flutter/test/cupertino/slider_test.dart
+++ b/packages/flutter/test/cupertino/slider_test.dart
@@ -11,6 +11,14 @@
 import '../widgets/semantics_tester.dart';
 
 void main() {
+
+  Future<Null> _dragSlider(WidgetTester tester, Key sliderKey) {
+    final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey));
+    const double unit = CupertinoThumbPainter.radius;
+    const double delta = 3.0 * unit;
+    return tester.dragFrom(topLeft + const Offset(unit, unit), const Offset(delta, 0.0));
+  }
+
   testWidgets('Slider does not move when tapped (LTR)', (WidgetTester tester) async {
     final Key sliderKey = new UniqueKey();
     double value = 0.0;
@@ -79,10 +87,10 @@
     expect(SchedulerBinding.instance.transientCallbackCount, equals(0));
   });
 
-
-  testWidgets('Slider moves when dragged (LTR)', (WidgetTester tester) async {
+  testWidgets('Slider calls onChangeStart once when interaction begins', (WidgetTester tester) async {
     final Key sliderKey = new UniqueKey();
     double value = 0.0;
+    int numberOfTimesOnChangeStartIsCalled = 0;
 
     await tester.pumpWidget(new Directionality(
       textDirection: TextDirection.ltr,
@@ -98,6 +106,91 @@
                     value = newValue;
                   });
                 },
+                onChangeStart: (double value) {
+                  numberOfTimesOnChangeStartIsCalled++;
+                }
+              ),
+            ),
+          );
+        },
+      ),
+    ));
+
+    await _dragSlider(tester, sliderKey);
+    
+    expect(numberOfTimesOnChangeStartIsCalled, equals(1));
+
+    await tester.pump(); // No animation should start.
+    // Check the transientCallbackCount before tearing down the widget to ensure
+    // that no animation is running.
+    expect(SchedulerBinding.instance.transientCallbackCount, equals(0));
+  });
+
+  testWidgets('Slider calls onChangeEnd once after interaction has ended', (WidgetTester tester) async {
+    final Key sliderKey = new UniqueKey();
+    double value = 0.0;
+    int numberOfTimesOnChangeEndIsCalled = 0;
+
+    await tester.pumpWidget(new Directionality(
+      textDirection: TextDirection.ltr,
+      child: new StatefulBuilder(
+        builder: (BuildContext context, StateSetter setState) {
+          return new Material(
+            child: new Center(
+              child: new CupertinoSlider(
+                key: sliderKey,
+                value: value,
+                onChanged: (double newValue) {
+                  setState(() {
+                    value = newValue;
+                  });
+                },
+                onChangeEnd: (double value) {
+                  numberOfTimesOnChangeEndIsCalled++;
+                }
+              ),
+            ),
+          );
+        },
+      ),
+    ));
+    
+    await _dragSlider(tester, sliderKey);
+    
+    expect(numberOfTimesOnChangeEndIsCalled, equals(1));
+
+    await tester.pump(); // No animation should start.
+    // Check the transientCallbackCount before tearing down the widget to ensure
+    // that no animation is running.
+    expect(SchedulerBinding.instance.transientCallbackCount, equals(0));
+  });
+
+  testWidgets('Slider moves when dragged (LTR)', (WidgetTester tester) async {
+    final Key sliderKey = new UniqueKey();
+    double value = 0.0;
+    double startValue;
+    double endValue;
+
+    await tester.pumpWidget(new Directionality(
+      textDirection: TextDirection.ltr,
+      child: new StatefulBuilder(
+        builder: (BuildContext context, StateSetter setState) {
+          return new Material(
+            child: new Center(
+              child: new CupertinoSlider(
+                key: sliderKey,
+                value: value,
+                onChanged: (double newValue) {
+                  setState(() {
+                    value = newValue;
+                  });
+                },
+                onChangeStart: (double value) {
+                  startValue = value;
+                },
+                onChangeEnd: (double value) {
+                  endValue = value;
+                }
               ),
             ),
           );
@@ -106,12 +199,18 @@
     ));
 
     expect(value, equals(0.0));
+    
     final Offset topLeft = tester.getTopLeft(find.byKey(sliderKey));
     const double unit = CupertinoThumbPainter.radius;
     const double delta = 3.0 * unit;
     await tester.dragFrom(topLeft + const Offset(unit, unit), const Offset(delta, 0.0));
+
     final Size size = tester.getSize(find.byKey(sliderKey));
-    expect(value, equals(delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius))));
+    final double finalValue = delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius));
+    expect(startValue, equals(0.0));
+    expect(value, equals(finalValue));
+    expect(endValue, equals(finalValue));
+    
     await tester.pump(); // No animation should start.
     // Check the transientCallbackCount before tearing down the widget to ensure
     // that no animation is running.
@@ -121,7 +220,9 @@
   testWidgets('Slider moves when dragged (RTL)', (WidgetTester tester) async {
     final Key sliderKey = new UniqueKey();
     double value = 0.0;
-
+    double startValue;
+    double endValue;
+    
     await tester.pumpWidget(new Directionality(
       textDirection: TextDirection.rtl,
       child: new StatefulBuilder(
@@ -136,6 +237,16 @@
                     value = newValue;
                   });
                 },
+                onChangeStart: (double value) {
+                  setState(() {
+                    startValue = value;
+                  });
+                },
+                onChangeEnd: (double value) {
+                  setState(() {
+                    endValue = value;
+                  });
+                }
               ),
             ),
           );
@@ -144,12 +255,18 @@
     ));
 
     expect(value, equals(0.0));
+    
     final Offset bottomRight = tester.getBottomRight(find.byKey(sliderKey));
     const double unit = CupertinoThumbPainter.radius;
     const double delta = 3.0 * unit;
     await tester.dragFrom(bottomRight - const Offset(unit, unit), const Offset(-delta, 0.0));
+    
     final Size size = tester.getSize(find.byKey(sliderKey));
-    expect(value, equals(delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius))));
+    final double finalValue = delta / (size.width - 2.0 * (8.0 + CupertinoThumbPainter.radius));
+    expect(startValue, equals(0.0));
+    expect(value, equals(finalValue));
+    expect(endValue, equals(finalValue));
+    
     await tester.pump(); // No animation should start.
     // Check the transientCallbackCount before tearing down the widget to ensure
     // that no animation is running.