1

I'm learning Provider and my test app draws images from a Firestore database into a ListView. I'd like a LongPress on any image to make the whole list toggle and redraw with checkbox selector icons, as below, similar to the way the gallery works:

My code works, but it throws an exception on every LongPress stating that "setState() or markNeedsBuild() was called during build," and I'm pulling my hair out trying to figure out how to either delay the ChangeNotifier until the widget tree is built? Or some other way to accomplish this task?

My Provider class simply accepts a List of my PictureModel class and has a toggleSelectors() method which notifies listeners. Here's the code:

class PicturesProvider with ChangeNotifier {
  List<PictureModel> _pictures = [];
  bool visible = false;

  UnmodifiableListView<PictureModel> get allPictures => UnmodifiableListView(_pictures);
  UnmodifiableListView<PictureModel> get selectedPictures =>
      UnmodifiableListView(_pictures.where((pic) => pic.selected));

  void addPictures(List<PictureModel> picList) {
    _pictures.addAll(picList);
    notifyListeners();
  }

  void toggleSelectors() {
    visible = !visible;
    _pictures.forEach((pic) {
      pic.selectVisible = visible;
    });
    notifyListeners();
  }
}

I have a SinglePicture UI class that loads a network image into an AspectRatio widget and wraps it with a GestureDetector to toggle the selectors and present them on the top of a Stack widget, like so:

Widget build(BuildContext context) {
    int originalHeight, originalWidth;
    return AspectRatio(
      aspectRatio: pictureModel.aspectRatio,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          FutureBuilder<ui.Image>(
            future: _getImage(),
            builder: (BuildContext context, AsyncSnapshot<ui.Image> snapshot) {
              if (snapshot.hasData) {
                ui.Image image = snapshot.data;
                originalHeight = image.height;
                originalWidth = image.width;
                return GestureDetector(
                  onLongPress: () => Provider.of<PicturesProvider>(context, listen: false).toggleSelectors(),
                  child: RawImage(
                    image: image,
                    fit: BoxFit.cover,
                    // if portrait image, move down slightly for headroom
                    alignment: Alignment(0, originalHeight > originalWidth ? -0.2 : 0),
                  ),
                );
              } else {
                return Center(child: CircularProgressIndicator());
              }
            },
          ),
          Positioned(
            left: 10.0,
            top: 10.0,
            child: pictureModel.selectVisible == false
                ? Container(
                    height: 0.0,
                    width: 0.0,
                  )
                : pictureModel.selected == false
                    ? Icon(
                        Icons.check_box_outline_blank,
                        size: 30.0,
                        color: Colors.white,
                      )
                    : Icon(
                        Icons.check_box,
                        size: 30.0,
                        color: Colors.white,
                      ),
          )
        ],
      ),
    );
  }

This SinglePicture class is then called from my PicturesList UI class which simply builds a ListView, like so:

class PicturesList extends StatelessWidget {
  final List<PictureModel> pictures;
  PicturesList({@required this.pictures});
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: pictures.length,
      cacheExtent: 3,
      itemBuilder: (context, index) {
        return SinglePicture(
          pictureModel: pictures[index],
        );
      },
    );
  }

The whole shebang is then called from a FutureBuilder in my app, which builds the app, like so:

body: FutureBuilder(
  future: appProject.fetchProject(), // Snapshot of database
  builder: (BuildContext context, AsyncSnapshot snapshot) {
    if (snapshot.hasData) {
      // Get all picture URLs from project snapshot
      List<dynamic> picUrls = snapshot.data['pictures'].map((pic) => pic['pic_cloud']).toList();
      // Create list of PictureModel objects for Provider
      List<PictureModel> pictures = picUrls.map((url) => PictureModel(imageUrl: url, imageHeight: 250.0, selectVisible: false)).toList();
      // Add list of PictureModel objects to Provider for UI render
      context.watch<PicturesProvider>().addPictures(pictures);
      return SafeArea(
        child: PicturesList(
          pictures: context.watch<PicturesProvider>().allPictures,
        ),
      );
    } else if (snapshot.hasError) {
      print('Error');
    } else {
      return Center(
        child: CircularProgressIndicator(),
      );
    }
  },
),

Please, if anybody has a hint about how I can accomplish this toggle action without throwing exceptions, I'd be very grateful. Thank you in advance!

nvoigt
  • 75,013
  • 26
  • 93
  • 142
ConleeC
  • 337
  • 6
  • 13
  • 1
    You misused FutureBuilder. See https://stackoverflow.com/questions/52249578/how-to-deal-with-unwanted-widget-build for more information – Rémi Rousselet Jul 25 '20 at 21:03
  • @RémiRousselet Thank you, kind sir. This explains a LOT of things that were sort of whacky in my code. The only problem now is that I have to go back and rethink EVERYTHING that I've done, to make sure my builder methods are clean. It works though, and I'll post updated code for others to see, in case it's helpful. Thanks again! – ConleeC Jul 25 '20 at 22:43

1 Answers1

0

Thanks to Remi Rousselet for the answer:

I have been using .builder methods wrong since the get-go and now need to go revisit ALL of my code and make sure they are clean.

To make this code work, I moved the Future out of my FutureBuilder and called it in the initState method, per Remi's guidance. I also had to create a new initializer method in my Provider class that did NOT notify listeners, so I could build the list for the first time.

Here are the code snippets to make my images 'selectable' with a LongPress and to be able to individually select them with a tap, as seen in the following image:

My PictureModel:

class PictureModel {
  final String imageUrl;
  final double aspectRatio;
  double imageHeight;
  bool selectVisible;
  bool selected;

  PictureModel({
    @required this.imageUrl,
    @required this.imageHeight,
    this.aspectRatio = 4.0 / 3.0,
    this.selectVisible = false,
    this.selected = false,
  });

  @override
  String toString() {
    return 'Image URL: ${this.imageUrl}\n'
        'Image Height: ${this.imageHeight}\n'
        'Aspect Ratio: ${this.aspectRatio}\n'
        'Select Visible: ${this.selectVisible}\n'
        'Selected: ${this.selected}\n';
  }
}

My PictureProvider model:

class PicturesProvider with ChangeNotifier {
  List<PictureModel> _pictures = [];
  bool visible = false;

  UnmodifiableListView<PictureModel> get allPictures => UnmodifiableListView(_pictures);
  UnmodifiableListView<PictureModel> get selectedPictures =>
      UnmodifiableListView(_pictures.where((pic) => pic.selected));

  void initialize(List<PictureModel> picList) {
    _pictures.addAll(picList);
  }

  void addPictures(List<PictureModel> picList) {
    _pictures.addAll(picList);
    notifyListeners();
  }

  void toggleSelected(int index) {
    _pictures[index].selected = !_pictures[index].selected;
    notifyListeners();
  }

  void toggleSelectors() {
    this.visible = !this.visible;
    _pictures.forEach((pic) {
      pic.selectVisible = visible;
    });
    notifyListeners();
  }
}

My SinglePicture UI class:

class SinglePicture extends StatelessWidget {
  final PictureModel pictureModel;
  const SinglePicture({Key key, this.pictureModel}) : super(key: key);

  Future<ui.Image> _getImage() {
    Completer<ui.Image> completer = new Completer<ui.Image>();
    new NetworkImage(pictureModel.imageUrl).resolve(new ImageConfiguration()).addListener(
      new ImageStreamListener(
        (ImageInfo image, bool _) {
          completer.complete(image.image);
        },
      ),
    );
    return completer.future;
  }

  @override
  Widget build(BuildContext context) {
    int originalHeight, originalWidth;
    return AspectRatio(
      aspectRatio: pictureModel.aspectRatio,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          FutureBuilder<ui.Image>(
            future: _getImage(),
            builder: (BuildContext context, AsyncSnapshot<ui.Image> snapshot) {
              if (snapshot.hasData) {
                ui.Image image = snapshot.data;
                originalHeight = image.height;
                originalWidth = image.width;
                return RawImage(
                  image: image,
                  fit: BoxFit.cover,
                  // if portrait image, move down slightly for headroom
                  alignment: Alignment(0, originalHeight > originalWidth ? -0.2 : 0),
                );
              } else {
                return Center(child: CircularProgressIndicator());
              }
            },
          ),
          Positioned(
            left: 10.0,
            top: 10.0,
            child: pictureModel.selectVisible == false
                ? Container(
                    height: 0.0,
                    width: 0.0,
                  )
                : pictureModel.selected == false
                    ? Icon(
                        Icons.check_box_outline_blank,
                        size: 30.0,
                        color: Colors.white,
                      )
                    : Icon(
                        Icons.check_box,
                        size: 30.0,
                        color: Colors.white,
                      ),
          )
        ],
      ),
    );
  }
}

My PicturesList UI class:

class PicturesList extends StatelessWidget {
  PicturesList(this.listOfPics);
  final List<PictureModel> listOfPics;
  @override
  Widget build(BuildContext context) {
    context.watch<PicturesProvider>().initialize(listOfPics);
    final List<PictureModel> pictures = context.watch<PicturesProvider>().allPictures;
    return ListView.builder(
      itemCount: pictures.length,
      cacheExtent: 3,
      itemBuilder: (context, index) {
        return GestureDetector(
          onLongPress: () => Provider.of<PicturesProvider>(context, listen: false).toggleSelectors(),
          onTap: () {
            if (Provider.of<PicturesProvider>(context, listen: false).visible) {
              Provider.of<PicturesProvider>(context, listen: false).toggleSelected(index);
            }
          },
          child: SinglePicture(
            pictureModel: pictures[index],
          ),
        );
      },
    );
  }
}

And last but not least, the FutureBuilder in the app from where all of this was called...

body: FutureBuilder(
  future: projFuture,
  // ignore: missing_return
  builder: (BuildContext context, AsyncSnapshot snapshot) {
    if (snapshot.hasData) {
      // Get all picture URLs from project snapshot
      List<dynamic> picUrls = snapshot.data['pictures'].map((pic) => pic['pic_cloud']).toList();
      // Create list of PictureModel objects for Provider
      List<PictureModel> pictures = picUrls
          .map((url) => PictureModel(imageUrl: url, imageHeight: 250.0, selectVisible: false))
          .toList();
      // Add list of PictureModel objects to Provider for UI render
      // context.watch<PicturesProvider>().addPictures(pictures);
      return SafeArea(
        child: PicturesList(pictures),
      );
    } else if (snapshot.hasError) {
      print('error');
    } else {
      return Center(
        child: CircularProgressIndicator(),
      );
    }
  },
),

Sorry for the long follow-up, but I figured I'd try to detail as much as possible how to make this work, in case it is useful to anybody else. Also, if anybody has further suggestions on how to improve this code, PLEASE let me know.

Thanks in advance.

ConleeC
  • 337
  • 6
  • 13