Creating the Ultimate Base Screen Class based on MVVM Architecture in Flutter: GetX — Part 1

Ximya
9 min readApr 28, 2023

--

📝 Self-diagnostic Checklist
✔️ I am using the GetX state management library.
✔️ I have implemented the MVVM architecture in my project.
✔️ The number of managed screen widgets in the project is more than 5.

Do you meet all the criteria on this checklist? If so, this post will provide tips on using a structured base screen template module in the MVVM architecture to intuitively organize your screens and significantly improve development productivity. You can apply these tips directly to your projects.

This post covers the following concepts:

  • Encapsulation
  • Generic Types
  • Abstract Classes
  • @protected keyword
  • @immutable keyword

The Role of View in MVVM Architecture

First, let’s briefly touch upon MVVM. In MVVM architecture, each component has a clear role and responsibility to emphasize the separation of user interface and business logic. This allows you to clearly define the roles and responsibilities of the Presentation layer.

The roles and responsibilities of the Presentation layer can be divided into two main categories:

  1. Creating and providing user interfaces: The Presentation layer is responsible for composing the UI and providing it to the user. This involves designing and laying out the screen, as well as providing an interface for user interaction.
  2. Interacting and data transfer with ViewModel: The Presentation layer interacts with the ViewModel, and is responsible for transferring data. Through this, it can receive necessary data from the ViewModel or transmit updated data back to the ViewModel. Additionally, it can update the UI as needed to provide visual feedback to the user.

The BaseScreen module that we will introduce is based on the perspective of MVVM architecture, and it aims to help developers conveniently and intuitively compose UI, while focusing on perfectly separating View and ViewModel.

Base Screen Module

Let’s take a look at the code first.

import 'package:flutter/material.dart';
import 'package:get/get.dart';

abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}

return Container(
color: unSafeAreaColor,
child: wrapWithSafeArea
? SafeArea(
top: setTopSafeArea,
bottom: setBottomSafeArea,
child: _buildScaffold(context),
)
: _buildScaffold(context),
);
}

Widget _buildScaffold(BuildContext context) {
return Scaffold(
extendBody: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: buildAppBar(context),
body: buildScreen(context),
backgroundColor: screenBackgroundColor,
bottomNavigationBar: buildBottomNavigationBar(context),
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: buildFloatingActionButton,
);
}

@protected
Color? get unSafeAreaColor => Colors.black;

@protected
bool get resizeToAvoidBottomInset => true;

@protected
Widget? get buildFloatingActionButton => null;

@protected
FloatingActionButtonLocation? get floatingActionButtonLocation => null;

@protected
bool get extendBodyBehindAppBar => false;

@protected
Color? get screenBackgroundColor => Colors.white;

@protected
Widget? buildBottomNavigationBar(BuildContext context) => null;

@protected
Widget buildScreen(BuildContext context);

@protected
PreferredSizeWidget? buildAppBar(BuildContext context) => null;

@protected
bool get wrapWithSafeArea => true;

@protected
bool get setBottomSafeArea => true;

@protected
bool get setTopSafeArea => true;

@protected
void initViewModel() {
vm.initialized;
}

@protected
T get vm => controller;
}

The BaseScreen class is an abstract template for common elements of a typical app screen, which can be used in various screens. By utilizing the elements that make up the screen, this class provides a consistent skeleton structure, greatly improving development productivity.

Additionally, the BaseScreen class inherits from GetView and uses the type T, which extends GetxController. This allows it to work in a 1-to-1 correspondence with the GetxController used as a ViewModel according to the MVVM structure. This architecture supports easy data exchange in the View.

As such, the BaseScreen class abstracts common functionalities required in app development into a template, and helps to easily implement data flow that aligns with the MVVM architecture.

Components

Now, let’s take a closer look at the key components of the BaseScreen module one by one.

1. build method (SafeArea)

  @override
