workouttest_app/lib/library/numberpicker.dart
2020-10-28 14:05:31 +01:00

808 lines
26 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:infinite_listview/infinite_listview.dart';
/// Created by Marcin Szałek
///Define a text mapper to transform the text displayed by the picker
typedef String TextMapper(String numberText);
///NumberPicker is a widget designed to pick a number between #minValue and #maxValue
class NumberPicker extends StatelessWidget {
///height of every list element for normal number picker
///width of every list element for horizontal number picker
static const double kDefaultItemExtent = 60.0;
///width of list view for normal number picker
///height of list view for horizontal number picker
static const double kDefaultListViewCrossAxisSize = 120.0;
///constructor for horizontal number picker
NumberPicker.horizontal({
Key key,
@required int initialValue,
@required this.minValue,
@required this.maxValue,
@required this.onChanged,
this.textMapper,
this.itemExtent = kDefaultItemExtent,
this.listViewHeight = kDefaultListViewCrossAxisSize,
this.step = 1,
this.zeroPad = false,
this.highlightSelectedValue = true,
this.decoration,
this.haptics = false,
this.textStyle,
this.textStyleHighlighted
}) : assert(initialValue != null),
assert(minValue != null),
assert(maxValue != null),
assert(maxValue > minValue),
assert(initialValue >= minValue && initialValue <= maxValue),
assert(step > 0),
selectedIntValue = initialValue,
selectedDecimalValue = -1,
decimalPlaces = 0,
intScrollController = ScrollController(
initialScrollOffset: (initialValue - minValue) ~/ step * itemExtent,
),
scrollDirection = Axis.horizontal,
decimalScrollController = null,
listViewWidth = 3 * itemExtent,
infiniteLoop = false,
integerItemCount = (maxValue - minValue) ~/ step + 1,
super(key: key);
///constructor for integer number picker
NumberPicker.integer({
Key key,
@required int initialValue,
@required this.minValue,
@required this.maxValue,
@required this.onChanged,
this.textMapper,
this.itemExtent = kDefaultItemExtent,
this.listViewWidth = kDefaultListViewCrossAxisSize,
this.step = 1,
this.scrollDirection = Axis.vertical,
this.infiniteLoop = false,
this.zeroPad = false,
this.highlightSelectedValue = true,
this.decoration,
this.haptics = false,
this.textStyle,
this.textStyleHighlighted
}) : assert(initialValue != null),
assert(minValue != null),
assert(maxValue != null),
assert(maxValue > minValue),
assert(initialValue >= minValue && initialValue <= maxValue),
assert(step > 0),
assert(scrollDirection != null),
selectedIntValue = initialValue,
selectedDecimalValue = -1,
decimalPlaces = 0,
intScrollController = infiniteLoop
? InfiniteScrollController(
initialScrollOffset:
(initialValue - minValue) ~/ step * itemExtent,
)
: ScrollController(
initialScrollOffset:
(initialValue - minValue) ~/ step * itemExtent,
),
decimalScrollController = null,
listViewHeight = 3 * itemExtent,
integerItemCount = (maxValue - minValue) ~/ step + 1,
super(key: key);
///constructor for decimal number picker
NumberPicker.decimal({
Key key,
@required double initialValue,
@required this.minValue,
@required this.maxValue,
@required this.onChanged,
this.textMapper,
this.decimalPlaces = 1,
this.itemExtent = kDefaultItemExtent,
this.listViewWidth = kDefaultListViewCrossAxisSize,
this.highlightSelectedValue = true,
this.decoration,
this.haptics = false,
this.textStyle,
this.textStyleHighlighted
}) : assert(initialValue != null),
assert(minValue != null),
assert(maxValue != null),
assert(decimalPlaces != null && decimalPlaces > 0),
assert(maxValue > minValue),
assert(initialValue >= minValue && initialValue <= maxValue),
selectedIntValue = initialValue.floor(),
selectedDecimalValue = ((initialValue - initialValue.floorToDouble()) *
math.pow(10, decimalPlaces))
.round(),
intScrollController = ScrollController(
initialScrollOffset: (initialValue.floor() - minValue) * itemExtent,
),
decimalScrollController = ScrollController(
initialScrollOffset: ((initialValue - initialValue.floorToDouble()) *
math.pow(10, decimalPlaces))
.roundToDouble() *
itemExtent,
),
listViewHeight = 3 * itemExtent,
step = 1,
scrollDirection = Axis.vertical,
integerItemCount = maxValue.floor() - minValue.floor() + 1,
infiniteLoop = false,
zeroPad = false,
super(key: key);
///called when selected value changes
final ValueChanged<num> onChanged;
///min value user can pick
final int minValue;
///max value user can pick
final int maxValue;
///build the text of each item on the picker
final TextMapper textMapper;
///inidcates how many decimal places to show
/// e.g. 0=>[1,2,3...], 1=>[1.0, 1.1, 1.2...] 2=>[1.00, 1.01, 1.02...]
final int decimalPlaces;
///height of every list element in pixels
final double itemExtent;
///height of list view in pixels
final double listViewHeight;
///width of list view in pixels
final double listViewWidth;
///ScrollController used for integer list
final ScrollController intScrollController;
///ScrollController used for decimal list
final ScrollController decimalScrollController;
///Currently selected integer value
final int selectedIntValue;
///Currently selected decimal value
final int selectedDecimalValue;
///If currently selected value should be highlighted
final bool highlightSelectedValue;
///Decoration to apply to central box where the selected value is placed
final Decoration decoration;
///Step between elements. Only for integer datePicker
///Examples:
/// if step is 100 the following elements may be 100, 200, 300...
/// if min=0, max=6, step=3, then items will be 0, 3 and 6
/// if min=0, max=5, step=3, then items will be 0 and 3.
final int step;
/// Direction of scrolling
final Axis scrollDirection;
///Repeat values infinitely
final bool infiniteLoop;
///Pads displayed integer values up to the length of maxValue
final bool zeroPad;
///Amount of items
final int integerItemCount;
///Whether to trigger haptic pulses or not
final bool haptics;
///TextStyle of the non-highlighted numbers
final TextStyle textStyle;
///TextStyle of the highlighted numbers
final TextStyle textStyleHighlighted;
//
//----------------------------- PUBLIC ------------------------------
//
/// Used to animate integer number picker to new selected value
void animateInt(int valueToSelect) {
int diff = valueToSelect - minValue;
int index = diff ~/ step;
animateIntToIndex(index);
}
/// Used to animate integer number picker to new selected index
void animateIntToIndex(int index) {
_animate(intScrollController, index * itemExtent);
}
/// Used to animate decimal part of double value to new selected value
void animateDecimal(int decimalValue) {
_animate(decimalScrollController, decimalValue * itemExtent);
}
/// Used to animate decimal number picker to selected value
void animateDecimalAndInteger(double valueToSelect) {
animateInt(valueToSelect.floor());
animateDecimal(((valueToSelect - valueToSelect.floorToDouble()) *
math.pow(10, decimalPlaces))
.round());
}
//
//----------------------------- VIEWS -----------------------------
//
///main widget
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
if (infiniteLoop) {
return _integerInfiniteListView(themeData);
}
if (decimalPlaces == 0) {
return _integerListView(themeData);
} else {
return Row(
children: <Widget>[
_integerListView(themeData),
_decimalListView(themeData),
],
mainAxisAlignment: MainAxisAlignment.center,
);
}
}
Widget _integerListView(ThemeData themeData) {
TextStyle defaultStyle = textStyle == null ?
themeData.textTheme.body1 : textStyle;
TextStyle selectedStyle = textStyleHighlighted == null ?
themeData.textTheme.headline.copyWith(color: themeData.accentColor)
: textStyleHighlighted;
var listItemCount = integerItemCount + 2;
return Listener(
onPointerUp: (ev) {
///used to detect that user stopped scrolling
if (intScrollController.position.activity is HoldScrollActivity) {
animateInt(selectedIntValue);
}
},
child: NotificationListener(
child: Container(
height: listViewHeight,
width: listViewWidth,
child: Stack(
children: <Widget>[
ListView.builder(
scrollDirection: scrollDirection,
controller: intScrollController,
itemExtent: itemExtent,
itemCount: listItemCount,
cacheExtent: _calculateCacheExtent(listItemCount),
itemBuilder: (BuildContext context, int index) {
final int value = _intValueFromIndex(index);
//define special style for selected (middle) element
final TextStyle itemStyle =
value == selectedIntValue && highlightSelectedValue
? selectedStyle
: defaultStyle;
double top = defaultStyle != null && defaultStyle.fontSize != null
? listViewHeight / 2 - defaultStyle.fontSize / 2 - 15
: listViewHeight / 2 - 22;
double left = defaultStyle != null && defaultStyle.fontSize != null
? listViewWidth / 6 - defaultStyle.fontSize / 2 - 10
: listViewHeight / 2 - 27;
bool isExtra = index == 0 || index == listItemCount - 1;
return isExtra
? Container() //empty first and last element
: Center(
child: value != selectedIntValue ?
Container(
padding: EdgeInsets.only(top: 15, left: 10, right: 5, bottom: 10),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: value < selectedIntValue ? Alignment.centerRight : Alignment.centerLeft,
end: value < selectedIntValue ? Alignment.centerLeft : Alignment.centerRight,
colors: [Colors.white12, Colors.black12]),
borderRadius: BorderRadius.circular(8.0),
),
),
Positioned(
top: top,
left: left,
child:
Text(
getDisplayedValue(value),
style: itemStyle,
),
),
],
)
) :
Text(
getDisplayedValue(value),
style: itemStyle,
),
);
},
),
_NumberPickerSelectedItemDecoration(
axis: scrollDirection,
itemExtent: itemExtent,
decoration: decoration,
),
],
),
),
onNotification: _onIntegerNotification,
),
);
}
Widget _decimalListView(ThemeData themeData) {
TextStyle defaultStyle = textStyle == null ?
themeData.textTheme.body1 : textStyle;
TextStyle selectedStyle = textStyleHighlighted == null ?
themeData.textTheme.headline.copyWith(color: themeData.accentColor)
: textStyleHighlighted;
int decimalItemCount =
selectedIntValue == maxValue ? 3 : math.pow(10, decimalPlaces) + 2;
return Listener(
onPointerUp: (ev) {
///used to detect that user stopped scrolling
if (decimalScrollController.position.activity is HoldScrollActivity) {
animateDecimal(selectedDecimalValue);
}
},
child: NotificationListener(
child: Container(
height: listViewHeight,
width: listViewWidth,
child: Stack(
children: <Widget>[
ListView.builder(
controller: decimalScrollController,
itemExtent: itemExtent,
itemCount: decimalItemCount,
itemBuilder: (BuildContext context, int index) {
final int value = index - 1;
//define special style for selected (middle) element
final TextStyle itemStyle =
value == selectedDecimalValue && highlightSelectedValue
? selectedStyle
: defaultStyle;
bool isExtra = index == 0 || index == decimalItemCount - 1;
return isExtra
? Container() //empty first and last element
: Center(
child: Text(
value.toString().padLeft(decimalPlaces, '0'),
style: itemStyle,
),
);
},
),
_NumberPickerSelectedItemDecoration(
axis: scrollDirection,
itemExtent: itemExtent,
decoration: decoration,
),
],
),
),
onNotification: _onDecimalNotification,
),
);
}
Widget _integerInfiniteListView(ThemeData themeData) {
TextStyle defaultStyle = textStyle == null ?
themeData.textTheme.body1 : textStyle;
TextStyle selectedStyle = textStyleHighlighted == null ?
themeData.textTheme.headline.copyWith(color: themeData.accentColor)
: textStyleHighlighted;
return Listener(
onPointerUp: (ev) {
///used to detect that user stopped scrolling
if (intScrollController.position.activity is HoldScrollActivity) {
_animateIntWhenUserStoppedScrolling(selectedIntValue);
}
},
child: NotificationListener(
child: Container(
height: listViewHeight,
width: listViewWidth,
child: Stack(
children: <Widget>[
InfiniteListView.builder(
controller: intScrollController,
itemExtent: itemExtent,
itemBuilder: (BuildContext context, int index) {
final int value = _intValueFromIndex(index);
//define special style for selected (middle) element
final TextStyle itemStyle =
value == selectedIntValue && highlightSelectedValue
? selectedStyle
: defaultStyle;
return Center(
child: Text(
getDisplayedValue(value),
style: itemStyle,
),
);
},
),
_NumberPickerSelectedItemDecoration(
axis: scrollDirection,
itemExtent: itemExtent,
decoration: decoration,
),
],
),
),
onNotification: _onIntegerNotification,
),
);
}
String getDisplayedValue(int value) {
final text = zeroPad
? value.toString().padLeft(maxValue.toString().length, '0')
: value.toString();
return textMapper != null ? textMapper(text) : text;
}
//
// ----------------------------- LOGIC -----------------------------
//
int _intValueFromIndex(int index) {
index--;
index %= integerItemCount;
return minValue + index * step;
}
bool _onIntegerNotification(Notification notification) {
if (notification is ScrollNotification) {
//calculate
int intIndexOfMiddleElement =
(notification.metrics.pixels / itemExtent).round();
if (!infiniteLoop) {
intIndexOfMiddleElement =
intIndexOfMiddleElement.clamp(0, integerItemCount - 1);
}
int intValueInTheMiddle = _intValueFromIndex(intIndexOfMiddleElement + 1);
intValueInTheMiddle = _normalizeIntegerMiddleValue(intValueInTheMiddle);
if (_userStoppedScrolling(notification, intScrollController)) {
//center selected value
animateIntToIndex(intIndexOfMiddleElement);
}
//update selection
if (intValueInTheMiddle != selectedIntValue) {
num newValue;
if (decimalPlaces == 0) {
//return integer value
newValue = (intValueInTheMiddle);
} else {
if (intValueInTheMiddle == maxValue) {
//if new value is maxValue, then return that value and ignore decimal
newValue = (intValueInTheMiddle.toDouble());
animateDecimal(0);
} else {
//return integer+decimal
double decimalPart = _toDecimal(selectedDecimalValue);
newValue = ((intValueInTheMiddle + decimalPart).toDouble());
}
}
if (haptics) {
HapticFeedback.selectionClick();
}
onChanged(newValue);
}
}
return true;
}
bool _onDecimalNotification(Notification notification) {
if (notification is ScrollNotification) {
//calculate middle value
int indexOfMiddleElement =
(notification.metrics.pixels + listViewHeight / 2) ~/ itemExtent;
int decimalValueInTheMiddle = indexOfMiddleElement - 1;
decimalValueInTheMiddle =
_normalizeDecimalMiddleValue(decimalValueInTheMiddle);
if (_userStoppedScrolling(notification, decimalScrollController)) {
//center selected value
animateDecimal(decimalValueInTheMiddle);
}
//update selection
if (selectedIntValue != maxValue &&
decimalValueInTheMiddle != selectedDecimalValue) {
double decimalPart = _toDecimal(decimalValueInTheMiddle);
double newValue = ((selectedIntValue + decimalPart).toDouble());
if (haptics) {
HapticFeedback.selectionClick();
}
onChanged(newValue);
}
}
return true;
}
///There was a bug, when if there was small integer range, e.g. from 1 to 5,
///When user scrolled to the top, whole listview got displayed.
///To prevent this we are calculating cacheExtent by our own so it gets smaller if number of items is smaller
double _calculateCacheExtent(int itemCount) {
double cacheExtent = 250.0; //default cache extent
if ((itemCount - 2) * kDefaultItemExtent <= cacheExtent) {
cacheExtent = ((itemCount - 3) * kDefaultItemExtent);
}
return cacheExtent;
}
///When overscroll occurs on iOS,
///we can end up with value not in the range between [minValue] and [maxValue]
///To avoid going out of range, we change values out of range to border values.
int _normalizeMiddleValue(int valueInTheMiddle, int min, int max) {
return math.max(math.min(valueInTheMiddle, max), min);
}
int _normalizeIntegerMiddleValue(int integerValueInTheMiddle) {
//make sure that max is a multiple of step
int max = (maxValue ~/ step) * step;
return _normalizeMiddleValue(integerValueInTheMiddle, minValue, max);
}
int _normalizeDecimalMiddleValue(int decimalValueInTheMiddle) {
return _normalizeMiddleValue(
decimalValueInTheMiddle, 0, math.pow(10, decimalPlaces) - 1);
}
///indicates if user has stopped scrolling so we can center value in the middle
bool _userStoppedScrolling(
Notification notification,
ScrollController scrollController,
) {
return notification is UserScrollNotification &&
notification.direction == ScrollDirection.idle &&
scrollController.position.activity is! HoldScrollActivity;
}
/// Allows to find currently selected element index and animate this element
/// Use it only when user manually stops scrolling in infinite loop
void _animateIntWhenUserStoppedScrolling(int valueToSelect) {
// estimated index of currently selected element based on offset and item extent
int currentlySelectedElementIndex =
intScrollController.offset ~/ itemExtent;
// when more(less) than half of the top(bottom) element is hidden
// then we should increment(decrement) index in case of positive(negative) offset
if (intScrollController.offset > 0 &&
intScrollController.offset % itemExtent > itemExtent / 2) {
currentlySelectedElementIndex++;
} else if (intScrollController.offset < 0 &&
intScrollController.offset % itemExtent < itemExtent / 2) {
currentlySelectedElementIndex--;
}
animateIntToIndex(currentlySelectedElementIndex);
}
///converts integer indicator of decimal value to double
///e.g. decimalPlaces = 1, value = 4 >>> result = 0.4
/// decimalPlaces = 2, value = 12 >>> result = 0.12
double _toDecimal(int decimalValueAsInteger) {
return double.parse((decimalValueAsInteger * math.pow(10, -decimalPlaces))
.toStringAsFixed(decimalPlaces));
}
///scroll to selected value
_animate(ScrollController scrollController, double value) {
scrollController.animateTo(
value,
duration: Duration(seconds: 1),
curve: ElasticOutCurve(),
);
}
}
class _NumberPickerSelectedItemDecoration extends StatelessWidget {
final Axis axis;
final double itemExtent;
final Decoration decoration;
const _NumberPickerSelectedItemDecoration(
{Key key,
@required this.axis,
@required this.itemExtent,
@required this.decoration})
: super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: IgnorePointer(
child: Container(
width: isVertical ? double.infinity : itemExtent,
height: isVertical ? itemExtent : double.infinity,
decoration: decoration,
),
),
);
}
bool get isVertical => axis == Axis.vertical;
}
///Returns AlertDialog as a Widget so it is designed to be used in showDialog method
class NumberPickerDialog extends StatefulWidget {
final int minValue;
final int maxValue;
final int initialIntegerValue;
final double initialDoubleValue;
final int decimalPlaces;
final Widget title;
final EdgeInsets titlePadding;
final Widget confirmWidget;
final Widget cancelWidget;
final int step;
final bool infiniteLoop;
final bool zeroPad;
final bool highlightSelectedValue;
final Decoration decoration;
final TextMapper textMapper;
final bool haptics;
///constructor for integer values
NumberPickerDialog.integer({
@required this.minValue,
@required this.maxValue,
@required this.initialIntegerValue,
this.title,
this.titlePadding,
this.step = 1,
this.infiniteLoop = false,
this.zeroPad = false,
this.highlightSelectedValue = true,
this.decoration,
this.textMapper,
this.haptics = false,
Widget confirmWidget,
Widget cancelWidget,
}) : confirmWidget = confirmWidget ?? Text("OK"),
cancelWidget = cancelWidget ?? Text("CANCEL"),
decimalPlaces = 0,
initialDoubleValue = -1.0;
///constructor for decimal values
NumberPickerDialog.decimal({
@required this.minValue,
@required this.maxValue,
@required this.initialDoubleValue,
this.decimalPlaces = 1,
this.title,
this.titlePadding,
this.highlightSelectedValue = true,
this.decoration,
this.textMapper,
this.haptics = false,
Widget confirmWidget,
Widget cancelWidget,
}) : confirmWidget = confirmWidget ?? Text("OK"),
cancelWidget = cancelWidget ?? Text("CANCEL"),
initialIntegerValue = -1,
step = 1,
infiniteLoop = false,
zeroPad = false;
@override
State<NumberPickerDialog> createState() => _NumberPickerDialogControllerState(
initialIntegerValue, initialDoubleValue);
}
class _NumberPickerDialogControllerState extends State<NumberPickerDialog> {
int selectedIntValue;
double selectedDoubleValue;
_NumberPickerDialogControllerState(
this.selectedIntValue, this.selectedDoubleValue);
void _handleValueChanged(num value) {
if (value is int) {
setState(() => selectedIntValue = value);
} else {
setState(() => selectedDoubleValue = value);
}
}
NumberPicker _buildNumberPicker() {
if (widget.decimalPlaces > 0) {
return NumberPicker.decimal(
initialValue: selectedDoubleValue,
minValue: widget.minValue,
maxValue: widget.maxValue,
decimalPlaces: widget.decimalPlaces,
highlightSelectedValue: widget.highlightSelectedValue,
decoration: widget.decoration,
onChanged: _handleValueChanged,
textMapper: widget.textMapper,
haptics: widget.haptics,
);
} else {
return NumberPicker.integer(
initialValue: selectedIntValue,
minValue: widget.minValue,
maxValue: widget.maxValue,
step: widget.step,
infiniteLoop: widget.infiniteLoop,
zeroPad: widget.zeroPad,
highlightSelectedValue: widget.highlightSelectedValue,
decoration: widget.decoration,
onChanged: _handleValueChanged,
textMapper: widget.textMapper,
haptics: widget.haptics,
);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: widget.title,
titlePadding: widget.titlePadding,
content: _buildNumberPicker(),
actions: [
FlatButton(
onPressed: () => Navigator.of(context).pop(),
child: widget.cancelWidget,
),
FlatButton(
onPressed: () => Navigator.of(context).pop(widget.decimalPlaces > 0
? selectedDoubleValue
: selectedIntValue),
child: widget.confirmWidget),
],
);
}
}