Help us understand the problem. What is going on with this article?

【BLoC/RxDart入門】 Flutterの公式チュートリアルを書き換える

More than 1 year has passed since last update.

完成デモ

2018-05-26 17.04.14.gif
GitHub にあげてあります => https://github.com/sensuikan1973/Flutter_RxDart_GetStarted

公式チュートリアル「GetStarted」に、「いいね件数の表示機能」を追加しただけ!
BLoCパターン」と「RxDart」をテーマに実装しました。1

なお追加機能は単純ですがテーマのもと実装したので、チュートリアルとは全然違うコードになってます。
また、Dartには標準でStreamがある ため、RxDartの使用は部分的になっています。(RxDartの登場はここ)

備考

そもそもRxDartを使うモチベは?

Google I/O '18 | Build reactive mobile apps with Flutterを見ると、図とともに以下のような説明がある。
「UIプログラミングで状態の変更を伝えるにあたっては、DartStreamsとRxDartを使うのがオススメ」(意訳)
screenshot.png

上記のGoogle I/Oの動画は、Flutter状態管理(Rx等)をわかりやすく説明していて必見だと感じました。
このデモのソースコードめちゃくちゃ参考にしました!

RxDart

導入方法

Dart Package | rxdartに記載のある通り。
導入したら、import 'package:rxdart/rxdart.dart';だけを追加してデバッグが成功することを確認しましょう。

なお、シミュレータを起動するのは以下のような手順。
[iOS]
open -a Simulator.app
[Android]
1. Android Studioを起動し、Tools > AVD Managerで起動しておく。
2. VSCodeからRunすると、それを認識してAndroidで起動してくれる

使い方

本記事では、WordBlocクラスでRxDartを使用しています。(具体的にはBehaviorSubject)

実践的なサンプルはここ等が参考になります。
余談ですが、READMEコナミコマンドが登場してて面白いです。

実装

ディレクトリ構成は以下の通り。 今回関係あるのは、lib以下のみ。
スクリーンショット 2018-05-26 17.34.28.png

Modelを定義

WordItem

文字列をnameとして持つだけのクラス

word_item.dart
class WordItem {
  final String name;
  const WordItem(this.name);

  @override
  String toString() => "$name";
}

Word

今回はWordを表示するアプリであり、それをモデル化したもの

word.dart
import 'package:sample/models/word_item.dart';
import 'dart:collection';

class Word {
  final List<WordItem> _items = <WordItem>[];

  Word();

  Word.clone(Word word) {
    _items.addAll(word._items);
  }

  int get itemCount => _items.length;

  // 変更不可なリスト
  UnmodifiableListView<WordItem> get items => UnmodifiableListView(_items);

  void add (String name) {
    _updateCountAdd(name);
  }

  void remove (String name) {
    _updateCountRemove(name);
  }

  @override
  String toString() => "$items";

  void _updateCountAdd(String name) {
    for (int i = 0; i < _items.length; i++) {
      final item = _items[i];
      // 既に追加済みなら無視
      if (name == item.name) { return; }
    }
    _items.add(WordItem(name));
  }

  void _updateCountRemove(String name) {
    for (int i = 0; i < _items.length; i++) {
      final item = _items[i];
      if (name == item.name) {
         _items.removeAt(i);
      }
    }
  }
}

Suggestion

今回のアプリは、ランダムな単語を生成する外部パッケージを使用しているわけだが、その生成による単語の候補をモデル化したもの。
WordPairはチュートリアルで説明がある通り、english_wordsパッケージのもの。

suggestion.dart
import 'dart:collection';
import 'package:english_words/english_words.dart';

final Suggestion suggestion = Suggestion();

class Suggestion {
  final List<WordPair> _suggestedWords = <WordPair>[]; // 生成された単語を保持

  Suggestion();

  Suggestion.clone(Suggestion suggestion) {
    _suggestedWords.addAll(suggestion._suggestedWords);
  }

  int get suggestionCount => _suggestedWords.length;

  UnmodifiableListView<WordPair> get suggestedWords => UnmodifiableListView(_suggestedWords);

  void add (WordPair wordPair) {
    _updateCount(wordPair);
  }

  // WordPairを一気に生成して、一気に追加するために定義
  void addMulti (List<WordPair> wordPairs) {
    for (int i = 0; i < wordPairs.length; ++i) {
      _updateCount(wordPairs[i]);
    }
  }

  @override
  String toString() => "$suggestedWords";

  void _updateCount(WordPair wordPair) {
    _suggestedWords.add(wordPair);
  }
}

Widgetを定義

CountLabel

いいね件数を表示するラベルを定義

count_label.dart
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

class CountLabel extends StatefulWidget {
  final int favoriteCount; //表示したいカウント

  CountLabel({
    Key key,
    @required this.favoriteCount,
  })  : assert(favoriteCount >= 0),
        super(key:key);

  @override
  CountLabelState createState() {
    return CountLabelState();
  }
}

class CountLabelState extends State<CountLabel> {
  @override
  Widget build(BuildContext context) {
    return Text(
      widget.favoriteCount.toString() ,
      style: new TextStyle(
        fontWeight:  FontWeight.bold,
        fontSize: 35.0, //目立つようにでかくしてある
        color: Colors.pink),
      );
  }
}

FavoritePage

いいねした単語を閲覧するページのViewを定義

favorite_page.dart
import 'package:flutter/material.dart';
import 'package:sample/models/word.dart';

class FavoritePage extends StatelessWidget {
  FavoritePage(this.word);
  final Word word;

  static const routeName = "/favorite";

  @override
  Widget build(BuildContext context) {
    final tiles = word.items.map(
      (item){
        return new ListTile(
          title: new Text(item.name)
        );
      }
    );

    final divided = ListTile
      .divideTiles(
        context: context,
        tiles: tiles,
      )
      .toList();

    return new Scaffold(
      appBar: AppBar(
        title: Text("Your Favorite")
      ),
      body: new ListView(children: divided),
    );
  }
}

Componentを定義

WordProvider

WordBlocのラッパーであり、WordBlocへの参照を持つ。

word_provider.dart
import 'package:flutter/widgets.dart';
import 'package:sample/word_bloc.dart';

class WordProvider extends InheritedWidget {
  final WordBloc wordBloc;

  WordProvider({
    Key key,
    WordBloc wordBloc,
    Widget child,
  })  : wordBloc = wordBloc ?? WordBloc(),
        super(key: key, child: child);

@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;

// WordBlocへの参照を提供
static WordBloc of(BuildContext context) =>
  (context.inheritFromWidgetOfExactType(WordProvider) as WordProvider)
    .wordBloc;
}

WordBloc

今回の要

word_bloc.dart
import 'dart:async';

import 'package:sample/models/word.dart';
import 'package:sample/models/word_item.dart';
import 'package:rxdart/subjects.dart';

class WordAddition {
  final String name;
  WordAddition(this.name);
}

class WordRemoval {
  final String name;
  WordRemoval(this.name);
}

class WordBloc {
  final Word _word = Word();

  final BehaviorSubject<List<WordItem>> _items =
      BehaviorSubject<List<WordItem>>(seedValue: []);

  final BehaviorSubject<int> _itemCount =
      BehaviorSubject<int>(seedValue: 0);

  final StreamController<WordAddition> _wordAdditionController =
      StreamController<WordAddition>();

  final StreamController<WordRemoval> _wordRemovalController =
  StreamController<WordRemoval>();

  WordBloc() {
    _wordAdditionController.stream.listen((addition){
      int currentCount = _word.itemCount;
      _word.add(addition.name);
      _items.add(_word.items);
      int updateCount = _word.itemCount;
      if (updateCount != currentCount) {
        _itemCount.add(updateCount);
      }
    });

    _wordRemovalController.stream.listen((removal){
      int currentCount = _word.itemCount;
      _word.remove(removal.name);
      print(_word.items.toString());
      _items.add(_word.items);
      int updateCount = _word.itemCount;
      if (updateCount != currentCount) {
        _itemCount.add(updateCount);
      }
    });
  }

  Sink<WordAddition> get wordAddition => _wordAdditionController.sink;

  Sink<WordRemoval> get wordRemoval => _wordRemovalController.sink;

  Stream<int> get itemCount => _itemCount.stream;

  Stream<List<WordItem>> get items => _items.stream;

  void dispose() {
    _items.close();
    _itemCount.close();
    _wordAdditionController.close();
    _wordRemovalController.close();
  }
}

BehaviorSubjectを使ってる

今回登場する、RxDartはココだけ
BehaviorSubjectは、StreamControllerの一種。
役割としては、「最新の値が追加されたら、それを購読者に発行する」ことであり、最後に発行した値をキャッシュしている。
今回は、「いいねされた単語のリスト」と「その数」をBehaviorSubjectに追加することで、購読者にそれを知らせるようにしている。

BlocFavoritePage

いいねした単語の表示

bloc_favorite_page.dart
import 'package:flutter/material.dart';
import 'package:sample/models/word_item.dart';
import 'package:sample/word_provider.dart';

class BlocFavoritePage extends StatelessWidget{
  BlocFavoritePage();

  static const routeName = "/favorite";

  @override
  Widget build(BuildContext context) {
    final word = WordProvider.of(context);
    return Scaffold(
      appBar: AppBar(
        title: Text("Your Favorite"),
      ),
      body: StreamBuilder<List<WordItem>>(
        stream: word.items,
        builder: (context, snapshot) {
          if (snapshot.data == null || snapshot.data.isEmpty) {
            return Center(child: Text('Empty'));
          }
          final tiles = snapshot.data.map(
            (item) {
              return new ListTile(
                title: new Text(item.name)
              );
            }
          );

          final divided = ListTile
            .divideTiles(
              context: context,
              tiles: tiles,
            )
            .toList();

          return new ListView(children: divided);
        },
      ),
    );
  }
}

StreamBuilderで、bodyを生成している。
stream: word.itemsbuilder: (context, snapshot) {}の指定により、word.itemsというStreamに応じてViewを作っているということ。

Main_

main.dart
import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';
import 'package:sample/bloc_favorite_page.dart';
import 'package:sample/models/word_item.dart';
import 'package:sample/word_bloc.dart';
import 'package:sample/word_provider.dart';
import 'package:sample/models/suggestion.dart';
import 'package:sample/widgets/count_label.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return WordProvider(
      child: MaterialApp(
        title: 'Startup Name Generator',
        theme: new ThemeData(primaryColor: Colors.white),
        home: RandomWordsHomePage(),
        routes: <String, WidgetBuilder> {
          // 「./favorite」に、BlocFavoritePageを登録
          BlocFavoritePage.routeName: (context) => BlocFavoritePage()
        },
      ),
    );
  }
}

class RandomWordsHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final wordBloc = WordProvider.of(context);
    return Scaffold(
      appBar: AppBar(
        title: Text("Startup Name Generator"),
        actions: <Widget>[
          StreamBuilder<int>(
            stream: wordBloc.itemCount,
            initialData: 0,
            builder: (context, snapshot) => CountLabel(
              favoriteCount: snapshot.data,
            )
          ),
          new IconButton(
            icon: const Icon(Icons.list),
            onPressed: (){
              Navigator.of(context).pushNamed(BlocFavoritePage.routeName);
            }
          )
        ],
      ),
      body: WordList(),
    );
  }
}

class WordList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new ListView.builder(
      padding: const EdgeInsets.all(16.0),

      itemBuilder: (context, i) {
        if (i.isOdd) return new Divider();

        final index = i ~/ 2;
        if (index >= suggestion.suggestionCount) {
          const addNum = 10;
          suggestion.addMulti(generateWordPairs().take(addNum).toList());
        }
        return _buildRow(WordProvider.of(context), suggestion.suggestedWords[index]);
      }
    );
  }
}

// Favorite済み、すなわちword.itemsに含まれていれば、赤いハートアイコンにする
Widget _buildRow(WordBloc word, WordPair pair) {
  return new StreamBuilder<List<WordItem>>(
    stream: word.items,
    builder: (_, snapshot) {
      if (snapshot.data == null || snapshot.data.isEmpty) {
        return _createWordListTile(word, false, pair.asPascalCase);
      } else {
        final addedWord = snapshot.data.map(
          (item) {
            return item.name;
          }
        );
        final alreadyAdded = addedWord.toString().contains(pair.asPascalCase);
        return _createWordListTile(word, alreadyAdded, pair.asPascalCase);
      }
    }
  );
}

ListTile _createWordListTile(WordBloc word, bool isFavorited, String title) {
  return new ListTile(
    title: new Text(title),
    trailing: new Icon(
      isFavorited ? Icons.favorite : Icons.favorite_border,
      color: isFavorited ? Colors.red : null,
    ),
    onTap: () {
      if (isFavorited) {
        word.wordRemoval.add(WordRemoval(title));
      } else {
        word.wordAddition.add(WordAddition(title));
      }
    },
  );
}

【いいね件数】
StreamBuilder<int>で、wordBlocのitemCountに応じてCountLabelを生成するようにしている。
これで、いいねの追加に応じてCountLabelの表示も変えられる。

【いいね済みかどうかの表示】
StreamBuilder<List<WordItem>>で、word.itemsに応じてアイコンを生成するようにしている。
これで、いいね済みかどうかに合わせてアイコンを表示できる。

おわりに

Flutterにおける、状態管理「DartStreams + RxDart」をやってみました。

Flutter、慣れれば結構早く開発できそうな気がしたので、積極的に採用を検討していきたい:raised_hand:

sensuikan1973
Flutter+GCP/Firebase. Othello player.
https://github.com/sensuikan1973
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした