Design Patterns in Flutter: A Comprehensive Guide
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 Counter
state 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 State
class 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!