Flutter Drag and Drop – State Management

We have showed how to implement a simple drag and drop application in Flutter. However the state management in the blog post was very simplistic and probably only useful for the most basic use cases. Of course, you can still learn about drag and drop and integrate it with your own state management solution. In this blog post, we will show how to integrate the drag and drop with the Riverpod state management solution. However, there are more excellent state management solutions with which you can do exactly the same.

Setup the project

Before we can start with coding, we are going to add some dependencies to the project. First, we will need Flutter Hooks and Hooks Riverpod for the state management. Then, for the hash method, we are going to use the quiver package.

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.17.0
  hooks_riverpod: ^0.14.0+4
  quiver: ^3.0.1

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

flutter pub get

That is it! We can now start with rewriting the example

Create the models

In this example, we will use two different checkers. Black and white checkers. We assume this might be used for a turn-based game where one player may move the white checkers and another player the black checkers.

enum PlayerType {
  BLACK,
  WHITE,
}

We will also need a coordinate. The coordinate references a place on the grid. Each square on the grid has a position. The top left corner is 0,0 and the bottom right corner will be 8,8. We are going to override the equals operator so that we can compare the coordinates without having to compare the x and y of the coordinate. This means we also have to override the hashCode. Here we will use the hash2 method provided by the quiver package to create a hash from the x and y values.

import 'package:quiver/core.dart';

class Coordinate {
  final int x;
  final int y;

  Coordinate(this.x, this.y);

  @override
  bool operator ==(Object o) => o is Coordinate && o.y == y && o.x == x;

  @override
  int get hashCode => hash2(x, y);
}

Each square will have a coordinate. This is just a wrapper class, for when we might need to retain more information about squares.

import 'package:drag_drop_state_management/app/model/coordinate.dart';

class Square {
  final Coordinate coordinate;

  Square(this.coordinate);
}

Finally, the checker. Each checker has an id, so that we can easily compare checkers. Furthermore, we have a player type, to define whether it is a black or white checker. The checkers have a coordinate, that references on which square it is placed. The dragging boolean defines whether the checkers is currently dragged. We will need this for removing the display of the checker on the current square.

class Checker {
  final int id;
  final PlayerType type;
  final bool isDragging;
  final Coordinate coordinate;

  Checker(this.id, this.type, this.coordinate, {this.isDragging = false});

  Checker copyWith({bool? isDragging, Coordinate? coordinate}) {
    print('hello');
    return Checker(this.id, type, coordinate ?? this.coordinate,
        isDragging: isDragging ?? this.isDragging);
  }

  @override
  bool operator ==(Object o) => o is Checker && o.id == id;

  @override
  int get hashCode => id;
}

Setup the ViewModel

We are going to create a simple provider for the current player. For this, we use a simple provider and state provider. You can find more details in this blog post with a Riverpod State Management Example. The state provider will manage the current player. Here black is the starting player. The provider makes this value accessible in the Widgets.

final _currentPlayer = StateProvider((ref) => PlayerType.BLACK);
final currentPlayer = Provider((ref) => ref.watch(_currentPlayer).state);

Here things get a little bit more interesting! We are going to create another provider which will keep track of the information about the checkers. We are going to provide some methods to help the Widgets change the state of the current checkers. The first method will start the dragging of a checker. That means we have to reset the state and adjust the dragging variable of the checker. Then, we can apply the same trick to cancel the drag. Finally, the finishing drag method. We will call this method from the square with the new position. We adjust the coordinate of that checker. Furthermore, we use the watch function we supplied to the provider to set the current player’s new state.

final checkers = StateNotifierProvider<CheckerNotifier, List<Checker>>(
    (ref) => CheckerNotifier(ref.read));

class CheckerNotifier extends StateNotifier<List<Checker>> {
  Reader read;
  CheckerNotifier(this.read)
      : super([
          Checker(1, PlayerType.BLACK, new Coordinate(0, 0)),
          Checker(2, PlayerType.WHITE, new Coordinate(0, 1))
        ]);

  void add(Checker checker) {
    state = [...state, checker];
  }

  void startDrag(Checker selectedChecker) {
    state = [
      for (final checker in state)
        if (checker == selectedChecker)
          checker.copyWith(isDragging: true)
        else
          checker,
    ];
  }

  void cancelDrag(Checker selectedChecker) {
    state = [
      for (final checker in state)
        if (checker == selectedChecker)
          checker.copyWith(isDragging: false)
        else
          checker,
    ];
  }

