I have a username textfield, a password textfield and a button.
When one of these fields are empty, the button should be disabled (onPressed null). When both of these fields contains text, the button should be enabled (onPressed not null).
A very basic usecase, which I cannot find a solution for.
ValueListenableBuilder only works for a single TextEditingController, and Listenable.merge cannot be uses on multiple TextEditingController´s either.
I read another solution where you could wrap multiple ValueListenableBuilders into another widget, and then use this widget. But this seems to "complex" for me for such a simple problem, and this is not a nice scaleable solution.
I´ve tried creating a getter:
get loginButtonEnabled {
if (_usernameController.text != '' && _passwordController.text != '' && !loading) {
return true;
}
return false;
}
then use it on the button:
ElevatedButton.icon(
onPressed: loginButtonEnabled ? loginClicked : null,
label: Text('Login'),
),
and when onChanged fired on my textfields, they would call setState() to update the ui.
Problem with this is, that when the user types in some text, the textfield loses focus for some reason. And furthermore, this is not an elegant solution either.
So is there a more suitable approach to this common usecase?
You can follow this snippet, I am listening both controller. You might use StatefulWidget, for that overrideinitState and dispose state
class LogForm extends StatelessWidget {
const LogForm({super.key});
#override
Widget build(BuildContext context) {
final ValueNotifier<bool> isEnabled = ValueNotifier(false);
late final TextEditingController passController;
late final TextEditingController nameController;
nameController = TextEditingController()
..addListener(() {
if (passController.text.isNotEmpty && nameController.text.isNotEmpty) {
isEnabled.value = true;
} else {
isEnabled.value = false;
}
});
passController = TextEditingController()
..addListener(() {
if (passController.text.isNotEmpty && nameController.text.isNotEmpty) {
isEnabled.value = true;
} else {
isEnabled.value = false;
}
});
return Column(
children: [
TextFormField(
controller: nameController,
),
TextFormField(
controller: passController,
),
ValueListenableBuilder<bool>(
valueListenable: isEnabled,
builder: (context, value, child) {
return ElevatedButton(
onPressed: value ? () {} : null, child: Text("Btn"));
},
)
],
);
}
}
Related
I found a similar behaviour on Safari:
The problem statement is that on tap of normal text, the text field should be editable and we can able to edit the text.
Have an example
In this scenario, we can see how on tap of initial text the editable text field is getting displayed and we can able to edit the text.
So let’s start with the process:
First, we have to initialize the variables.
bool _isEditingText = false;
TextEditingController _editingController;
String initialText = "Initial Text";
_isEditingText is the boolean variable and we have to set it false because we have to make it true when the user is tap on text.
TextEditingController -whenever a user modifies a text field with an associated TextEditingController, the text field edits and the controller notifies the listener. Listeners can then read the text and selection properties that the user has typed and updated. Basically the text editing controller is used to get the updated value from the text field.
initialText -Initial value, set to the text.
When we use any type of controller then we have to initialize and dispose of the controller.
So first initialize the controller in init state.
#override
void initState() {
super.initState();
_editingController = TextEditingController(text: initialText);
}
#override
void dispose() {
_editingController.dispose();
super.dispose();
}
‘dispose()’ is called when the State object is removed, which is permanent.
This method is used to unsubscribe and cancel all animations, streams, etc.
The framework calls this method when this state object will never build again.
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("'Editable Text"),
),
body: Center(
child: _editTitleTextField(),
),
);
}
In build widget, I am simply displaying a widget _editTitleTextField().
Widget _editTitleTextField() {
if (_isEditingText)
return Center(
child: TextField(
onSubmitted: (newValue){
setState(() {
initialText = newValue;
_isEditingText =false;
});
},
autofocus: true,
controller: _editingController,
),
);
return InkWell(
onTap: () {
setState(() {
_isEditingText = true;
});
},
child: Text(
initialText,
style: TextStyle(
color: Colors.black,
fontSize: 18.0,
),
);
}
so what exactly the _editTitleTextField() widget does is, if the value of _isEditingText is false then simply show the text and on tap of text set the value of _isEditingText to true.
When _isEditingText is true then _editTitleTextField return text field. Textfield has parameter onSubmitted, so in onSubmitted method new value is assigned to initialText which is the updated value getting from _editingController.
Tada! This way we make the text editable and update the value of the text!
This can be done by having a StatefulWidget that keeps track of the state, by using a boolean for example. Let's say this bool is named editing. When editing == false you display a Text widget with the text to display, wrapped within a handler for the tap, such as a GestureDetector. When the user taps, the state is changed to editing = true. In this state you'll display a TextField with this value. You can use a TextEditingController within in this StatefulWidget and update its value there. This would allow you to up have all text selected when the user taps and changes to the editing state
I believe this is the correct answer:
class InlineEditableText extends StatefulWidget {
const InlineEditableText({
Key? key,
required this.text,
required this.style,
}) : super(key: key);
final String text;
final TextStyle style;
#override
State<InlineEditableText> createState() => _InlineEditableTextState();
}
class _InlineEditableTextState extends State<InlineEditableText> {
var _isEditing = false;
final _focusNode = FocusNode();
late String _text = widget.text;
late final TextStyle _style = widget.style;
late TextEditingController _controller;
#override
void initState() {
_controller = TextEditingController(text: _text);
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
setState(() => _isEditing = false);
} else {
_controller.selection = TextSelection(
baseOffset: 0,
extentOffset: _controller.value.text.runes.length,
);
}
});
super.initState();
}
#override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onDoubleTap: () => setState(() {
_isEditing = !_isEditing;
_focusNode.requestFocus();
}),
child: TextField(
maxLines: 1,
style: _style,
focusNode: _focusNode,
controller: _controller,
onSubmitted: (changed) {
setState(() {
_text = changed;
_isEditing = false;
});
},
showCursor: _isEditing,
cursorColor: Colors.black,
enableInteractiveSelection: _isEditing,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 4.4,
),
border: _isEditing
? const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(0)),
)
: InputBorder.none,
),
),
);
}
}
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.
I created a slider-based stepper form using TabBarView which validate the input before switching. It works, but when I go back, the state was reset. This behavior leads me to an empty form when I try to collect the data at the end of the tab.
I have googled for few hours and have been tried switching the current GetView<MyController> to the classic StatefulWidget with AutomaticKeepAliveMixin with no luck, so I revert it.
I'm a bit stuck, I wonder if there is any other way to achieve this, the GetX way, if possible.
visual explanation
`
create_account_form_slider.dart
class CreateAccountFormSlider extends GetView<CreateAccountController> {
#override
Widget build(BuildContext context) {
return Expanded(
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: controller.tabController,
children: [
_buildEmailForm(),
_buildNameForm(),
_buildPasswordForm(),
],
),
);
}
Widget _buildEmailForm() {
return Form(
key: controller.emailFormKey,
child: Column(
children: [
Spacer(), // Necessary to push the input to the bottom constraint, Align class doesn't work.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: FormInput(
focusNode: controller.emailFocusNode,
margin: EdgeInsets.zero,
label: 'create_account_form_email'.tr,
hintText: 'janedoe#example.com',
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
validator: controller.emailValidator,
onFieldSubmitted: (_) => controller.next(),
),
),
],
),
);
}
... each form has similar structure (almost identical), so i will not include it here
create_account_controller.dart
class CreateAccountController extends GetxController
with SingleGetTickerProviderMixin {
final tabIndex = 0.obs;
final emailFormKey = GlobalKey<FormState>();
FormState get emailForm => emailFormKey.currentState;
final emailFocusNode = FocusNode();
final email = ''.obs;
TabController tabController;
#override
void onInit() {
_initTabController();
super.onInit();
}
#override
void onClose() {
_disposeFocusNodes();
_disposeTabController();
super.onClose();
}
/// Initialize tab controller and add a listener.
void _initTabController() {
tabController = TabController(vsync: this, length: 3);
tabController.addListener(_tabListener);
}
/// Listen on tab change and update `tabIndex`
void _tabListener() => tabIndex(tabController.index);
/// Dispose tab controller and remove its listener.
void _disposeTabController() {
tabController.removeListener(_tabListener);
tabController.dispose();
}
/// Dispose all the focus nodes.
void _disposeFocusNodes() {
emailFocusNode.dispose();
}
/// Animate to the next slide.
void _nextSlide() => tabController.animateTo(tabIndex() + 1);
/// Animate to the next slide or submit if current tab is the last tab.
void next() {
if (tabIndex().isEqual(0) && emailForm.validate()) {
_nextSlide();
return focusScope.requestFocus(nameFocusNode);
}
...
}
/// A function that checks the validity of the given value.
///
/// When the email is empty, show required error message and when the email
/// is invalid, show the invalid message.
String emailValidator(String val) {
if (val.isEmpty) return 'create_account_form_error_email_required'.tr;
if (!val.isEmail) return 'create_account_form_error_email_invalid'.tr;
return null;
}
/// Submit data to the server.
void _submit() {
print('TODO: implement submit');
print(email());
}
}
I made it by saving the form and adding an initialValue on my custom FormInput widget then put the observable variable onto each related FormInput. No need to use keepalive mixin.
create_account_controller.dart
/// Animate to the next slide or submit if current tab is the last tab.
void next() {
if (tabIndex().isEqual(0) && emailForm.validate()) {
// save the form so the value persisted into the .obs variable
emailForm.save();
// slide to next form
_nextSlide();
// TODO: wouldn't it be nice if we use autofocus since we only have one input each form?
return focusScope.requestFocus(nameFocusNode);
}
...
}
create_account_form_slider.dart
Obx( // wrap the input inside an Obx to rebuild with the new value
() => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: FormInput(
focusNode: controller.emailFocusNode,
label: 'create_account_form_email'.tr,
hintText: 'janedoe#example.com',
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
validator: controller.emailValidator,
onFieldSubmitted: (_) => controller.next(),
initialValue: controller.email(), // use initial value to keep current value when user go back from the next slide
onSaved: controller.email, // persist current value into the .obs variable
),
),
),
FYI: The FormInput is just a regular TextInput, only decoration is modified. This should work with the regular flutter TextInput.
if you want to use AutomaticKeepAliveMixin in GetX like StatefulWidget. You can add the parameter 'permanent: true' in Get.put like this
Get.put<HomeController>(
HomeController(),
permanent: true,
);
Full code on HomeBinding like this
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
class HomeBinding extends Bindings {
#override
void dependencies() {
Get.put<HomeController>(
HomeController(),
permanent: true,
);
}
}
Currently, when a user fills in a TextField the text is lost if they navigate away and then return. How can I get the text to stay in the field upon their return?
Here's the stateful widget I'm using;
class _EditPageState extends State<EditPage> {
final _formKey = GlobalKey<FormState>();
String audiotitle;
#override
Widget build(BuildContext context) {
return Scaffold(
body: Form(
key: _formKey,
child: Container(
child: TextField(
decoration: new InputDecoration(
hintText: widget.oldaudiotitle,
),
keyboardType: TextInputType.text,
onChanged: (titleText) {
setState(() {
this.audiotitle = titleText;
});
},
),
),
),
);
}
}
What am I doing wrong here?
you have two ways :
store the Data of the text field and set the data in init method
(use sharedpreferences or any other database as per your requirements)
TextEditingController controller = TextEditingController();
#override
void initState() {
// TODO: implement initState
// retrive the Data
if(data != null) {
controller = new TextEditingController(text: data);
}
}
or if the first screen is navigating in the second Screen than just pop that screen
Navigator.pop(context);
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.