'Flutter: ListView not rebuilding after parent changes state

By using ListView i came into a problem i don't understand what is going on. I got one ListView with a list of Task and a second with a list of TaskTile (stateless widget with ListTile in build method).

With the first List i map the tasks into TaskTile, the rebuild works as expected but in the second it works only if i scroll the list and not when the state changes.

Here is the code for the two lists and a gif representing the problem:

import 'package:flutter/material.dart';
import 'package:todo/screens/task/task_screen.dart';

void main() {
 runApp(const MyApp());
}

class MyApp extends StatelessWidget {
 const MyApp({Key? key}) : super(key: key);

 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     title: 'Flutter Demo',
     theme: ThemeData(
       primarySwatch: Colors.blue,
     ),
     home: const TaskScreen(),
   );
 } 
}

class Task {
 String title;
 bool completed;

 Task({required this.title, this.completed = false});

 void toggleCompleted() {
   completed = !completed;
 }
}

class TaskScreen extends StatefulWidget {
 const TaskScreen({Key? key}) : super(key: key);

 @override
 State<TaskScreen> createState() => _TaskScreenState();
}

class _TaskScreenState extends State<TaskScreen> {
 List<Task> tasks = [
   Task(title: "task1"),
   Task(title: "task2"),
   Task(title: "task3"),
 ];

 List<TaskTile> taskTiles = [
   TaskTile(task: Task(title: "tile1")),
   TaskTile(task: Task(title: "tile2")),
   TaskTile(task: Task(title: "tile3")),
 ];

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text("Tasks"),
     ),
     body: SafeArea(
       child: Column(
         children: [
           Expanded(
               child: TasksList(
             tasks: tasks,
           )),
           const Divider(
             thickness: 10,
             color: Colors.blue,
           ),
           Expanded(
               child: TaskTilesList(
             tasks: taskTiles,
           )),
           Row(
             mainAxisAlignment: MainAxisAlignment.spaceEvenly,
             children: [
               ElevatedButton(
                   onPressed: () {
                     setState(() {
                       tasks.add(Task(title: "Task"));
                     });
                   },
                   child: const Text("Add Task")),
               ElevatedButton(
                   onPressed: () {
                     setState(() {
                       taskTiles.add(TaskTile(task: Task(title: "Tile")));
                     });
                   },
                   child: const Text("Add Task Tile")),
             ],
           ),
         ],
       ),
     ),
   );
 }
}

class TasksList extends StatelessWidget {
 const TasksList({Key? key, required this.tasks}) : super(key: key);

 final List<Task> tasks;

 @override
 Widget build(BuildContext context) {
   //************* List of Task mapped to TaskTile *******************
   return ListView(children: tasks.map((e) => TaskTile(task: e)).toList());
 }
}

class TaskTilesList extends StatelessWidget {
 const TaskTilesList({Key? key, required this.tasks}) : super(key: key);

 final List<TaskTile> tasks;

 @override
 Widget build(BuildContext context) {
   
   //************ List of TaskTile *************
   return ListView(
     children: tasks,
   );
 }
}

class TaskTile extends StatefulWidget {
 const TaskTile({Key? key, required this.task}) : super(key: key);

 final Task task;

 @override
 State<TaskTile> createState() => _TaskTileState();
}

class _TaskTileState extends State<TaskTile> {
 bool checkBoxValue = false;

 @override
 Widget build(BuildContext context) {
   print("Task: ${widget.task.title}");
   return ListTile(
     title: Text(
       widget.task.title,
       style: TextStyle(color: checkBoxValue ? Colors.black : Colors.black),
     ),
     trailing: TileCheckbox(
         checkBoxValue: checkBoxValue,
         callback: (val) {
           setState(() {
             checkBoxValue = val;
           });
         }),
     onTap: () {
       print("tile tapped");
       setState(() {});
     },
   );
 }
}

class TileCheckbox extends StatelessWidget {
 const TileCheckbox(
     {Key? key, required this.checkBoxValue, required this.callback})
     : super(key: key);

 final bool checkBoxValue;
 final Function callback;

 @override
 Widget build(BuildContext context) {
   return Checkbox(
     value: checkBoxValue,
     onChanged: (bool? value) {
       callback(value);
     },
   );
}
}

ListView bug

When i replace ListView with ListView.builder in the problematic list it works but i don't understand why it does not work without the builder constructor. I tried to understand it with prints when i add the new TaskTile, in console the two lists are rebuild i can even see the newly added TaskTile but it does not appear in UI without scrolling the list. Is it supposed to work like this? What is the difference when passing a list of TaskTile instead of a mapped list of TaskTile?



Solution 1:[1]

By definition ListView requires you to create all items at once, while the builder constructor creates a lazy list which loads the itens on demand

from the oficial docs:

ListView: Creates a scrollable, linear array of widgets from an explicit List. This constructor is appropriate for list views with a small number of children because constructing the List requires doing work for every child that could possibly be displayed in the list view instead of just those children that are actually visible.

ListView.builder Creates a scrollable, linear array of widgets that are created on demand. This constructor is appropriate for list views with a large (or infinite) number of children because the builder is called only for those children that are actually visible.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Fabrício Gerhardt Jr