TSR
TSR

Reputation: 20386

Why setState is not rebuilding my child widget?

In this counter example, reproducible on dart pad, I am expecting the child widget to rebuild when I tap on the increment button because it is in the same tree as a the build tree of CounterParentState.

That or there is something about widget tree or the setState method that I don't understand.

import 'package:flutter/material.dart';

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

class Controller {
  late CounterParentState view;

  void attach(CounterParentState v) {
    this.view = v;
  }

  int counter = 0;

  void incrementCounter() {
    counter++;
    this.view.applyState();
  }
}

class CounterChild extends StatelessWidget {
  final Controller controller;

  const CounterChild({Key? key, required this.controller}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '${controller.counter}',
            style: Theme.of(context).textTheme.headline4,
          ),
        ],
      ),
    );
  }
}

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

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

class MyAppState extends State<MyApp> {
  final controller = Controller();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterParent(
        title: 'Flutter Demo Home Page',
        controller: controller,
        child: CounterChild(controller: controller),
      ),
    );
  }
}

class CounterParent extends StatefulWidget {
  final Controller controller;
  final String title;
  final Widget child;

  CounterParent({
    Key? key,
    required this.title,
    required this.controller,
    required this.child,
  }) : super(key: key);

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

class CounterParentState extends State<CounterParent> {
  void applyState() {
    setState(() {});
  }

  @override
  void initState() {
    widget.controller.attach(this);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: widget.child,
      floatingActionButton: FloatingActionButton(
        onPressed: widget.controller.incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

Why I chose this design ?

I choose this design because I wanted to be able to re-use the CounterParent and freely replace the child of the CounterParent

class CounterStylishChild extends StatelessWidget {
  final Controller controller;

  const CounterStylishChild({Key? key, required this.controller}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            'You have pushed the button this many times: (And I am stylish too)',
          ),
          Text(
            '${controller.counter}',
            style: Theme.of(context).textTheme.headline4,
          ),
        ],
      ),
    );
  }
}
class MyMoreStylishApp extends StatefulWidget {
  const MyMoreStylishApp({Key? key}) : super(key: key);

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

class MyMoreStylishAppState extends State<MyMoreStylishApp> {

  final controller = Controller();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterParent(
        title: 'Counter Parent with a better looking child',
        controller: controller,
        child: CounterStylishChild(controller: controller),
      ),
    );
  }
}

Upvotes: 0

Views: 1546

Answers (1)

Christopher Moore
Christopher Moore

Reputation: 17113

setState is working as expected and rebuilding CounterParent, but you passed a Widget instance ("child") that is stored in CounterParent and stays constant as it's constructed in MyAppState, which is never rebuilt in your code. This is the equivalent of "storing" a widget, which some do to prevent unnecessary rebuilds.

For example, the AnimatedBuilder widget does this to prevent unnecessary rebuilds of larger children, while rebuilding the widgets that have changing values that create the animation.

The AnimatedBuilder example code may provide more understanding:

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _controller,
    child: Container(
      width: 200.0,
      height: 200.0,
      color: Colors.green,
      child: const Center(
        child: Text('Whee!'),
      ),
    ),
    builder: (BuildContext context, Widget? child) {
      return Transform.rotate(
        angle: _controller.value * 2.0 * math.pi,
        child: child,
      );
    },
  );
}

The "complex" child (the Container) is passed to the AnimatedBuilder, which stores the widget. It can then pass this widget as a parameter in the builder that is called rapidly. The Transform widget gets its new rotation value on every call of the builder, but the potentially "complex" child is never actually rebuilt.

This is identical to the situation you have here, except your situation is unintentional. It's inadvisable to have multiple widgets so dependent on each other and organized in such a complex manner like what you have here.

Though you don't ask for this in your question, I'll provide a fix. This may not be what you want and it may defeat the purpose of this elaborate design, but it's the simple way to make a pattern such as this function. Elaboration as to why you wanted to do this would be helpful if this solution is not satisfactory.

