Commit 732a83d4 authored by Adam Barth's avatar Adam Barth Committed by GitHub

Add CupertinoSwitch (#7326)

This patch contains a first draft of an iOS-style switch. We'll likely need to
tweak the constants upon closer study, but the basics are here.
parent 500981fe
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
<excludeFolder url="file://$MODULE_DIR$/packages" /> <excludeFolder url="file://$MODULE_DIR$/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/animation/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/animation/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/cassowary/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/cassowary/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/cupertino/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/engine/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/engine/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/examples/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/examples/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/foundation/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/foundation/packages" />
......
// 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.
/// Flutter widgets implementing the current iOS design language.
///
/// To use, import `package:flutter/cupertino.dart`.
library cupertino;
export 'src/cupertino/switch.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/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
/// An iOS-style switch.
///
/// Used to toggle the on/off state of a single setting.
///
/// The switch itself does not maintain any state. Instead, when the state of
/// the switch changes, the widget calls the [onChanged] callback. Most widgets
/// that use a switch will listen for the [onChanged] callback and rebuild the
/// switch with a new [value] to update the visual appearance of the switch.
///
/// See also:
///
/// * <https://developer.apple.com/ios/human-interface-guidelines/ui-controls/switches/>
class CupertinoSwitch extends StatefulWidget {
CupertinoSwitch({
Key key,
@required this.value,
@required this.onChanged,
this.activeColor: const Color(0xFF4CD964),
}) : super(key: key);
/// Whether this switch is on or off.
final bool value;
/// Called when the user toggles with switch on or off.
///
/// The switch passes the new value to the callback but does not actually
/// change state until the parent widget rebuilds the switch with the new
/// value.
///
/// If null, the switch 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 CupertinoSwitch(
/// value: _giveVerse,
/// onChanged: (bool newValue) {
/// setState(() {
/// _giveVerse = newValue;
/// });
/// },
/// ),
/// ```
final ValueChanged<bool> onChanged;
/// The color to use when this switch is on.
final Color activeColor;
@override
_CupertinoSwitchState createState() => new _CupertinoSwitchState();
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('value: ${value ? "on" : "off"}');
if (onChanged == null)
description.add('disabled');
}
}
class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return new _CupertinoSwitchRenderObjectWidget(
value: config.value,
activeColor: config.activeColor,
onChanged: config.onChanged,
vsync: this,
);
}
}
class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
_CupertinoSwitchRenderObjectWidget({
Key key,
this.value,
this.activeColor,
this.onChanged,
this.vsync,
}) : super(key: key);
final bool value;
final Color activeColor;
final ValueChanged<bool> onChanged;
final TickerProvider vsync;
@override
_RenderCupertinoSwitch createRenderObject(BuildContext context) {
return new _RenderCupertinoSwitch(
value: value,
activeColor: activeColor,
onChanged: onChanged,
vsync: vsync,
);
}
@override
void updateRenderObject(BuildContext context, _RenderCupertinoSwitch renderObject) {
renderObject
..value = value
..activeColor = activeColor
..onChanged = onChanged
..vsync = vsync;
}
}
const double _kTrackHeight = 25.0;
const double _kTrackWidth = 42.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 = 11.0;
const double _kThumbExtension = 6.0;
const double _kSwitchWidth = 56.0;
const double _kSwitchHeight = 36.0;
const Color _kTrackColor = const Color(0xFFDDDDDD);
const Color _kThumbColor = const Color(0xFFFFFFFF);
const Color _kThumbShadowColor = const Color(0x4C000000);
const Duration _kReactionDuration = const Duration(milliseconds: 300);
const Duration _kToggleDuration = const Duration(milliseconds: 200);
final MaskFilter _kShadowMaskFilter = new MaskFilter.blur(BlurStyle.normal, BoxShadow.convertRadiusToSigma(3.0));
class _RenderCupertinoSwitch extends RenderConstrainedBox implements SemanticsActionHandler {
_RenderCupertinoSwitch({
bool value,
Color activeColor,
ValueChanged<bool> onChanged,
@required TickerProvider vsync,
}) : _value = value,
_activeColor = activeColor,
_onChanged = onChanged,
_vsync = vsync,
super(additionalConstraints: new BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) {
assert(value != null);
assert(activeColor != null);
assert(vsync != null);
_tap = new TapGestureRecognizer()
..onTapDown = _handleTapDown
..onTap = _handleTap
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
_drag = new HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_positionController = new AnimationController(
duration: _kToggleDuration,
value: value ? 1.0 : 0.0,
vsync: vsync,
);
_position = new CurvedAnimation(
parent: _positionController,
curve: Curves.linear,
)..addListener(markNeedsPaint)
..addStatusListener(_handlePositionStateChanged);
_reactionController = new AnimationController(
duration: _kReactionDuration,
vsync: vsync,
);
_reaction = new CurvedAnimation(
parent: _reactionController,
curve: Curves.ease,
)..addListener(markNeedsPaint);
}
AnimationController _positionController;
CurvedAnimation _position;
AnimationController _reactionController;
Animation<double> _reaction;
bool get value => _value;
bool _value;
set value(bool value) {
assert(value != null);
if (value == _value)
return;
_value = value;
markNeedsSemanticsUpdate(onlyChanges: true, noGeometry: true);
_position
..curve = Curves.ease
..reverseCurve = Curves.ease.flipped;
if (value)
_positionController.forward();
else
_positionController.reverse();
}
TickerProvider get vsync => _vsync;
TickerProvider _vsync;
set vsync(TickerProvider value) {
assert(value != null);
if (value == _vsync)
return;
_vsync = value;
_positionController.resync(vsync);
_reactionController.resync(vsync);
}
Color get activeColor => _activeColor;
Color _activeColor;
set activeColor(Color value) {
assert(value != null);
if (value == _activeColor)
return;
_activeColor = value;
markNeedsPaint();
}
ValueChanged<bool> get onChanged => _onChanged;
ValueChanged<bool> _onChanged;
set onChanged(ValueChanged<bool> value) {
if (value == _onChanged)
return;
final bool wasInteractive = isInteractive;
_onChanged = value;
if (wasInteractive != isInteractive) {
markNeedsPaint();
markNeedsSemanticsUpdate(noGeometry: true);
}
}
bool get isInteractive => onChanged != null;
TapGestureRecognizer _tap;
HorizontalDragGestureRecognizer _drag;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
if (value)
_positionController.forward();
else
_positionController.reverse();
if (isInteractive) {
switch (_reactionController.status) {
case AnimationStatus.forward:
_reactionController.forward();
break;
case AnimationStatus.reverse:
_reactionController.reverse();
break;
case AnimationStatus.dismissed:
case AnimationStatus.completed:
// nothing to do
break;
}
}
}
@override
void detach() {
_positionController.stop();
_reactionController.stop();
super.detach();
}
void _handlePositionStateChanged(AnimationStatus status) {
if (isInteractive) {
if (status == AnimationStatus.completed && !_value)
onChanged(true);
else if (status == AnimationStatus.dismissed && _value)
onChanged(false);
}
}
void _handleTapDown(TapDownDetails details) {
if (isInteractive)
_reactionController.forward();
}
void _handleTap() {
if (isInteractive)
onChanged(!_value);
}
void _handleTapUp(TapUpDetails details) {
if (isInteractive)
_reactionController.reverse();
}
void _handleTapCancel() {
if (isInteractive)
_reactionController.reverse();
}
void _handleDragStart(DragStartDetails details) {
if (isInteractive)
_reactionController.forward();
}
void _handleDragUpdate(DragUpdateDetails details) {
if (isInteractive) {
_position
..curve = null
..reverseCurve = null;
_positionController.value += details.primaryDelta / _kTrackInnerLength;
}
}
void _handleDragEnd(DragEndDetails details) {
if (_position.value >= 0.5)
_positionController.forward();
else
_positionController.reverse();
_reactionController.reverse();
}
@override
bool hitTestSelf(Point position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && isInteractive) {
_drag.addPointer(event);
_tap.addPointer(event);
}
}
@override
bool get isSemanticBoundary => isInteractive;
@override
SemanticsAnnotator get semanticsAnnotator => _annotate;
void _annotate(SemanticsNode semantics) {
semantics
..hasCheckedState = true
..isChecked = _value;
if (isInteractive)
semantics.addAction(SemanticsAction.tap);
}
@override
void performAction(SemanticsAction action) {
if (action == SemanticsAction.tap)
_handleTap();
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
final double currentPosition = _position.value;
final double currentReactionValue = _reaction.value;
final Color trackColor = _value ? activeColor : _kTrackColor;
final double borderThickness = 1.0 + (_kTrackRadius - 1.0) * math.max(currentReactionValue, currentPosition);
final Paint paint = new Paint()
..color = trackColor;
final Rect trackRect = new Rect.fromLTWH(
offset.dx + (size.width - _kTrackWidth) / 2.0,
offset.dy + (size.height - _kTrackHeight) / 2.0,
_kTrackWidth,
_kTrackHeight
);
final RRect outerRRect = new RRect.fromRectAndRadius(trackRect, const Radius.circular(_kTrackRadius));
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 thumbLeft = lerpDouble(
trackRect.left + _kTrackInnerStart - _kThumbRadius,
trackRect.left + _kTrackInnerEnd - _kThumbRadius - currentThumbExtension,
currentPosition,
);
final double thumbRight = lerpDouble(
trackRect.left + _kTrackInnerStart + _kThumbRadius + currentThumbExtension,
trackRect.left + _kTrackInnerEnd + _kThumbRadius,
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.shift(const Offset(0.0, 3.0)), paint);
paint
..color = _kThumbColor
..maskFilter = null;
canvas.drawRRect(thumbRRect, paint);
}
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('value: ${value ? "checked" : "unchecked"}');
if (!isInteractive)
description.add('disabled');
}
}
...@@ -290,12 +290,12 @@ class _RenderSwitch extends RenderToggleable { ...@@ -290,12 +290,12 @@ class _RenderSwitch extends RenderToggleable {
HorizontalDragGestureRecognizer _drag; HorizontalDragGestureRecognizer _drag;
void _handleDragStart(DragStartDetails details) { void _handleDragStart(DragStartDetails details) {
if (onChanged != null) if (isInteractive)
reactionController.forward(); reactionController.forward();
} }
void _handleDragUpdate(DragUpdateDetails details) { void _handleDragUpdate(DragUpdateDetails details) {
if (onChanged != null) { if (isInteractive) {
position position
..curve = null ..curve = null
..reverseCurve = null; ..reverseCurve = null;
......
...@@ -526,13 +526,20 @@ class BoxShadow { ...@@ -526,13 +526,20 @@ class BoxShadow {
/// The amount the box should be inflated prior to applying the blur. /// The amount the box should be inflated prior to applying the blur.
final double spreadRadius; final double spreadRadius;
/// The [blurRadius] in sigmas instead of logical pixels. /// Converts a blur radius in pixels to sigmas.
/// ///
/// See the sigma argument to [MaskFilter.blur]. /// See the sigma argument to [MaskFilter.blur].
/// //
// See SkBlurMask::ConvertRadiusToSigma(). // See SkBlurMask::ConvertRadiusToSigma().
// <https://github.com/google/skia/blob/bb5b77db51d2e149ee66db284903572a5aac09be/src/effects/SkBlurMask.cpp#L23> // <https://github.com/google/skia/blob/bb5b77db51d2e149ee66db284903572a5aac09be/src/effects/SkBlurMask.cpp#L23>
double get blurSigma => blurRadius * 0.57735 + 0.5; static double convertRadiusToSigma(double radius) {
return radius * 0.57735 + 0.5;
}
/// The [blurRadius] in sigmas instead of logical pixels.
///
/// See the sigma argument to [MaskFilter.blur].
double get blurSigma => convertRadiusToSigma(blurRadius);
/// Returns a new box shadow with its offset, blurRadius, and spreadRadius scaled by the given factor. /// Returns a new box shadow with its offset, blurRadius, and spreadRadius scaled by the given factor.
BoxShadow scale(double factor) { BoxShadow scale(double factor) {
......
// 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/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Switch can toggle on tap', (WidgetTester tester) async {
Key switchKey = new UniqueKey();
bool value = false;
await tester.pumpWidget(
new StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return new Center(
child: new CupertinoSwitch(
key: switchKey,
value: value,
onChanged: (bool newValue) {
setState(() {
value = newValue;
});
},
),
);
},
),
);
expect(value, isFalse);
await tester.tap(find.byKey(switchKey));
expect(value, isTrue);
});
}
...@@ -7,6 +7,21 @@ ...@@ -7,6 +7,21 @@
<excludeFolder url="file://$MODULE_DIR$/bin/packages" /> <excludeFolder url="file://$MODULE_DIR$/bin/packages" />
<excludeFolder url="file://$MODULE_DIR$/build" /> <excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/packages" /> <excludeFolder url="file://$MODULE_DIR$/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/good/.pub" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/good/build" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/good/lib/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/good/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/syntax_error/.pub" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/syntax_error/build" />
<excludeFolder url="file://$MODULE_DIR$/test/data/dart_dependencies_test/syntax_error/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/intellij/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/intellij/plugins/Dart/lib/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/intellij/plugins/Dart/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/intellij/plugins/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/process_manager/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/data/process_manager/replay/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/src/ios/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/src/ios/packages" />
<excludeFolder url="file://$MODULE_DIR$/test/src/packages" /> <excludeFolder url="file://$MODULE_DIR$/test/src/packages" />
......
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