skip to Main Content

Description

I have a stateful widget W. As a state, it has a list l = [S(), S()], where S is a stateless widget.

When I swap the elements of l inside setState() two S are rebuilt. In other words, the existing S are not reused.

Why?

Concrete Example

DartPad

import 'dart:math';

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme:
          ThemeData(brightness: Brightness.dark, primaryColor: Colors.blueGrey),
      home: W(),
      debugShowCheckedModeBanner: false,
    );
  }
}

class W extends StatefulWidget {
  const W({
    super.key,
  });

  @override
  State<W> createState() => _WState();
}

class _WState extends State<W> {
  List<Tile> l = [Tile(text: "hello"), Tile(text: "world")];
  // List<ColorCard> l = [ColorCard(), ColorCard()];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        floatingActionButton: FloatingActionButton(
            onPressed: () =>
                setState(() => {this.l.insert(1, this.l.removeAt(0))}),
            child: Icon(Icons.swap_horiz)),
        body: Center(
            child: Row(
          children: this.l,
        )));
  }
}

class Tile extends StatelessWidget {
  final String text;

  const Tile({required String this.text, super.key});

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      ColorCard(),
      Text(this.text),
    ]);
  }
}

Color createRandomColor() {
  final colors = [
    Colors.red,
    Colors.blue,
    Colors.yellow,
    Colors.green,
    Colors.purple,
  ];
  return colors[Random().nextInt(colors.length)];
}

class ColorCard extends StatelessWidget {
  final color = createRandomColor();

  ColorCard({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      color: this.color,
    );
  }
}

The stateful widget W has the state

List<Tile> l = [Tile(text: "hello"), Tile(text: "world")];

and swapping the elements of l makes Tile() always rebuilt for some reason though Tile extends StatelessWidget.

enter image description here

Tile has this structure:

Tile (stateless)
- Stack
  - ColorCard (stateless)
    - Container
  - Text

More strangely, when I comment the first line and uncomment the second line in the snippet below, then ColorCard() is not rebuilt at all (DartPad).

  List<Tile> l = [Tile(text: "hello"), Tile(text: "world")];
  // List<ColorCard> l = [ColorCard(), ColorCard()];

enter image description here

Adding a key to Tile() solves the problem (DartPad),

  List<Tile> l = [
    Tile(key: UniqueKey(), text: "hello"),
    Tile(key: UniqueKey(), text: "world")
  ];
  // List<ColorCard> l = [ColorCard(), ColorCard()];

though I thought keys aren’t required as Keys! What are they good for? says

If you find yourself adding, removing, or reordering a collection of widgets of the same type that hold some state, using keys is likely in your future.

enter image description here

Why?

2

