
Привет, коллеги!
Хочу поделиться своим опытом работы с формами во 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. Какие подходы используете вы? Сталкивались ли вы с проблемами локализации валидационных сообщений, и как их решали?
ссылка на оригинал статьи https://habr.com/ru/articles/837444/
Добавить комментарий