完成デモ
GitHub にあげてあります => https://github.com/sensuikan1973/Flutter_RxDart_GetStarted
公式チュートリアル「GetStarted」に、「いいね件数の表示機能」を追加しただけ!
「BLoCパターン」と「RxDart」をテーマに実装しました。1
なお追加機能は単純ですがテーマのもと実装したので、チュートリアルとは全然違うコードになってます。
また、Dartには標準でStreamがある ため、RxDartの使用は部分的になっています。(RxDartの登場はここ)
備考
- BLoC + Rx で実装するために見るもの という記事に、前提となる知識を書いたのでよろしければそちらも。
- この記事は Google I/O '18 | Build reactive mobile apps with Flutter の発表直後に書いたものです。
- GitHub の Issue には書きましたが、Flutter や RxDart のアプデなどに追従するとより望ましい書き方ができます。(時間に余裕ができたら push しておきます...)
そもそもRxDartを使うモチベは?
**Google I/O '18 | Build reactive mobile apps with Flutter**を見ると、図とともに以下のような説明がある。
「UIプログラミングで状態の変更を伝えるにあたっては、DartStreamsとRxDartを使うのがオススメ」(意訳)
上記のGoogle I/Oの動画は、Flutterと状態管理(Rx等)をわかりやすく説明していて必見だと感じました。
このデモのソースコード、めちゃくちゃ参考にしました!
RxDart
##導入方法
Dart Package | rxdartに記載のある通り。
導入したら、import 'package:rxdart/rxdart.dart';
だけを追加してデバッグが成功することを確認しましょう。
なお、シミュレータを起動するのは以下のような手順。
[iOS]
open -a Simulator.app
[Android]
- Android Studioを起動し、Tools > AVD Managerで起動しておく。
- VSCodeからRunすると、それを認識してAndroidで起動してくれる
使い方
本記事では、WordBlocクラスでRxDartを使用しています。(具体的にはBehaviorSubject)
実践的なサンプルはここ等が参考になります。
余談ですが、READMEにコナミコマンドが登場してて面白いです。
実装
ディレクトリ構成は以下の通り。 今回関係あるのは、lib以下のみ。
Modelを定義
WordItem
文字列をnameとして持つだけのクラス
class WordItem {
final String name;
const WordItem(this.name);
@override
String toString() => "$name";
}
Word
今回はWordを表示するアプリであり、それをモデル化したもの
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パッケージのもの。
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
いいね件数を表示するラベルを定義
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を定義
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への参照を持つ。
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
今回の要
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
いいねした単語の表示
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.items
とbuilder: (context, snapshot) {}
の指定により、word.itemsというStreamに応じてViewを作っているということ。
Main_
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、慣れれば結構早く開発できそうな気がしたので、積極的に採用を検討していきたい
-
参考「BLocについて」
・ 公式の説明動画
・ FlutterのBLoCパターンをAngularで理解する
・ DartからきたBLoCの話 ↩