rrousselGit / flutter_hooks

React hooks for Flutter. Hooks are a new kind of object that manages a Widget life-cycles. They are used to increase code sharing between widgets and as a complete replacement for StatefulWidget.
MIT License
3.13k stars 179 forks source link

Watch complex Object for change #378

Closed masus04 closed 1 year ago

masus04 commented 1 year ago

Is your feature request related to a problem? Please describe. Hooks, like providers, do not seem to rebuild when a complex object changes its values, but only when the object itself is replaced. This behaviour makes sense in most cases, but some times, developers would like a widget to rebuild if a value of a hook object changes.

Consider a media player that has a PlayerState. A simple button should display a play icon if the PlayerState is stopped and a stop button if the PlayerState is playing.

class Example extends HookWidget {
  const Example({super.key});

  @override
  Widget build(BuildContext context) {
    final player = useState(MediaPlayer());

    return IconButton(
      icon: Icon(
        player.state == PlayerState.playing ? Icons.play_circle : Icons.stop_circle,
      ),
      onPressed: () {
        if (player.state == PlayerState.playing) {
          player.stop();
        } else {
          player.play();
        }
      },
    );
  }
}

Since the player changes its own internal state, but does not create a new player object, the hook does not rebuild and therefore the icon is not changed.

Describe the solution you'd like I would love to have the option to listen to an attribute of the player and rerender the HookWidget whenever it changes. I could immagine something like this in order to specify an attribute of player that I would like to subscribe to:

final playerState = player.select((MediaPlayer player) => player.state);

This would change the full example to the following:

class Example extends HookWidget {
  const Example({super.key});

  @override
  Widget build(BuildContext context) {
    final player = useState(MediaPlayer());

    final playerState = player.select((MediaPlayer player) => player.state);

    return IconButton(
      icon: Icon(
        playerState == PlayerState.playing ? Icons.play_circle : Icons.stop_circle,
      ),
      onPressed: () {
        if (playerState == PlayerState.playing) {
          player.stop();
        } else {
          player.play();
        }
      },
    );
  }
}

Describe alternatives you've considered I have tried to simply create a new hook for the PlayerState, but then I run into issues where the widget can be disposed of and the hooks are not cleanly disposed, etc.

Currently i use the following workaround:

final isMounted = useIsMounted();
final playerState = useState(player.state);

player.onPlayerStateChanged.listen((state) {
  if (isMounted()) {
    playerState.value = state;
  }
});

I've also tried to use useStream on the player.onPlayerStateChanged stream, but that somehow did not work either, as it did not always rebuild when the state changed.

rrousselGit commented 1 year ago

You should make your model a listenable object (such as using ChangeNotifier). There's no way hooks are going to be able to listen to changes on a plain dart object

You could do:

class Model with ChangeNotifier {
  int get count => _count;
  int _count =0;
  set count(int value) {
    _count++:
    notifyListeners();
  }
}

...

Model useModel() => useMemoized(Model.new, const []);

...

final model = useModel();
// rebuild the UI on model change. Could be done inside useModel itself if you wish
// But doing it outside enables using useListenableSelector too if you want to.
useListenable(model);
return Text(model.count);
masus04 commented 1 year ago

You should make your model a listenable object (such as using ChangeNotifier). There's no way hooks are going to be able to listen to changes on a plain dart object

You could do:

class Model with ChangeNotifier {
  int get count => _count;
  int _count =0;
  set count(int value) {
    _count++:
    notifyListeners();
  }
}

...

Model useModel() => useMemoized(Model.new, const []);

...

final model = useModel();
// rebuild the UI on model change. Could be done inside useModel itself if you wish
// But doing it outside enables using useListenableSelector too if you want to.
useListenable(model);
return Text(model.count);

My main issue is that I don't control the MediaPlayer object since it's imported from a library. Any solution for that?

rrousselGit commented 1 year ago

Make a wrapper around it

class Model with ChangeNotifier {
  final OtherModel _model;

  int get count => _model.count;
  set count(int value) {
    _model.count++;
    notifyListeners();
  }
}