Skip to content

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);
}
}

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 about MyWidget 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 call setState(), which would rebuild MyWidget and all of its children. If this functionality were encapsulated in the MyText widget, only the MyText widget would be rebuilt when the Text is tapped.

The Flutter team has released a great YouTube video about this topic.

Here are some more great resources on this subject: