A positive counter app built with Flutter
ticker
A trivial counter app.
Development notes
for posterity’s sake
Scrolling digits
Use the ListWheelScrollView
widget to show digits one above the other. The widget requires two fields: itemExtent
and children
. In this last property create a list of widget to show the ten numbers.
children: List<Widget>.generate(
digits,
(index) => FittedBox(
child: Text(
('$index'),
),
),
),
FittedBox
helps to expand the text to stretch to the available space.
Control
The goal is to ultimately handle the scrolling through two buttons instead of the wheel.
Disable the wheel’s scrolling with physics
.
ListWheelScrollView(
physics: const NeverScrollableScrollPhysics(),
)
Make the widget into a stateful widget to manage state.
class Ticker extends StatefulWidget {
}
class _TickerState extends State<Ticker> {
}
Initialize a variable for the controller in the subclass of state.
late FixedExtentScrollController _controller;
Set up the controller in the initState
lifecycle method.
@override
void initState() {
super.initState();
_controller = FixedExtentScrollController();
}
Dispose of the resources allocated to the controller through the lifecycle method.
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Include the controller in the wheel.
ListWheelScrollView(
controller: _controller,
)
Modify the widget tree to return the list above a row with two columns, to count up and down.
Column
Expanded
Center
ListWheelScrollView
Row
IconButton
IconButton
Add a function to the onPressed
field to scroll up and down.
onPressed: () => _scroll(-1),
onPressed: () => _scroll(1),
Define _scroll
to update the wheel through the controller.
void _scroll(int direction) {
}
Retrieve the current value with _controller.selectedItem
and change it according to the input direction.
_controller.jumpToItem(
_controller.selectedItem + 1 * direction
);
Use jumpToItem
to move to the neighboring items instantaneously. Use animateToItem
to move with an animation instead.
Add the required duration and curve.
_controller.animateToItem(
_controller.selectedItem + 1 * direction,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutSine
);
Infinite scroll
Connected to the list wheel widget you find ListWheelScrollView.useDelegate
to generate the children programmatically. In the required childDelegate
field you can use the ListWheelChildLoopingListDelegate
widget to create an closed wheel, or rather a repeating wheel with the input digits.
ListWheelScrollView.useDelegate(
childDelegate: ListWheelChildLoopingListDelegate(
children: [] // List<Widget>...
)
)
Ultimately, however, I chose not to pursue the looping route. This is more as a matter of preference in terms of the values assumed by the controller in _controller.selectedItem
, which I’d rather keep in a given range instead of extending to large positive or negative numbers.
Keeping the existing ListWheelScrollView
widget create a list with one more number than necessary.
List<Widget>.generate(digits, )
+List<Widget>.generate(digits + 1,)
Remove the excess in the text widget.
Text(
(index % digits).toString()
)
In the scrolling function directly update the current item directly in the two instances when the selected item falls outside of the list.
if(direction == -1 && _controller.selectedItem == 0) {
_controller.jumpToItem(digits);
} else if(direction == 1 && _controller.selectedItem == digits) {
_controller.jumpToItem(0);
}
The illusion works since you jump to the item before you animate the wheel and ultimately hide all numbers except the one displayed in the center — with the overAndUnderCenterOpacity
property.
Multiple wheels
The idea is to ultimately implement a counting feature which exceeds the unit column.
Have the Ticker
widget receive a named property for the number of columns, with a default value of 3
.
final int columns;
const Ticker({
Key? key,
this.columns = 3,
}) : super(key: key);
In the stateful widget create a list of controllers instead of a single instance of FixedExtentScrollController
.
late List<FixedExtentScrollController> _controllers;
Initialize the multiple controllers in the init function.
_controllers = List<FixedExtentScrollController>.generate(
widget.columns,
(_) => FixedExtentScrollController()
);
Dispose of all controllers in the matching lifecycle method.
for(FixedExtentScrollController controller in _controllers) {
controller.dispose();
}
In the widget tree the idea is to loop through the controllers to add one wheel for each column.
In terms of widget tree wrap ListWheelScrollView
in an Expanded
widget. Add the multiple Expanded
widgets to a row to have the wheel side by side.
Row
Expanded
Center
ListWheelScrollView
Expanded
Center
ListWheelScrollView
In the controller
field add the respective controller, from the looping function.
_controllers.map(
(controller) => Expanded(
child: ListWheelScrollView(
controller: controller,
)
)
).toList()
In the children
field generate a list for each wheel instead of relying on the one created ahead of time.
children: List<Widget>.generate(
digits + 1,
// ...
)
Using a single list does not seem to cause issues, at least with a superficial look, but it is reasonable to create a set for each separate wheel.
Update counter
With multiple columns you need to consider when a digit exceeds the range in either direction.
The idea is to update the scroll
function to immediately modify the last column, then move backwards if necessary.
Initialize a counter variable to start at the end of the controllers’ list.
int index = _controllers.length;
In a do..while
loop decrement the counter variable — which explains why the initial value is actually off by one.
do {
index -= 1;
} while(index > 0);
If you were to modify all columns you’d execute the previous logic for every single controller.
do {
index -= 1;
// _controllers[index]
} while(index > 0)
To consider only the [0-9] extremes, update the while
condition considering the selected item and direction.
while (
index > 0 &&
(
(direction == 1 && _controllers[index].selectedItem == digits - 1)
||
(direction == -1 && _controllers[index].selectedItem == digits)
)
);
In the body of the repeating block log the selected item to double check the value.
Scroll order
As a matter of preference the application counts numbers by having larger values above smaller ones. One way to achieve the feat is to:
-
reverse the list of widget describing the numbers
List<Widget>.generate( // digits + 1, ... ).reversed.toList()
-
flip the input direction in the
_scroll
functionvoid _scroll(int direction) { direction *= -1; }
-
reconsider the condition in the
while
statement since the order of the numbers is flipped
Initial count
In the moment the application stores the count value locally — perhaps through shared-preferences
— it is helpful to have the stateful widget receive a value for the initial count.
Ticker(count: 109)
Initialize the variable to have the named property optional.
class Ticker extends StatefulWidget {
final int count;
const Ticker({
Key? key,
this.count = 0
// ...
}) : super(key: key);
}
In the subclass of state update the columns through the controllers. Since the logic relies on the ListWheelScrollView
widgets actually existing include the instructions in the initState
lifecycle and a function which runs as the widget is built.
// initialize controllers
WidgetsBinding.instance?.addPostFrameCallback((_) {
// update controllers
});
Note that the order of the numbers in the lists is reversed, so you need to map the individual digits to the corresponding index.
Once you extract the number for each column in a variable digit
:
-
update the controllers to jump at the bottom of the wheel
_controllers[index].jumpToItem(digits);
-
animate the controllers back to the correct value
_controllers[index].animateToItem( digits - digit, // ... )
To compute the digit consider the input count and begin with the last column.
int count = widget.count;
int index = _controllers.length - 1;
In a while loop continue extracting the digit as long as 1. the count is a positive number and 2. there are columns left.
while(count > 0 && index >= 0) {
}
Extract the digit with the modulo operator.
int digit = count % digits;
Once you update the controller update the count and index to eventually exit the loop.
count = count ~/ digits;
index --;
~/
works as a shorthand for integer division, (count / digits).toInt()
.
Staggered animation
The goal is to stagger the scrolling animation, both for successive columns and for the initial count.
For either start with a value describing the total duration and compute the number of milliseconds devoted to each digit.
For successive columns devote up to 600 milliseconds, in case every digit is flipped.
int duration = 600 ~/ _controllers.length;
For the initial count devote up to 2 seconds, since it is ultimately possible to scroll to higher digits and the animation introduces the application.
int duration = 2000 ~/ _controllers.length;
Initialize a variable to keep track of the delay and increment this number with each column, with each digit.
// successive column
delay += duration ~/ 3;
// initial count
delay += duration - (duration ~/ digits);
Consider a smaller amount than the total duration to have successive scrolls take place before the previous instance has had a chance to finish.
Use Future.delayed
to animate the controller after the prescribed delay.
Future.delayed(
Duration(milliseconds: delay),,
() => // animateToItem
)
Most importantly, be sure that the delayed animation actually updates the current controller. This means either extracting the index in a separate variable or the controller itself.
FixedExtentScrollController controller = _controllers[index];
// later
controller.animateToItem()
index
is updated in the while loop so that using the variable would mean the method would be applied on the last available instance.
-_controllers[index].animateToItem()