The CounterChild should be instantiated in the CounterParent. This way, when setState is called for the parent, a new instance of the child is create and it is rebuilt.

import 'package:flutter/material.dart';

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

class Controller {
  late CounterParentState view;

  void attach(CounterParentState v) {
    this.view = v;
  }

  int counter = 0;

  void incrementCounter() {
    counter++;
    this.view.applyState();
  }
}

class CounterChild extends StatelessWidget {
  final Controller controller;

  const CounterChild({Key? key, required this.controller}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '${controller.counter}',
            style: Theme.of(context).textTheme.headline4,
          ),
        ],
      ),
    );
  }
}

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

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

class MyAppState extends State<MyApp> {
  final controller = Controller();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterParent(
        title: 'Flutter Demo Home Page',
        controller: controller,
      ),
    );
  }
}

class CounterParent extends StatefulWidget {
  final Controller controller;
  final String title;

  CounterParent({
    Key? key,
    required this.title,
    required this.controller,
  }) : super(key: key);

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

class CounterParentState extends State<CounterParent> {
  void applyState() {
    setState(() {});
  }

  @override
  void initState() {
    widget.controller.attach(this);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: CounterChild(controller: widget.controller),
      floatingActionButton: FloatingActionButton(
        onPressed: widget.controller.incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

If you'd like to keep the CounterChild where it is, you might consider using InheritedNotifier:

import 'package:flutter/material.dart';

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

class Controller extends ChangeNotifier {
  int counter = 0;

  void incrementCounter() {
    counter++;
    notifyListeners();
  }
}

class Counter extends InheritedNotifier<Controller> { 
  const Counter({
    Key? key,
    required Controller controller,
    required Widget child,
  }) : super(key: key, child: child, notifier: controller);
  
  static Controller of(BuildContext context) {
    final Controller? result = context.dependOnInheritedWidgetOfExactType<Counter>()?.notifier;
    assert(result != null, 'No Controller found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(Counter old) => notifier?.counter != old.notifier?.counter;
}

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '${Counter.of(context).counter}',
            style: Theme.of(context).textTheme.headline4,
          ),
        ],
      ),
    );
  }
}

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

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

class MyAppState extends State<MyApp> {
  final Controller controller = Controller();
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Counter(
        controller: controller,
        child: CounterParent(
          title: 'Flutter Demo Home Page',
          child: CounterChild(),
        ),
      ),
    );
  }
}

class CounterParent extends StatefulWidget {
  final String title;
  final Widget child;

  CounterParent({
    Key? key,
    required this.title,
    required this.child,
  }) : super(key: key);

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

class CounterParentState extends State<CounterParent> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: widget.child,
      floatingActionButton: FloatingActionButton(
        onPressed: Counter.of(context).incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

Or if you want to preserve more of your original structure, you need to pass your MyApp setState instead of your CounterParent setState so both the parent and child are rebuilt:

import 'package:flutter/material.dart';

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

class Controller extends ChangeNotifier {
  int counter = 0;

  void incrementCounter() {
    counter++;
    notifyListeners();
  }
}

class CounterChild extends StatelessWidget {
  final Controller controller;
  
  const CounterChild({Key? key, required this.controller}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '${controller.counter}',
            style: Theme.of(context).textTheme.headline4,
          ),
        ],
      ),
    );
  }
}

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

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

class MyAppState extends State<MyApp> {
  late final Controller controller;
  
  @override
  void initState() {
    super.initState();
    
    controller = Controller()
      ..addListener(()=>setState((){}));
  }
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterParent(
        controller: controller,
        title: 'Flutter Demo Home Page',
        child: CounterChild(controller: controller),
      ),
    );
  }
}

class CounterParent extends StatefulWidget {
  final String title;
  final Widget child;
  final Controller controller;

  CounterParent({
    Key? key,
    required this.title,
    required this.controller,
    required this.child,
  }) : super(key: key);

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

class CounterParentState extends State<CounterParent> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: widget.child,
      floatingActionButton: FloatingActionButton(
        onPressed: widget.controller.incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

Upvotes: 5

Related Questions