5

I'm able to successfully animate an AnimatedList's contents in Flutter when the list data is stored in the same component that owns the list widget (i.e., there's no rebuild happening when there's changes to the list data). I run into issues when I try to get the items for the list from a ChangeNotifier using Provider and Consumer.

The component that owns the AnimatedList, let's call it ListPage, is built with a Consumer<ListItemService>. My understanding is that ListPage is then rebuilt whenever the service updates the list data and calls notifyListeners(). When that happens, I'm not sure where within ListPage I could call AnimatedListState.insertItem to animate the list, since during the build the list state is still null. The result is a list that doesn't animate its contents.

I think my question boils down to "how do I manage state for this list if the contents are fetched and updated in real time?", and ideally I'd like to understand what's going on but I'm open to suggestions on how I should change this if this isn't the best way to approach the task.

Here's some code that illustrates the problem:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<AuthService>(
          create: (_) => AuthService(),
        ),
        ChangeNotifierProxyProvider<AuthService, ListItemService>(
          create: (_) => ListItemService(),
          update: (_, authService, listItemService) =>
              listItemService!..update(authService),
        ),
      ],
      child: MaterialApp(
        home: HomePage(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Consumer<ListItemService>(
      builder: (context, listItemService, _) =>
          ListPage(items: listItemService.items),
    );
  }
}

// Implementation details aren't really relevant, but
// this only updates if the user logs in or out.
class AuthService extends ChangeNotifier {}

class ListItemService extends ChangeNotifier {
  List<Item> _items = [];
  List<Item> get items => _items;

  Future<void> update(AuthService authService) async {
    // Method that subscribes to a Firestore snapshot
    // and calls notifyListeners() after updating _items.
  }
}

class Item {
  Item({required this.needsUpdate, required this.content});

  final String content;
  bool needsUpdate;
}

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

  final List<Item> items;

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

class _ListPageState extends State<ListPage> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey();
  late int _initialItemCount;

  @override
  void initState() {
    _initialItemCount = widget.items.length;
    super.initState();
  }

  void _updateList() {
    for (int i = 0; i < widget.items.length; i++) {
      final item = widget.items[i];
      if (item.needsUpdate) {
        // _listKey.currentState is null here if called
        // from the build method.
        _listKey.currentState?.insertItem(i);
        item.needsUpdate = false;
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    _updateList();
    return AnimatedList(
      key: _listKey,
      initialItemCount: _initialItemCount,
      itemBuilder: (context, index, animation) => SizeTransition(
        sizeFactor: animation,
        child: Text(widget.items[index].content),
      ),
    );
  }
}
sleighty
  • 895
  • 9
  • 29
  • BTW, I've looked at this question but my control is flowing the opposite way. In the question, the component that owns the `AnimatedList` decides when to remove elements; in my app, the component is simply waiting for updates (even when it does add elements, it does so by telling the provider that an item should be added and then listens for changes from Firestore): https://stackoverflow.com/questions/61816446/flutter-animatedlist-with-provider-pattern – sleighty Sep 24 '21 at 08:06

1 Answers1

5

You can use didUpdateWidget and check the difference between the old and new list. "Checking the difference" means looking at what has been removed vs added. In you case the Item widget should have something to be identified. You can use Equatable for example so that an equality between Items is an equality between their properties.

One other important aspect is that you are dealing with a list, which is mutable, but Widgets should be immutable. Therefore it is crucial that whenever you modify the list, you actually create a new one.

Here are the implementations details, the most interesting part being the comment of course (though the rendering is fun as well ;)):

import 'dart:async';
import 'dart:math';

import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<AuthService>(
          create: (_) => AuthService(),
        ),
        ChangeNotifierProxyProvider<AuthService, ListItemService>(
          create: (_) => ListItemService(),
          update: (_, authService, listItemService) => listItemService!..update(authService),
        ),
      ],
      child: MaterialApp(
        home: HomePage(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Material(
      child: SafeArea(
        child: Consumer<ListItemService>(
          builder: (context, listItemService, _) => ListPage(
            // List.from is very important because it creates a new list instead of
            // giving the old one mutated
            items: List.from(listItemService.items),
          ),
        ),
      ),
    );
  }
}

// Implementation details aren't really relevant, but
// this only updates if the user logs in or out.
class AuthService extends ChangeNotifier {}

class ListItemService extends ChangeNotifier {
  List<Item> _items = [];

  List<Item> get items => _items;

  Future<void> update(AuthService authService) async {
    // Every 5 seconds
    Timer.periodic(Duration(seconds: 5), (timer) {
      // Either create or delete an item randomly
      if (Random().nextDouble() > 0.5 && _items.isNotEmpty) {
        _items.removeAt(Random().nextInt(_items.length));
      } else {
        _items.add(
          Item(
            needsUpdate: true,
            content: 'This is item with random number ${Random().nextInt(10000)}',
          ),
        );
      }
      notifyListeners();
    });
  }
}

class Item extends Equatable {
  Item({required this.needsUpdate, required this.content});

  final String content;
  bool needsUpdate;

  @override
  List<Object?> get props => [content]; // Not sure you want to include needsUpdate?
}

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

  final List<Item> items;

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

class _ListPageState extends State<ListPage> {
  final _listKey = GlobalKey<AnimatedListState>();

  // You can use widget if you use late
  late int _initialItemCount = widget.items.length;

  /// Handles any removal of [Item]
  _handleRemovedItems({
    required List<Item> oldItems,
    required List<Item> newItems,
  }) {
    // If an [Item] was in the old but is not in the new, it has
    // been removed
    for (var i = 0; i < oldItems.length; i++) {
      final _oldItem = oldItems[i];
      // Here the equality checks use [content] thanks to Equatable
      if (!newItems.contains(_oldItem)) {
        _listKey.currentState?.removeItem(
          i,
          (context, animation) => SizeTransition(
            sizeFactor: animation,
            child: Text(oldItems[i].content),
          ),
        );
      }
    }
  }

  /// Handles any added [Item]
  _handleAddedItems({
    required List<Item> oldItems,
    required List<Item> newItems,
  }) {
    // If an [Item] is in the new but was not in the old, it has
    // been added
    for (var i = 0; i < newItems.length; i++) {
      // Here the equality checks use [content] thanks to Equatable
      if (!oldItems.contains(newItems[i])) {
        _listKey.currentState?.insertItem(i);
      }
    }
  }

  // Here you can check any update
  @override
  void didUpdateWidget(covariant ListPage oldWidget) {
    super.didUpdateWidget(oldWidget);
    _handleAddedItems(oldItems: oldWidget.items, newItems: widget.items);
    _handleRemovedItems(oldItems: oldWidget.items, newItems: widget.items);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedList(
      key: _listKey,
      initialItemCount: _initialItemCount,
      itemBuilder: (context, index, animation) => SizeTransition(
        sizeFactor: animation,
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Text(widget.items[index].content),
        ),
      ),
    );
  }
}
Lulupointu
  • 3,364
  • 1
  • 12
  • 28
  • Out of curiosity, why is it better to create a new list from the items in the provider rather than reusing the same list? I'm guessing it has to do with the internals of Flutter? Also, nice tip on being able to use widget if using late. – sleighty Sep 24 '21 at 20:38
  • Made the changes, here's what I noticed: didUpdateWidget runs before build (is that right?), but somehow the \_listKey.currentState is now not null. So I tried checking for it within build and somehow it's also not null. I thought the whole problem was caused by the AnimatedListState always being null in `build`, but now I realize it's only null in the _first_ call to build. However, the animation still doesn't display and my hunch is that it's because `didUpdateWidget` is called before `build`. So, even though `insertItem` is called, the widget is quickly rebuilt which cancels the animation? – sleighty Sep 24 '21 at 20:43
  • One last thought (sorry this has been bugging me for 3 entire days I need to get it out lol): even when completely skipping the call to `insertItem`, the list still renders it (without animations), which further points in the direction that the widget is re-rendering "over" the animation. I wonder if lifting up the animation state key would make a difference? Would that make the parent of the ListPage widget own and create that state? I should mention that ListPage is being pushed by a Navigator in a MaterialRoute, though I have no reason to believe that would mess things up. – sleighty Sep 24 '21 at 20:47
  • Using the same list instead of creating a new one will cause oldWidget._items to point to the same list as widget._items (since you did not create a new one, they are the same). So you would not be able to check for the differences ;) `didUpdateWidget` DOES run before build but only when the widget updates, so not the FIRST time build is called. This is why _listKey.currentState is not null. I don't know why called `insertItem` in build does not work, this seems weird but anyway its bad practice to place this call in build since the checks it requires are enough in `didUpdateWidget` – Lulupointu Sep 24 '21 at 20:56
  • Concerning your last comment, I can't reproduce what you are describing. If I comment out `_handleAddedItems` and `_handleRemovedItems` from the code I gave you, nothing happens for me, as expected. Considering what you are describing with the key I think you don't really understand what they are and what they do. It's quite a complex topic but if you are interested see this: https://www.youtube.com/watch?v=kn0EOS-ZiIc&t=299s – Lulupointu Sep 24 '21 at 21:06
  • Also you should accept the answer if it answered your question ;) – Lulupointu Sep 24 '21 at 21:06
  • Yeah I definitely am not familiar with how keys work in this context. My understanding was that they're used for keeping track of widgets in the tree but the way AnimatedList wants them to be used is weird to me. Ahh I see. I'm actually signaling changes in each item using Firebase's DocumentChange type so I got that covered (i.e., no need to compare old and new lists). But I'm trying to understand why your list doesn't show the changes and mine does. Will keep playing with it later on. – sleighty Sep 24 '21 at 21:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/237497/discussion-between-bruno-ely-and-lulupointu). – sleighty Sep 25 '21 at 21:28
  • @Lulupointu Thanks for the example, it helped a lot. It is very hard to believe there is no source about using Provider with AnimatedList. – bahadir arslan Apr 01 '22 at 14:01