Riverpod State Management Example – Tic Tac Toe

We are going to show another example of Riverpod. This time we will create Tic Tac Toe. As shown earlier, we can easily create Tic Tac Toe with animation in Flutter. However, the state management was all over the place. In this blog post, we will show a better way to manage the state with Riverpod. Secondly, we are going to show another example of Flutter Freezed. Immutables can greatly help with reducing errors in your code and it works great with Riverpod.

Setup the Project

Before we can start with coding, we are going to add some dependencies to the project. We will need Flutter Hooks, and Hooks Riverpod for the state management. For the immutable objects, we will add Freezed Annotations to the dependencies. Furthermore, we have to add the Freezed dependency to the development dependencies to generate the code of the immutable objects.

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation: ^0.12.0
  flutter_hooks: ^0.14.0
  hooks_riverpod: ^0.12.1

dev_dependencies:
  build_runner:
  freezed: ^0.12.2

Do not forget to install the dependency, running the following command:

flutter pub get

Creating the Model

For the model, we are going to create three different immutables. One for the tiles, one for the progress of the game, and one that combines both to manage the state of the whole game. The tiles are pretty simple, they have an x and a y value representing the place on the board. With the @freezed annotation, the generator will generate the code for us. We create a factory method, that will be picked up as a constructor for the Tile.

import 'package:freezed_annotation/freezed_annotation.dart';

part 'Tile.freezed.dart';

@freezed
abstract class Tile with _$Tile {
  factory Tile(int x, int y) = _Tile;
}

Freezed provides another feature, namely Union objects. With Union objects, we can easily create different types for the progress of the game. First, we are going to create an object, Finished for when the game is finished with the result of the game. Secondly, we will create an InProgress object, which means that the game is in progress. (doh)

import 'package:freezed_annotation/freezed_annotation.dart';

part 'Progress.freezed.dart';

@freezed
abstract class Progress with _$Progress {
  factory Progress.finished(FinishedState winner) = Finished;
  factory Progress.inProgress() = InProgress;
}

enum FinishedState { CROSS, CIRCLE, DRAW }

Finally, we are going to create a GameState for the state of the game. The information we need:

  1. Which player clicked which tile: Map<Tile, PlayerType>
  2. The current player: PlayerType
  3. Whether the game is finished or not: Progress
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:freezed_riverpod_state/model/Progress.dart';

import 'PlayerType.dart';
import 'Tile.dart';

part 'GameState.freezed.dart';

@freezed
abstract class GameState with _$GameState {
  factory GameState(Map tiles,
      {@Default(PlayerType.CIRCLE) PlayerType currentPlayer,
      Progress progress}) = _GameState;
}

Now that we have created our immutable objects, we can generate them with the following command:

flutter pub run build_runner build

Managing the Game

For the managing the state of the game, we are going to introduce a StateNotifier. We already have an object, GameState we are going to use as state. The StateNotifier will notify consumers, when the state has changed. We are going to introduce a toggle method that will toggle the selected player on tile. Afterwards, we will trigger the updates by mapping the tiles to a new map to detect changes and setting the current player to the next player.

class GameStateNotifier extends StateNotifier {
  GameStateNotifier(GameState state) : super(state) {
    Map tiles = Map();
    for (int x = 0; x < 3; x++) {
      for (int y = 0; y < 3; y++) {
        tiles.putIfAbsent(Tile(x, y), () => PlayerType.EMPTY);
      }
    }
    this.state = state.copyWith(tiles: tiles);
  }

  toggle(Tile tile) {
    state.tiles[tile] = state.currentPlayer;
    state = state.copyWith(
      currentPlayer: _nextPlayer(),
      tiles: state.tiles.map((key, value) => MapEntry(key, value)),
    );
  }

  PlayerType _nextPlayer() {
    if (state.currentPlayer == PlayerType.CIRCLE) {
      return PlayerType.CROSS;
    }
    return PlayerType.CIRCLE;
  }
}

Showing the Current State

Before we are going to start creating widgets, we are going to create two different consumers. One of the whole list of tiles and another one for the entry of a single tile. We create the StateNotifierProvider _gameState and from this one, we can derive the first provider we need. The second provider will be filled in the Widget itself.

final _gameState =
    StateNotifierProvider((_) => GameStateNotifier(GameState(Map())));
final _tiles = Provider((ref) => ref.watch(_gameState.state).tiles);
final _currentTile = ScopedProvider>(null);

