riverpodを使うときにviewとcontrollerを分けて、contextを使ったダイアログ表示も頑張る Flutter/Dart

こんにちは。[virapture株式会社](https://virapture.com)の[もぐめっと](https://mogmet.com)です。

![mogmet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a8ed2c6d-a067-9fbc-093a-8d5588c44141.jpeg)
弊社のオフィスにはバーがあるのですが夜になるとネオンが光りだしてエロい雰囲気になります。ついつい飲んでしまうようなオフィスです。

最近flutterを書いているのですが、riverpodを使う際のinit処理とか諸々をどうしたもんかと悩んだところ、[@hhiroshi_ja](https://twitter.com/hhiroshi_ja)にriverpodを使うならviewとcontroller処理をわけて書くといいよの共有をもらったので、メモがてら記載しておきます。

riverpodの基本的な使い方は他記事に投げます。

https://zenn.dev/umatoma/articles/2026ef43bdb0f4

## riverpodを使わない場合の処理

StateのinitState()を使って初期処理を行うような処理を想定します。
今回のサンプルとしてはみんな大好きカウンターアプリで初期値として10をいれるといったことを想定します。
（実際の使い方でいうとAPIを叩きに行くとか考えられる）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/f599971a-c3a3-6c47-2460-392a7e306aff.png)

[最初のソースはこんな感じ](https://github.com/mogmet/riverpodinit/blob/main/lib/main.dart)。

```main.dart
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  void initState() {
    super.initState();
    setState(() {
      _counter = 10; // 初期化処理！
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

```

見事にviewとcontrollerが入り混じったよくあるViewControllerな設計になってます。

## riverpodを使ってviewとcontrollerを分ける

riverpodを使う場合は基本的にStatefulWidgetを使わず、StatelessWidgetか、ConsumerWidgetを使って組み込みます。
今回はConsumerWidgetを使ってviewとcontrollerを分けてみます。

[差分だけのバージョンはこちら](https://github.com/mogmet/riverpodinit/pull/1/files)

```my_home_page_controller.dart
/// 動的に表示を変えたいState
class MyHomePageControllerState {
  MyHomePageControllerState({this.counter = 0});
  final int counter;

  /// 値を変えるようのメソッド
  MyHomePageControllerState copyWith({required int counter}) {
    return MyHomePageControllerState(counter: counter);
  }
}

/// コントローラ
class MyHomePageController extends StateNotifier<MyHomePageControllerState> {
  MyHomePageController(): super(MyHomePageControllerState()) {
    state = state.copyWith(counter: 10); // 初期化処理！
  }
  // riverpodで使うproviderはコントローラ用なのでstaticで定義
  static final provider = StateNotifierProvider.autoDispose((ref) => MyHomePageController());

  void incrementCounter() {
    state = state.copyWith(counter: state.counter + 1);
  }
}

```

```main.dart
void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    // 値が変わったら再描画してほしいのでwatchする
    final counter = watch(MyHomePageController.provider).counter;
    // stateの監視ではなく、controllerの処理を呼びたいときに使うのでreadで呼ぶ
    final controller = context.read(MyHomePageController.provider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme
                  .of(context)
                  .textTheme
                  .headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: controller.incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

```

riverpodのStateNotifierを使うことでcontrollerとviewを切り分けすることができるようになりました！
androidのViewBindingと使い方が近い感じがしますね。

## ダイアログを表示したい場合

初期化処理でエラーおこったからダイアログ表示したい！けどダイアログはコンテキストが必要・・・どうしよう・・・
と悩んでいる方たちへ。

こちらもstateをうまく使うことで解決できます。

https://zenn.dev/muttsu_623/articles/17deec8d3e3005365aa0

上記記事を参考に```WidgetsBinding.instance?.addPostFrameCallback```を使った方法で実装できました。

[差分だけのバージョンはこちら](https://github.com/mogmet/riverpodinit/pull/2/files)

stateをerrorにも対応できるようにします。

```my_home_page_controller.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 動的に表示を変えたいState
class MyHomePageControllerState {
  MyHomePageControllerState({this.counter = 0, this.exception});
  final int counter;
  final Exception? exception;

  /// 値を変えるようのメソッド
  MyHomePageControllerState copyWith({required int counter, Exception? exception}) {
    return MyHomePageControllerState(counter: counter, exception: exception);
  }
}

/// コントローラ
class MyHomePageController extends StateNotifier<MyHomePageControllerState> {
  MyHomePageController(): super(MyHomePageControllerState()) {
    // 初期化中にエラーが起こってしまった！
    state = state.copyWith(counter: state.counter, exception: Exception('error'));
  }
  // riverpodで使うproviderはコントローラ用なのでstaticで定義
  static final provider = StateNotifierProvider((ref) => MyHomePageController());

  void incrementCounter() {
    state = state.copyWith(counter: state.counter + 1, exception: state.exception);
  }

  void dismissException() {
    state = state.copyWith(counter: state.counter, exception: null);
  }
}


```

```main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'my_home_page_controller.dart';

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    // 値が変わったら再描画してほしいのでwatchする
    final counter = watch(MyHomePageController.provider).counter;
    final exception = watch(MyHomePageController.provider).exception;
    // stateの監視ではなく、controllerの処理を呼びたいときに使うのでreadで呼ぶ
    final controller = context.read(MyHomePageController.provider.notifier);
    showError(context, exception);
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme
                  .of(context)
                  .textTheme
                  .headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: controller.incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

  void showError(BuildContext context, Exception? exception) {
    if (exception == null) {
      return;
    }
    // 直接表示してしまうと、ビルド前にエラーが起きた場合ダイアログが表示できないのでaddPostFrameCallbackをうまく使う
    WidgetsBinding.instance?.addPostFrameCallback((_) {
      showDialog(
          context: context,
          builder: (BuildContext childContext) {
            return AlertDialog(title: Text(exception.toString()));
          });
      // エラーが再表示されないようにエラーを抹消する
      context.read(MyHomePageController.provider.notifier).dismissException();
    });
  }
}
```

無事表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/f70e0b20-2651-a279-f663-06f920ff4302.png)

肝になるのはaddPostFrameCallbackになるのですが、コメントにある通り、そのまま表示しちゃうとエラーになるのでビルドが終わったタイミングで表示するようにしています。

```dart
  void showError(BuildContext context, Exception? exception) {
    if (exception == null) {
      return;
    }
    // 直接表示してしまうと、ビルド前にエラーが起きた場合ダイアログが表示できないのでaddPostFrameCallbackをうまく使う
    WidgetsBinding.instance?.addPostFrameCallback((_) {
      showDialog(
          context: context,
          builder: (BuildContext childContext) {
            return AlertDialog(title: Text(exception.toString()));
          });
      // エラーが再表示されないように抹消する
      context.read(MyHomePageController.provider.notifier).dismissException();
    });
  }
```

## わからないこと/イケてないところ

[紹介した記事](https://zenn.dev/muttsu_623/articles/17deec8d3e3005365aa0)では最終的にProviderListenerを使っていましたが、onChangeが初回ビルド時には呼ばれないためうまくダイアログ表示ができませんでした。。。
また、エラー表示後わざわざstateからエラーを削除してるのでStreamとか使ってうまくダイアログ表示できないかなぁとか思ったりもしてます。

もしどなたかもっと良い書き方あったら教えて下さい。

## まとめ

riverpodとStateNotifierを使うことで処理の切り分けをすることができました！
ProviderNotifierを使う名称に関してはcontrollerなのか、viewmodelなのか、notifierなのかと色々あるみたいですが、Controllerが良さげな雰囲気も醸し出してます。

https://twitter.com/_mono/status/1239487057333911552?ref_src=twsrc%5Etfw%7Ctwcamp%[…]tps%3A%2F%2Fqiita.com%2F_masaokb%2Fitems%2Ffe77495db0aeba226d2a

また、色々とflutterのいろはを教えてくれた[@hhiroshi_ja](https://twitter.com/hhiroshi_ja)にspecial thanxでございます。

最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/