LoginSignup
11
14

More than 3 years have passed since last update.

【Flutter】BLoCパターンでアプリを作成してみた。~カウンターアプリ、githubリポジトリ検索アプリ~

Last updated at Posted at 2020-04-06

BLoCパターンとは

BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。

こちらを参考にしてください。

サンプルアプリの紹介

Githubはこちら

カウンターアプリ

プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。

カウンターアプリカウンターアプリ

githubリポジトリ検索アプリ

TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。

githubリポジトリ検索アプリgithubリポジトリ検索アプリ

ソースコード解説

main.dart
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の作成

counter_page.dart
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の生成

counter_bloc.dart
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の要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
image.png
引用: 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)を流している。
Dart
_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の生成

search_page.dart
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の生成

search_bloc.dart
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を利用しました。

こちら参考記事になります。

api_service.dart
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'
  });
}
chopper_client_creater.dart
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(),
    );
  }
}
api_service.chopper.dart
// 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);
  }
}

参考記事

11
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
14