Search code examples
flutterdarttimergesturedetector

Timer.periodic() callback interferes with GestureDetector


Edit

Essentially, I just want to know if there is a way that two widgets can run parallelly in Flutter. In my case, I want a Timer widget and the Game widget running together without interfering with each other's execution.

I have tried using compute and isolates but apparently, an isolate can't be used to change widget state.

I am developing a memory game and I wanted to include a timer on the top of the card deck. Below is my code for the same.

The problem is, that when the timer starts,

@override
  void initState() {
    previousClick = _CardTileState();
    super.initState();
    st.start();
    timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
      int ms = st.elapsedMilliseconds;
      int sec = (ms ~/ 1000) % 60;
      int min = (ms ~/ 1000) ~/ 60;
      if (min == 2) {
        st.stop();
        st.reset();
        timer.cancel();
      } else {
        setState(() {
          mins = strFormat(min);
          secs = strFormat(sec);
        });
      }
    });
  }

I can no longer tap my cards to match them. The onTap() callback of GestureDetector doesn't get executed.

@override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        if (widget.isClickable) {
          print('clicked ' + widget.index.toString());
          widget.parent.handleClick(this);
        }
      },

How do I resolve this? Here is the complete code :

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:save_the_planet/data.dart';

class GameScreen extends StatefulWidget {
  GameScreen({Key key, this.title}) : super(key: key);
  final String title;
  @override
  _GameScreenState createState() => _GameScreenState();
}

class _GameScreenState extends State<GameScreen> {
  List<CardTile> cards = getCards();
  _CardTileState previousClick;
  String click = "";
  int score = 0;
  String mins = "00";
  String secs = "00";
  Stopwatch st = Stopwatch();
  Timer timer;

