1
votes

I have a list of stateful widgets where the user can add, remove, and interact with items in the list. Removing items from the list causes subsequent items in the list to rebuild as they shift to fill the deleted row. This results in a loss of state data for these widgets - though they should remain unaltered other than their location on the screen. I want to be able to maintain state for the remaining items in the list even as their position changes.

Below is a simplified version of my app which consists primarily of a list of StatefulWidgets. The user can add items to the list ("tasks" in my app) via the floating action button or remove them by swiping. Any item in the list can be highlighted by tapping the item, which changes the state of the background color of the item. If multiple items are highlighted in the list, and an item (other than the last item in the list) is removed, the items that shift to replace the removed item lose their state data (i.e. the background color resets to transparent). I suspect this is because _taskList rebuilds since I call setState() to update the display after a task is removed. I want to know if there is a clean way to maintain state data for the remaining tasks after a task is removed from _taskList.

void main() => runApp(new TimeTrackApp());

class TimeTrackApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Time Tracker',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new TimeTrackHome(title: 'Task List'),
    );
  }
}

class TimeTrackHome extends StatefulWidget {
  TimeTrackHome({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _TimeTrackHomeState createState() => new _TimeTrackHomeState();
}

class _TimeTrackHomeState extends State<TimeTrackHome> {
  TextEditingController _textController;
  List<TaskItem> _taskList = new List<TaskItem>();

  void _addTaskDialog() async {
    _textController = TextEditingController();

    await showDialog(
        context: context,
        builder: (_) => new AlertDialog(
              title: new Text("Add A New Task"),
              content: new TextField(
                controller: _textController,
                decoration: InputDecoration(
                    border: InputBorder.none, hintText: 'Enter the task name'),
              ),
              actions: <Widget>[
                new FlatButton(
                    onPressed: () => Navigator.pop(context),
                    child: const Text("CANCEL")),
                new FlatButton(
                    onPressed: (() {
                      Navigator.pop(context);
                      _addTask(_textController.text);
                    }),
                    child: const Text("ADD"))
              ],
            ));
  }

  void _addTask(String title) {
    setState(() {
      // add the new task
      _taskList.add(TaskItem(
        name: title,
      ));
    });
  }

  @override
  void initState() {
    _taskList = List<TaskItem>();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Align(
        alignment: Alignment.topCenter,
        child: ListView.builder(
            padding: EdgeInsets.all(0.0),
            itemExtent: 60.0,
            itemCount: _taskList.length,
            itemBuilder: (BuildContext context, int index) {
              if (index < _taskList.length) {
                return Dismissible(
                  key: ObjectKey(_taskList[index]),
                  onDismissed: (direction) {
                    if(this.mounted) {
                      setState(() {
                        _taskList.removeAt(index);
                      });
                    }
                  },
                  child: _taskList[index],
                );
              }
            }),
      ),
      floatingActionButton: new FloatingActionButton(
        onPressed: _addTaskDialog,
        tooltip: 'Click to add a new task',
        child: new Icon(Icons.add),
      ),
    );
  }
}

class TaskItem extends StatefulWidget {
  final String name;

  TaskItem({Key key, this.name}) : super(key: key);
  TaskItem.from(TaskItem other) : name = other.name;

  @override
  State<StatefulWidget> createState() => new _TaskState();
}

class _TaskState extends State<TaskItem> {
  static final _taskFont =
  const TextStyle(fontSize: 26.0, fontWeight: FontWeight.bold);
  Color _color = Colors.transparent;

  void _highlightTask() {
    setState(() {
      if(_color == Colors.transparent) {
        _color = Colors.greenAccent;
      }
      else {
        _color = Colors.transparent;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: <Widget>[
      Material(
        color: _color,
        child: ListTile(
          title: Text(
            widget.name,
            style: _taskFont,
            textAlign: TextAlign.center,
          ),
          onTap: () {
            _highlightTask();
          },
        ),
      ),
      Divider(
        height: 0.0,
      ),
    ]);
  }
}
1

1 Answers

2
votes

I ended up solving the problem by creating an intermediate class which contains a reference to the StatefulWidget and transferred over all the state variables. The State class accesses the state variables through a reference to the intermediate class. The higher level widget that contained and managed a List of the StatefulWidget now access the StatefulWidget through this intermediate class. I'm not entirely confident in the "correctness" of my solution as I haven't found any other examples of this, so I am still open to suggestions.

My intermediate class is as follows:

class TaskItemData {
  // StatefulWidget reference
  TaskItem widget;
  Color _color = Colors.transparent;

  TaskItemData({String name: "",}) {
    _color = Colors.transparent;
    widget = TaskItem(name: name, stateData: this,);
  }
}

My StatefulWidget and its corresponding State classes are nearly unchanged, except that the state variables no longer reside in the State class. I also added a reference to the intermediate class inside my StatefulWidget which gets initialized in the constructor. Previous uses of state variables in my State class now get accessed through the reference to the intermediate class. The modified StatefulWidget and State classes is as follows:

class TaskItem extends StatefulWidget {
  final String name;
  // intermediate class reference
  final TaskItemData stateData;

  TaskItem({Key key, this.name, this.stateData}) : super(key: key);

  @override
  State<StatefulWidget> createState() => new _TaskItemState();
}

class _TaskItemState extends State<TaskItem> {
  static final _taskFont =
  const TextStyle(fontSize: 26.0, fontWeight: FontWeight.bold);

  void _highlightTask() {
    setState(() {
      if(widget.stateData._color == Colors.transparent) {
        widget.stateData._color = Colors.greenAccent;
      }
      else {
        widget.stateData._color = Colors.transparent;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: <Widget>[
      Material(
        color: widget.stateData._color,
        child: ListTile(
          title: Text(
            widget.name,
            style: _taskFont,
            textAlign: TextAlign.center,
          ),
          onTap: () {
            _highlightTask();
          },
        ),
      ),
      Divider(
        height: 0.0,
      ),
    ]);
  }
}

The widget containing the List of TaskItem objects has been replaced with a List of TaskItemData. The ListViewBuilder child now accesses the TaskItem widget through the intermediate class (i.e. child: _taskList[index], has changed to child: _taskList[index].widget,). It is as follows:

class _TimeTrackHomeState extends State<TimeTrackHome> {
  TextEditingController _textController;
  List<TaskItemData> _taskList = new List<TaskItemData>();

  void _addTaskDialog() async {
    _textController = TextEditingController();

    await showDialog(
        context: context,
        builder: (_) => new AlertDialog(
          title: new Text("Add A New Task"),
          content: new TextField(
            controller: _textController,
            decoration: InputDecoration(
                border: InputBorder.none, hintText: 'Enter the task name'),
          ),
          actions: <Widget>[
            new FlatButton(
                onPressed: () => Navigator.pop(context),
                child: const Text("CANCEL")),
            new FlatButton(
                onPressed: (() {
                  Navigator.pop(context);
                  _addTask(_textController.text);
                }),
                child: const Text("ADD"))
          ],
        ));
  }

  void _addTask(String title) {
    setState(() {
      // add the new task
      _taskList.add(TaskItemData(
        name: title,
      ));
    });
  }

  @override
  void initState() {
    _taskList = List<TaskItemData>();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Align(
        alignment: Alignment.topCenter,
        child: ListView.builder(
            padding: EdgeInsets.all(0.0),
            itemExtent: 60.0,
            itemCount: _taskList.length,
            itemBuilder: (BuildContext context, int index) {
              if (index < _taskList.length) {
                return Dismissible(
                  key: ObjectKey(_taskList[index]),
                  onDismissed: (direction) {
                    if(this.mounted) {
                      setState(() {
                        _taskList.removeAt(index);
                      });
                    }
                  },
                  child: _taskList[index].widget,
                );
              }
            }),
      ),
      floatingActionButton: new FloatingActionButton(
        onPressed: _addTaskDialog,
        tooltip: 'Click to add a new task',
        child: new Icon(Icons.add),
      ),
    );
  }
}