BLoCパターンとは
BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。
こちらを参考にしてください。
サンプルアプリの紹介
カウンターアプリ
プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。
githubリポジトリ検索アプリ
TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。
ソースコード解説
import 'Model/counter_bloc.dart';
import 'Model/search_bloc.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'UI/counter_page.dart';
import 'UI/search_page.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MultiProvider(
providers: [
Provider<CounterBloc>(
create: (context) => CounterBloc(),
dispose: (context, bloc) => bloc.dispose(),
),
Provider<SearchBloc>(
create: (context) => SearchBloc(),
dispose: (context, bloc) => bloc.dispose(),
),
],
child: MyHomePage(title: 'Flutter BLoC Sample'),
)
);
}
}
class MyHomePage extends StatelessWidget {
MyHomePage({this.title});
final String title;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Text(title),
bottom: TabBar(
tabs: <Widget>[
new Tab(
text: "Count",
),
new Tab(
text: "Search",
),
],
),
),
body: TabBarView(
children: <Widget> [
CounterPage(),
SearchPage(),
],
),
// This trailing comma makes auto-formatting nicer for build methods.
),
);
}
}
Providerの利用
Providerを使うことで、childパラメータに指定したWidget以下全てのWidgetで、同じBLoCインスタンスにアクセスすることができます。
複数のProviderを設定する場合は、MultiProviderを設定します。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MultiProvider(
providers: [
Provider<CounterBloc>(
create: (context) => CounterBloc(),
dispose: (context, bloc) => bloc.dispose(),
),
Provider<SearchBloc>(
create: (context) => SearchBloc(),
dispose: (context, bloc) => bloc.dispose(),
),
],
child: MyHomePage(title: 'Flutter BLoC Sample'),
)
);
}
}
Counter UIの作成
import '../Model/counter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CounterPage extends StatelessWidget {
CounterPage();
@override
Widget build(BuildContext context) {
final counterBloc = Provider.of<CounterBloc>(context);
return new Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
StreamBuilder(
initialData: 0,
stream: counterBloc.count,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.display1,
);
},
)
],
),
),
floatingActionButton: Column(
verticalDirection: VerticalDirection.up, // childrenの先頭を下に配置
mainAxisSize: MainAxisSize.min,
children: <Widget>[
FloatingActionButton(
onPressed: () {
counterBloc.changeCountAction.add(false);
},
tooltip: 'Declement',
child: Icon(Icons.remove),
),
Container( // 余白のためContainerでラップ
margin: EdgeInsets.only(bottom: 16.0),
child: FloatingActionButton(
onPressed: () {
counterBloc.changeCountAction.add(true);
},
tooltip: 'Increment',
backgroundColor: Colors.redAccent,
child: Icon(Icons.add),
),
),
],
)
);
}
}
Counter BLoCの生成
import 'dart:async';
import 'package:rxdart/rxdart.dart';
class CounterBloc {
// input
final _actionController = BehaviorSubject<bool>();
Sink<void> get changeCountAction => _actionController.sink;
//output
final _countController = BehaviorSubject<int>();
Stream<int> get count => _countController.stream;
int _count = 0;
CounterBloc() {
_actionController.stream.listen((isPlus) {
if (isPlus) {
_count++;
} else {
_count--;
}
_countController.sink.add(_count);
});
}
void dispose() {
_actionController.close();
_countController.close();
}
}
BLoCの呼び出し
BLoCは、子Widgetのbuild()メソッドで呼ぶのが定番です。
@override
Widget build(BuildContext context) {
final counterBloc = Provider.of<CounterBloc>(context);
return new Scaffold(
Sink<T>.add()
でBLoCに値を送る
例では、counterBloc.changeCountAction
に対して、プラスならTrue,マイナスならFalseのBooleanを渡しています。
child: FloatingActionButton(
onPressed: () {
counterBloc.changeCountAction.add(true);
},
Stream.listen
でinputの値に対して処理を実行
Stream.listen
で流れてきたBooleanを受け取る。
ちなみにStreamはこんなイメージを持ってもらえば良いと思う。
用意された川に対して、今回だとBooleanの要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
引用: https://medium.com/@teivah/reactivewm-a-reactive-framework-for-webmethods-2c91c7de82b3
CounterBloc() {
_actionController.stream.listen((isPlus) {
if (isPlus) {
_count++;
} else {
_count--;
}
_countController.sink.add(_count);
});
}
以下のコードで再度、別のStreamに要素(この例ではint)を流している。
_countController.sink.add(_count);
StreamBuilder
で値の受け取り
StreamBuilderを使って、Streamの値を反映します。StreamBuilderを使うことで、build()メソッドを呼ぶことなくStreamの値に応じてこの箇所だけUIを更新することができます。
StreamBuilder(
initialData: 0,
stream: counterBloc.count,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.display1,
);
},
)
これで、カウンターアプリは完成です。
続いて、、、githubのリポジトリ検索アプリを紹介
検索画面UIの生成
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../Model/search_bloc.dart';
import 'package:flutter_inappbrowser/flutter_inappbrowser.dart';
class SearchPage extends StatelessWidget {
final _formKey = GlobalKey<FormState>();
TextEditingController queryInputController = TextEditingController(text: '');
@override
Widget build(BuildContext context) {
final searchBloc = Provider.of<SearchBloc>(context);
return Scaffold(
body: Center(
child: Column(children: <Widget>[
Form(
key: _formKey,
child: Column(children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: TextFormField(
decoration: InputDecoration(
labelText: '検索キーワード',
),
controller: queryInputController,
))),
RaisedButton(
child: const Text('検索'),
onPressed: () =>
searchBloc.changeQuery.add(queryInputController.text)),
])),
StreamBuilder(
stream: searchBloc.result,
builder: (context, snapshot) {
if (snapshot.hasError) {
print(snapshot.error);
// snapshot.error を使ったWidgetを返す
// snapshot は AsyncSnapshot<T> で
}
if (snapshot.data != null) {
return Expanded(
child: ListView.builder(
scrollDirection: Axis.vertical,
shrinkWrap: true,
itemCount: snapshot.data.length,
itemBuilder: (context, int index) {
var item = snapshot.data[index];
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.black38),
),
),
child: ListTile(
title: Text(item['full_name']),
subtitle: Text('Star: ' + item['stargazers_count'].toString()),
onTap: () {
this.openBrowser(url: item['html_url']);
},
));
}),
);
}
return Container();
})
])));
}
openBrowser({String url}) {
ChromeSafariBrowser browser = ChromeSafariBrowser();
browser.open(url: url, options: ChromeSafariBrowserClassOptions(
androidChromeCustomTabsOptions: AndroidChromeCustomTabsOptions(
addShareButton: true,
toolbarBackgroundColor: "#ff0000",
enableUrlBarHiding: true,
),
iosSafariOptions: IosSafariOptions(
barCollapsingEnabled: true,
)
));
}
}
検索BLoCの生成
import 'dart:async';
import 'dart:convert';
import 'package:flutterbloc/Model/API/api_service.dart';
import 'package:rxdart/rxdart.dart';
import 'API/chopper_client_creater.dart';
import 'package:chopper/chopper.dart';
class SearchBloc {
final searchApi = SearchApi();
final searchQueryController = BehaviorSubject<String>();
Stream<String> get query => searchQueryController.stream;
StreamSink<String> get changeQuery => searchQueryController.sink;
// APIの返り値となるSearchResult型を自作したと仮定
final searchResultController = BehaviorSubject<List<dynamic>>();
Stream<List<dynamic>> get result => searchResultController.stream;
StreamSink<List<dynamic>> get changeResult => searchResultController.sink;
SearchBloc() {
query.listen((v) async {
// APIの返り値となるSearchResult型を自作したと仮定
final List<dynamic> searchResults = await searchApi.fetchApi(query: v);
print("------");
print(searchResults);
print("------");
if (searchResults.isEmpty) {
changeResult.addError(searchResults);
} else {
changeResult.add(searchResults);
}
});
}
void dispose() {
searchResultController.close();
searchQueryController.close();
}
}
class SearchApi {
final ApiService service =
ApiService.create(ChopperClientCreator.create());
Future<List<dynamic>> fetchApi({String query}) async {
final Response response = await service.fetchApi(query: query);
if (response.isSuccessful) {
return response.body['items'];
} else {
print(response.error);
}
}
}
APIモデルの生成
今回はChopperを利用しました。
こちら参考記事になります。
import 'package:chopper/chopper.dart';
part 'api_service.chopper.dart';
@ChopperApi(baseUrl: '')
abstract class ApiService extends ChopperService {
static ApiService create([ChopperClient client]) =>
_$ApiService(client);
@Get(path: "/repositories")
Future<Response> fetchApi({
@Query('q') String query,
@Query('sort') String sort = 'stars'
});
}
import 'package:chopper/chopper.dart';
class ChopperClientCreator {
static final String baseUrl = "https://api.github.com/search";
static ChopperClient create() {
return ChopperClient(
baseUrl: ChopperClientCreator.baseUrl,
converter: JsonConverter(),
);
}
}
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_service.dart';
// **************************************************************************
// ChopperGenerator
// **************************************************************************
// ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations
class _$ApiService extends ApiService {
_$ApiService([ChopperClient client]) {
if (client == null) return;
this.client = client;
}
@override
final definitionType = ApiService;
@override
Future<Response<dynamic>> fetchApi({String query, String sort = 'stars'}) {
final $url = '/repositories';
final $params = <String, dynamic>{'q': query, 'sort': sort};
final $request = Request('GET', $url, client.baseUrl, parameters: $params);
return client.send<dynamic, dynamic>($request);
}
}