Widgets
Widgets are the reusable building blocks of your app’s user interface. It is important to design them to be readable, maintainable, performant, and testable. By following these principles, you can ensure a smooth development process and a high-quality user experience.
Page/Views
Each page should be composed of two classes: a Page
, which is responsible for defining the page’s route and gathering all the dependencies needed from the context; and a View
, where the “real” implementation of the UI resides.
Distinguishing between a Page
and its View
allows the Page
to provide dependencies to the View
, enabling the view’s dependencies to be mocked when testing.
class LoginPage extends StatelessWidget { const LoginPage({super.key});
@override Widget build(BuildContext context) { return BlocProvider( create: (context) { final authenticationRepository = context.read<AuthenticationRepository>(); return LoginBloc( authenticationRepository: authenticationRepository, ); }, child: const LoginView(), ); }}
class LoginView extends StatelessWidget { @visibleForTesting const LoginView({super.key});
@override Widget build(BuildContext context) { // omitted }}
We can easily write tests for the LoginView
by mocking the LoginBloc
and providing it directly to the view.
class _MockLoginBloc extends MockBloc<LoginBloc, LoginState> implements LoginBloc {}
void main() { group('LoginView', () { late LoginBloc loginBloc;
setUp(() { loginBloc = _MockLoginBloc(); });
testWidgets('renders correctly', (tester) async { await tester.pumpApp( BlocProvider<LoginBloc>.value( value: loginBloc, child: LoginView(), ), );
expect(find.byType(LoginView), findsOneWidget); });
testWidgets('when on state A, render X', (tester) async { await tester.pumpApp( BlocProvider<LoginBloc>.value( value: loginBloc, child: LoginView(), ), );
expect(find.byType(X), findsOneWidget); }); });}
Use standalone Widgets over helper methods
If a Widget starts growing with complexity, you might want to split the build method up. Instead of creating a function, simply create a new Widget.
The recommended approach is to create an entirely separate class for your widget.
class MyWidget extends StatelessWidget { const MyWidget({super.key});
@override Widget build(BuildContext context) { return const MyText('Hello World!'); }}
class MyText extends StatelessWidget { const MyText(this.text, {super.key});
final String text;
@override Widget build(BuildContext context) { return Text(text); }}
Avoid creating a method that returns a widget.
class MyWidget extends StatelessWidget { const MyWidget({super.key});
@override Widget build(BuildContext context) { return _getText('Hello World!'); }
Text _getText(String text) { return Text(text); }}
Why Create a New Widget?
Creating a new widget provides several benefits over using a helper method:
- Testability: You can write widget tests for the
MyText
widget without worrying aboutMyWidget
or any of the dependencies that it might require. - Maintainability: Smaller widgets are easier to maintain and aren’t coupled to their parent widget. These widgets will also have their own BuildContext, so you don’t have to worry about using the wrong or an invalid context.
- Reusability: Creating new widgets allows you to easily reuse the widget to compose larger widgets.
- Performance: Using a helper method to return a widget that could update the state could cause unnecessary rebuilds of the entire widget. Imagine that the
Text
widget triggered an animation when tapped. We would need to callsetState()
, which would rebuildMyWidget
and all of its children. If this functionality were encapsulated in theMyText
widget, only theMyText
widget would be rebuilt when theText
is tapped.
The Flutter team has released a great YouTube video about this topic.
Here are some more great resources on this subject: