From 72d2706ee1b3fe24930dbfdfb8fa8d2860e87bdd Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Tue, 24 Nov 2015 22:49:07 -0800 Subject: [PATCH] Finish TimePicker After this patch, TimePicker should work correctly. Fixes #559 --- .../lib/time_picker_demo.dart | 2 +- .../flutter/lib/src/material/constants.dart | 5 +- .../flutter/lib/src/material/time_picker.dart | 164 ++++++++++++++---- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/examples/material_gallery/lib/time_picker_demo.dart b/examples/material_gallery/lib/time_picker_demo.dart index bd24d6c77e..9e9addb6fa 100644 --- a/examples/material_gallery/lib/time_picker_demo.dart +++ b/examples/material_gallery/lib/time_picker_demo.dart @@ -29,7 +29,7 @@ class _TimePickerDemoState extends State { Widget build(BuildContext context) { return new Column([ - new Text('${_selectedTime.hour}:${_selectedTime.minute}'), + new Text('$_selectedTime'), new RaisedButton( onPressed: _handleSelectTime, child: new Text('SELECT TIME') diff --git a/packages/flutter/lib/src/material/constants.dart b/packages/flutter/lib/src/material/constants.dart index d27a0fcdd3..900acfb196 100644 --- a/packages/flutter/lib/src/material/constants.dart +++ b/packages/flutter/lib/src/material/constants.dart @@ -2,8 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Modeled after Android's ViewConfiguration: -// https://github.com/android/platform_frameworks_base/blob/master/core/java/android/view/ViewConfiguration.java +import 'package:flutter/widgets.dart'; // TODO(ianh): Figure out actual specced height for status bar const double kStatusBarHeight = 50.0; @@ -32,3 +31,5 @@ const Duration kScrollbarFadeDelay = const Duration(milliseconds: 300); const double kFadingEdgeLength = 12.0; const double kPressedStateDuration = 64.0; // units? const Duration kThemeChangeDuration = const Duration(milliseconds: 200); + +const EdgeDims kDialogHeadingPadding = const EdgeDims.TRBL(24.0, 24.0, 20.0, 24.0); diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 7b012621d9..f495e2b1b2 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:flutter/animation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -12,11 +13,27 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'theme.dart'; import 'typography.dart'; +import 'constants.dart'; +const Duration _kDialAnimateDuration = const Duration(milliseconds: 200); +const double _kTwoPi = 2 * math.PI; +const int _kHoursPerDay = 24; +const int _kHoursPerPeriod = 12; +const int _kMinutesPerHour = 60; + +enum DayPeriod { + am, + pm, +} + +/// A value representing a time during the day class TimeOfDay { const TimeOfDay({ this.hour, this.minute }); + /// Returns a new TimeOfDay with the hour and/or minute replaced. TimeOfDay replacing({ int hour, int minute }) { + assert(hour == null || (hour >= 0 && hour < _kHoursPerDay)); + assert(minute == null || (minute >= 0 && minute < _kMinutesPerHour)); return new TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute); } @@ -26,6 +43,39 @@ class TimeOfDay { /// The selected minute. final int minute; + /// Whether this time of day is before or after noon. + DayPeriod get period => hour < _kHoursPerPeriod ? DayPeriod.am : DayPeriod.pm; + + /// Which hour of the current period (e.g., am or pm) this time is. + int get hourOfPeriod => hour - periodOffset; + + String _addLeadingZeroIfNeeded(int value) { + if (value < 10) + return '0$value'; + return value.toString(); + } + + /// A string representing the hour, in 24 hour time (e.g., '04' or '18'). + String get hourLabel => _addLeadingZeroIfNeeded(hour); + + /// A string representing the minute (e.g., '07'). + String get minuteLabel => _addLeadingZeroIfNeeded(minute); + + /// A string representing the hour of the current period (e.g., '4' or '6'). + String get hourOfPeriodLabel { + // TODO(ianh): Localize. + final int hourOfPeriod = this.hourOfPeriod; + if (hourOfPeriod == 0) + return '12'; + return hourOfPeriod.toString(); + } + + /// A string representing the current period (e.g., 'a.m.'). + String get periodLabel => period == DayPeriod.am ? 'a.m.' : 'p.m.'; // TODO(ianh): Localize. + + /// The hour at which the current period starts. + int get periodOffset => period == DayPeriod.am ? 0 : _kHoursPerPeriod; + bool operator ==(dynamic other) { if (other is! TimeOfDay) return false; @@ -41,7 +91,8 @@ class TimeOfDay { return value; } - String toString() => 'TimeOfDay(hour: $hour, minute: $minute)'; + // TODO(ianh): Localize. + String toString() => '$hourOfPeriodLabel:$minuteLabel $periodLabel'; } enum _TimePickerMode { hour, minute } @@ -74,7 +125,8 @@ class _TimePickerState extends State { Widget header = new _TimePickerHeader( selectedTime: config.selectedTime, mode: _mode, - onModeChanged: _handleModeChanged + onModeChanged: _handleModeChanged, + onChanged: config.onChanged ); return new Column([ header, @@ -93,9 +145,14 @@ class _TimePickerState extends State { } } -// Shows the selected date in large font and toggles between year and day mode +// TODO(ianh): Localize! class _TimePickerHeader extends StatelessComponent { - _TimePickerHeader({ this.selectedTime, this.mode, this.onModeChanged }) { + _TimePickerHeader({ + this.selectedTime, + this.mode, + this.onModeChanged, + this.onChanged + }) { assert(selectedTime != null); assert(mode != null); } @@ -103,12 +160,18 @@ class _TimePickerHeader extends StatelessComponent { final TimeOfDay selectedTime; final _TimePickerMode mode; final ValueChanged<_TimePickerMode> onModeChanged; + final ValueChanged onChanged; void _handleChangeMode(_TimePickerMode value) { if (value != mode) onModeChanged(value); } + void _handleChangeDayPeriod() { + int newHour = (selectedTime.hour + _kHoursPerPeriod) % _kHoursPerDay; + onChanged(selectedTime.replacing(hour: newHour)); + } + Widget build(BuildContext context) { ThemeData theme = Theme.of(context); TextTheme headerTheme = theme.primaryTextTheme; @@ -131,27 +194,45 @@ class _TimePickerHeader extends StatelessComponent { TextStyle hourStyle = mode == _TimePickerMode.hour ? activeStyle : inactiveStyle; TextStyle minuteStyle = mode == _TimePickerMode.minute ? activeStyle : inactiveStyle; + TextStyle amStyle = headerTheme.subhead.copyWith( + color: selectedTime.period == DayPeriod.am ? activeColor: inactiveColor + ); + TextStyle pmStyle = headerTheme.subhead.copyWith( + color: selectedTime.period == DayPeriod.pm ? activeColor: inactiveColor + ); + return new Container( - padding: new EdgeDims.all(10.0), + padding: kDialogHeadingPadding, decoration: new BoxDecoration(backgroundColor: theme.primaryColor), child: new Row([ new GestureDetector( onTap: () => _handleChangeMode(_TimePickerMode.hour), - child: new Text(selectedTime.hour.toString(), style: hourStyle) + child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle) ), new Text(':', style: inactiveStyle), new GestureDetector( onTap: () => _handleChangeMode(_TimePickerMode.minute), - child: new Text(selectedTime.minute.toString(), style: minuteStyle) + child: new Text(selectedTime.minuteLabel, style: minuteStyle) ), + new GestureDetector( + onTap: _handleChangeDayPeriod, + behavior: HitTestBehavior.opaque, + child: new Container( + padding: const EdgeDims.only(left: 16.0, right: 24.0), + child: new Column([ + new Text('AM', style: amStyle), + new Container( + padding: const EdgeDims.only(top: 4.0), + child: new Text('PM', style: pmStyle) + ), + ], justifyContent: FlexJustifyContent.end) + ) + ) ], justifyContent: FlexJustifyContent.end) ); } } -final List _kHours = _initHours(); -final List _kMinutes = _initMinutes(); - List _initPainters(List labels) { TextStyle style = Typography.black.subhead.copyWith(height: 1.0); List painters = new List(labels.length); @@ -215,7 +296,7 @@ class _DialPainter extends CustomPainter { primaryPaint.strokeWidth = 2.0; canvas.drawLine(centerPoint, currentPoint, primaryPaint); - double labelThetaIncrement = -2 * math.PI / _kHours.length; + double labelThetaIncrement = -_kTwoPi / labels.length; double labelTheta = math.PI / 2.0; for (TextPainter label in labels) { @@ -249,33 +330,54 @@ class _Dial extends StatefulComponent { } class _DialState extends State<_Dial> { - double _theta; - void initState() { super.initState(); - _theta = _getThetaForTime(config.selectedTime); + _theta = new ValuePerformance( + variable: new AnimatedValue(_getThetaForTime(config.selectedTime), curve: Curves.ease), + duration: _kDialAnimateDuration + )..addListener(() => setState(() { })); } void didUpdateConfig(_Dial oldConfig) { - if (config.mode != oldConfig.mode) - _theta = _getThetaForTime(config.selectedTime); + if (config.mode != oldConfig.mode && !_dragging) + _animateTo(_getThetaForTime(config.selectedTime)); + } + + ValuePerformance _theta; + bool _dragging = false; + + static double _nearest(double target, double a, double b) { + return ((target - a).abs() < (target - b).abs()) ? a : b; + } + + void _animateTo(double targetTheta) { + double currentTheta = _theta.value; + double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); + beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); + _theta + ..variable.begin = beginTheta + ..variable.end = targetTheta + ..progress = 0.0 + ..play(); } double _getThetaForTime(TimeOfDay time) { double fraction = (config.mode == _TimePickerMode.hour) ? - (time.hour / 12) % 12 : (time.minute / 60) % 60; - return math.PI / 2.0 - fraction * 2 * math.PI; + (time.hour / _kHoursPerPeriod) % _kHoursPerPeriod : + (time.minute / _kMinutesPerHour) % _kMinutesPerHour; + return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi; } TimeOfDay _getTimeForTheta(double theta) { - double fraction = (0.25 - (theta % (2 * math.PI)) / (2 * math.PI)) % 1.0; + double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; if (config.mode == _TimePickerMode.hour) { + int hourOfPeriod = (fraction * _kHoursPerPeriod).round() % _kHoursPerPeriod; return config.selectedTime.replacing( - hour: (fraction * 12).round() + hour: hourOfPeriod + config.selectedTime.periodOffset ); } else { return config.selectedTime.replacing( - minute: (fraction * 60).round() + minute: (fraction * _kMinutesPerHour).round() % _kMinutesPerHour ); } } @@ -283,7 +385,7 @@ class _DialState extends State<_Dial> { void _notifyOnChangedIfNeeded() { if (config.onChanged == null) return; - TimeOfDay current = _getTimeForTheta(_theta); + TimeOfDay current = _getTimeForTheta(_theta.value); if (current != config.selectedTime) config.onChanged(current); } @@ -291,7 +393,7 @@ class _DialState extends State<_Dial> { void _updateThetaForPan() { setState(() { Offset offset = _position - _center; - _theta = (math.atan2(offset.dx, offset.dy) - math.PI / 2.0) % (2 * math.PI); + _theta.variable.value = (math.atan2(offset.dx, offset.dy) - math.PI / 2.0) % _kTwoPi; }); } @@ -299,6 +401,8 @@ class _DialState extends State<_Dial> { Point _center; void _handlePanStart(Point globalPosition) { + assert(!_dragging); + _dragging = true; RenderBox box = context.findRenderObject(); _position = box.globalToLocal(globalPosition); double radius = box.size.shortestSide / 2.0; @@ -314,14 +418,16 @@ class _DialState extends State<_Dial> { } void _handlePanEnd(Offset velocity) { + assert(_dragging); + _dragging = false; _position = null; _center = null; - setState(() { - // TODO(abarth): Animate to the final value. - _theta = _getThetaForTime(config.selectedTime); - }); + _animateTo(_getThetaForTime(config.selectedTime)); } + final List _hours = _initHours(); + final List _minutes = _initMinutes(); + Widget build(BuildContext context) { return new GestureDetector( onPanStart: _handlePanStart, @@ -329,9 +435,9 @@ class _DialState extends State<_Dial> { onPanEnd: _handlePanEnd, child: new CustomPaint( painter: new _DialPainter( - labels: config.mode == _TimePickerMode.hour ? _kHours : _kMinutes, + labels: config.mode == _TimePickerMode.hour ? _hours : _minutes, primaryColor: Theme.of(context).primaryColor, - theta: _theta + theta: _theta.value ) ) );