Design Patterns in Flutter: A Comprehensive Guide

Nima Farzin
8 min readJun 11, 2024

--

In Flutter app development, using design patterns can greatly enhance the robustness, maintainability, and scalability of your applications. This article explores several common design patterns with detailed explanations and simple examples for each. Whether you’re a beginner or looking to deepen your understanding, these concepts will help you elevate your Flutter development skills.

1. Singleton Pattern

The Singleton Pattern ensures a class has only one instance and provides a global point of access to it. This is useful when you need to control access to shared resources, such as configurations or a database connection.

class Singleton {
static final Singleton _instance = Singleton._internal();

factory Singleton() {
return _instance;
}

Singleton._internal();

void doSomething() {
print("Doing something");
}
}

void main() {
Singleton singleton1 = Singleton();
Singleton singleton2 = Singleton();

singleton1.doSomething();
singleton2.doSomething();

print(singleton1 == singleton2); // true
}

In this example, the Singleton class ensures only one instance of itself is created. The factory constructor returns the single instance, and the private constructor _internal is used to create the instance.

class Navigator extends StatefulWidget {
// Singleton instance
static final Navigator _instance = Navigator._internal();

// Private constructor
Navigator._internal();

// Factory constructor
factory Navigator() {
return _instance;
}

// Other methods and properties
}

The Navigator class in Flutter SDK uses the Singleton pattern. This ensures there is a single instance of Navigator that manages the app's navigation state.

2. Provider Pattern

The Provider Pattern is essential for state management in Flutter, allowing you to manage and share state between widgets efficiently.

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

void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: MyApp(),
),
);
}

class Counter with ChangeNotifier {
int _count = 0;

int get count => _count;

void increment() {
_count++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Provider Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Consumer<Counter>(
builder: (context, counter, child) => Text(
'${counter.count}',
style: Theme.of(context).textTheme.headline4,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<Counter>().increment(),
tooltip: 'Increment',
child: Icon(Icons.add),
),
),
);
}
}

Here, the Counter class extends ChangeNotifier, allowing it to notify listeners when its state changes. The ChangeNotifierProvider provides this state to the widget tree, and the Consumer widget rebuilds when the Counterstate changes.

3. Factory Pattern

The Factory Pattern provides a way to create objects without specifying the exact class of the object that will be created. This is useful for managing different object types through a common interface.

abstract class Animal {
void speak();
}

class Dog implements Animal {
@override
void speak() {
print("Woof!");
}
}

class Cat implements Animal {
@override
void speak() {
print("Meow!");
}
}

class AnimalFactory {
static Animal createAnimal(String type) {
if (type == 'dog') {
return Dog();
} else if (type == 'cat') {
return Cat();
} else {
throw Exception('Animal type not supported');
}
}
}

void main() {
Animal dog = AnimalFactory.createAnimal('dog');
Animal cat = AnimalFactory.createAnimal('cat');

dog.speak();
cat.speak();
}

In this example, AnimalFactory creates instances of Dog or Cat based on the provided type, allowing for flexible object creation without exposing the instantiation logic.

class TextEditingController extends ValueNotifier<TextEditingValue> {
TextEditingController({ String? text })
: super(text == null ? TextEditingValue.empty : TextEditingValue(text: text));

// Other methods and properties
}

The TextEditingController uses a factory method to create instances based on initial text.

4. Builder Pattern

The Builder Pattern is used to construct complex objects step by step, making it easier to create objects with many optional parameters.

class Burger {
String? bread;
String? meat;
String? cheese;

@override
String toString() {
return 'Burger with $bread bread, $meat meat, and $cheese cheese';
}
}

class BurgerBuilder {
Burger _burger = Burger();

BurgerBuilder setBread(String bread) {
_burger.bread = bread;
return this;
}

BurgerBuilder setMeat(String meat) {
_burger.meat = meat;
return this;
}

BurgerBuilder setCheese(String cheese) {
_burger.cheese = cheese;
return this;
}

Burger build() {
return _burger;
}
}

