Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does my StatefulWidget lose its state?

Tags:

flutter

I'm building a tinder-like card swipe mechanism where I have a list of cards widget in a Stack widget. The first card is wrapped inside a Dismissible widget which, on dismiss, dismiss the top card and add a new one at the bottom.

My issue here is that, when the first card is dismissed and the second one becomes the first, that card is momentarily disposed and initState is called again

But, as Linus Torvalds once said,

"Talk is cheap, show me the code"

so I put together a example:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Container(color: Colors.white, child: CardStack()),
    );
  }
}

class CardStack extends StatefulWidget {
  @override
  _CardStackState createState() => _CardStackState();
}

class _CardStackState extends State<CardStack> {
  final List<String> _postIds = ["1", "2", "3"];
  int _nextId = 4;

  _updatePosts(DismissDirection dir) {
    setState(() {
      _postIds.removeAt(0);
      _postIds.add((_nextId++).toString());
    });
  }

  List<Widget> _buildCards() {
    List<Widget> cards = [];
    for (int i = 0; i < _postIds.length; ++i) {
      if (i == 0) {
        cards.insert(
            0,
            Dismissible(
              key: UniqueKey(),
              onDismissed: _updatePosts,
              child: Card(key: ValueKey(_postIds[i]), postId: _postIds[i]),
            ));
      } else {
        cards.insert(0, Card(key: ValueKey(_postIds[i]), postId: _postIds[i]));
      }
    }
    return cards;
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      alignment: Alignment.center,
      children: _buildCards(),
    );
  }
}

class Card extends StatefulWidget {
  Card({Key key, this.postId}) : super(key: key);

  final String postId;

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

class _CardState extends State<Card> {
  @override
  void initState() {
    super.initState();
    print("Init state card with id ${widget.postId}");
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200,
      width: 200,
      color: Colors.blueAccent,
      child: Center(
        child: Text(widget.postId),
      ),
    );
  }
}

if I run that it logs

I/flutter (26740): Init state card with id 3
I/flutter (26740): Init state card with id 2
I/flutter (26740): Init state card with id 1
// swipe
I/flutter (26740): Init state card with id 4
I/flutter (26740): Init state card with id 2 <- unwanted rebuild

I think the rebuild of the first card is due to the fact that is is now wrapped inside the Dismissible widget so flutter doesn't know how to reuse the underlying Card widget.

Is there a way to prevent this unwanted rebuild here?

like image 405
Théo Champion Avatar asked Dec 14 '19 18:12

Théo Champion


People also ask

How to rebuild Flutter widget?

Using setState to rebuild widgets Flutter gives you access to setState() . In this case, we have to ensure setState() has the new values. When setState() is called, Flutter will know to get these new values and mark the widget that needs to be rebuilt.

How do I create a stateful widget?

To create a stateful widget in a flutter, use the createState() method. The stateful widget is the widget that describes part of a user interface by building a constellation of other widgets that represent a user interface more concretely. A stateful Widget means a widget that has a mutable state.


1 Answers

The issue is, the location of your StatefulWidget is changing inside the widget tree.

It could be that your StatefulWidget is conditionally wrapped inside a specific widget, such that your widget tree varies between:

SomeWidget
  MyStatefulWidget

and:

SomeWidget
  AnotherWidget
    MyStatefulWidget

By default, Flutter will not understand that MyStatefulWidget moved inside the widget tree.

Instead, it'll think that the previous MyStatefulWidget got removed, and the new one is completely unrelated to the original one.

This leads to dispose being called on the previous State, and initState being called on the new State.


To prevent this, you have a few solutions.

Do not conditionally wrap your widget in another one

If you can, this is the best solution: Refactor your layout such that the widget tree is always the same.

For example, if you want to only the first widget in a ListView to be dismissible, then instead of:

ListView.builder(
  itemBuilder: (_, index) {
    final child = MyStatefulWidget();
    if (index == 0) {
      return Dismissible(key: ValueKey(index), onDismissed: ..., child: child);
    }
  }
)

do:

ListView.builder(
  itemBuilder: (_, index) {
    return Dismissible(
      key: ValueKey(someValue),
      onDismissed: ...,
      confirmDismiss: (_) async => index == 0, // only first item can be dismissed
      child: MyStatefulWidget();
    );
  }
)

This way, the tree does not conditionally change, but the behavior is still conditional

Use GlobalKey

Less ideal as more costly, but more flexible.

You can use GlobalKey to tell Flutter that a specific widget can move from one location to another.

As such, we would be able to write:

Widget build(BuildContext context) {
  if (something) {
    return SomeTree(
      child: MyStatefulWidget(key: GlobalObjectKey('my widget'));
    );
  } else {
    return AnotherTree(
      child: MyStatefulWidget(key: GlobalObjectKey('my widget'));
    );
  }
}

This way, while transitioning from SomeTree to AnotherTree, the widget MyStatefulWidget will preserve its state.

As a little reminder, avoid it if possible. This is relatively costly.

like image 55
Rémi Rousselet Avatar answered Sep 17 '22 20:09

Rémi Rousselet