Widget build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}
return Container(
color: unSafeAreaColor,
child: wrapWithSafeArea
? SafeArea(
top: setTopSafeArea,
bottom: setBottomSafeArea,
child: _buildScaffold(context),
)
: _buildScaffold(context),
);
}

This method creates the screen components and conditionally uses SafeArea to decide whether to wrap the content of the screen in a safe area. Additionally, the setBottomSafeArea and setTopSafeArea properties are applied to the SafeArea widget, which is used to set the top and bottom safe areas. These properties can be overridden in subclasses to set the top and bottom safe areas according to individual screens.

Also, it wraps the Scaffold with a Container widget, and the color attribute of the container has the unSafeAreaColor applied, so you can set the color value outside of the safe area, as shown in the code above.

2. _buildScaffold method

  Widget _buildScaffold(BuildContext context) {
return Scaffold(
extendBody: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: buildAppBar(context),
body: buildScreen(context),
backgroundColor: screenBackgroundColor,
bottomNavigationBar: buildBottomNavigationBar(context),
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: buildFloatingActionButton,
);
}

This method constructs the Scaffold widget, which provides a basic skeleton structure. It manages various components such as AppBar, Screen Body, BackgroundColor, BottomNavigationBar, and FloatingActionButton.

Also, the buildScreen method is a method that must be necessarily overridden in subclasses.

  @protected
Widget buildScreen(BuildContext context);

@protected
Widget? buildBottomNavigationBar(BuildContext context) => null;

@protected
PreferredSizeWidget? buildAppBar(BuildContext context) => null;

All three of these methods are declared as abstract methods. Among them, the buildScreen method does not have a default value set and is not a nullable widget, so it is a required method that must be overridden.

3. Various @protected properties and methods

  @protected
Color? get unSafeAreaColor => Colors.black;

@protected
bool get resizeToAvoidBottomInset => true;

@protected
Widget? get buildFloatingActionButton => null;

@protected
FloatingActionButtonLocation? get floatingActionButtonLocation => null;

@protected
bool get extendBodyBehindAppBar => false;

@protected
Color? get screenBackgroundColor => Colors.white;

@protected
Widget? buildBottomNavigationBar(BuildContext context) => null;

@protected
Widget buildScreen(BuildContext context);

@protected
PreferredSizeWidget? buildAppBar(BuildContext context) => null;

@protected
bool get wrapWithSafeArea => true;

@protected
bool get setBottomSafeArea => true;

@protected
bool get setTopSafeArea => true;

The getter methods used in the code above can be overridden in subclasses, providing customized behavior for each screen.

The @protected keyword, used repeatedly here, is a metadata annotation in the Dart language, which serves to limit direct use of the property or method from outside the class. Instead, the property or method can only be overridden or called in a subclass that inherits the class.

By doing so, the following benefits can be obtained:

  • Encapsulation: Hiding the internal implementation details of a class and limiting the interface exposed to the outside. This makes it easier to maintain the code as the interface used outside remains the same even if the class’s implementation is changed.
  • Clarity of inheritance hierarchy: Since properties and methods marked with @protected can only be used in subclasses, it is clear which properties and methods should be overridden and used along the inheritance hierarchy. This improves code readability and understanding.
  • Extensibility: Since properties and methods with the @protected keyword can be overridden in subclasses, the behavior can be changed or extended in derived classes. This improves the flexibility and extensibility of the code.
  • Prevention of unexpected errors: Preventing incorrect use of the class and limiting direct modification or calling of the internal properties or methods from outside, reducing unexpected errors or bugs.

Additionally, this structure uses getters rather than class member variables, so it can be considered that it takes into account the overall memory usage by calculating and returning the value only when needed.

4. vm property

abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);

.....(some code)

@protected
T get vm => controller;

This code is the most crucial part of the BaseScreen module. The BaseScreen class inherits from GetView<T> to interact with the ViewModel (GetxController). Here, the generic type T represents a type that inherits from GetxController.

NOTE: Generic Type
<T extends GetxController> defines a generic type. T is a generic type variable that acts as a type placeholder in a state where the actual type is not specified. In other words, the extends GetxController part indicates that T must be a type that inherits the GetxController class.

