Commit 3176921d authored by Adam Barth's avatar Adam Barth Committed by GitHub

Add CupertinoSlider (#7336)

This widget matches the style of the iOS slider widget. We don't yet
have the proper visual design for the disabled state.
parent 96fa6c3e
......@@ -7,4 +7,6 @@
/// To use, import `package:flutter/cupertino.dart`.
library cupertino;
export 'src/cupertino/slider.dart';
export 'src/cupertino/switch.dart';
export 'src/cupertino/thumb_painter.dart';
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'thumb_painter.dart';
/// An iOS-style slider.
///
/// Used to select from a range of values.
///
/// A slider can be used to select from either a continuous or a discrete set of
/// values. The default is use a continuous range of values from [min] to [max].
/// To use discrete values, use a non-null value for [divisions], which
/// indicates the number of discrete intervals. For example, if [min] is 0.0 and
/// [max] is 50.0 and [divisions] is 5, then the slider can take on the values
/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0.
///
/// The slider itself does not maintain any state. Instead, when the state 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.
///
/// See also:
///
/// * <https://developer.apple.com/ios/human-interface-guidelines/ui-controls/sliders/>
class CupertinoSlider extends StatefulWidget {
/// Creates an iOS-style slider.
///
/// The slider itself does not maintain any state. Instead, when the state 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.
///
/// * [value] determines currently selected value for this slider.
/// * [onChanged] is called when the user selects a new value for the slider.
CupertinoSlider({
Key key,
@required this.value,
@required this.onChanged,
this.min: 0.0,
this.max: 1.0,
this.divisions,
this.activeColor: const Color(0xFF027AFF),
}) : super(key: key) {
assert(value != null);
assert(min != null);
assert(max != null);
assert(value >= min && value <= max);
assert(divisions == null || divisions > 0);
}
/// 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 when the user selects a new value for the slider.
///
/// 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:
///
/// ```dart
/// new CupertinoSlider(
/// value: _duelCommandment.toDouble(),
/// min: 1.0,
/// max: 10.0,
/// divisions: 10,
/// onChanged: (double newValue) {
/// setState(() {
/// _duelCommandment = newValue.round();
/// });
/// },
/// ),
/// ```
final ValueChanged<double> onChanged;
/// The minium value the user can select.
///
/// Defaults to 0.0.
final double min;
/// The maximum value the user can select.
///
/// Defaults to 1.0.
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 color to use for the portion of the slider that has been selected.
final Color activeColor;
@override
_CupertinoSliderState createState() => new _CupertinoSliderState();
}
class _CupertinoSliderState extends State<CupertinoSlider> with TickerProviderStateMixin {
void _handleChanged(double value) {
assert(config.onChanged != null);
config.onChanged(value * (config.max - config.min) + config.min);
}
@override
Widget build(BuildContext context) {
return new _CupertinoSliderRenderObjectWidget(
value: (config.value - config.min) / (config.max - config.min),
divisions: config.divisions,
activeColor: config.activeColor,
onChanged: config.onChanged != null ? _handleChanged : null,
vsync: this,
);
}
}
class _CupertinoSliderRenderObjectWidget extends LeafRenderObjectWidget {
_CupertinoSliderRenderObjectWidget({
Key key,
this.value,
this.divisions,
this.activeColor,
this.onChanged,
this.vsync,
}) : super(key: key);
final double value;
final int divisions;
final Color activeColor;
final ValueChanged<double> onChanged;
final TickerProvider vsync;
@override
_RenderCupertinoSlider createRenderObject(BuildContext context) {
return new _RenderCupertinoSlider(
value: value,
divisions: divisions,
activeColor: activeColor,
onChanged: onChanged,
vsync: vsync,
);
}
@override
void updateRenderObject(BuildContext context, _RenderCupertinoSlider renderObject) {
renderObject
..value = value
..divisions = divisions
..activeColor = activeColor
..onChanged = onChanged;
// Ticker provider cannot change since there's a 1:1 relationship between
// the _SliderRenderObjectWidget object and the _SliderState object.
}
}
const double _kPadding = 8.0;
const double _kTrackHeight = 2.0;
const Color _kTrackColor = const Color(0xFFB5B5B5);
const double _kSliderHeight = 2.0 * (CupertinoThumbPainter.radius + _kPadding);
const double _kSliderWidth = 176.0; // Matches Material Design slider.
final Duration _kDiscreteTransitionDuration = const Duration(milliseconds: 500);
const double _kAdjustmentUnit = 0.1; // Matches iOS implementation of material slider.
class _RenderCupertinoSlider extends RenderConstrainedBox implements SemanticsActionHandler {
_RenderCupertinoSlider({
double value,
int divisions,
Color activeColor,
this.onChanged,
TickerProvider vsync,
}) : _value = value,
_divisions = divisions,
_activeColor = activeColor,
super(additionalConstraints: const BoxConstraints.tightFor(width: _kSliderWidth, height: _kSliderHeight)) {
assert(value != null && value >= 0.0 && value <= 1.0);
_drag = new HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_position = new AnimationController(
value: value,
duration: _kDiscreteTransitionDuration,
vsync: vsync,
)..addListener(markNeedsPaint);
}
double get value => _value;
double _value;
set value(double newValue) {
assert(newValue != null && newValue >= 0.0 && newValue <= 1.0);
if (newValue == _value)
return;
_value = newValue;
if (divisions != null)
_position.animateTo(newValue, curve: Curves.fastOutSlowIn);
else
_position.value = newValue;
}
int get divisions => _divisions;
int _divisions;
set divisions(int newDivisions) {
if (newDivisions == _divisions)
return;
_divisions = newDivisions;
markNeedsPaint();
}
Color get activeColor => _activeColor;
Color _activeColor;
set activeColor(Color value) {
if (value == _activeColor)
return;
_activeColor = value;
markNeedsPaint();
}
ValueChanged<double> onChanged;
AnimationController _position;
HorizontalDragGestureRecognizer _drag;
double _currentDragValue = 0.0;
double get _discretizedCurrentDragValue {
double dragValue = _currentDragValue.clamp(0.0, 1.0);
if (divisions != null)
dragValue = (dragValue * divisions).round() / divisions;
return dragValue;
}
double get _trackLeft => _kPadding;
double get _trackRight => size.width - _kPadding;
double get _thumbCenter => lerpDouble(_trackLeft + CupertinoThumbPainter.radius, _trackRight - CupertinoThumbPainter.radius, _value);
bool get isInteractive => onChanged != null;
void _handleDragStart(DragStartDetails details) {
if (isInteractive) {
_currentDragValue = _value;
onChanged(_discretizedCurrentDragValue);
}
}
void _handleDragUpdate(DragUpdateDetails details) {
if (isInteractive) {
final double extent = math.max(_kPadding, size.width - 2.0 * (_kPadding + CupertinoThumbPainter.radius));
_currentDragValue += details.primaryDelta / extent;
onChanged(_discretizedCurrentDragValue);
}
}
void _handleDragEnd(DragEndDetails details) {
_currentDragValue = 0.0;
}
@override
bool hitTestSelf(Point position) {
return (position.x - _thumbCenter).abs() < CupertinoThumbPainter.radius + _kPadding;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && isInteractive)
_drag.addPointer(event);
}
final CupertinoThumbPainter _thumbPainter = new CupertinoThumbPainter();
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
final double value = _position.value;
final double trackCenter = offset.dy + size.height / 2.0;
final double trackLeft = offset.dx + _trackLeft;
final double trackTop = trackCenter - 1.0;
final double trackBottom = trackCenter + 1.0;
final double trackRight = offset.dx + _trackRight;
final double trackActive = offset.dx + _thumbCenter;
final Paint paint = new Paint();
if (value > 0.0) {
paint.color = _activeColor;
canvas.drawRRect(new RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), paint);
}
if (value < 1.0) {
paint.color = _kTrackColor;
canvas.drawRRect(new RRect.fromLTRBXY(trackActive, trackTop, trackRight, trackBottom, 1.0, 1.0), paint);
}
final Point thumbCenter = new Point(trackActive, trackCenter);
_thumbPainter.paint(canvas, new Rect.fromCircle(center: thumbCenter, radius: CupertinoThumbPainter.radius));
}
@override
bool get isSemanticBoundary => isInteractive;
@override
SemanticsAnnotator get semanticsAnnotator => _annotate;
void _annotate(SemanticsNode semantics) {
if (isInteractive)
semantics.addAdjustmentActions();
}
@override
void performAction(SemanticsAction action) {
final double unit = divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
switch (action) {
case SemanticsAction.increase:
if (isInteractive)
onChanged((value + unit).clamp(0.0, 1.0));
break;
case SemanticsAction.decrease:
if (isInteractive)
onChanged((value - unit).clamp(0.0, 1.0));
break;
default:
assert(false);
break;
}
}
}
......@@ -10,6 +10,8 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import 'thumb_painter.dart';
/// An iOS-style switch.
///
/// Used to toggle the on/off state of a single setting.
......@@ -118,24 +120,18 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
}
}
const double _kTrackWidth = 51.0;
const double _kTrackHeight = 31.0;
const double _kTrackRadius = _kTrackHeight / 2.0;
const double _kTrackInnerStart = _kTrackHeight / 2.0;
const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart;
const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart;
const double _kThumbRadius = 14.0;
const double _kThumbExtension = 7.0;
const double _kSwitchWidth = 59.0;
const double _kSwitchHeight = 39.0;
const Color _kTrackColor = const Color(0xFFE5E5E5);
const Color _kThumbColor = const Color(0xFFFFFFFF);
const Color _kThumbShadowColor = const Color(0x2C000000);
const Duration _kReactionDuration = const Duration(milliseconds: 300);
const Duration _kToggleDuration = const Duration(milliseconds: 200);
final MaskFilter _kShadowMaskFilter = new MaskFilter.blur(BlurStyle.normal, BoxShadow.convertRadiusToSigma(1.0));
class _RenderCupertinoSwitch extends RenderConstrainedBox implements SemanticsActionHandler {
_RenderCupertinoSwitch({
......@@ -355,6 +351,8 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox implements SemanticsAc
_handleTap();
}
final CupertinoThumbPainter _thumbPainter = new CupertinoThumbPainter();
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
......@@ -378,33 +376,25 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox implements SemanticsAc
final RRect innerRRect = new RRect.fromRectAndRadius(trackRect.deflate(borderThickness), const Radius.circular(_kTrackRadius));
canvas.drawDRRect(outerRRect, innerRRect, paint);
final double currentThumbExtension = _kThumbExtension * currentReactionValue;
final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue;
final double thumbLeft = lerpDouble(
trackRect.left + _kTrackInnerStart - _kThumbRadius,
trackRect.left + _kTrackInnerEnd - _kThumbRadius - currentThumbExtension,
trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius,
trackRect.left + _kTrackInnerEnd - CupertinoThumbPainter.radius - currentThumbExtension,
currentPosition,
);
final double thumbRight = lerpDouble(
trackRect.left + _kTrackInnerStart + _kThumbRadius + currentThumbExtension,
trackRect.left + _kTrackInnerEnd + _kThumbRadius,
trackRect.left + _kTrackInnerStart + CupertinoThumbPainter.radius + currentThumbExtension,
trackRect.left + _kTrackInnerEnd + CupertinoThumbPainter.radius,
currentPosition,
);
final double thumbCenterY = offset.dy + size.height / 2.0;
final RRect thumbRRect = new RRect.fromRectAndRadius(new Rect.fromLTRB(
thumbLeft, thumbCenterY - _kThumbRadius, thumbRight, thumbCenterY + _kThumbRadius
), const Radius.circular(_kThumbRadius));
paint
..color = _kThumbShadowColor
..maskFilter = _kShadowMaskFilter;
canvas.drawRRect(thumbRRect, paint);
canvas.drawRRect(thumbRRect.shift(const Offset(0.0, 3.0)), paint);
paint
..color = _kThumbColor
..maskFilter = null;
canvas.drawRRect(thumbRRect, paint);
_thumbPainter.paint(canvas, new Rect.fromLTRB(
thumbLeft,
thumbCenterY - CupertinoThumbPainter.radius,
thumbRight,
thumbCenterY + CupertinoThumbPainter.radius,
));
}
@override
......
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/painting.dart';
final MaskFilter _kShadowMaskFilter = new MaskFilter.blur(BlurStyle.normal, BoxShadow.convertRadiusToSigma(1.0));
class CupertinoThumbPainter {
CupertinoThumbPainter({
this.color: const Color(0xFFFFFFFF),
this.shadowColor: const Color(0x2C000000),
});
final Color color;
final Color shadowColor;
static const double radius = 14.0;
static const double extension = 7.0;
void paint(Canvas canvas, Rect rect) {
final RRect rrect = new RRect.fromRectAndRadius(rect, new Radius.circular(rect.shortestSide / 2.0));
Paint paint = new Paint()
..color = shadowColor
..maskFilter = _kShadowMaskFilter;
canvas.drawRRect(rrect, paint);
canvas.drawRRect(rrect.shift(const Offset(0.0, 3.0)), paint);
paint
..color = color
..maskFilter = null;
canvas.drawRRect(rrect, paint);
}
}
......@@ -355,7 +355,6 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
_currentDragValue = (globalToLocal(details.globalPosition).x - _kReactionRadius) / _trackLength;
onChanged(_discretizedCurrentDragValue);
_reactionController.forward();
markNeedsPaint();
}
}
......@@ -371,7 +370,6 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
_active = false;
_currentDragValue = 0.0;
_reactionController.reverse();
markNeedsPaint();
}
}
......@@ -490,14 +488,15 @@ class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandl
@override
void performAction(SemanticsAction action) {
final double unit = divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
switch (action) {
case SemanticsAction.increase:
if (isInteractive)
onChanged((value + _kAdjustmentUnit).clamp(0.0, 1.0));
onChanged((value + unit).clamp(0.0, 1.0));
break;
case SemanticsAction.decrease:
if (isInteractive)
onChanged((value - _kAdjustmentUnit).clamp(0.0, 1.0));
onChanged((value - unit).clamp(0.0, 1.0));
break;
default:
assert(false);
......
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Slider does not move when tapped', (WidgetTester tester) async {
final Key sliderKey = new UniqueKey();
double value = 0.0;
await tester.pumpWidget(
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;
});
},
),
),
);
},
),
);
expect(value, equals(0.0));
await tester.tap(find.byKey(sliderKey));
expect(value, equals(0.0));
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', (WidgetTester tester) async {
final Key sliderKey = new UniqueKey();
double value = 0.0;
await tester.pumpWidget(
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;
});
},
),
),
);
},
),
);
expect(value, equals(0.0));
final Point topLeft = tester.getTopLeft(find.byKey(sliderKey));
const double unit = CupertinoThumbPainter.radius;
const double delta = 3.0 * unit;
await tester.scrollAt(
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))));
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));
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment