Removing the M from MVVM with SwiftUI

Thomas Ricouard
Stackademic
Published in
5 min readApr 8, 2024

--

Various screenshots of Ice Cubes features

I get this question so often that I finally want to write about it. It won’t be a long post on iOS app architecture, and it won’t even be hot takes. It’s just how I ship iOS apps those days, especially Ice Cubes, my open-source SwiftUI Mastodon client. If you encapsulate your code well enough, your views are just a representation of states, nothing less, nothing more.

You can also download the app from the iOS, macOS, and visionOS App Store here.

I recently added a new feature, and with all the toolkits I already built in Ice Cubes, it took me only a couple of hours to wire it together. It’s about supporting the new notifications filter feature that Mastodon added on their backend and web frontend. I’ll not demo the feature in this story as what is interesting to us is the code.

When writing the code for this feature, I actually came back to the codebase after a few weeks of not touching it. One goal was to distill that actual code to the absolute minimum. This is one of the only ways to stay sane when working on my open-source projects. Don’t get me wrong, I love it, but with work life and family life, keeping the extra code at a minimum is a lot of fun.

First, we’ll look at the list of notification requests. It’s a view with a list of requests that the user can interact with. Let’s see how I built that without using any view model.

public struct NotificationsRequestsListView: View {
@Environment(Client.self) private var client
@Environment(Theme.self) private var theme

enum ViewState {
case loading
case error
case requests(_ data: [NotificationsRequest])
}
@State private var viewState: ViewState = .loading

The first two lines are about retrieving two environments; the first one Client provides the view with all the necessary high-level functions to do network requests to Mastodon. The second one Theme offers all I need to customize the look of the views.

Then, I define the ViewState, this is the most important takeaway of this story as here I defined the various states the view will display directly within it. The initial state is the loading.

Let’s now see how this view is drawn on the screen

  public var body: some View {
List {
switch viewState {
case .loading:
ProgressView()
.listSectionSeparator(.hidden)
case .error:
ErrorView(title: "notifications.error.title",
message: "notifications.error.message",
buttonTitle: "action.retry")
{
await fetchRequests()
}
.listSectionSeparator(.hidden)
case let .requests(data):
ForEach(data) { request in
NotificationsRequestsRowView(request: request)
.swipeActions {
Button {
Task { await acceptRequest(request) }
} label: {
Label("account.follow-request.accept", systemImage: "checkmark")
}

Button {
Task { await dismissRequest(request) }
} label: {
Label("account.follow-request.reject", systemImage: "xmark")
}
.tint(.red)
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.navigationTitle("notifications.content-filter.requests.title")
.navigationBarTitleDisplayMode(.inline)
.task {
await fetchRequests()
}
.refreshable {
await fetchRequests()
}
}

You’ll probably notice how short the code is for a view that represents three different states, and it’s because one of the best way to remove the M from MVVM is to split your view into tiny, simple structures. Apple built SwiftUI this way, State, Binding and Environment allows your views to interact with each other straightforwardly.

When the view is loading, I use the built-in ProgressView. If there is an error, I use my own ErrorView, which provides a button to retry. Finally, if there is data, I display it using another view, and I’ve added some swipe gestures for the user to interact with the notification requests.

The last part of this view contains some sync functions that this view will use. The code is well encapsulated, so having it in the view is absolutely fine. Client is high-level and tested independently, so having those one-liner requests at the view level is not a problem.

  private func fetchRequests() async {
do {
viewState = .requests(try await client.get(endpoint: Notifications.requests))
} catch {
viewState = .error
}
}

private func acceptRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.acceptRequest(id: request.id))
await fetchRequests()
}

private func dismissRequest(_ request: NotificationsRequest) async {
_ = try? await client.post(endpoint: Notifications.dismissRequest(id: request.id))
await fetchRequests()
}

Some improvements could be to add error management to the acceptRequest and dismissRequest function. If this fails, an alert could be presented to the user so he can retry. We could also do some optimistic behavior and remove the requests from the currently displayed requests as soon as the user interacts with them. And to be clear, all this code would still live in this view.

Now, let’s look at NotificationsRequestsRowView, it’s a pure view without much interaction. But it’s yet another example of a pure state view. A static object is passed to this view, and the sole purpose of this view is to display it.

struct NotificationsRequestsRowView: View {
@Environment(Theme.self) private var theme
@Environment(RouterPath.self) private var routerPath
@Environment(Client.self) private var client

let request: NotificationsRequest

var body: some View {
HStack(alignment: .center, spacing: 8) {
AvatarView(request.account.avatar, config: .embed)

VStack(alignment: .leading) {
EmojiTextApp(request.account.cachedDisplayName, emojis: request.account.emojis)
.font(.scaledBody)
.foregroundStyle(theme.labelColor)
.lineLimit(1)
Text(request.account.acct)
.font(.scaledFootnote)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 4)
Spacer()
Text(request.notificationsCount)
.font(.footnote)
.monospacedDigit()
.foregroundStyle(theme.primaryBackgroundColor)
.padding(8)
.background(.secondary)
.clipShape(Circle())

Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.onTapGesture {
routerPath.navigate(to: .notificationForAccount(accountId: request.account.id))
}
.listRowInsets(.init(top: 12,
leading: .layoutPadding,
bottom: 12,
trailing: .layoutPadding))
.listRowBackground(theme.primaryBackgroundColor)
}
}

Now, let’s get down to some questions people ask me over and over.

How do you test this?

What value does testing your views add to your project? What do you want to test? Since your views are just expressions of a state, the best tests are snapshot tests. If you built previews for your view, snapshot testing is basically the same thing. Input your state to your view, capture a screenshot of it, and compare this screenshot to a new one the next time you want to test it.

What you should test with unit testing is all your building blocks, in my case Client , and Theme for example. If they work correctly, my view will work correctly.

And with environments, it’s effortless to build them in a way for you to inject different implementations for previews, tests, production, etc… Environments is literally free dependency injection for your whole view hierarchy.

But this pattern only work for simple REST application..

Not really. Sure, Ice Cubes is a Mastodon client, but it also has a ton of client-side features, uses SwiftData extensively, and provides many functionalities unrelated to the Mastodon API. It’s all about keeping the view code at the minimum, and as soon as it becomes too big for one view, split it!

Goodbye MVVM, glory to VV 🚀

Stackademic 🎓

Thank you for reading until the end. Before you go:

--

--

📱 🚀 🇫🇷 [Entrepreneur, iOS/Mac & Web dev] | Now @Medium, @Glose 📖| Past @google 🔍 | Co-founded few companies before, a movies 🎥 app and smart browser one.