Answers


  1. Every StatelessWidget within widget W will be rebuilt when you rebuild W with calling setState, this is what Flutter does. Stateless widgets are not constants, being stateless means only that they are immutable after they are created and inserted into the widget tree and do not have an internal, changeable state that should be reflected in the UI of that particular widget.

    Since calling ColorCard() is within the build method of Tile,

      @override
      Widget build(BuildContext context) {
        return Stack(children: [
          ColorCard(),
          Text(this.text),
        ]);
      }
    

    every time the build method is called (and it will be called by using setState in _WState), the constructor of ColorCard widget will be executed and will result in a random color. That’s why you experience colors changing in the first version.

    On the other hand side, in the second version, when you uncomment,

    List<ColorCard> l = [ColorCard(), ColorCard()];
    

    something different happens. The random colors are assigned to both ColorCard widgets once they are created as elements in the l list, once their constructors run. With setState you change the order of the two elements but they are not re-created so the constructors don’t run again, meaning the colors will not change.

    You can easily test this behaviour in the second version if you change the code so that the color is not assigned to ColorCard in the constructor but in the build method. Define ColorCard like this,

    class ColorCard extends StatelessWidget {
    
      ColorCard({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Container(
          width: 100,
          height: 100,
          color: createRandomColor(),
        );
      }
    }
    

    and you will have the colors changing in the second version as well, since the function createRandomColor() is now called from the build method.

    Login or Signup to reply.
  2. The strange part comes from the canUpdate check that widgets have, without a key, every element in an array of the same type is equal and that translates to everything can be updated, A(key: null) is equal to A(key: null), then because stateless components always rebuild every parent setState will require a new build from every non instantiated child.

    Doesn’t that mean that it can’t hold a "state"?, well, there are other checks (like this one) to verify that there is a need to rebuild something; in the drag example you can see that SquareCard.build is being called and nothing changes.

    With that in this pad the fix is quite simple, the actual effect is that tells flutter that the widgets are not same and swaps them.

    -  const Tile({required String this.text, super.key});
    +  Tile({required String this.text}) : super(key: ValueKey(text));
    

    A great example for this would be draggable objects, here there are two "kinds" of the same SquareCard, the instantiated wont get changed except when 2 of them swap places bought get rebuilt with the same color and the tree component (the one with ‘Add / Remove’) will always get rebuilt on every state (using a key wont change anything for this one).

    (With ‘Add / Remove’, to add, drag it to an element on the wrap; to remove, drag the element to it)

    class DragPage extends StatefulWidget {
      DragPage({super.key});
    
      @override
      State<DragPage> createState() => _DragPage();
    }
    
    // Maps the dragged to the target
    typedef SrcDst = MapEntry<SquareCard, SquareCard>;
    
    class _DragPage extends State<DragPage> {
      late final cards = List<Widget>.generate(
        5, (i) => SquareCard(text: 'Card $i', stream: _drag_stream)
      );
    
      final _drag_stream = StreamController<SrcDst>();
    
      @override
      void initState() {
        _drag_stream.stream.listen(
          (SrcDst ev) {
            print('${ev.key} > ${ev.value}');
    
            var widget_ph;
            var src_index = cards.indexOf(ev.key);
            var dst_index = cards.indexOf(ev.value);
    
            if (src_index == -1) {
              widget_ph = SquareCard(
                text: 'Card ${cards.length}',
                stream: _drag_stream,
              );
            } else {
              widget_ph = cards.removeAt(
                cards.indexOf(ev.key),
              );
            }
            if (dst_index != -1) {
              cards.insert(dst_index, widget_ph);
            }
            setState(() => null);
          },
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              SizedBox(
                  width: 350,
                  child: Wrap(
                    children: cards,
                  )),
              Center(
                child: SquareCard(
                  text: 'Add / Remove',
                  stream: _drag_stream,
                ),
              )
            ],
          ),
        );
      }
    }
    

    A small thing to point here is that toStringShort() prints out the key used if there was one, so if you add one, on the build it will show witch one was.

    class SquareCard extends StatelessWidget {
      final String text;
      final StreamController<SrcDst> stream;
      final Color color = Color(0xFF000000 | Random().nextInt(0xFFFFFF));
    
      SquareCard({super.key, required this.text, required this.stream});
    
      Widget build_card(BuildContext ctx, bool is_drag) {
        return Container(
          color: is_drag ? color.withAlpha(180) : color,
          child: Center(child: Text(text)),
          height: 100,
          width: 100,
        );
      }
    
      @override
      Widget build(BuildContext ctx) {
        print('Building ${this.toStringShort()}');
        return DragTarget<SquareCard>(
          builder: (ctx, a, r) {
            return Draggable<SquareCard>(
              feedback: Material(child: build_card(ctx, true)),
              childWhenDragging: build_card(ctx, true),
              child: build_card(ctx, false),
              data: this,
            );
          },
          onAccept: (SquareCard other) => stream.add(
            SrcDst(other, this),
          ),
        );
      }
    }
    

    And the rest of the code.

    import 'dart:math';
    import 'dart:async';
    import 'package:flutter/material.dart';
    
    void main() => runApp(const App());
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext ctx) {
        return MaterialApp(
          home: Scaffold(
            body: DragPage(),
          ),
        );
      }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search