To display the current tiles, we will use a Gridview, with an axis count of three. To provide the current value to the child Widget, we are going to use a ScopedProvider. We can override ScopedProvider in the ProviderScope. Thus around each tile, we will wrap a new ProviderScope and update the value of the ScopedProvider. Doing this, we can use the useProvider hook in the child Widget.

class Tiles extends HookWidget {
  @override
  Widget build(BuildContext context) {
    var currentTiles = useProvider(_tiles);
    return Container(
      child: GridView.count(
        physics: new NeverScrollableScrollPhysics(),
        padding: EdgeInsets.all(12),
        crossAxisCount: 3,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
        children: currentTiles.entries
            .map((entry) => ProviderScope(
                overrides: [_currentTile.overrideWithValue(entry)],
                child: TileWidget()))
            .toList(),
      ),
    );
  }
}

class TileWidget extends HookWidget {
  const TileWidget({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final tileEntry = useProvider(_currentTile);
    switch (tileEntry.value) {
      case PlayerType.CROSS:
        return crossWidget(_controller);
      case PlayerType.CIRCLE:
        return circleWidget(_controller);
      case PlayerType.EMPTY:
        return emptyWidget(context, tileEntry.key);
    }
    throw Exception("PlayerType ${tileEntry.value} not supported");
  }

  Widget emptyWidget(BuildContext context, Tile tile) {
    return GestureDetector(
        onTap: () => {context.read(_gameState).toggle(tile)},
        child: Container(
          color: Colors.green[600],
        ));
  }

  Widget crossWidget(AnimationController _controller) {
          return Container(
            color: Colors.green[600],
            child: CustomPaint(
              painter: CrossPainter(),
            ),
          );
  }

  Widget circleWidget(AnimationController _controller) {
          return Container(
            color: Colors.green[600],
            child: CustomPaint(
              painter: CirclePainter(),
            ),
          );
  }
}

To draw the circle and the cross, we will use a CustomPainter. In this blog post, you can find more information about CirclePainter and CrossPainter.

Adding Animations

To start with the animation, we can use the useAnimation hook, to create an AnimationController. We want to start the animation when the value changes to a circle or cross. To do this, we can use another hook provided by Flutter Hooks, namely useValueChanged. Here we watch the changes, and when the value is not empty we start the animation. When the value becomes empty we reset the animations again. This should only happen after we finished a game and we want to play again.

For the animation, we will be using an AnimatedBuilder. The AnimatedBuilder expects a controller. In the builder, we can then access the value of the controller we defined earlier. Since we passed extra parameters, the forward call on the controller moves the value from zero to a hundred during the duration we defined. We pass this value to the CirclePainter and CrossPainter, to draw only parts of the circle and cross till the percentage is a hundred.

class TileWidget extends HookWidget {
  const TileWidget({Key key}) : super(key: key);
  final Duration duration = const Duration(milliseconds: 1000);

  @override
  Widget build(BuildContext context) {
    final tileEntry = useProvider(_currentTile);
    final _controller = useAnimationController(
        duration: duration, lowerBound: 0, upperBound: 100, initialValue: 0);
    useValueChanged(tileEntry.value, (_, __) {
      if (tileEntry.value == PlayerType.EMPTY) {
        _controller.reset();
      }
      if (tileEntry.value != PlayerType.EMPTY) {
        _controller.forward();
      }
    });

    switch (tileEntry.value) {
      case PlayerType.CROSS:
        return crossWidget(_controller);
      case PlayerType.CIRCLE:
        return circleWidget(_controller);
      case PlayerType.EMPTY:
        return emptyWidget(context, tileEntry.key);
    }
    throw Exception("PlayerType ${tileEntry.value} not supported");
  }

  Widget emptyWidget(BuildContext context, Tile tile) {
    return GestureDetector(
        onTap: () => {context.read(_gameState).toggle(tile)},
        child: Container(
          color: Colors.green[600],
        ));
  }

  Widget crossWidget(AnimationController _controller) {
    return AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(
            color: Colors.green[600],
            child: CustomPaint(
              painter: CrossPainter(_controller.value),
            ),
          );
        });
  }

  Widget circleWidget(AnimationController _controller) {
    return AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(
            color: Colors.green[600],
            child: CustomPaint(
              painter: CirclePainter(_controller.value),
            ),
          );
        });
  }
}

Game Finished Dialog

