Issue
I have developed an anagram solving game, which presents an anagram to the user that they have to solve as per below.
The layout is a table with a series of table rows, each row containing table cells. Each table cell has a container with a Text widget that can be empty or contain a character. The characters themselves are held in a List that is the same length as the number of table cells on the screen. Each Text widget in the table cells draws its text value from the corresponding item in the List.
The player can shuffle the characters if they want to display them in a different order. I have added animations to the Text widgets so that they fade out of view, the underlying List is randomly shuffled, and then the characters fade back into view in their new positions. The basic workflow is:
- User presses shuffle
- The program iterates over the List of characters and triggers the fade out animation for any Text widgets that have a text value The fade out animations last for 800 milliseconds
- The programme then shuffles the target word in the text List The programme iterates of the List of characters again and triggers the fade-in animations for any Text widgets that have a text value
My problem is that the animations do not always perform as planned. Sometimes the characters disappear and then fade-in. Sometimes they fade-out and remain hidden. Sometimes they work as planned above. I am assuming that this is because of the timing of the animations and my code. Currently I have a sequence of code in one class that executes the activities above in one go, as per the pseudo-code below
For each table cell {
if (table cell Text widget has a value) then {
trigger the Text widget fade-out animation;
}
}
Shuffle the text List;
For each table cell {
if (table cell Text widget has a value) then {
trigger the Text widget fade-in animation;
}
}
I assume that executing the code this way is causing the problem because it means that my fade-out animations will be triggered, the underlying text List will be shuffled whilst those animations are still running and the fade-out animations will also be triggered before the fade-out animations have finished.
My question is, what is the correct design pattern to control the execution timing of the animations and the shuffle function so that they execute sequentially without overlapping?
I have looked at creating a type of stack where I push the animations and shuffle functions onto a stack and then execute them, but that feels clunky because I need to differentiate between a number of parallel fade-out animations (for example, if the word to be guessed has 8 characters then my program triggers 8 fade-out animations) and then calling the shuffle function.
As per this post, I have also looked at using the .whenComplete() method:
_animationController.forward().whenComplete(() {
// put here the stuff you wanna do when animation completed!
});
But have the same issue that I would with a stack approach in terms of coordinating this for a number of parallel animations.
I have thought about designing my Text character widget so that I could pass a flag that would trigger the .whenComplete() method for the first Text widget in the grid with a value, and just let the other Text widget fade-out animations run separately. I could then shuffle the text at the end of the first Text widget fade-out animation using a callback and trigger the fade-in animations after the shuffle function.
Again, this feels kind of clunky and I want to know if I am missing something. Is there anything built into Flutter to support animation->non-animation function->animation chaining or is there a design pattern that would specifically address this problem in a graceful way?
Solution
I have implemented this using both callback functions and a stack since I felt that this would give me the most flexibility, e.g. if I wanted to hide/show the Text widgets with different start/end times to give it a more organic feel. This works, but as per my original question I am open to suggestions if there is a better way to implement this.
The basic execution workflow in pseudo-code is:
Grid Display
shuffle.onPressed() {
disable user input;
iterate over the grid {
if (cell contains a text value) {
push Text widget key onto a stack (List);
trigger the hide animation (pass callback #1);
}
}
}
Text widget hide animation
hide animation.whenComplete() {
call the next function (callback #1 - pass widget key);
}
Callback function #1
remove Text widget key from the stack;
if (stack is empty) {
executive shuffle function;
iterate over the grid;
if (cell contains a text value) {
push Text widget key onto a stack (List);
trigger the show animation (pass callback #2);
}
}
Text widget show animation
show animation.whenComplete() {
call the next function (callback #2 - pass widget key);
}
Callback function #2
remove Text widget key from the stack
if (stack is empty) {
enable user input;
}
I have included extracts of the code below to show how I have implemented this.
The main class showing the grid on screen has the following variables and functions.
class GridState extends State<Grid> {
// List containing Text widgets to displays in cells including unique
// keys and text values
final List<TextWidget> _letterList = _generateList(_generateKeys());
// Keys of animated widgets - used to track when these finish
final List<GlobalKey<TextWidgetState>> _animations = [];
bool _isInputEnabled = true; // Flag to control user input
@override
Widget build(BuildContext context) {
…
ElevatedButton(
onPressed: () {
if (_isInputEnabled) {
_hideTiles();
}
},
child: Text('shuffle', style: TextStyle(fontSize: _fontSize)),
),
…
}
// Function to hide the tiles on the screen using their animation
void _hideTiles() {
_isInputEnabled = false; // Disable user input
// Hide the existing tiles using animation
for (int i = 0; i < _letterList.length; i++) {
// Only animate if the tile has a text value
if (_letterList[i].hasText()) {
_animations.add(_letterList[i].key as GlobalKey<LetterTileState>);
_letterList[i].hide(_shuffleAndShow);
}
}
}
// Function to shuffle the text on screen and then re-show the tiles using
// their animations
void _shuffleAndShow() {
_animations.remove(key);
if (_animations.isEmpty) {
widget._letterGrid.shuffleText(
widget._letterGrid.getInputText(), widget._options.getCharType());
// Update the tiles with the new characters and show the new tile locations using animation
for (int i = 0; i < _letterList.length; i++) {
// Update tile with new character
_letterList[i].setText(
widget._letterGrid.getCell(i, widget._options.getCharType()));
// If the tile has a character then animate it
if (_letterList[i].hasText()) {
_animations.add(_letterList[i].key as GlobalKey<LetterTileState>);
_letterList[i].show(_enableInput);
}
}
}
}
// Function re-enable user input following the shuffle animations
void _enableInput(GlobalKey<LetterTileState> key) {
_animations.remove(key);
if (_animations.isEmpty) {
_isInputEnabled = true;
}
}
The Text widgets held in _letterList have the following animation functions, which call the callback function when they have finished. Note this code is in the State of a Statefulwidget.
// Animation variables
final Duration _timer = const Duration(milliseconds: 700);
late AnimationController _animationController;
late Animation<double> _rotateAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
// Set up the animation variables
_animationController = AnimationController(vsync: this, duration: _timer);
_rotateAnimation = Tween<double>(begin: 0, end: 6 * pi).animate(
CurvedAnimation(
parent: _animationController,
curve: const Interval(0, 1, curve: Curves.easeIn)));
_rotateAnimation.addListener(() {
setState(() {});
});
_scaleAnimation = Tween<double>(begin: 1, end: 0).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0, 0.95, curve: Curves.ease)));
_scaleAnimation.addListener(() {
setState(() {});
});
}
///
/// Animation functions
///
// Function to hide the tile - spin and shrink to nothing
void hide(Function callback) {s
_animationController.forward(from: 0).whenComplete(() {
_animationController.reset();
callback(widget.key);
});
}
// Function to show the tile - spin and grow from nothing
void show(Function callback) {
_animationController.reverse(from: 1).whenComplete(() {
_animationController.reset();
callback(widget.key);
});
}
UPDATE:
Having done more reading and built an experimental app using the default counter example, I have found that added Listeners and StatusListeners are another, perhaps better, way to do what I want. This would also work with the stack approach I used in my earlier answer as well.
Example code below:
main class:
import 'package:flutter/material.dart';
import 'counter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
Counter counter = Counter();
late AnimationController animationController;
late Animation<double> shrinkAnimation;
@override
void initState() {
animationController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500));
shrinkAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: animationController,
curve: const Interval(0.0, 1.0, curve: Curves.linear)));
shrinkAnimation.addListener(() {
setState(() {}); // Refresh the screen
});
shrinkAnimation.addStatusListener((status) {
switch (status) {
// Completed status is after the end of forward animation
case AnimationStatus.completed:
{
// Increment the counter
counter.increment();
// Do some work that isn't related to animation
int value = 0;
for (int i = 0; i < 1000; i++) {
value++;
}
print('finishing value is $value');
// Then reverse the animation
animationController.reverse();
}
break;
// Dismissed status is after the end of reverse animation
case AnimationStatus.dismissed:
{
animationController.reset();
}
break;
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return Transform.scale(
alignment: Alignment.center,
scale: shrinkAnimation.value,
child: Text(
'${counter.get()}',
style: Theme.of(context).textTheme.headline4,
),
);
}),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
animationController.forward(); // Shrink current value first
},
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
counter class:
import 'package:flutter/cupertino.dart';
class Counter {
int _counter = 0;
void increment() {
_counter++;
}
int get() {
return _counter;
}
}
Answered By - Twelve1110
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.