The vm getter method, annotated with @protected, uses the controller property, which allows access to the GetxController instance within the BaseScreen class. This helps to write clear code and improve readability. You can access the controller instance using the name vm.

Therefore, in Screen widgets that inherit from the BaseScreen class, you can conveniently access the ViewModel instance and write logical and readable code.

5. @immutable keyword

@immutable
abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);

.....
}

The @immutable keyword is an annotation in the Dart language that marks immutable objects. An immutable object is one whose state does not change once created. Therefore, by declaring the @immutable keyword, it enforces that class member variables must be declared as final.

But you might wonder🤔
Since you can override the getter properties when using a class that inherits from BaseScreen, it’s hard to see it as a completely immutable object. And since there are no member variables managed by this class, you might question the need to enforce final.

You’re right.

However, I prefer to use @immutable to make the code more explicit. This emphasizes that the class member variables should be declared as final and aims to make the class's nature closer to immutability.

In fact, the code will work without the @immutable annotation. But using this annotation helps to write clearer code.

6. initViewModel method

  @protected
void initViewModel() {
vm.initialized;
}

We’re almost done. Let’s look at the initViewModel method last.

The initViewModel method used in the BaseScreen class plays an important role when you need to be aware of GetX's lifecycle when the GetxController is lazy injected.

If you use Get.lazyPut to inject the controller, you need to use the initViewModel method to force initialization of the controller because the controller is set not to be injected until a specific widget accesses the GetxController instance.

class SplashViewModel extends GetxController {

void someInitialMethod() {
// some events
}

@override
void onInit() {
super.onInit();
someInitialMethod(); // <-- This method should be executed.
}
}

class SplashScreen extends GetView<SplashViewModel> {
const SplashScreen({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return const Center(
child: Text('Splash Screen'),
);
}
}

For example, suppose there is a Splash View and SplashViewModel. The controller is injected using Get.lazyPut, and the SplashScreen does not access the SplashViewModel instance. The goal is for the someInitialMethod() managed in ViewModel to be executed when the Splash screen appears.

However, since SplashScreen did not access the GetxController (SplashViewModel) instance, the GetxController itself will not be initialized, and the someInitialMethod event will not occur.

class SplashScreen extends GetView<SplashViewModel> {
const SplashScreen({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
/// Access the default GetxController instance
/// It doesn't matter which instance you access
controller.initialized;

//controller.isClosed;
//controller.isBlank;
//controller.runtimeType;


return const Center(
child: Text('Splash Screen'),
);
}
}

In this case, as in the code above, you need to force the initialization of the lazy-injected controller by accessing the initialized GetxController default instance.

As a result, you can consider that the GetxController is always guaranteed to be initialized when the screen is displayed.

abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);


@override
Widget build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}

....

@protected
void initViewModel() {
vm.initialized;
}

Now you should understand why the BaseScreen class uses the initViewModel method.

NOTE
To sum up, the initViewModel() method is used to guarantee that even GetxControllers with lazy injection are injected as soon as the screen is displayed.

Conclusion

In this post, we explored how the BaseScreen class can help developers implement screen structures more efficiently and clearly define the roles and responsibilities of View and ViewModel in the MVVM structure.

If the explanation was a bit complicated, I recommend cloning the GitHub repository with the BaseScreen example code and trying it out for yourself. It’s not difficult at all.

Once you get used to it, you’ll find it extremely convenient and your development productivity will increase significantly 😃

In the next post, I will discuss how to configure the BaseScreen class based on the Provider state management library.

Lastly

If you want to build a simple widget without creating a complete app screen based on Scaffold and separate the controller and screen layout, you can use the following code:

@immutable
abstract class BaseView<T extends GetxController> extends GetView<T> {
const BaseView({Key? key}) : super(key: key);

T get vm => controller;

@override
Widget build(BuildContext context) {
return buildView(context);
}

Widget buildView(BuildContext context);
}

--

--