Search code examples
flutterbloc

Unable to update a prticular tile of ListView.builder using bloc


Scenario

I have a ListView.builder that creates 5 items. Each tile has a text and an icon. When the icon is tapped on it needs to toggle the font of the text between bold and normal as well as the icon from read to unread.

I am using BLoC for state management.

Code

tile_state.dart

abstract class TileState {}

class UnreadState extends TileState {
  final bool isUnread;
  UnreadState({
    required this.isUnread,
  });
}

class ReadState extends TileState {
  final bool isUnread;
  ReadState({
    required this.isUnread,
  });
}

tile_event.dart

abstract class TileEvent {}

class UnreadEvent extends TileEvent {
  final bool unreadStatus;

  UnreadEvent(this.unreadStatus);
}

class ReadEvent extends TileEvent {
  final bool unreadStatus;

  ReadEvent(this.unreadStatus);
}

tile_bloc.dart

class TileBloc extends Bloc<TileEvent, TileState> {
  TileBloc() : super(UnreadState(isUnread: true)) {
    on<UnreadEvent>((event, emit) => emit(UnreadState(isUnread: true)));
    on<ReadEvent>((event, emit) => emit(ReadState(isUnread: false)));
  }
}

tile.dart

class CustomTile extends StatelessWidget {
  CustomTile({
    Key? key,
    required this.text,
    required this.isUnread,
    required this.onTap,
  }) : super(key: key);

  final String text;
  bool isUnread;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 7.5),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            text,
            style: TextStyle(
              fontWeight: isUnread ? FontWeight.w600 : FontWeight.w200,
            ),
          ),
          InkWell(
            onTap: onTap,
            child: Icon(
              isUnread ? Icons.mail_rounded : Icons.mail_outline_rounded,
            ),
          ),
        ],
      ),
    );
  }
}

tile_page.dart

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

  @override
  State<TilesPage> createState() => _TilesPageState();
}

class _TilesPageState extends State<TilesPage> {
  @override
  Widget build(BuildContext context) {
    final TileBloc bloc = context.read<TileBloc>();
    return Scaffold(
      appBar: AppBar(
        title: const Text("Tile Screen"),
      ),
      body: Center(
        child: Column(
          children: [
            Expanded(
              child: ListView.builder(
                itemCount: 5,
                itemBuilder: (context, index) {
                  return BlocBuilder<TileBloc, TileState>(
                    builder: (context, state) {
                      if (state is UnreadState) {
                        return CustomTile(
                          text: "Message $index",
                          isUnread: state.isUnread,
                          onTap: () {
                            bloc.add(ReadEvent(false));
                          },
                        );
                      } else if (state is ReadState) {
                        return CustomTile(
                          text: "Message $index",
                          isUnread: state.isUnread,
                          onTap: () {
                            bloc.add(UnreadEvent(true));
                          },
                        );
                      } else {
                        return const SizedBox();
                      }
                    },
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Problem

When I tap on a particular tile, instead of toggling the fontweight and icon for that tile, all the tiles are getting their fontweight and icons toggled at the same time, aka, the entire ListView is getting rebuilt.

Request

I would like to know what is wrong in my code that is causing this and how to enable only a single tile to rebuild instead of the entire list.


Solution

  • You need a list of N bool to control the N items, or just a list that will hold the selected item. A model class is better.

    class Item extends Equatable { ///for value equality, equtable package
      const Item({
        required this.message,
        required this.isRead,
      });
      final String message;
      final bool isRead;
    
      @override
      List<Object?> get props => [message, isRead];
    
      Item copyWith({
        String? message,
        bool? isRead,
      }) {
        return Item(
          message: message ?? this.message,
          isRead: isRead ?? this.isRead,
        );
      }
    }
    

    Now the only event is needed to toggle the read update. Skipping remove and other event for now.

    abstract class TileEvent {}
    
    class ToggleRead extends TileEvent {
      final Item item;
      ToggleRead(this.item);
    }
    

    Only state is needed to hold items

    class TileState extends Equatable {
      const TileState({required this.items});
      final List<Item> items;
      @override
      List<Object?> get props =>
          [items, identityHashCode(this)]; //not using listEquality , some reason spreed operation isn't working
    }
    

    And Bloc logic will be

    class TileBloc extends Bloc<TileEvent, TileState> {
      TileBloc({List<Item> initItems = const []})
          : super(TileState(items: initItems)) {
        on<ToggleRead>((event, emit) {
          final selectedItem = event.item;
          final currentItems = state.items;
    
          final selectedItemIndex = currentItems
              .indexOf(selectedItem); //you can pass just index instead of item
          if (selectedItemIndex > -1) {
            bool isRead = state.items[selectedItemIndex].isRead;
    
            currentItems[selectedItemIndex] =
                currentItems[selectedItemIndex].copyWith(isRead: !isRead);
    
            /// ... because state value is list
            emit(TileState(items: [...currentItems]));
          }
        });
      }
    }
    

    And test snippet

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: BlocProvider(
              create: (_) => TileBloc(
                  initItems: List.generate(5,
                      (index) => Item(message: "message $index", isRead: false))),
              child: TilesPage()),
        );
      }
    }
    
    class TilesPage extends StatefulWidget {
      const TilesPage({Key? key}) : super(key: key);
    
      @override
      State<TilesPage> createState() => _TilesPageState();
    }
    
    class _TilesPageState extends State<TilesPage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text("Tile Screen"),
          ),
          body: Center(
            child: Column(
              children: [
                Expanded(
                  child: BlocBuilder<TileBloc, TileState>(
                    builder: (context, state) {
                      return ListView.builder(
                        itemCount: state.items.length,
                        itemBuilder: (context, index) {
                          final isRead = state.items[index].isRead;
                          return CustomTile(
                            text: "Message $index",
                            isRead: isRead,
                            onTap: () {
                              final event = ToggleRead(state.items[index]);
                              BlocProvider.of<TileBloc>(context).add(event);
                            },
                          );
                        },
                      );
                    },
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class CustomTile extends StatelessWidget {
      const CustomTile({
        Key? key,
        required this.text,
        required this.isRead,
        required this.onTap,
      }) : super(key: key);
    
      final String text;
      final bool isRead;
      final VoidCallback onTap;
    
      @override
      Widget build(BuildContext context) {
        return Container(
          padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 7.5),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                text,
                style: TextStyle(
                  fontWeight: isRead ? FontWeight.w600 : FontWeight.w200,
                ),
              ),
              InkWell(
                onTap: onTap,
                child: Icon(
                  isRead ? Icons.mail_rounded : Icons.mail_outline_rounded,
                ),
              ),
            ],
          ),
        );
      }
    }