The Power of MVVM: State Management, APIs, and Declarative UI in SwiftUI, Compose, and Flutter
Hey everyone! π We've been on quite a journey, exploring declarative UI, state management, and sealed classes. Today, I am bringing it all together with the MVVM architectural pattern. We'll see how MVVM, along with proper state management using sealed classes/enums, can supercharge our apps when handling API calls. And of course, we'll explore this in SwiftUI, Jetpack Compose, and Flutter, while emphasizing the necessity of segregation.
Why Segregation Matters?
Before diving into the code, let's talk about why we need to segregate our code. Imagine trying to bake a cake with all the ingredients mixed together β itβs a recipe for disaster! Similarly, in app development, if we don't separate our code based on responsibilities, we end up with a tangled mess thatβs hard to understand, debug, and maintain.
MVVM, Sealed Classes/Enums, and APIs
In this setup:
The model handles data-related operations (API calls, database interactions).
The ViewModel fetches data using the Model, manages UI state, and exposes this state to the View. We'll use sealed classes/enums to represent these UI states.
The View observes the ViewModel's state and updates the UI declaratively.
Let's see it in action.
SwiftUI: MVVM with Combine and Enums
import SwiftUI
import Combine
// MARK: - Model (API Handling)
struct Photo: Codable, Identifiable {
let id: Int
let title: String
let thumbnailUrl: String
}
class ApiClient {
func fetchPhotos() -> AnyPublisher<[Photo], Error> {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/photos") else {
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map {$0.data }
.decode(type: [Photo].self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
// MARK: - ViewModel
enum ViewState<T> {
case loading
case loaded(data: T)
case error(message: String)
}
class PhotoViewModel: ObservableObject {
@Published var viewState: ViewState<[Photo]> = .loading
private let apiClient = ApiClient()
private var cancellables = Set<AnyCancellable>()
func fetchPhotos() {
viewState = .loading
apiClient.fetchPhotos()
.sink(receiveCompletion: { completion in
switch completion {
case .finished: break
case .failure(let error):
self.viewState = .error(message: error.localizedDescription)
}
}, receiveValue: { photos in
self.viewState = .loaded(data: photos)
})
.store(in: &cancellables)
}
}
// MARK: - View
struct PhotoListView: View {
@StateObject var viewModel = PhotoViewModel()
var body: some View {
VStack {
switch viewModel.viewState {
case .loading:
ProgressView("Loading...")
case .loaded(let photos):
List(photos) { photo in
Text(photo.title)
}
case .error(let message):
Text("Error: \(message)").foregroundColor(.red)
}
}.onAppear {
viewModel.fetchPhotos()
}
}
}
Model: ApiClient fetches data using URLSession and returns a Combine Publisher.
ViewModel: PhotoViewModel transforms the Model's data into a ViewState and exposes it using @Published for UI to observe.
View: PhotoListView observes viewModel.viewState and renders UI declaratively based on that state.
Jetpack Compose: MVVM with Sealed Classes
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.URL
// MARK: - Model
@Serializable
data class Album(
@SerialName("id") val id: Int,
@SerialName("title") val title: String
)
class ApiService {
suspend fun fetchAlbums(): List<Album> {
val url = URL("https://jsonplaceholder.typicode.com/albums")
val jsonString = url.readText()
return Json.decodeFromString(jsonString)
}
}
// MARK: - View Model
sealed class ViewState<out T> {
object Loading : ViewState<Nothing>()
data class Loaded<T>(val data: T) : ViewState<T>()
data class Error(val message: String) : ViewState<Nothing>()
}
class AlbumViewModel: ViewModel() {
private val apiService = ApiService()
var viewState by mutableStateOf<ViewState<List<Album>>>(ViewState.Loading)
private set
init {
fetchAlbums()
}
private fun fetchAlbums() {
viewModelScope.launch {
try {
val albums = apiService.fetchAlbums()
viewState = ViewState.Loaded(albums)
}catch (e: Exception) {
viewState = ViewState.Error(e.localizedMessage ?: "An error occurred")
}
}
}
}
// MARK: - View
@Composable
fun AlbumListScreen(viewModel: AlbumViewModel = AlbumViewModel()) {
val viewState = viewModel.viewState
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when(viewState) {
is ViewState.Loading -> {
CircularProgressIndicator()
Text(text="Loading...")
}
is ViewState.Loaded -> {
val albums = (viewState as ViewState.Loaded<List<Album>>).data
if (albums.isNotEmpty()) {
Column {
albums.forEach { album ->
Text(text = album.title, style = MaterialTheme.typography.h6)
}
}
}else{
Text(text="No data to display")
}
}
is ViewState.Error -> {
val errorMessage = (viewState as ViewState.Error).message
Text(text="Error: $errorMessage", color = MaterialTheme.colors.error)
}
}
}
}
Model: ApiService fetches data using a URL.
ViewModel: AlbumViewModel fetches albums and holds a ViewState, which can be loading, success, or error.
View: AlbumListScreen uses the viewState to render the UI accordingly.
Flutter: MVVM with BLoC and Enums
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_bloc/flutter_bloc.dart';
// MARK: - Model
class Todo {
final int id;
final String title;
Todo({required this.id, required this.title});
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(id: json['id'], title: json['title']);
}
}
class ApiService {
Future<List<Todo>> fetchTodos() async {
final response =
await http.get(Uri.parse('https://jsonplaceholder.typicode.com/todos'));
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((json) => Todo.fromJson(json)).toList();
} else {
throw Exception('Failed to load todos');
}
}
}
// MARK: - BLoC Events
sealed class TodoEvent {}
class FetchTodos extends TodoEvent {}
// MARK: - BLoC State
enum ViewState { loading, loaded, error }
class TodoState {
final ViewState state;
final List<Todo>? todos;
final String? errorMessage;
TodoState({required this.state, this.todos, this.errorMessage});
}
// MARK: - BLoC
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final ApiService apiService;
TodoBloc({required this.apiService}) : super(TodoState(state: ViewState.loading)) {
on<FetchTodos>((event, emit) async {
emit(TodoState(state: ViewState.loading));
try {
final todos = await apiService.fetchTodos();
emit(TodoState(state: ViewState.loaded, todos: todos));
} catch (e) {
emit(TodoState(state: ViewState.error, errorMessage: e.toString()));
}
});
add(FetchTodos());
}
}
// MARK: - View
class TodoListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => TodoBloc(apiService: ApiService()),
child: Scaffold(
appBar: AppBar(title: Text('Todo List')),
body: Center(
child: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
switch (state.state) {
case ViewState.loading:
return CircularProgressIndicator();
case ViewState.loaded:
final todos = state.todos;
return ListView.builder(
itemCount: todos!.length,
itemBuilder: (context, index) {
return ListTile(title: Text(todos[index].title));
},
);
case ViewState.error:
return Text("Error: ${state.errorMessage}");
}
},
),
),
),
);
}
}
Model: ApiService fetches todo data, while Todo represents a single todo item.
BLoC: TodoBloc receives events, uses ApiService to fetch todos, and emits states (loading, loaded, or error).
View: TodoListScreen listens to TodoBloc's state and updates its UI accordingly using BlocBuilder.
Benefits of MVVM and Segregation
Clear Code Structure: Code becomes much more organized and easier to read due to clear separation of concerns.
Easy Testing: You can test the ViewModel and Model in isolation, improving the reliability of your code.
Maintainability: Code becomes modular and easier to modify or extend as the application grows.
Scalability: The architecture is well-suited to scale as the app becomes more complex.
Reusability: ViewModels and Models can be reused across different views.
Wrapping Up
Using MVVM with state management, sealed classes/enums, and declarative UI not only simplifies the process of building modern applications but also gives us a more robust and scalable architecture. By adhering to these principles and separating concerns effectively, we can write cleaner, more maintainable, and ultimately, better applications.
Let me know if you see any areas where I can make this explanation even better! I'm always up for suggestions and improvements.
Clap-clap-clap... clap-clap-clap... Keep clapping until you hit 10! π
Ready to level up your Declarative UI skills? Explore my recent series.
Let's keep sharing, learning, and practicing! π€π»