void main() {
Burger burger = BurgerBuilder()
.setBread('Whole Wheat')
.setMeat('Beef')
.setCheese('Cheddar')
.build();

print(burger);
}

The BurgerBuilder class helps in constructing a Burger object step by step, making it clear and easy to understand the construction process.

showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Alert'),
content: Text('This is an alert dialog.'),
actions: <Widget>[
FlatButton(
child: Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);

Flutter’s AlertDialog makes use of the Builder pattern to construct its child widgets.

5. Repository Pattern

The Repository Pattern abstracts the data layer, providing a clean API for data access and making it easier to manage data from various sources such as databases or web services.

class User {
final String name;

User(this.name);
}

abstract class UserRepository {
Future<User> getUser();
}

class UserRepositoryImpl implements UserRepository {
@override
Future<User> getUser() async {
// Simulate fetching user from a database or API
await Future.delayed(Duration(seconds: 1));
return User('John Doe');
}
}

void main() async {
UserRepository userRepository = UserRepositoryImpl();
User user = await userRepository.getUser();
print('User: ${user.name}');
}

In this example, UserRepositoryImpl provides a method to fetch a User object, abstracting the underlying data retrieval logic.

class UserRepository {
final FirebaseAuth _firebaseAuth;

UserRepository({FirebaseAuth firebaseAuth})
: _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;

Future<User> signInWithCredentials(String email, String password) {
return _firebaseAuth.signInWithEmailAndPassword(email: email, password: password);
}

// Other methods for sign up, sign out, etc.
}

While the Repository pattern isn’t explicitly part of the Flutter SDK, it is commonly used in conjunction with Flutter, particularly in apps using firebase_database or other data sources. An example from the flutterfire library

6. Model-View-ViewModel (MVVM) Pattern

The MVVM Pattern separates the development of the graphical user interface from the business logic, making it easier to manage and test different components of the application.

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

void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterViewModel(),
child: MyApp(),
),
);
}

class CounterViewModel with ChangeNotifier {
int _counter = 0;

int get counter => _counter;

void increment() {
_counter++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('MVVM Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Consumer<CounterViewModel>(
builder: (context, viewModel, child) => Text(
'${viewModel.counter}',
style: Theme.of(context).textTheme.headline4,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterViewModel>().increment(),
tooltip: 'Increment',
child: Icon(Icons.add),
),
),
);
}
}

In this MVVM example, CounterViewModel handles the business logic and state, while the MyApp widget binds the UI to this state using Consumer.

7. Bloc Pattern

The Bloc Pattern uses Streams to manage state, facilitating the separation of business logic from the UI. This pattern is powerful for managing complex state in Flutter applications.

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

void main() {
runApp(MyApp());
}

class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);

void increment() => emit(state + 1);
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: BlocProvider(
create: (context) => CounterCubit(),
child: CounterScreen(),
),
);
}
}

class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final counterCubit = context.read<CounterCubit>();

return Scaffold(
appBar: AppBar(title: Text('Bloc Example')),
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) {
return Text('$count', style: Theme.of(context).textTheme.headline4);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counterCubit.increment(),
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

In this example, CounterCubit is a simplified version of a Bloc that manages the state of the counter. The CounterScreen widget uses BlocBuilder to rebuild the UI in response to state changes.

8. Observer Pattern

The Observer Pattern allows an object (the subject) to notify other objects (observers) about changes in its state, facilitating a decoupled and scalable system.

import 'dart:async';

class Subject {
final _controller = StreamController<int>();

void addObserver(void Function(int) observer) {
_controller.stream.listen(observer);
}

void notifyObservers(int state) {
_controller.sink.add(state);
}

void dispose() {
_controller.close();
}
}

void main() {
final subject = Subject();

subject.addObserver((state) => print('Observer 1: $state'));
subject.addObserver((state) => print('Observer 2: $state'));

subject.notifyObservers(1);
subject.notifyObservers(2);

subject.dispose();
}

In this example, Subject manages a stream of integer states and notifies observers when the state changes. Observers can react to these changes accordingly.

ValueNotifier<int> counter = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('Value: $value');
},
);

