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 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: [ _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: [ 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: [ 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: [ 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 createState() => _NumberPickerDialogControllerState( initialIntegerValue, initialDoubleValue); } class _NumberPickerDialogControllerState extends State { 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), ], ); } }