We can now change the tiles to crosses and circles. However, when one of the players has three in a row, nothing happens. Let’s change that! For this we are going to adjust the GameStateNotifier. When someone toggles, we will determine whether the game has finished or not and update the progress of the game based on the new condition

  toggle(Tile tile) {
    state.tiles[tile] = state.currentPlayer;
    state = state.copyWith(
      currentPlayer: _nextPlayer(),
      progress: _determineProgress(),
      tiles: state.tiles.map((key, value) => MapEntry(key, value)),
    );
  }

  Progress _determineProgress() {
    var finished = isFinished();
    if (finished == null) {
      return state.progress;
    }
    return Progress.finished(finished);
  }

  PlayerType _nextPlayer() {
    if (state.currentPlayer == PlayerType.CIRCLE) {
      return PlayerType.CROSS;
    }
    return PlayerType.CIRCLE;
  }

  FinishedState isFinished() {
    if (_hasThreeInARow(PlayerType.CIRCLE)) {
      return FinishedState.CIRCLE;
    }
    if (_hasThreeInARow(PlayerType.CROSS)) {
      return FinishedState.CROSS;
    }
    if (state.tiles.entries
            .where((element) => element.value == PlayerType.EMPTY)
            .toList()
            .length ==
        0) {
      return FinishedState.DRAW;
    }
    return null;
  }

We can now create a provider that listens to the current state of the progress variable. Just like the animations, we can use the useValueChanged to check whether the game has finished. We only want to trigger a dialog when the game has finished, so we use the maybeWhen function provided by Flutter Freezed. When the game has finished, we can also access the winner of the Finished object since we know it is in the Finished state. We are going to show a dialog, but only after a small delay, since we want to finish the animation of the last click.

final progress = Provider((ref) => ref.watch(_gameState.state).progress);
final _currentTile = ScopedProvider>(null);

class Tiles extends HookWidget {
  @override
  Widget build(BuildContext context) {
    var currentTiles = useProvider(_tiles);
    var _winner = useProvider(progress);
    useValueChanged(_winner, (_, __) {
      _winner.maybeWhen(
          finished: (winner) => triggerDialog(context, winner), orElse: null);
    });
    return Container(
      child: GridView.count(
        physics: new NeverScrollableScrollPhysics(),
        padding: EdgeInsets.all(12),
        crossAxisCount: 3,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
        children: currentTiles.entries
            .map((entry) => ProviderScope(
                overrides: [_currentTile.overrideWithValue(entry)],
                child: TileWidget()))
            .toList(),
      ),
    );
  }

  void triggerDialog(BuildContext context, FinishedState finishState) {
    Future.delayed(
        const Duration(milliseconds: 500),
        () => showDialog(
            context: context,
            barrierDismissible: false, // user must tap button!
            builder: (BuildContext context) {
              return FinishDialog(finishState);
            }));
  }
}

In the dialog, we want to reset the game to the initial state. For this, we can add a reset function to the GameStateNotifier. There are three things we reset here:

  1. The player, to the circle player
  2. The progress, to in progress
  3. The state of each tile, to empty
  reset() {
    state = state.copyWith(
        currentPlayer: PlayerType.CIRCLE,
        progress: Progress.inProgress(),
        tiles:
            state.tiles.map((key, value) => MapEntry(key, PlayerType.EMPTY)));
  }

Finally, we can create a simple alert dialog. Based on the winner, we alternate the title and subtitle. On the button press, we can access the GameStateNotifier to reset the game. Furthermore, we close the dialog.

FinishDialog extends StatelessWidget {
  final FinishedState _winner;

  FinishDialog(this._winner);

  String subtitle() {
    if (_winner == FinishedState.CROSS) {
      return "Cross won!";
    }
    if (_winner == FinishedState.CIRCLE) {
      return "Circle won!";
    }
    return "Nobody lost!";
  }

  String title() {
    if (_winner == FinishedState.DRAW) {
      return "We have no loser!";
    }
    return "We have a winner!";
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text(title()),
      content: SingleChildScrollView(
        child: ListBody(
          children: [
            Text(subtitle()),
          ],
        ),
      ),
      actions: [
        TextButton(
          child: Text('Play Again'),
          onPressed: () {
            context.read(_gameState).reset();
            Navigator.of(context).pop();
          },
        ),
      ],
    );
  }
}
Result of our implementation

Conclusion

After writing about tic tac toe with animations, we struggled with the state management. With this approach, all design approach is where it belongs and all the business logic remains in the same place. The Widgets remain in control of the animations so that the business logic can remain simple! The full code can be found here on Github. If you still have any questions, remarks, or suggestions, do not hesitate to leave a comment or send a message!

Leave a Reply