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.