  void finishDrag(Checker selectedChecker, Coordinate coordinate) {
    state = [
      for (final checker in state)
        if (checker == selectedChecker)
          checker.copyWith(isDragging: false, coordinate: coordinate)
        else
          checker
    ];
    read(_currentPlayer.notifier).state =
        read(_currentPlayer.notifier).state == PlayerType.BLACK
            ? PlayerType.WHITE
            : PlayerType.BLACK;
  }
}

Displaying the data

Before we start with the Widgets, we should wrap the main app with the provider scope. This makes sure we can use the providers in our Widgets.

void main() => runApp(ProviderScope(child: DragAndDropExample()));

class DragAndDropExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.green,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: Text("Drag and Drop Example"),
        ),
        body: BoardPage(),
      ),
    );
  }
}

Just like in the previous drag and drop blog post, we will create a grid with the GridView. This will create an eight by eight grid of square widgets. On this grid we will draw the DragTargets as SquareWidgets. The SquareWidgets will contain the checkers which we can start dragging around.

class BoardPage extends StatelessWidget {
  final List<Square> squares = SquareInitializer.squares();
  @override
  Widget build(BuildContext context) {
    return GridView.count(
      physics: new NeverScrollableScrollPhysics(),
      padding: const EdgeInsets.all(4),
      crossAxisCount: 8,
      children: squares.map((square) => SquareWidget(square)).toList(),
    );
  }
}

class SquareInitializer {
  static List<Square> squares() {
    List<Square> squares = [];
    for (var y = 0; y < 8; y++) {
      for (var x = 0; x < 8; x++) {
        squares.add(Square(Coordinate(x, y)));
      }
    }
    return squares;
  }
}

Implementing the SquareWidget

Before we start with the square Widget, let’s take a small look at how we can access the current checkers. We can use the useProvider method from Riverpod Hooks to watch the state of the checkers. Using this method makes sure that our Widget does update when the state is changed. We can access the state to check if any checker matches our current position. Caution: the firstWhereOrNull method is provided by the collection package, which you will have to import.

List<Checker> checking = useProvider(checkers);
Checker? checkerOnSquare = checking
    .firstWhereOrNull((element) => element.coordinate == square.coordinate);

Another piece of code we will use in the Widget is the context.read method. Here we can access the functions we provided in our StateNotifier. We have to read the notifier to access the methods provided.

context.read(checkers.notifier).finishDrag(data, square.coordinate);

To use those methods, we are going to create a HookWidget. In the HookWidget, we can use the hook method to access the state. Then, we use another hook to change the square when a checker is above the square. For this, we can use the useState hook to determine whether it is possible to drop the checker there. If it is possible, we are going to change the color of the square. The most important part here is the child of the Container. This is the CheckerWidget if the checker exists and is not currently being dragged.

class SquareWidget extends HookWidget {
  final Square square;

  SquareWidget(this.square);

  Color getColor(bool willAccept) {
    if (willAccept) {
      return Colors.brown[300]!;
    }

    if (square.coordinate.x % 2 == square.coordinate.y % 2) {
      return Colors.brown[500]!;
    }
    return Colors.brown[100]!;
  }

  @override
  Widget build(BuildContext context) {
    final willAccept = useState(false);
    List<Checker> checking = useProvider(checkers);
    Checker? checkerOnSquare = checking
        .firstWhereOrNull((element) => element.coordinate == square.coordinate);
    return DragTarget(
      builder: (BuildContext context, List<dynamic> candidateData, List<dynamic> rejectedData) {
        return Container(
            child: checkerOnSquare != null && !checkerOnSquare.isDragging
                ? CheckerWidget(checkerOnSquare)
                : Container(),
            color: getColor(willAccept.value));
      },
      onWillAccept: (data) {
        willAccept.value = true;
        return true;
      },
      onLeave: (data) {
        willAccept.value = false;
      },
      onAccept: (Checker data) {
        willAccept.value = false;
        context.read(checkers.notifier).finishDrag(data, square.coordinate);
      },
    );
  }
}

Implementing the CheckerWidget

We only have one job left. To implement the CheckerWidget. Here we display the checker. We access the current player and only allow the checker to move if it matches the current player. The type of checker determines the color and the context.read we can access the state provider to cancel the movement.

