Overview
The issue consists of the Flutter TextFormField loosing its cursor entirely upon the first character being inputted via a keyboard - see full replication steps below.
Note the TextFormField is watching a stream, which should not (& does not) emit any events during Issue Replication Steps 2-3 below. Upon onChanged within the TextFormField, the currentValueProvider Riverpod StateProvider is updated with the TextFormField value.
The minimum working example below features a valueStreamProvider Riverpod StreamProvider which may appear a bit of an odd use case - this is due to the actual valueStreamProvider Riverpod StreamProvider version being far more complex, however the below minimum working example demonstrates the issue that we are aiming to overcome.
Big shout out to Riverpod, fantastic bit of kit, thank you so much Remi Rousselet.
Please see full details of the issue below and we thank all feedback gratefully in advance. If you require any further details of examples please do not hesitate to reach out.
Issue replication steps:
flutter runto open the web app. TheTextFormFieldwill then initialise with the text 'Test Value' as expected; the initial text/value of theTextFormFieldis populated via thevalueStreamProviderRiverpodStreamProviderbeing watched.Click in the
TextFormField(cursor appears flashing as expected) & enter 1 x character (via a keyboard) - the issue then occurs where the cursor automatically disappears from theTextFormFieldand thus the user is no longer to type any further characters in theTextFormField(via a keyboard).For a second time, click in the
TextFormField(cursor appears flashing as expected) & enter 1+ character(s) (via a keyboard) - the cursor in theTextFormFielddoes not disappear (as expected) and the user is able to continue to type characters in theTextFormField(via a keyboard) as expected.
Expected behaviour (which is not happening):
flutter runto open the web app. TheTextFormFieldwill then initialise with the text 'Test Value' as expected; the initial text/value of theTextFormFieldis populated via thevalueStreamProviderRiverpodStreamProviderbeing watched.Click in the
TextFormField(cursor appears flashing as expected) & enter 1+ characters (as many characters as desired and via a keyboard). The cursor does not disappear from the 'TextFormField' at any point (unless a click event occurs which hits outside of the 'TextFormField').
Essentially as soon as the currentValueProvider Riverpod StateProvider is populated with 1+ character we wish the TextFormField to ignore (not receive) events from the valueStreamProvider Riverpod StreamProvider (thus the currentValueProvider Riverpod StateProvider is updated by the TextFormField to achieve this).
From debugging, we have found:
- The
final TextEditingController controller = useTextEditingController();correctly persists the state of the cursor position (throughout all of the Issue Replication Steps above). TheFocusNodestate is also persisted throughout all of the Issue Replication Steps above - and this focus is not lost throughout any of the steps. - If we omit the
valueStreamProviderRiverpodStreamProviderfrom theTextFormField, the issue in step 1 (Issue Replication Steps above) does not occur, so this is highly likely to be caused by thevalueStreamProviderRiverpodStreamProvider. We have logged throughout thevalueStreamProviderand no stream event is being emitted - perhaps theStreamProvideris emitting a loading event? Or? - When the cursor is lost in Step 1 from Issue Replication Steps above, logging has confirmed that the
TextformFieldwas rebuilt - thus probably the cause for the cursor being lost.
Minimum working example:
Please create a new flutter web app and then paste in the below files:
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp() ));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData( primarySwatch: Colors.blue ),
home: const Material(child: TextWidget()),
);
}
}
/// Riverpod Provider - current value
final currentValueProvider = StateProvider<String?>((final ref) => null);
/// Riverpod Provider - Value Stream
final valueStreamProvider = StreamProvider.autoDispose<String?>((final ref) async* {
// DB Document StreamProvider watched here - *** not in use in this minimal
// example *** but to illustrate why a StreamProvider is required
// for valueStreamProvider
// final String? firebaseDocumentStream = await ref.watch(firebaseDocumentStreamProvider
// .selectAsync((final String? v) => v?.firstName),
// );
// Only send event if 'Current Value' is not populated
final String? currentValue = ref.watch(currentValueProvider);
if (currentValue == null) {
yield* Stream<String?>.value('Test Value');
}
});
/// Text Widget
class TextWidget extends HookConsumerWidget {
const TextWidget({ final Key? key, }) : super(key: key);
@override
Widget build(final BuildContext context, final WidgetRef ref) {
final TextEditingController controller = useTextEditingController();
final FocusNode focusNode = useFocusNode();
return Consumer(
builder: (final BuildContext context, final WidgetRef ref, final Widget? child) {
// Listen to Value Stream
final String? value = ref.watch(valueStreamProvider).value;
return TextFormField(
key: UniqueKey(),
focusNode: focusNode,
controller: controller..text = value ?? "",
onChanged: (final String value) async {
// Update 'Current Value' Provider
ref.read(currentValueProvider.notifier).state = value;
},
);
}
);
}
}
pubspec.yaml
name: test_text_field_stream
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=2.18.5 <3.0.0'
dependencies:
flutter:
sdk: flutter
flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true
Edit after
To reply to Remi's kind feedback
Please see "The solution, with Remi's help (thanks!)" Answer to this post below.
Further Queries
In regards to The solution, with Remi's help (thanks!)" Answer to this post below, we have the following further queries if we may:
If
skipLoadingOnReloadis not set totrue, then theref.watch(valueStreamProvider).whengets stuckloading- why is this? If we setskipLoadingOnReload=truethis issue does not appear and thedatareturns as expected (i.e. builds / populates theTextFormFieldas expected)...Within the
TextFormFieldthekey: UniqueKey()was actually supposed to be akey: GlobalKey()so that we can write integration tests which interact-with/testTextFormField's- guess it is recommended not to have akey: GlobalKey()withinTextFormFieldand to instead add a parentContainerwith thekey: GlobalKey()and access the childTextFormFieldfrom there in integration tests? Are there any good articles that explain 'when' and 'when not' to usekeys, especially aroundTextFormField's, as it would be good to fully understand?If we use a
controller(TextEditingController) instead of theinitialValuefor theTextFormField(in our "To reply to Remi's kind feedback" example above), when the first character is entered into theTextFormField(i) the cursor jumps to the beginning of theTextFormField, and (ii) the first character entered never appears in theTextFormFieldbut the 2nd character onwards does... Why is this, perhaps this is outside the scope of this particular post/question. Please see a full example below of this point 3 issue (pubspec.yaml is the same as the original example):
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp() ));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData( primarySwatch: Colors.blue ),
home: const Material(child: TextWidget()),
);
}
}
/// Riverpod Provider - current value
final currentValueProvider = StateProvider<String?>((final ref) => null);
/// Riverpod Provider - Value Stream
final valueStreamProvider = StreamProvider.autoDispose<String?>((final ref) async* {
// DB Document StreamProvider watched here - *** not in use in this minimal
// example *** but to illustrate why a StreamProvider is required
// for valueStreamProvider
// final String? firebaseDocumentStream = await ref.watch(firebaseDocumentStreamProvider
// .selectAsync((final String? v) => v?.firstName),
// );
// Only send event if 'Current Value' is not populated
final String? currentValue = ref.watch(currentValueProvider);
if (currentValue == null) {
yield* Stream<String?>.value('Test Value');
}
});
/// Text Widget
class TextWidget extends HookConsumerWidget {
const TextWidget({ final Key? key, }) : super(key: key);
@override
Widget build(final BuildContext context, final WidgetRef ref) {
final controller = useTextEditingController();
return ref.watch(valueStreamProvider).when(
skipLoadingOnReload: true,
data: (final String? value) => TextFormField(
controller: controller..text = value ?? "",
onChanged: (final String value) async {
// Update 'Current Value' Provider
ref.read(currentValueProvider.notifier).state = value;
},
),
error: (final Object err, final StackTrace stack) =>
throw Exception("Deal with error TODO: $err"),
loading: () => const Text("Loading TODO"),
);
}
}
The solution, with Remi's help (thanks!)
Thank you to Remi for pointing us hard in the right direction - this is now working as expected with the following changes (full example below, pubspec.yaml is the same as the original example above):
key: UniqueKey()as kindly advised by Remi (thanks!!).whenwhen watching thevalueStreamProviderStreamProvider(i.e.ref.watch(valueStreamProvider).when) and setskipLoadingOnReload=truecontrollerproperty withinitialValueproperty within theTextFormField.