Tic-Tac-Toe - Intro to Flutter - Part 2

Aleksandr Riabov
Jun 3, 2023
Welcome back!
In this part we will go over creating actual Tic-Tac-Toe app.
This is our outline:
- How to Run a Flutter App
- Tic-Tac-Toe building
How to Run a Flutter App:
Step 1: Clone the Repository
- Open your terminal. (On Mac, press Command+Space, then search for Terminal)
- Change the directory to your desired location. For example, if you want to clone the repository on your desktop, run the following command:
cd Desktop
Feel free to choose another folder if you prefer.
- Clone the repository from GitHub using the following command:
git clone https://github.com/Alex-RV/Flutter-Workshop.git
Step 2: Navigate to the Project Folder
- Change the directory to the project folder:
cd Flutter-Workshop
- Open the project in Visual Studio Code by running the following command in the terminal:
code .
Step 3: Set Up the Simulator
Before running the Flutter app, we need to set up the simulator and the environment for flutter. For MacOS use: home brew to install Flutter:
brew install flutter
Next install Xcode and with Xcode you will get an a Simulator. You can download Xcode in Apple Store. After this use Command+Space to open Spotlight Search and type "Simulator" and open it from the search results.
Step 4: Run the Flutter App
Now that we have everything set up, let's run our Flutter app!
- In Visual Studio Code, open the terminal by pressing Control+` (backtick).
- Run the following command to get the required dependencies:
flutter pub get
- Run the app using the following command:
flutter run
This command will compile the app and launch it in the iOS Simulator.
- You will see the Flutter app running on the Simulator, ready for you to interact with!
For more detailed information and troubleshooting, refer to the project's documentation.
Tic-Tac-Toe building:
Step 1:
To build the first app, open the file named "main" in the "lib" folder of your Flutter project.
import 'package:flutter/material.dart';
void main() => runApp(TicTacToe());
class TicTacToe extends StatelessWidget {
const TicTacToe({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tic Tac Toe',
home: Text("Hello World"),
);
}
}
Try running this code. You will see that it creates a simple app with the text "Hello World" displayed.
Step 2:
Now, let's make a change and create a separate class for the game screen.
import 'package:flutter/material.dart';
void main() => runApp(TicTacToe());
class TicTacToe extends StatelessWidget {
const TicTacToe({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Tic Tac Toe',
home: GameScreen(),
);
}
}
class GameScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text("Hello world");
}
}
In this updated code, we have a new class called GameScreen that represents the game screen of the app. When you run the code, you will see the text "Hello world" displayed on the screen.
Step 3:
Now, let's start building the Tic-Tac-Toe game. We can enhance the GameScreen widget by adding a background color to the Scaffold using the backgroundColor property. We can specify the color using RGB values or choose from pre-built colors provided by Flutter, such as Colors.blue.
To center the elements vertically and horizontally within the Column, we can use the mainAxisAlignment and crossAxisAlignment properties. In this case, we'll set mainAxisAlignment to MainAxisAlignment.center to center the column vertically.
return Scaffold(
backgroundColor: Color.fromARGB(255, 5, 2, 77),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text("Hello World"),
],
),
);
Step 4:
The next step is to create the GameBoard class, which is a StatelessWidget that includes all the necessary variables for the Tic-Tac-Toe game. It has game, gameOver, and onTap as required parameters.
But before we continue let’s past some necessary functions and variables:
String lastValue = "X";
bool gameOver = false;
int turn = 0; // to check the draw
String result = "";
bool isWinner = false;
Game game = Game();
void onTap(int index) {
if (game.board[index] == "") {
game.board[index] = lastValue;
turn++;
gameOver = game.winnerCheck(lastValue, index);
if (gameOver) {
result = " is the Winner";
isWinner = true;
} else if (!gameOver && turn == 9) {
result = "It's a Draw!";
gameOver = true;
}
if (lastValue == "X") {
lastValue = "O";
} else {
lastValue = "X";
}
}
}
Put it inside of the GameScreen Widget. And after this create a GameBoard Widget with these required variables.
class GameBoard extends StatelessWidget {
const GameBoard({
required this.game,
required this.gameOver,
required this.onTap,
});
final Game game;
final bool gameOver;
final Function(int) onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
);
}
}
But before we continue add GameBoard into the GameScreen by following lines:
GameBoard(
gameOver: gameOver,
onTap: onTap,
game: game,
),
The next step is to define the sizes of the SizedBox.
return SizedBox(
width: boardWidth,
height: boardWidth,
child: Text("Hello World")
);
To determine the boardWidth, we will utilize the MediaQuery feature. MediaQuery is a Flutter class that provides information about the current device's screen size and orientation.
double boardWidth = MediaQuery.of(context).size.shortestSide;
Once we have defined the board width, we can proceed with creating the layout for our future board.
child: GridView.count(
crossAxisCount: 3,
padding: EdgeInsets.all(16.0),
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
children: List.generate(9, (index) {
return Text(
"Hello World",
style: TextStyle(color: Colors.white),
);
})),
- crossAxisCount is a property that defines the maximum number of columns in the grid layout.
- padding is a property that adds spacing to the edges of the grid cells.
- mainAxisSpacing is a property that defines the spacing between cells along the main axis (vertical spacing in this case).
- crossAxisSpacing is a property that defines the spacing between cells along the cross axis (horizontal spacing in this case).
- To generate the Tic-Tac-Toe board, we use List.generate with a count of 9, representing the number of cells in the Tic-Tac-Toe grid.
As you can see, we have got a total of nine texts saying "Hello World." Additionally, you may notice that we are utilizing a new parameter style, namely TextStyle(color: Colors.white), to modify the text color.
Step 5:
After generating the game board, let's move on to getting the necessary variables for each cell on the board.
Add the following lines of code inside the List.generate callback in the children property of the GridView.count:
final boardValue = game.board[index];
final isPlayable = !gameOver && boardValue.isEmpty;
In this two lines we declared 2 variables,
- boardValue is a variable that retrieves the current value from the game class, specifically from the board list, from the game_logic file which we can find in the lib folder near main.dart file.
- isPlayable variable is used to determine if the current cell on the board is free to play. It checks whether the game is not over (!gameOver) and if the boardValue is empty (boardValue.isEmpty). This ensures that the cell can only be played if the game is still ongoing and the cell is not already occupied.
To make each of our elements responsible for touch we are going to use an InkWell widget.
InkWell provides a visual ink splash on the screen when the user interacts with it. It's commonly used to add an interactive effect to widgets such as buttons, list items, or any other interactive elements in the UI.
children: List.generate(9, (index) {
final boardValue = game.board[index];
final isPlayable = !gameOver && boardValue.isEmpty;
return InkWell(
onTap: () => print("Hello $index"),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Color.fromARGB(255, 1, 37, 169),
borderRadius: BorderRadius.circular(16),
),
child: Text("Hello World"),
),
);
}),
As a child of InkWell, we can use the Container widget to set a background color and create space for our future images of Cross and Circle. To ensure the elements have the correct size, we should use the width and height properties. To make the cells visible, we can add element decoration with the BoxDecoration parameters, such as setting the color to blue using Colors.blue or any other color of your choice during customization. Additionally, we can round the cells slightly by using the borderRadius property provided by Flutter.
But first, let's print something when a cell is clicked:
onTap: () => print("Hello $index"),
As you can see, we are printing the current index of the cell. It's important to note that the first index in lists always starts with "0".
Step 6:
Now that we have 9 texts saying "Hello World," let's center them and replace them with our images.
In the child of InkWell, replace the Text widget with the Center widget:
child: Center(),
First, let's think about the logic of how it will work. We need to determine if this cell is empty or not when the board is empty. It's important to note that game.board represents the backend logic of our game, not the frontend. Next, add a child for the Center widget and make it change something if it's empty.
child: Center(
child: boardValue.isEmpty
? Text('')
: Text('Hello World')
),
We can interpret it as follows: if the board cell is empty, do nothing because we haven't changed it yet. As mentioned before, this is just an expression of the backend board. To change the backend board, we will use the onTap method.
onTap: isPlayable ? () => onTap(index) : null,
Here, we are changing the value of the cell when it's tapped, but only if the cell is playable. To verify if we actually changed the cell, let's add a print function inside onTap. Then, run the application!
void onTap(int index) {
if (game.board[index] == "") {
// . . .
print("Changing ${game.board[index]}"); // ADD THIS
}
}
When we tap the cell, we can see an X or O.
Now, let's add the images first and then discuss why the changes are not showing on our screen by adding an extra if statement. If the board value is X, we will have a cross; otherwise, we will have a circle.
child: Center(
child: boardValue.isEmpty
? Text('')
: game.board[index] == "X"
? Image.asset('assets/images/cross.png')
: Image.asset('assets/images/circle.png'),
),
Step 7:
Our next step is to convert the GameScreen class from a StatelessWidget to a StatefulWidget. This change allows us to manage the state of the widget and update it dynamically.
To make this change, follow these steps:
Update the GameScreen class declaration to extend StatefulWidget instead of StatelessWidget:
class GameScreen extends StatefulWidget {
const GameScreen({super.key});
@override
State<GameScreen> createState() => _GameScreenState();
}
Create a new private class _GameScreenState that extends State<GameScreen>. This class will be responsible for managing the state of the GameScreen widget:
class _GameScreenState extends State<GameScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body:
// ... REST OF THE CODE
);
}}
Now we can change the state of the elements, but we still won’t be able to see the changes because our function onTap is not changing the state of elements.
For this add the function setState into the onTap function.
void onTap(int index) {
if (game.board[index] == "") {
setState(() {
// ... REST OF THE CODE
});
}
}
This is a good place to have a break and ask them to think about what we could do next.
Step 8:
After we are done with the board, let's create a display that will show who moves next. For this, let's create a new class called TurnDisplay, where we will have a row displaying "It's <someone>'s turn."
class TurnDisplay extends StatelessWidget {
const TurnDisplay({required this.lastValue});
final String lastValue;
@override
Widget build(BuildContext context) {
return Row();
}
}
In this element, we require the lastValue (representing the next person's move) to display the correct image.
To center the row, we use mainAxisAlignment: MainAxisAlignment.center and crossAxisAlignment: CrossAxisAlignment.center.
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
After centering it, we add the Text() widget as a child of the Row(). To style the Text widget, use the style property and TextStyle. To change the color, use color: Colors.white or your preferred color.
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"It's <someone> turn",
style: TextStyle(color: Colors.white),
),
],
);
Finally, don't forget to add the TurnDisplay class to the _GameScreenState element:
TurnDisplay(lastValue: lastValue,)
Step 9:
Now, let's make it work as it should. First, separate it into 3 widgets: the first one will be responsible for the word "It's", the next one for "who's move next," and finish it with the word "turn."
Text(
"It's ",
style: TextStyle(
color: Colors.white,
fontSize: 58,
),
),
Let's also make the letter bigger by using the fontSize property. After that, add the box that will show the correct image. We can use SizedBox to adjust the size and position the image. The logic is pretty simple: if lastValue == "X", it means that the next move is a cross.
SizedBox(
width: 100,
height: 100,
child: lastValue == "X"
? Image.asset('assets/images/cross.png')
: Image.asset('assets/images/circle.png'),
),
Finally, add another Text widget with "turn".
Text(
" turn",
style: TextStyle(
color: Colors.white,
fontSize: 58,
),
),
Step 10:
And, of course, by the end of the game, we do need to see who won or if it's a draw. For this, let's create a ResultDisplay Widget. Just like we did before with the TurnDisplay, we will use a Row, and to hide the elements that we're not going to show until the end of the game, we'll use the Visibility widget. This means that the widget, which is a child of the Visibility widget, will only appear when a certain condition is met.
class ResultDisplay extends StatelessWidget {
const ResultDisplay({required this.isWinner});
final bool isWinner;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Visibility(
visible: isWinner,
child: Text("Hello world!"),
),
],
);
}
}
Here, we added the isWinner variable as a condition (if isWinner equals true) for showing the Text Widget.
Don't forget to call it in _GameScreenState:
ResultDisplay(isWinner: isWinner),
Step 11:
Next, let's add player winners. To do this, we'll add a SizedBox widget that will display the image of the last player who made a move, similar to what we used in TurnDisplay. However, instead of showing the image for the next move, it will display the image for the last player. Don't forget to add the required variable lastValue.
child: SizedBox(
width: 50,
height: 50,
child: lastValue == "O"
? Image.asset('assets/images/cross.png')
: Image.asset('assets/images/circle.png'),
),
Make sure to include the necessary variables:
required this.lastValue,
final String lastValue;
Now, when we run the app, we will see the image of the player who won if there is a winner.
Now let's look at our onTap function. You can see that we have a variable called result which changes depending on whether we have a winner or a draw. Let's add it to our widget!
For this, we'll use the Text widget. Try to do it yourself first. Here's a hint: place this Text widget as a child of Row.
Text(
result,
style: TextStyle(color: Colors.white, fontSize: 50.0),
),
Don't forget to include the necessary variables:
required this.result,
final String result;
And update the call to this widget:
ResultDisplay(lastValue: lastValue, result: result, isWinner: isWinner),