class CheckerWidget extends HookWidget {
  final Checker checker;

  int allowMove(PlayerType playerType) {
    if (playerType == checker.type) {
      return 1;
    }
    return 0;
  }

  getColor() {
    if (PlayerType.WHITE == checker.type) {
      return Colors.white;
    }
    return Colors.black;
  }

  const CheckerWidget(this.checker);

  @override
  Widget build(BuildContext context) {
    final current = useProvider(currentPlayer);

    return Draggable(
      data: checker,
      maxSimultaneousDrags: allowMove(current),
      onDragStarted: () {
        context.read(checkers.notifier).startDrag(checker);
      },
      onDraggableCanceled: (a, b) {
        context.read(checkers.notifier).cancelDrag(checker);
      },
      feedback: Container(
        child: Icon(
          Icons.circle,
          color: getColor(),
          size: 35,
        ),
      ),
      child: Container(
        child: Icon(
          Icons.circle,
          color: getColor(),
          size: 35,
        ),
      ),
    );
  }
}

Now, when we run the application we can see the checkerboard. We can also drag around the checkers, but only the checker of the current player.

Drag and drop in Flutter with state management

Questions

The last blog post about drag and drop, had some questions. Those questions can be easily solved with the new state management in place.

If I want to swap the checkers, how do I do that?

I assume that the swap will happen when a checker is placed on a square with another checker. We would have to adjust the SquareWidget. Currently, the onWillAccept only returns true if there is no checker placed there. We can adjust that to:

onWillAccept: (data) {
   willAccept.value = true;
   return true;
},

Now we only have to change to finishDrag method. We can look if there is a checker on the destination. If there is a checker on the destination, we can replace the coordinates of that checker with that of the selected checker. This is all we need to do, to swap two checkers.

  void finishDrag(Checker selectedChecker, Coordinate coordinate) {
    Checker? checkerOnDestination =
        state.firstWhereOrNull((checker) => checker.coordinate == coordinate);
    Coordinate origin = selectedChecker.coordinate;
    state = [
      for (final checker in state)
        if (checker == selectedChecker)
          checker.copyWith(isDragging: false, coordinate: coordinate)
        else if (checker == checkerOnDestination)
          checker.copyWith(coordinate: origin)
        else
          checker
    ];
    read(_currentPlayer.notifier).state =
        read(_currentPlayer.notifier).state == PlayerType.BLACK
            ? PlayerType.WHITE
            : PlayerType.BLACK;
  }
If I want to count every movement(when a checker is dropped in the table)?

We are going to add another StateProvider, just like the provider for the current player.

final count = StateProvider((ref) => 0);

This provider can be adjust in the finishDrag method. We can increase the count by one each time a drag is finished. This way we counted every movement.

  void finishDrag(Checker selectedChecker, Coordinate coordinate) {
    Checker? checkerOnDestination =
        state.firstWhereOrNull((checker) => checker.coordinate == coordinate);
    Coordinate origin = selectedChecker.coordinate;
    state = [
      for (final checker in state)
        if (checker == selectedChecker)
          checker.copyWith(isDragging: false, coordinate: coordinate)
        else if (checker == checkerOnDestination)
          checker.copyWith(coordinate: origin)
        else
          checker
    ];
    read(count.notifier).state = read(count.notifier).state + 1;
    read(_currentPlayer.notifier).state =
        read(_currentPlayer.notifier).state == PlayerType.BLACK
            ? PlayerType.WHITE
            : PlayerType.BLACK;
  }
My game will allow the value of a checker to be added with another(in the same line). How could I sum an Element of the square with another using your approach?

Since I am not totally sure what is meant here, I will provide a general approach. I assume each checker has a value. This means we have to adjust the model of the checker. If we want to be able to adjust the value of the checker we should also make the value an input variable for the copyWith method. This value can be accessed on the finish drag method or even from a button. We can also find the other checkers in the column or row by selecting the values from the state:

List<Checker> checkers = state.where((element) => element.coordinate.y == coordinate.y).toList();

The code of this blog post can be found here on Github. If you are looking for the code provided in the answers to the questions, you should look at the questions branch. Thank you for reading. If you are looking for a more detailed description of the drag and drop, I would suggest reading the original blog post. If you want to know more about testing with Riverpod, you might be interested in reading this blog post. Thank you for reading, and if you have any questions, do not be afraid to ask them.

Leave a Reply