  @override
  void initState() {
    previousClick = _CardTileState();
    super.initState();
    st.start();
    timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
      int ms = st.elapsedMilliseconds;
      int sec = (ms ~/ 1000) % 60;
      int min = (ms ~/ 1000) ~/ 60;
      if (min == 2) {
        st.stop();
        st.reset();
        timer.cancel();
      } else {
        setState(() {
          mins = strFormat(min);
          secs = strFormat(sec);
        });
      }
    });
  }

  String strFormat(int val) {
    String res = "";
    if (val < 10)
      res = "0" + val.toString();
    else
      res = val.toString();
    return res;
  }

  void handleClick(_CardTileState source) {
    try {
      if (previousClick.widget.getIsUncovered()) {
        if (click == 'first click') {
          print('second click');
          click = 'second click';
          source.setState(() {
            source.widget.setIsUncovered(true);
            source.widget.setIsClickable(false);
          });
          Timer(Duration(milliseconds: 1000), () {
            if (source.widget.getImagePath() ==
                previousClick.widget.getImagePath()) {
              score += 100;
              print(score);
              if (score == 1000) {
                print('game over');
              }
              previousClick = _CardTileState();
            } else {
              source.setState(() {
                source.widget.setIsUncovered(false);
                source.widget.setIsClickable(true);
              });
              previousClick.setState(() {
                previousClick.widget.setIsUncovered(false);
                previousClick.widget.setIsClickable(true);
              });
              previousClick = _CardTileState();
            }
          });
        }
      }
    } catch (e) {
      print('first click');
      click = 'first click';
      source.setState(() {
        source.widget.setIsUncovered(true);
        source.widget.setIsClickable(false);
      });
      previousClick = source;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        height: MediaQuery.of(context).size.height,
        color: Colors.black,
        child: Column(
          children: <Widget>[
            Padding(padding: EdgeInsets.only(top: 25)),
            SizedBox(
              width: MediaQuery.of(context).size.width / 3,
              child: Card(
                margin: EdgeInsets.all(5),
                color: Colors.white54,
                child: Row(
                  children: <Widget>[
                    Icon(Icons.access_time),
                    Text(mins + ":" + secs),
                  ],
                ),
              ),
            ),
            GridView(
              gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                  maxCrossAxisExtent: 100.0, mainAxisSpacing: 0.0),
              shrinkWrap: true,
              scrollDirection: Axis.vertical,
              children: List.generate(
                cards.length,
                (index) {
                  cards[index].setIsUncovered(true);
                  cards[index].setIsClickable(false);
                  cards[index].setIndex(index);
                  cards[index].setParent(this);
                  return cards[index];
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ignore: must_be_immutable
class CardTile extends StatefulWidget {
  String imagePath;
  bool isClickable;
  bool isUncovered;
  int index;
  _GameScreenState parent;

  CardTile(
      {Key key,
      this.imagePath,
      this.isClickable,
      this.isUncovered,
      this.index,
      this.parent})
      : super(key: key);

  void setImagePath(String path) {
    this.imagePath = path;
  }

  void setIsClickable(bool val) {
    this.isClickable = val;
  }

  void setIsUncovered(bool val) {
    this.isUncovered = val;
  }

  void setIndex(int val) {
    this.index = val;
  }

  void setParent(_GameScreenState val) {
    this.parent = val;
  }

  String getImagePath() {
    return this.imagePath;
  }

  bool getIsClickable() {
    return this.isClickable;
  }

  bool getIsUncovered() {
    return this.isUncovered;
  }

  int getIndex() {
    return this.index;
  }

  _GameScreenState getParent() {
    return this.parent;
  }

  @override
  _CardTileState createState() => _CardTileState();
}

class _CardTileState extends State<CardTile> {
  @override
  void initState() {
    super.initState();
    Timer(
      Duration(seconds: 5),
      () {
        setState(() {
          widget.setIsUncovered(false);
          widget.setIsClickable(true);
        });
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        if (widget.isClickable) {
          print('clicked ' + widget.index.toString());
          widget.parent.handleClick(this);
        }
      },
      child: Card(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(10),
        ),
        child: Container(
          margin: EdgeInsets.all(5),
          child: Container(
            child: Image.asset(
                widget.isUncovered ? widget.imagePath : 'assets/default1.png'),
          ),
        ),
      ),
    );
  }
}

Solution

  • The problem is in your timer you setState each second, and you're generating the list in the build method with the attributes clickable and uncovered to false and true respectively, when you setState this list will rebuild each second and even if you changed their values to true in ther initState a second later they will change back to false and you will be stuck with uncovered cards without tapping

    timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
      int ms = st.elapsedMilliseconds;
      int sec = (ms ~/ 1000) % 60;
      int min = (ms ~/ 1000) ~/ 60;
      if (min == 2) {
        st.stop();
        st.reset();
        timer.cancel();
      } else {
        setState(() { //this setState
          mins = strFormat(min);
          secs = strFormat(sec);
        });
      }
    });
    

    just generate the List in initState and pass it to the children argument of GridView

    List<Widget> listOfCards; //Create a variable to save your Widget List
    
    @override
      void initState() {
        previousClick = _CardTileState();
        listOfCards = List<Widget>.generate(cards.length, //Generate it here in initState
          (index) {
             //Each time setState ran this code generated the List with this attributes to true and false
             // Now in initState you create this list once, and even with the setState this list will not be generated again
             cards[index].setIsUncovered(true);
                  cards[index].setIsClickable(false);
                  cards[index].setIndex(index);
                  cards[index].setParent(this);
                  return cards[index];
          },
       );
        super.initState();
        st.start();
        timer = Timer.periodic(Duration(seconds: 1), (Timer t) {
          int ms = st.elapsedMilliseconds;
          int sec = (ms ~/ 1000) % 60;
          int min = (ms ~/ 1000) ~/ 60;
          if (min == 2) {
            st.stop();
            st.reset();
            timer.cancel();
          } else {
            setState(() {
              mins = strFormat(min);
              secs = strFormat(sec);
            });
          }
        });
      }
    

    And then in GridView just pass the variable listOfCards

      GridView(
          gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
            maxCrossAxisExtent: 100.0, mainAxisSpacing: 0.0),
          shrinkWrap: true,
          scrollDirection: Axis.vertical,
          children: listOfCards
      ),
    

    After testing if the code works as you wanted I can only tell you to try and change the whole logic with some state management solution (Bloc, get_it, Provider, etc), this code will be a mess the more you try to mantain it if you don't split the logic somewhere else. Also:

    ignore: must_be_immutable

    This should be a red flag that something is off because reading the StatefulWidget documentation you will see that

    StatefulWidget instances themselves are immutable and store their mutable state either in separate State objects that are created by the createState method

    Not following these guidelines could lead to weird behaviors later.

    I know that at first you just want it to work and then you will polish it, just saying it as a tip to encourage you to try different approaches and see what is the best for you