Flutter: Good practices for FormFields. Do I create a widget for each type of field?

Viewed 648

I want to reuse different types of fields in different forms and I have created a separate Widget that returns TextFormField.

Logically, different types of fields have their own validations and other properties, so I have started looking into inheritance and so on to avoid rewriting same chunks of code.

From what I have learnt, Flutter does not encourage inheritance of widgets, so my question is on the best practices of reusing code for various form fields in flutter to remain readability and keep the code clean.

Any tips?

2 Answers

In my experience, I rarely had the need to use other widgets than the original form fields provided by flutter. What I found useful to reuse are validation functions for each fields, since they often have common needs in term of validation.

These are just two basic samples. I pass them to the validator argument of the form field whenever it's needed.

String? validatorForMissingFields(String? input) {
  if (input == null || input.isEmpty || input.trim().isEmpty) {
    return "Mandatory field";
  }
  return null;
}

String? validatorForMissingFieldsAndLength(String? input, int length) {
  if (input == null || input.isEmpty || input.trim().isEmpty) {
     return "Mandatory field";
  }
  if (input.length != length) {
   return 'Not long enough';
  }
  return null;
 }

In any case, instead of extending a basic widget, I prefer to create a new one containing the basic widget with some fixed properties, and others that can be customized. This example does not involve form fields, but I think it can better explain my point.

///if text is not null, icon is ignored
class RectButton extends StatelessWidget {
  final Function()? onPressed;
  final String? text;
  final IconData? icon;
  final Color color;
  const RectButton({this.text, this.icon, required this.onPressed, Key? key, this.color = mainLightColor}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(12.0),
      child: OutlinedButton(
          style: ButtonStyle(
            side: MaterialStateProperty.all(BorderSide(color: color)),
            overlayColor: MaterialStateColor.resolveWith((states) => color.withOpacity(0.5)),
            backgroundColor: MaterialStateColor.resolveWith((states) => color.withOpacity(0.3)),
          ),
          onPressed: onPressed,
          child: text != null
              ? Text(
                  text!,
                  style: TextStyle(fontWeight: FontWeight.bold, color: color),
                )
              : Icon(
                  icon,
                  color: color,
                )),
    );
  }
}

In order to maintain the same look&feel in all the app, I created a custom button with some 'invisible' widgets above it that allowed me to set some properties without extending a basic widget. The properties I needed to be customized are passed to the constructor.

You can create a class to store only the important things like a label or a controller and then use a wrap widget and a for loop to generate the widgets.

Here's an example:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final List<TextFieldData> _allFieldData = [
    TextFieldData(
      label: 'field 1',
      validator: numberOnlyValidator,
      autovalidateMode: AutovalidateMode.onUserInteraction,
    ),
    TextFieldData(
      label: 'field 2',
      validator: canBeEmptyValidator,
    ),
    TextFieldData(
      label: 'field 3',
      validator: numberOnlyValidator,
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: SingleChildScrollView(
          child: Form(
            child: Wrap(
              runSpacing: 16,
              spacing: 16,
              children: [
                for (var fieldData in _allFieldData)
                  ConstrainedBox(
                    constraints: const BoxConstraints(maxWidth: 250),
                    child: TextFormField(
                      decoration: InputDecoration(label: Text(fieldData.label)),
                      controller: fieldData.controller,
                      autovalidateMode: fieldData.autovalidateMode,
                      validator: fieldData.validator,
                    ),
                  )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

const String numbersOnlyError = 'Only numbers';
const String requiredFieldError = 'Required field';
RegExp numbersOnlyRegexp = RegExp(r'^[0-9]\d*(,\d+)?$');

String? numberOnlyValidator(String? value) {
  if (value == null || value.isEmpty) {
    return requiredFieldError;
  } else if (!numbersOnlyRegexp.hasMatch(value)) {
    return numbersOnlyError;
  }
  return null;
}

String? canBeEmptyValidator(String? value) {
  return null;
}

class TextFieldData {
  final String label;
  final String? Function(String?)? validator;
  final AutovalidateMode autovalidateMode;

  TextEditingController controller = TextEditingController();

  TextFieldData({
    required this.label,
    required this.validator,
    this.autovalidateMode = AutovalidateMode.disabled,
  });
}

And then you can do whatever you want using the .controller of each item inside _allFieldData

Note: I put everything in the same file for simplicity but you would normally have the class and the validators in separate files.

Related