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:

  1. flutter run to open the web app. The TextFormField will then initialise with the text 'Test Value' as expected; the initial text/value of the TextFormField is populated via the valueStreamProvider Riverpod StreamProvider being watched.

  2. 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 the TextFormField and thus the user is no longer to type any further characters in the TextFormField (via a keyboard).

  3. For a second time, click in the TextFormField (cursor appears flashing as expected) & enter 1+ character(s) (via a keyboard) - the cursor in the TextFormField does not disappear (as expected) and the user is able to continue to type characters in the TextFormField (via a keyboard) as expected.

Expected behaviour (which is not happening):

  1. flutter run to open the web app. The TextFormField will then initialise with the text 'Test Value' as expected; the initial text/value of the TextFormField is populated via the valueStreamProvider Riverpod StreamProvider being watched.

  2. 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). The FocusNode 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 Riverpod StreamProvider from the TextFormField, the issue in step 1 (Issue Replication Steps above) does not occur, so this is highly likely to be caused by the valueStreamProvider Riverpod StreamProvider. We have logged throughout the valueStreamProvider and no stream event is being emitted - perhaps the StreamProvider 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:

  1. If skipLoadingOnReload is not set to true, then the ref.watch(valueStreamProvider).when gets stuck loading - why is this? If we set skipLoadingOnReload = true this issue does not appear and the data returns as expected (i.e. builds / populates the TextFormField as expected)...

  2. Within the TextFormField the key: UniqueKey() was actually supposed to be a key: GlobalKey() so that we can write integration tests which interact-with/test TextFormField's - guess it is recommended not to have a key: GlobalKey() within TextFormField and to instead add a parent Container with the key: GlobalKey() and access the child TextFormField from there in integration tests? Are there any good articles that explain 'when' and 'when not' to use keys, especially around TextFormField's, as it would be good to fully understand?

  3. If we use a controller (TextEditingController) instead of the initialValue for the TextFormField (in our "To reply to Remi's kind feedback" example above), when the first character is entered into the TextFormField (i) the cursor jumps to the beginning of the TextFormField, and (ii) the first character entered never appears in the TextFormField 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"),
    );
  }
}
1

There are 1 answers

0
20937u47897 On

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):

  1. Omit the key: UniqueKey() as kindly advised by Remi (thanks!!)
  2. Implement .when when watching the valueStreamProvider StreamProvider (i.e. ref.watch(valueStreamProvider).when) and set skipLoadingOnReload = true
  3. Replace the controller property with initialValue property within the TextFormField.
import 'package:flutter/material.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) {

    return ref.watch(valueStreamProvider).when(
      skipLoadingOnReload: true,
      data: (final String? value) => TextFormField(
        initialValue: 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"),
    );
  }
}