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 run
to open the web app. TheTextFormField
will then initialise with the text 'Test Value' as expected; the initial text/value of theTextFormField
is populated via thevalueStreamProvider
RiverpodStreamProvider
being 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 theTextFormField
and 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 theTextFormField
does 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 run
to open the web app. TheTextFormField
will then initialise with the text 'Test Value' as expected; the initial text/value of theTextFormField
is populated via thevalueStreamProvider
RiverpodStreamProvider
being 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). TheFocusNode
state 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
valueStreamProvider
RiverpodStreamProvider
from theTextFormField
, the issue in step 1 (Issue Replication Steps above) does not occur, so this is highly likely to be caused by thevalueStreamProvider
RiverpodStreamProvider
. We have logged throughout thevalueStreamProvider
and no stream event is being emitted - perhaps theStreamProvider
is emitting a loading event? Or? - When the cursor is lost in Step 1 from Issue Replication Steps above, logging has confirmed that the
TextformField
was 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
skipLoadingOnReload
is not set totrue
, then theref.watch(valueStreamProvider).when
gets stuckloading
- why is this? If we setskipLoadingOnReload
=true
this issue does not appear and thedata
returns as expected (i.e. builds / populates theTextFormField
as expected)...Within the
TextFormField
thekey: 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()
withinTextFormField
and to instead add a parentContainer
with thekey: GlobalKey()
and access the childTextFormField
from 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 theinitialValue
for 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 theTextFormField
but 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!!).when
when watching thevalueStreamProvider
StreamProvider
(i.e.ref.watch(valueStreamProvider).when
) and setskipLoadingOnReload
=true
controller
property withinitialValue
property within theTextFormField
.