Flutter: Autocomplete not showing suggestions when suggestion list changes - flutter

Framework & Architecture
I have a specific architecture in my Flutter app. I am using BLoC pattern (flutter_bloc) to maintain state and fetch data from remote server.
How autocomplete should behave
I want to build autocomplete input. When user types, it starts fetching data from server after few milliseconds. As user types, the list of suggestions should be updated from the remote server and shown with filtered values to the user. Additionally, I need to set initial value of the autocomplete text field if such value is present 1. The way data is presented is also custom. Suggestion list presents user with suggestions containing both name and id values but text field can only contain name value (this name value is also used for searching the suggestions) 2.
I am not having much luck when using RawAutocomplete widget from Flutter material library. I have succeeded in making the initial value appear in the field by leveraging TextEditingController and didUpdateWidget method. The problem is, when I'm typing into the field, the suggestions are being fetched and passed to the widget but the suggestion list (built via optionsViewBuilder) is not being built. Usually the list appears if I change value in the field but that's too late to be useful.
This is what I have tried:
Link to live demo
NOTE: Try typing "xyz", that is a pattern that should match one of the suggestions. Waiting a bit and deleting single character will show the suggestions.
I am attaching two components as an example. Parent component called DetailPage takes care of triggering fetch of the suggestions and also stores selected suggestion / value of the input. Child component DetailPageForm contains actual input.
The example is artificially constrained but it is in regular MaterialApp parent widget. For brevity I'm not including BLoC code and just using regular streams. The code runs fine and I created it specifically for this example.
DetailPage
import 'dart:async';
import 'package:flutter/material.dart';
import 'detail_page_form.dart';
#immutable
class Suggestion {
const Suggestion({
this.id,
this.name,
});
final int id;
final String name;
}
class MockApi {
final _streamController = StreamController<List<Suggestion>>();
Future<void> fetch() async {
await Future.delayed(Duration(seconds: 2));
_streamController.add([
Suggestion(id: 1, name: 'xyz'),
Suggestion(id: 2, name: 'jkl'),
]);
}
void dispose() {
_streamController.close();
}
Stream<List<Suggestion>> get stream => _streamController.stream;
}
class DetailPage extends StatefulWidget {
final _mockApi = MockApi();
void _fetchSuggestions(String query) {
print('Fetching with query: $query');
_mockApi.fetch();
}
#override
_DetailPageState createState() => _DetailPageState(
onFetch: _fetchSuggestions,
stream: _mockApi.stream,
);
}
class _DetailPageState extends State<DetailPage> {
_DetailPageState({
this.onFetch,
this.stream,
});
final OnFetchCallback onFetch;
final Stream<List<Suggestion>> stream;
/* NOTE: This value can be used for initial value of the
autocomplete input
*/
Suggestion _value;
_handleSelect(Suggestion suggestion) {
setState(() {
_value = suggestion;
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Detail')),
body: StreamBuilder<List<Suggestion>>(
initialData: [],
stream: stream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
padding: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
color: Colors.red,
),
child: Flex(
direction: Axis.horizontal,
children: [ Text(snapshot.error.toString()) ]
)
);
}
return DetailPageForm(
list: snapshot.data,
value: _value != null ? _value.name : '',
onSelect: _handleSelect,
onFetch: onFetch,
);
}));
}
}
DetailPageForm
import 'dart:async';
import 'package:flutter/material.dart';
import 'detail_page.dart';
typedef OnFetchCallback = void Function(String);
typedef OnSelectCallback = void Function(Suggestion);
class DetailPageForm extends StatefulWidget {
DetailPageForm({
this.list,
this.value,
this.onFetch,
this.onSelect,
});
final List<Suggestion> list;
final String value;
final OnFetchCallback onFetch;
final OnSelectCallback onSelect;
#override
_DetailPageFormState createState() => _DetailPageFormState();
}
class _DetailPageFormState extends State<DetailPageForm> {
Timer _debounce;
TextEditingController _controller = TextEditingController();
FocusNode _focusNode = FocusNode();
List<Suggestion> _list;
#override
void initState() {
super.initState();
_controller.text = widget.value ?? '';
_list = widget.list;
}
#override
void dispose() {
super.dispose();
_controller.dispose();
}
#override
void didUpdateWidget(covariant DetailPageForm oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller = TextEditingController.fromValue(TextEditingValue(
text: widget.value,
selection: TextSelection.fromPosition(TextPosition(offset: widget.value.length)),
));
}
if (oldWidget.list != widget.list) {
setState(() {
_list = widget.list;
});
}
}
void _handleInput(String value) {
if (_debounce != null && _debounce.isActive) {
_debounce.cancel();
}
_debounce = Timer(const Duration(milliseconds: 300), () {
widget.onFetch(value);
});
}
#override
Widget build(BuildContext context) {
print(_list);
return Container(
padding: const EdgeInsets.all(10.0),
child: RawAutocomplete<Suggestion>(
focusNode: _focusNode,
textEditingController: _controller,
optionsBuilder: (TextEditingValue textEditingValue) {
return _list.where((Suggestion option) {
return option.name
.trim()
.toLowerCase()
.contains(textEditingValue.text.trim().toLowerCase());
});
},
fieldViewBuilder: (BuildContext context,
TextEditingController textEditingController,
FocusNode focusNode,
VoidCallback onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
onChanged: _handleInput,
onFieldSubmitted: (String value) {
onFieldSubmitted();
},
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: SizedBox(
height: 200.0,
child: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
return GestureDetector(
onTap: () {
onSelected(option);
},
child: ListTile(
title: Text('${option.id} ${option.name}'),
),
);
},
),
),
),
);
},
onSelected: widget.onSelect,
));
}
}
On the image you can see right at the end that I have to delete one letter to get the suggestions to show up.
Expected behaviour
I expect the suggestions list to be re-built every time new suggestions are available and provide them to the user.
1 The reason for that being that the input should show user a value that was selected before. This value might also be stored on the device. So the input is either empty or pre-filled with the value.
2 This example is constrained but basically text field should not contain the same text that the suggestions contain for specific reasons.

I solved this by calling the notifyListeners method which exists on the TextEditingController while I was setting the suggestions to the state.
setState(() {
_isFetching = false;
_suggestions = suggestions.sublist(0, min(suggestions.length, 5));
_searchController.notifyListeners();
});
The linter did say I should be implementing the ChangeNotifier class onto the Widget, but In this case I did not have to, it worked without it.

Move your _handleInput inside optionsBuilder becucaue the latter is called first.
optionsBuilder: (TextEditingValue textEditingValue) {
_handleInput(textEditingValue.text); // await if necessary
return _list.where((Suggestion option) {
return option.name
.trim()
.toLowerCase()
.contains(textEditingValue.text.trim().toLowerCase());
});
},

In 'optionsViewBuilder' in DetailsFormPage you need to pass _list instead of options.

Related

Flutter: GetX Obx() could not rebuild ListView items

I am working on a simple ListView. I managed to update the list view with the correct data, see items which is <String>[].obs, when it got populated the data, I can see the list view is populated.
However, it seems after the list view items are built, they are not observing my selected change, which is 0.obs. From the debugging code, I can see the selected got updated, the title changes accordingly, but the list view items did not rebuild (and hence change color), and not reflecting if they are being selected.
Please help me to understand why selected change did not trigger list item rebuild, and how to fix. Thanks!
My home_controller.dart:
import 'package:get/get.dart';
class HomeController extends GetxController {
final loading = true.obs;
final items = <String>[].obs;
final selected = 0.obs;
final count = 0.obs;
#override
void onInit() {
fetchItems();
super.onInit();
}
Future<void> fetchItems() async {
loading.value = true;
Future.delayed(const Duration(seconds: 5), () {
final newItems = ['abc', 'def', 'ghij', 'klmnopq'];
items.assignAll(newItems);
loading.value = false;
});
}
void onHover(int index) {
selected.value = index;
print('onHover: $index');
}
}
And my home_view.dart:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
class HomeView extends GetView<HomeController> {
const HomeView({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title:
Obx(() => Text('HomeView: selected ${controller.selected.value}')),
centerTitle: true,
),
body: Obx(() => Center(
child: controller.loading.value
? const CircularProgressIndicator()
: ListView.builder(
itemCount: controller.items.length,
itemBuilder: (BuildContext context, int index) {
final color = controller.selected.value == index
? Colors.green
: Colors.grey;
return MouseRegion(
onHover: (event) {
controller.onHover(index);
},
onExit: ((event) {
final selected = controller.selected.value;
print(
'exiting: $index, current selected: ${selected}');
}),
child: ListTile(
leading:
Container(width: 40, height: 40, color: color),
title: Text(controller.items[index]),
),
);
},
),
)),
);
}
}
I believe wrapping your MouseRegion with another Obx would solve it for you. It being inside another builder will not make it able to be observed by the outer Obx

Is it okay to use an onPressed function in a ChangeNotifier child class

I am using provider for state management, and I've given onTap a value in a function in the ChangeNotifier child class but my app is unresponsive, I mean, when i tap on the widget, it doesn't update state, however, it does change the values i need it to change tho, i know this coz I am debugPrinting in the onTap function and when i tap, it actually prints that the button got tapped, but state doesn't update, widget remains the same until i hot restart, then it updates everything, even hot reload doesn't update it, here's the function
class Storage extends ChangeNotifier{
static const _storage = FlutterSecureStorage();
static const _listKey = 'progress';
List _dataMaps = [];
List<DayTile> dayTileMain = [];
void createDataMap() {
for (int i = 1; i < 101; i++) {
final data = Data(number: i).toJson();
_dataMaps.add(data);
}
}
void createDayTiles() {
for(Map<String, dynamic> data in _dataMaps) {
bool isDone = data['i'];
final dayTile = DayTile(
number: data['n'],
isDone: isDone,
// This is where i need to rebuild the tree
onTap: () async {
data['i'] = true;
notifyListeners();
print(data['i']);
print(isDone);
await writeToStorage();
},
);
dayTileMain.add(dayTile);
}
print('data tiles created');
}
}
and here is the DayTile class
class DayTile extends StatelessWidget {
const DayTile({
Key? key,
required this.number,
required this.isDone,
required this.onTap,
}) : super(key: key);
final int number;
final VoidCallback onTap;
final bool isDone;
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 50,
width: MediaQuery.of(context).size.width * .15,
decoration: BoxDecoration(
color: !isDone
? const Color(0xffedecea)
: const Color(0xffedecea).withOpacity(0.1),
borderRadius: BorderRadius.circular(5),
),
child: Center(
child: Stack(
alignment: Alignment.center,
children: [
Center(
child: Text(
number.toString(),
style: const TextStyle(
color: Color(0xff576aa4),
),
),
),
Visibility(
visible: isDone,
child: const Divider(
color: Colors.black,
),
),
],
),
),
),
);
}
}
here is where I listen for the change
Wrap(
spacing: 13,
runSpacing: 13,
children: Provider.of<Storage>(context).dayTileMain,
),
when data['i'] is true, it should update the current instance of DayTile() that it's on in the loop, and in DayTile() I use the value of data['i'] to set the value of bool isDone and depending on whether isDone is true or false, the color of the widget changes and some other things, BUT, they don't change onTap, but they change after I hot restart, when it's read the storage and restored the saved data, could the secureStorage writing to storage at the same time be affecting it?
I solved it, turns out it's not a good idea to listen for events in the model class, it won't listen, so instead of generating the list of widgets in the model class, I moved it outta there, and instead generated it inside the wrap widget, and instead of listening for a list in the model class, i just had the list there in my wrap, if it was a listview i was tryna generated, i would've done this initially with a ListView.builder() i didn't know you could generate a list inside the children of the wrap widget, so i just stuck to defining it in the model, I came across this stack question Flutter: How to use Wrap instead of ListView.builder?
and that's how i knew how to build widgets inside a children property, i was actually just looking for a ListView.builder() version for the Wrap widget, all said, this is what my stuff is looking like
Model
class Storage extends ChangeNotifier {
static const _storage = FlutterSecureStorage();
static const _listKey = 'progress';
List _dataMaps = [];
List<DayTile> dayTileMain = [];
void createDataMap() {
for (int i = 1; i < 101; i++) {
final data = Data(number: i).toJson();
_dataMaps.add(data);
}
}
int get listLength {
return _dataMaps.length;
}
UnmodifiableListView get dataMaps {
return UnmodifiableListView(_dataMaps);
}
void pressed(Map<String, dynamic> map) async {
map['i'] = true;
await writeToStorage();
notifyListeners();
}
Future writeToStorage() async {
final value = json.encode(_dataMaps);
await _storage.write(key: _listKey, value: value);
}
Future<void> getTasks() async {
print('getTasks called');
final value = await _storage.read(key: _listKey);
final taskList = value == null ? null : List.from(jsonDecode(value));
if (taskList == null) {
print('getTasks is null');
createDataMap();
// createDayTiles();
} else {
print('getTasks is not null');
print(taskList);
_dataMaps = taskList;
// createDayTiles();
}
}
Future readFromStorage() async {
await getTasks();
notifyListeners();
}
}
Wrap Builder
class DayTiles extends StatelessWidget {
const DayTiles({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Consumer<Storage>(
builder: (_, storageData, __) => Wrap(
spacing: 13,
runSpacing: 13,
children: [
for(Map<String, dynamic> data in storageData.dataMaps)
DayTile(
number: data['n'],
onTap: () {
storageData.pressed(data);
},
isDone: data['i'],
),
],
),
);
}
}
and instead of using a wrap and listening for changes to it's children in the screen class, i just directly use the DayTiles() custom widget i created

Flutter: How to extract arguments which comes from another widget in a StatefulWidget?

i'm trying to extract the ModalRoute as a Global value in a StatefulWidget but it's not working, i can extract it locally under Widget build(BuildContext context) and it will work but the Global methods and widgets that i'm working on wont work, please help :'(
Here is my code,
it starts from here:
home.dart
GestureDetector(
onTap: ()async{
await Navigator.of(context).pushNamed(MainTankHomePage.routeName, arguments: widget.tankID);
//widget.tankID is a String and i extracted it in MainTankHomePage.dart with ModalRoute as a String it works perfectly so no need to change anything here
},
MainTankHomePage.dart
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:smart_tank1/main_tank_detail_ui/home/bottom_nav_bar.dart';
import 'package:smart_tank1/main_tank_detail_ui/hydration_pool/hydration_pool_page.dart';
import 'package:smart_tank1/main_tank_detail_ui/hydration_progress/hydration_progress_page.dart';
import 'package:smart_tank1/main_tank_detail_ui/summary/summary_page.dart';
class MainTankHomePage extends StatefulWidget {
static const routeName = 'main-screen';
#override
_MainTankHomePageState createState() => _MainTankHomePageState();
}
class _MainTankHomePageState extends State<MainTankHomePage> {
//------------------------------------------
//Here is my global methods that i worked on
//------------------------------------------
late final tanksID = ModalRoute.of(context)!.settings.arguments as String; // added late to get the (context) work without error line but it didn't work
final _pages = <Widget>[
//----------------------------------------
//Here is the problem that i'm facing!
//Done all of the parameters work in each widget with a required tankID
//So what i need here is just passing the extracted ModalRoute here which is the tanksID to each widget but it's not working
//-----------------------------------------
MainTankHydrationPoolPage(tankID: tanksID,),
MainHydrationProgressPage(tankID: tanksID,),
SummaryPage(tanksID: tanksID),
//-----------------------------
//Here is the error i get
//String tanksID
//package:smart_tank1/main_tank_detail_ui/home/main_tank_home_page.dart
//The instance member 'tanksID' can't be accessed in an initializer.
//Try replacing the reference to the instance member with a different //expression
//-------------------------------
];
int _currentPage = 0;
void _changePage(int index) {
if (index == _currentPage) return;
setState(() {
_currentPage = index;
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
PageTransitionSwitcher(
transitionBuilder: (
child,
primaryAnimation,
secondaryAnimation,
) {
return FadeThroughTransition(
fillColor: Theme.of(context).backgroundColor,
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: _pages[_currentPage],
),
BottomNavBar(
currentPage: _currentPage,
onChanged: _changePage,
),
],
),
);
}
}
It is possible to get null value from ModalRoute, I will suggest using nullable data.
late final String? tanksID = ModalRoute.of(context)?.settings.arguments as String?;
And pass default value while it gets null
MainTankHydrationPoolPage(tankID: tanksID??"default id",),
Or ignore build
if(tanksID!=null) MainTankHydrationPoolPage(tankID: tanksID!,),
Make sure checking null before using !.
late List<Widget> _pages;
#override
void initState() {
_pages = [
.....,
];
super.initState();
}
Check more about null-safety and check this answer.

Flutter bloc listener not listening to state changes

I am using flutter_bloc library to create a verification code page. Here is what I tried to do.
class PhonePage extends StatelessWidget {
static Route route() {
return MaterialPageRoute<void>(builder: (_) => PhonePage());
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: BlocProvider(
create: (_) =>
ValidationCubit(context.repository<AuthenticationRepository>()),
child: PhoneForm(),
),
);
}
}
class PhoneForm extends StatelessWidget {
#override
Widget build(BuildContext context) {
return BlocConsumer<ValidationCubit, ValidationState>(
listener: (context, state) {
print('Listener has been called');
if (state.status.isSubmissionFailure) {
_showVerificationError(context);
} else if (state.status.isSubmissionSuccess) {
_showVerificationSuccess(context);
}
},
builder: (context, state) {
return Container(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_HeaderAndTitle(),
_VerificationInput(),
_VerifyButton()
],
),
),
),
);
},
);
}
void _showVerificationError(context) {
Scaffold.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Validation error')));
}
void _showVerificationSuccess(context) {
Scaffold.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text('Validation Success'),
backgroundColor: Colors.green,
));
}
}
...
class _VerifyButton extends StatelessWidget {
#override
Widget build(BuildContext context) {
return BlocBuilder<ValidationCubit, ValidationState>(
builder: (context, state) {
return RaisedButton.icon(
color: Colors.blue,
padding: EdgeInsets.symmetric(horizontal: 38.0, vertical: 12.0),
textColor: Colors.white,
icon: state.status.isSubmissionInProgress
? Icon(FontAwesomeIcons.ellipsisH)
: Icon(null),
label: Text(state.status.isSubmissionInProgress ? '' : 'Verify',
style: TextStyle(fontSize: 16.0)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
onPressed: state.status.isValid
? () => context.bloc<ValidationCubit>().verifyCode()
: null);
});
}
}
Now the verifyCode() function is an async function defined inside ValidationCubit. It emits states with status set to loading, success and failure. However the listener doesn't pick up those changes and show the snackbars. I couldn't figure why? I am also using the Formz library as suggested in the flutter_bloc documentation. Here is the verifyCode part.
Future<void> verifyCode() async {
if (!state.status.isValidated) return;
emit(state.copyWith(status: FormzStatus.submissionInProgress));
try {
// send validation code to server here
await _authenticationRepository.loginWithEmailAndPassword(
email: 'email#email.com', password: '12');
emit(state.copyWith(status: FormzStatus.submissionSuccess));
} on Exception {
emit(state.copyWith(status: FormzStatus.submissionFailure));
}
}
The verification code model looks like this:
class ValidationState extends Equatable {
final VerificationCode verificationCode;
final FormzStatus status;
const ValidationState({this.verificationCode, this.status});
#override
List<Object> get props => [verificationCode];
ValidationState copyWith(
{VerificationCode verificationCode, FormzStatus status}) {
return ValidationState(
verificationCode: verificationCode ?? this.verificationCode,
status: status ?? this.status);
}
}
And the validation state class is:
class ValidationState extends Equatable {
final VerificationCode verificationCode;
final FormzStatus status;
const ValidationState({this.verificationCode, this.status});
#override
List<Object> get props => [verificationCode];
ValidationState copyWith(
{VerificationCode verificationCode, FormzStatus status}) {
return ValidationState(
verificationCode: verificationCode ?? this.verificationCode,
status: status ?? this.status);
}
}
I think the problem is your state class.
listener is only called once for each state change (NOT including the initial state) unlike builder in BlocBuilder and is a void function.
Every time when a new state is emitted by the Cubit it is compared with the previous one, and if they are "DIFFERENT", the listener function is triggered.
In you situation, you are using Equatable with only verificationCode as props, which means when two states are compared only the verificationCodes are tested. In this way BLoC consumer thinks that the two states are equal and do not triggers the listener function again.
If you check your verifyCode() function the only changing parameter is status.
In order to fix that add the status property to the props list in your state class.
#override
List<Object> get props => [this.verificationCode, this.status];
if you want to update same state just add one state before calling your updating state
like this
if you want to update 'Loaded' state again call 'Loading' state before that and than call 'Loaded' state so BlocListener and BlocBuilder will listen to it
Edited
I have changed using bloc to cubit for this and cubit can emmit same state continuously and bloclistener and blocbuilder can listen to it
I needed to delete a list item from the list and app should pops a pop-up before delete. Somehow you decline the delete pop up via no button or click outside of the pop-up, last state doesn't change. After that, if you want to delete same item, it wasn't trigger cause all parameters are same with the previous state and equatable says its same. To get rid of this issue, you need to define a rand function and put just before your emit state. You need to add a new parameter to your state and you need to add to the props. It works like a charm.
My state;
class SomeDeleteOnPressedState extends SomeState
with EquatableMixin {
final int index;
final List<Result> result;
final String currentLocation;
final int rand;/// this is the unique part.
SomeDeleteOnPressedState({
required this.index,
required this.result,
required this.currentLocation,
required this.rand,
});
// don't forget to add rand parameter in props. it will make the difference here.
#override
List<Object> get props => <Object>[index, result, currentLocation, rand];
}
and my bloc;
on<SomeDeleteEvent>((event,emit){
int rand =Random().nextInt(100000);
emit(
SomeDeleteOnPressedState(
currentLocation: event.currentLocation,
index: event.index,
result: event.result,
rand: rand,/// every time it will send a different rand, so this state is always will be different.
),
);
});
Hope it helps.

How to set DropdownButton's value programmatically?

For example, in order to set the text in a TextFormField, I can use a TextEditingController:
textEditingController = TextEditingController()
...
TextFormField(
controller: textEditingController
);
...
textEditingController.text = 'my text'; // This is where I can set the text in the TextFormField
Is there a similar way to programmatically set the selection in a DropdownButton? As far as I know, simply setting the value field in a DropdownButton won't suffice since the change won't be applied without calling the setState from the wrapping state object.
As #CopsOnRoad commented, there seem to be no shortcuts here and setState must be called in order to reflect the change in the DropdownButton's selected value. The problem is, setState is protected so I needed to go through some loops to make sure it was called when needed. I ended up doing this by implementing a notifier which the DropdownButton's state would be a listener of. Something along the lines of the following:
class MyStatefulWidget extends StatefulWidget {
final _valueNotifier = ValueNotifier<String>(null);
#override
State<StatefulWidget> createState() => MyState(_valueNotifier);
// This exposes the ability to change the DropdownButtons's value
void setDropdownValue(String value) {
// This will notify the state and will eventually call setState
_valueNotifier.value = value;
}
}
class MyState extends State<MyStatefulWidget> {
String _selection;
MyState(ValueNotifier<String> valueNotifier) {
valueNotifier.addListener(() {
setState(() {
_selection = valueNotifier.value;
});
});
}
#override
Widget build(BuildContext context) {
return DropdownButton<String>(
items: [
DropdownMenuItem<String>(
value: "1",
child: Text(
"1",
),
),
DropdownMenuItem<String>(
value: "2",
child: Text(
"2",
),
)
],
onChanged: (value) {
setState(() {
_selection = value;
});
},
value: _selection,
);
}
}
I created a simplified DropdownButton to be able to use a controller, it can be used like this:
SimpleDropdownButton(
values: _values,
itemBuilder: (value) => Text(value),
controller: _controller,
onChanged: (value) => print(_controller.value),
)
Basically the SimpleDropdownButton wraps a DropdownButton and handles the creation of its DropdownItems according to the list of values received and according to the way you want to display these values.
If you don't set a controller, then the SimpleDropdownButton will handle the selected value like we always do with DropdownButton using setState().
If you do set a controller, then the SimpleDropdownButton starts listening to the controller to know when to call setState() to update the selected value. So, if someone selects an item (onChanged) the SimpleDropdownButton won't call setState() but will set the new value to the controller and the controller will notify the listeners, and one of these listeners is SimpleDropdownButton who will call setState() to update the selected value. This way, if you set a new value to the controller, SimpleDropdownButton will be notified. Also, since the value is always stored on the controller, it can accessed at anytime.
Here is the implementation, you may want to pass more parameters to the DropdownButton:
class SimpleDropdownButton<T> extends StatefulWidget {
final List<T> values;
final Widget Function(T value) itemBuilder;
final SimpleDropdownButtonController<T> controller;
final ValueChanged onChanged;
SimpleDropdownButton(
{this.controller,
#required this.values,
#required this.itemBuilder,
this.onChanged});
#override
_SimpleDropdownButtonState<T> createState() =>
_SimpleDropdownButtonState<T>();
}
class _SimpleDropdownButtonState<T> extends State<SimpleDropdownButton<T>> {
T _value;
#override
void initState() {
super.initState();
if (widget.controller != null) {
_value = widget.controller.value;
widget.controller.addListener(() => setState(() {
_value = widget.controller.value;
}));
}
}
#override
void dispose() {
widget.controller?.close();
super.dispose();
}
#override
Widget build(BuildContext context) {
return DropdownButton(
value: _value,
items: widget.values
.map((value) => DropdownMenuItem(
value: value,
child: widget.itemBuilder(value),
))
.toList(),
onChanged: (value) {
if (widget.controller != null) {
widget.controller.value = value;
} else {
setState(() {
_value = value;
});
}
widget.onChanged?.call(value);
},
);
}
}
class SimpleDropdownButtonController<T> {
List<VoidCallback> _listeners = [];
T _value;
SimpleDropdownButtonController([this._value]);
get value => _value;
set value(T value) {
_value = value;
_listeners?.forEach((listener) => listener());
}
void addListener(VoidCallback listener) => _listeners.add(listener);
void close() => _listeners?.clear();
}
And an example to use it:
final _values = ["Value 1", "Value 2", "Value 3", "Value 4"];
final _controller = SimpleDropdownButtonController("Value 1");
#override
Widget build(BuildContext context) {
print('build()');
return Scaffold(
appBar: AppBar(title: Text("SimpleDropdownButton")),
floatingActionButton: FloatingActionButton(
onPressed: () => _controller.value = "Value 3",
),
body: SimpleDropdownButton(
values: _values,
itemBuilder: (value) => Text(value),
controller: _controller,
onChanged: (value) => print(_controller.value),
),
);
}
If you separate the logic from your ui, and pass events through streams that are listened to by your ui, you can get around using setState and the logic is easier to work with.
StreamBuilder is a great widget that can simplify your ui code a lot if you get used to using it. Essentially, every time a new value passes through the stream, the builder function is re-run, and whatever was put into the stream, like a new dropdown button value, can be found in snapshot.data.
Here's an example:
in your ui, you might build the dropdown button like this:
StreamBuilder<String>(
stream: logicClass.dropdownValueStream,
builder: (context, snapshot) {
return DropdownButton(
items: logicClass.menuItems,
onChanged: logicClass.selectItem,
value: snapshot.data,
);
})
and in the logic class you would build the stream like this:
StreamController<String> _dropDownController = StreamController<String>();
Stream<String> get dropdownValueStream => _dropDownController.stream;
Function get selectItem => _dropDownController.sink.add;
Finally, if you want to do anything with this data, you can store it in the logic class as it passes through the stream. This is essentially separation of UI logic from business logic, or the Bloc pattern.