Привет, коллеги!
Хочу поделиться своим опытом работы с формами во Flutter. Каждый из нас сталкивался с задачей создания сложных форм и хочу рассказать о подходе с использованием нового пакета form_model.
Почему form_model?
-
Он помогает отделить логику валидации от UI, что значительно упрощает поддержку кода.
-
Предоставляет гибкую систему валидации с возможностью создания кастомных валидаторов.
-
Хорошо интегрируется с BLoC (хотя, думаю, и с другими подходами к управлению состоянием тоже будет работать).
-
Справляется со сложными структурами форм без особых усилий.
Для начала добавим следующие зависимости в pubspec.yaml
-
form_model
-
flutter_bloc
-
freezed
Кастомные объекты
Для демонстрации работы со сложными типами данных создадим простой класс Address:
@freezed
class Address with _$Address {
const factory Address({
required String street,
required String city,
required String country,
}) = _Address;
}
State Management
Для управления c состоянием я обычно использую подход с единым состоянием (single-state approach). Вот как выглядит мой класс StateStatus:
@freezed
class StateStatus with _$StateStatus {
const factory StateStatus() = PureStatus;
const factory StateStatus.loading() = LoadingStatus;
const factory StateStatus.success([dynamic data]) = SuccessStatus;
const factory StateStatus.error([String? message]) = ErrorStatus;
}
Это позволяет представить четыре различных состояния формы: исходное, загрузка, успех и ошибка.
Теперь можем переходить к основному, к реализации самого блока
SignUpState
@freezed
class SignUpState with _$SignUpState {
const factory SignUpState({
@Default(StateStatus()) StateStatus status,
@Default(FormModel<String>(validators: [
RequiredValidator(),
EmailValidator(),
]))
FormModel email,
@Default(FormModel<String>(validators: [
RequiredValidator(),
PasswordLengthValidator(minLength: 8),
PasswordLowercaseValidator(),
PasswordUppercaseValidator(),
PasswordSpecialCharValidator(),
]))
FormModel password,
@Default(FormModel<String>(validators: [
RequiredValidator(),
StringConfirmPasswordMatchValidator(),
]))
FormModel confirmPassword,
@Default(FormModel<String>(validators: [
RequiredValidator(),
StringMinLengthValidator(minLength: 6),
CustomValidator(validator: _validateUsername),
]))
FormModel<String> username,
@Default(FormModel<Address>(validators: [
RequiredValidator(),
CustomValidator(validator: _validateStreet),
CustomValidator(validator: _validateCity),
CustomValidator(validator: _validateCountry),
]))
FormModel address,
@Default(FormModel<bool>(validators: [
BoolAgreeToTermsAndConditionsValidator(),
]))
FormModel<bool> agreeToTerms,
}) = _SignUpState;
}
String? _validateUsername(String? value) {
if (value == null) return null;
if (!value.startsWith('@')) {
return 'Username should start with @';
}
return null;
}
String? _validateStreet(Address? value) {
if (value == null) return null;
if (value.street.isEmpty) {
return 'Street is required';
}
return null;
}
String? _validateCity(Address? value) {
if (value == null) return null;
if (value.city.isEmpty) {
return 'City is required';
}
return null;
}
String? _validateCountry(Address? value) {
if (value == null) return null;
if (value.country.isEmpty) {
return 'Country is required';
}
return null;
}
Здесь определяем FormModel для каждого поля формы. Интересный момент: form_model позволяет комбинировать валидаторы, что дает возможность создавать сложные правила валидации, оставаясь при этом в рамках принципа единой ответственности.
Например, для пароля используется несколько валидаторов.
Каждый валидатор отвечает только за одну проверку, что упрощает тестирование и повторное использование кода.
SignUpEvent
@freezed
class SignUpEvent with _$SignUpEvent {
const factory SignUpEvent.emailChanged(String value) = _EmailChanged;
const factory SignUpEvent.passwordChanged(String value) = _PasswordChanged;
const factory SignUpEvent.confirmPasswordChanged(String value) = _ConfirmPasswordChanged;
const factory SignUpEvent.usernameChanged(String value) = _UsernameChanged;
const factory SignUpEvent.addressChanged(String value) = _AddressChanged;
const factory SignUpEvent.agreeToTermsChanged(bool value) = _AgreeToTermsChanged;
const factory SignUpEvent.submitted() = _Submitted;
}
SignUpBloc
class SignUpBloc extends Bloc<SignUpEvent, SignUpState> {
SignUpBloc() : super(const SignUpState()) {
on<_EmailChanged>(_onEmailChanged);
on<_PasswordChanged>(_onPasswordChanged);
on<_ConfirmPasswordChanged>(_onConfirmPasswordChanged);
on<_UsernameChanged>(_onUsernameChanged);
on<_AddressChanged>(_onAddressChanged);
on<_AgreeToTermsChanged>(_onAgreeToTermsChanged);
on<_Submitted>(_onSubmitted);
}
void _onEmailChanged(_EmailChanged event, Emitter<SignUpState> emit) {
emit(state.copyWith(email: state.email.setValue(event.value)));
}
void _onPasswordChanged(_PasswordChanged event, Emitter<SignUpState> emit) {
emit(state.copyWith(
password: state.password.setValue(event.value),
confirmPassword: state.confirmPassword.replaceValidator(
predicate: (validator) => validator is StringConfirmPasswordMatchValidator,
newValidator: StringConfirmPasswordMatchValidator(matchingValue: event.value),
),
));
}
void _onConfirmPasswordChanged(_ConfirmPasswordChanged event, Emitter<SignUpState> emit) {
emit(state.copyWith(confirmPassword: state.confirmPassword.setValue(event.value)));
}
void _onUsernameChanged(_UsernameChanged event, Emitter<SignUpState> emit) {
emit(state.copyWith(username: state.username.setValue(event.value)));
}
void _onAddressChanged(_AddressChanged event, Emitter<SignUpState> emit) {
final parts = event.value
.split(',')
.map(
(e) => e.trim(),
)
.toList();
final address =
Address(street: parts[0], city: parts.length > 1 ? parts[1] : '', country: parts.length > 2 ? parts[2] : '');
emit(state.copyWith(address: state.address.setValue(address)));
}
void _onAgreeToTermsChanged(_AgreeToTermsChanged event, Emitter<SignUpState> emit) {
emit(state.copyWith(agreeToTerms: state.agreeToTerms.setValue(event.value)));
}
void _onSubmitted(_Submitted event, Emitter<SignUpState> emit) async {
emit(state.copyWith(
email: state.email.validate(),
password: state.password.validate(),
confirmPassword: state.confirmPassword.validate(),
username: state.username.validate(),
address: state.address.validate(),
agreeToTerms: state.agreeToTerms.validate(),
));
if (areAllFormModelsValid([
state.email,
state.password,
state.confirmPassword,
state.username,
state.address,
state.agreeToTerms,
])) {
emit(state.copyWith(status: const LoadingStatus()));
//do some logic here
await Future.delayed(const Duration(seconds: 2));
emit(state.copyWith(status: const SuccessStatus()));
}
}
}
Здесь есть несколько интересных моментов:
-
Каждый FormModel возвращает новый экземпляр при изменении, что обеспечивает иммутабельность.
-
При изменении пароля обновляем также валидатор поля подтверждения пароля.
-
Для адреса разбиваем входную строку на части, создавая новый объект Address.
UI
class SignUpPage extends StatelessWidget {
const SignUpPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SignUpBloc(),
child: Builder(
builder: (context) {
final bloc = context.read<SignUpBloc>();
return BlocConsumer<SignUpBloc, SignUpState>(
listener: (context, state) {
// do something on success status
},
builder: (BuildContext context, SignUpState state) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 40),
TextField(
onChanged: (value) => bloc.add(SignUpEvent.emailChanged(value)),
decoration: InputDecoration(
labelText: 'Email',
errorText: state.email.error?.translatedMessage,
),
),
const SizedBox(height: 16),
TextField(
onChanged: (value) => bloc.add(SignUpEvent.passwordChanged(value)),
decoration: InputDecoration(
labelText: 'Password',
errorText: state.password.error?.translatedMessage,
),
obscureText: true,
),
const SizedBox(height: 16),
TextField(
onChanged: (value) => bloc.add(SignUpEvent.confirmPasswordChanged(value)),
decoration: InputDecoration(
labelText: 'Confirm Password',
errorText: state.confirmPassword.error?.translatedMessage,
),
obscureText: true,
),
const SizedBox(height: 16),
TextField(
onChanged: (value) => bloc.add(SignUpEvent.usernameChanged(value)),
decoration: InputDecoration(
labelText: 'Username @',
errorText: state.username.error?.translatedMessage,
),
),
const SizedBox(height: 16),
TextField(
onChanged: (value) => bloc.add(SignUpEvent.addressChanged(value)),
decoration: InputDecoration(
labelText: 'Address (street, city, country)',
errorText: state.address.error?.translatedMessage,
),
),
const SizedBox(height: 16),
CheckboxListTile(
value: state.agreeToTerms.value ?? false,
onChanged: (value) => bloc.add(
SignUpEvent.agreeToTermsChanged(value ?? false),
),
),
if (state.agreeToTerms.error != null)
Text(
state.agreeToTerms.error!.translatedMessage ?? '',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => bloc.add(const SignUpEvent.submitted()),
child: state.status is LoadingStatus
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(),
)
: const Text('Submit'),
)
],
),
),
),
);
},
);
},
),
);
}
}
Запускаем валидацию только при отправке формы, а не при каждом вводе пользователя. Мне кажется, это обеспечивает лучший UX, но, конечно, это дело вкуса.
Заключение
Этот подход позволит создать довольно сложную форму, сохранив при этом код чистым и легко поддерживаемым. Приятным бонусом является встроенная поддержка локализации в form_model. Это значительно упрощает процесс добавления переводов для сообщений об ошибках валидации.
Конечно, это не единственный способ работы с формами во Flutter, но он весьма удобный. Комбинация form_model и BLoC обеспечит гибкость в валидации и управлении состоянием, а также облегчит интернационализацию.
Буду рад услышать ваше мнение и опыт работы с формами во Flutter. Какие подходы используете вы? Сталкивались ли вы с проблемами локализации валидационных сообщений, и как их решали?
Автор: sukhrob_djumaev