counter.value = 1; // This will trigger the ValueListenableBuilder to rebuild

The ValueListenableBuilder widget in Flutter SDK uses the Observer pattern. It listens to a ValueListenable and rebuilds the widget tree when the value changes.

9. Decorator Pattern

The Decorator Pattern allows behavior to be added to individual objects dynamically, without affecting the behavior of other objects from the same class. This pattern is useful for extending the functionality of objects in a flexible and reusable manner.

abstract class Coffee {
String description = "Unknown Coffee";

String getDescription() {
return description;
}

double cost();
}

class Espresso extends Coffee {
Espresso() {
description = "Espresso";
}

@override
double cost() {
return 1.99;
}
}

abstract class CondimentDecorator extends Coffee {
Coffee coffee;
CondimentDecorator(this.coffee);
}

class Milk extends CondimentDecorator {
Milk(Coffee coffee) : super(coffee);

@override
String getDescription() {
return coffee.getDescription() + ", Milk";
}

@override
double cost() {
return coffee.cost() + 0.5;
}
}

void main() {
Coffee coffee = Espresso();
print('${coffee.getDescription()} \$${coffee.cost()}');

coffee = Milk(coffee);
print('${coffee.getDescription()} \$${coffee.cost()}');
}

In this example, Milk extends the functionality of a Coffee object by adding milk to it, demonstrating how the Decorator Pattern can be used to add behaviors to objects dynamically.

Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.blueAccent),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Hello, world!'),
),
);

Flutter’s widget system can be seen as an implementation of the Decorator pattern. Widgets are built by composing other widgets, allowing for a flexible and extensible UI structure.

10. Template Method Pattern

Defines the skeleton of an algorithm in the superclass, allowing subclasses to override specific steps.Break the algorithm into steps and create a template method in the base class that calls these steps. Subclasses implement the steps while sharing the overall structure.

abstract class DataMiner {
// Template method
void mineData() {
openFile();
extractData();
parseData();
analyzeData();
closeFile();
}

// Steps
void openFile();
void extractData();
void parseData();
void analyzeData() {
// Default implementation
print('Analyzing data...');
}
void closeFile();
}


class CSVDataMiner extends DataMiner {
@override
void openFile() {
print('Opening CSV file...');
}

@override
void extractData() {
print('Extracting data from CSV file...');
}

@override
void parseData() {
print('Parsing CSV data...');
}

@override
void closeFile() {
print('Closing CSV file...');
}
}

The State class in Flutter, which is used for stateful widgets, uses a form of the Template Method pattern. The Stateclass defines the lifecycle methods that can be overridden by subclasses to define specific behavior without changing the overall structure of the lifecycle.

abstract class State<T extends StatefulWidget> {
void initState() {
// Initialization code here
}

void didChangeDependencies() {
// Code that runs when dependencies change
}

void setState(VoidCallback fn) {
// Code to update the state
}

void dispose() {
// Cleanup code here
}

@protected
Widget build(BuildContext context);
}


class MyState extends State<MyWidget> {
@override
void initState() {
super.initState();
// Custom initialization code
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
// Custom dependency change handling
}

@override
Widget build(BuildContext context) {
return Text('Hello, World!');
}

@override
void dispose() {
// Custom cleanup code
super.dispose();
}
}

Conclusion

These design patterns are powerful tools that can help you write cleaner, more maintainable, and scalable Flutter applications. By understanding and applying these patterns, you can improve your ability to manage complex state and business logic within your Flutter apps. Each pattern offers unique benefits and can be chosen based on the specific needs and architecture of your application. Happy coding!

You can find me on LinkedIn and GitHub

www.linkedin.com/in/nimafarzin-pr

https://github.com/nimafarzin-pr

--

--