GardenMobile & interfaces

A stream-based BLoC search screen in Flutter

An older separation of input, loading, results, and asynchronous search logic.

A search screen has several kinds of state: the text being entered, whether work is in progress, and the results or error to display. This earlier Flutter BLoC example kept the transformation outside the widget and exposed streams to the UI.

Historical design. The snippets use older Flutter and RxDart APIs, plus application-specific models. They are preserved as a design experiment, not a newly compiled implementation.

Connect the three UI regions

Input forwards text changes to the BLoC:

child: TextField(
    onChanged: bloc.searchUser.push,
    autofocus: true,
    decoration: InputDecoration(
      border: InputBorder.none,
      hintText: 'Please enter a search term',
    ),
  ),

A separate stream controls the loading indicator:

  Container(
    child: StreamBuilder(
      stream: bloc.loading.stream,
      builder: (context, loading) {
        if (loading.hasData && loading.data) {
          return Center(
            child: CircularProgressIndicator(),
          );
        }
        return Container();
      },
    ),
  ),

The result area renders errors, an empty state, or a list with detail navigation:

  Expanded(
    child: StreamBuilder(
      stream: bloc.searchUser.stream,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Text(snapshot.error.toString());
        }
        if (!snapshot.hasData || (snapshot?.data)?.length == 0) {
          return Text('No data');
        }
        List<UserBase> users = snapshot.data;
        return ListView.builder(
          itemCount: users.length,
          itemBuilder: (BuildContext context, int index) {
            return FlatButton(
              child: Row(
                children: <Widget>[
                  CircleAvatar(
                    backgroundImage:
                        NetworkImage(users[index].avatarUrl),
                    radius: 20.0,
                  ),
                  Padding(
                    padding: EdgeInsets.only(left: 10, right: 10),
                    child: Text('${users[index].login}'),
                  ),
                ],
              ),
              onPressed: () {
                Navigator.push(
                    context,
                    MaterialPageRoute(
                        builder: (context) =>
                            DetailScreen(userBase: users[index])));
              },
            );
          },
        );
      },
    ),
  ),

Map input to output

The small generic BLoC wraps input and output subjects:

import 'package:rxdart/rxdart.dart';

class Bloc<I, O> {
  // @nhancv 2019-10-04: Input data driven
  final BehaviorSubject<I> _inputSubject = BehaviorSubject<I>();

  // @nhancv 2019-10-04: Output data driven
  final BehaviorSubject<O> _outputSubject = BehaviorSubject<O>();

  // @nhancv 2019-10-04: Dynamic logic
  // Transfer data from input to mapper to output
  set logic(Observable<O> Function(Observable<I> input) mapper) {
    mapper(_inputSubject).listen(_outputSubject.sink.add);
  }

  // @nhancv 2019-10-04: Push input data to BLoC
  void push(I input) => _inputSubject.sink.add(input);

  // @nhancv 2019-10-04: Stream output from BLoC
  Stream<O> get stream => _outputSubject;

  // @nhancv 2019-10-04: Dispose BLoC
  void dispose() {
    _inputSubject.close();
    _outputSubject.close();
  }

}

A BehaviorSubject keeps a latest value for a new listener. That helps a rebuilt listener render existing state, but does not make every stream side effect or subscription lifetime automatically safe.

Build the search transformation

import 'dart:convert';

import 'package:bflutter/bflutter.dart';
import 'package:bflutter_poc/api.dart';
import 'package:bflutter_poc/model/user_base.dart';
import 'package:flutter/cupertino.dart';
import 'package:rxdart/rxdart.dart';

class SearchBloc {
  final loading = BlocDefault<bool>();
  final searchUser = Bloc<String, List<UserBase>>();

  SearchBloc() {
    _initSearchUserLogic();
  }

  void _initSearchUserLogic() {
    searchUser.logic = (Observable<String> input) => input
            .distinct()
            .debounceTime(Duration(milliseconds: 500))
            .flatMap((input) {
          //show loading
          loading.push(true);
          if (input.isEmpty) return Observable.just(null);
          return Observable.fromFuture(Api().searchUsers(input));
        }).map((data) {
          if (data == null) {
            return <UserBase>[];
          }

          if (data.statusCode == 200) {
            final List<UserBase> result = json
                .decode(data.body)['items']
                .cast<Map<String, dynamic>>()
                .map<UserBase>((item) => UserBase.fromJson(item))
                .toList();
            return result;
          } else {
            throw (data.body);
          }
        }).handleError((error) {
          loading.push(false);
          throw error;
        }).doOnData((data) {
          loading.push(false);
        });
  }

  void dispose() {
    loading.dispose();
    searchUser.dispose();
  }
}

The pipeline deduplicates text, waits 500 milliseconds, calls the API, maps the result, and updates loading state. Its flatMap can leave searches overlapping; an older response may arrive later. That ordering needs a policy if the latest query is supposed to win.

Dispose owned resources

class ___SearchInfoState extends State<_SearchInfo> {
  final bloc = SearchBloc();

  @override
  void dispose() {
    bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {}

}

The final excerpt is a lifecycle sketch with an empty build body, not a standalone widget. The mapper subscription and subjects also need consistent ownership and cancellation.

The original public example repository provides the surrounding application context. No search request or Flutter build was run for this revision.

← Back to the garden