Skip to content

Custom Reactive Widgets

Joan Pablo edited this page Jul 7, 2022 · 27 revisions

Reactive Forms is not limited just too common widgets in Forms like text, dropdowns, sliders switch fields and etc, you can easily create custom widgets that two-way binds to FormControls and create your own set of Reactive Widgets.

In the next section, we are going to build a Reactive Widget called Counter.

Counter Custom Reactive Widget

OK, first let's see how many subcomponents are composed of our Counter widget. We can identify three widgets:

  • Decrement Button
  • Text Display
  • Increment Button

So, let's code our Counter:

import 'package:flutter/material.dart';

class Counter extends StatelessWidget {
  /// The value of the Counter
  final int value;

  /// Creates a [Counter] instance.
  /// The [value] of the counter is required and must not by null.
  const Counter({
      required this.value,
    });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        IconButton(
          icon: const Icon(Icons.chevron_left),
          onPressed: () {}, // on pressed decrement value
        ),
        Text(value.toString()), // here we will show the value
        IconButton(
          icon: const Icon(Icons.chevron_right),
          onPressed: () {}, // on pressed increment value
        ),
      ],
    );
  }
}

We have created a very simple Stateless widget composed of two IconButton one for increment and another for decrement of the value and a Text to display the current value of the Counter.

Let's see how we can consume this widget:

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Counter(value: 10),  // the widget only displays the number 10, for now...
      ),
    );
  }
}

So far we have created a widget that can display a value but can't increment or decrement this value. Because the widget is Stateless and doesn't save any value it will notify the desire of the user to increment or decrement that value. Somebody else will change the value and rebuild the Counter with the new value. If it sounds complex, trust me you will see it's a very easy pattern:

Let's add two callbacks functions to our Counter so it can notify the outside world when the user clicks buttons:

import 'package:flutter/material.dart';

class Counter extends StatelessWidget {
  /// The value of the Counter.
  final int value;

  /// The callback to notify that the user has pressed the increment button.
  final VoidCallback onDecrementPressed;

  /// The callback to notify that the user has pressed the decrement button.
  final VoidCallback onIncrementPressed;

  /// Creates a [Counter] instance.
  ///
  /// The [value] of the counter is required and must not be null.
  ///
  /// The [onDecrementPressed] callback notifies the intention of the
  /// user to decrement the value of the counter.
  /// 
  /// The [onIncrementPressed] callback notifies the intention of the
  /// user to increment the value of the counter.
  const Counter({
    required this.value,
    required this.onDecrementPressed,
    required this.onIncrementPressed,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        IconButton(
          icon: const Icon(Icons.chevron_left),
          onPressed: onDecrementPressed, // call on decrement callback
        ),
        Text(value.toString()),
        IconButton(
          icon: const Icon(Icons.chevron_right),
          onPressed: onIncrementPressed, // call on increment callback
        ),
      ],
    );
  }
}

Now we have a Widget that displays a value and that notifies through callbacks the desire of the user to change that value.

Now let's consume again this widget but this time let's create a FormGroup and use a ReactiveForm. We are going to modify our HomeScreen widget previously seen:

import 'package:flutter/material.dart';
import 'package:reactive_forms/reactive_forms.dart';

class HomeScreen extends StatefulWidget {
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // We don't recommend the use of ReactiveForms in this way. 
  // We highly recommend using the Provider plugin 
  // or any other state management library.
  //
  // We have declared the FormGroup within a Stateful Widget only for
  // demonstration purposes and to simplify the explanation in this documentation.
  final form = FormGroup({
    'counter': FormControl<int>(value: 0),
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ReactiveForm( // a reactive form binded to form
          formGroup: this.form,
          // Counter widget not yet bound to FormControl `counter`
          child: Counter(
            value: 10,
            onDecrementPressed: () {},
            onIncrementPressed: () {},
          ),
        ),
      ),
    );
  }
}

We don't recommend the use of Reactive Forms inside a Stateful Widget. It will works but its not a good practice for real production applications. We highly recommend using the Provider plugin or any other state management library for a better and clean solution.

Now lets turn out our Counter widget into a ReactiveCounter widget. We will declare the class ReactiveCounter and extends from ReactiveFormField and then suply to the parent class the formControlName and a builder function:

import 'package:flutter/material.dart';
import 'package:reactive_forms/reactive_forms.dart';

class ReactiveCounter extends ReactiveFormField<int, int> {
  ReactiveCounter({
    required String formControlName,
  }) : super(
            formControlName: formControlName,
            builder: (ReactiveFormFieldState<int, int> field) {

              // make sure never to pass null value to the Counter widget.
              final fieldValue = field.value ?? 0;

              return Counter(
                value: fieldValue,
                onDecrementPressed: () => field.didChange(fieldValue - 1),
                onIncrementPressed: () => field.didChange(fieldValue + 1),
              );
            });

  @override
  ReactiveFormFieldState<int, int> createState() =>
      ReactiveFormFieldState<int, int>();
}

What happened? The steeps were the following:

  1. Extends from ReactiveFormField, this is a Stateful widget that maintains state and is binding with the FormControl.
  2. Supply a builder function in the parent constructor super. This function will return your Stateless widget, in this case, the Counter widget. The builder function will be called every time the FormControl changes its value.
  3. Each time you want to update the value of the FormControl you call the function didChange of the ReactiveFormField passing the new value.

So we are almost done, now lets consume our new ReactiveCounter, we will also add another widget that listen for changes in the FormControl just to visualize how it stay sincronized with our new ReactiveCounter:

import 'package:flutter/material.dart';
import 'package:reactive_forms/reactive_forms.dart';

class HomeScreen extends StatefulWidget {
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  // We don't recommend the use of ReactiveForms in this way. 
  // We highly recommend using the Provider plugin 
  // or any other state management library.
  //
  // We have declared the FormGroup within a Stateful Widget only for
  // demonstration purposes and to simplify the explanation in this documentation.
  final form = FormGroup({
    'counter': FormControl<int>(value: 0),
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ReactiveForm(
          formGroup: form,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              // this is another widget that visualizes the 
              // value of the `counter` control, it is not 
              // mandatory, just for demonstration purposes.
              ReactiveValueListenableBuilder<int>(
                formControlName: 'counter',
                builder: (context, control, child) =>
                    Text(control.isNotNull ? control.value.toString() : '0'),
              ),
              // this is our new Reactive Widget ;)
              ReactiveCounter(
                formControlName: 'counter',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

The final result:

Counter Final Result Custom Reactive Widget

Clone this wiki locally