The making of Ice Cubes, an open source, SwiftUI Mastodon client.

This is the beginning of a series of articles about the making of Ice Cubes

Thomas Ricouard
10 min readFeb 20, 2023

1. Mastodon

Wether your believe in Mastodon or not is not the goal of this article, I won’t even spend time explaining what Mastodon is, or compare it to Twitter. But I can tell you one thing: I’m on it, and it’s here to stay. You can also join me on mastodon.social using this link. Or join any other server like iosdev.space. A big part of the iOS, Swift, Apple community migrated there already and a lot of great conversations are going on.

Ice Cubes default icon

This is the first article in what I hope will be a long series of stories about the making of the Ice Cubes app. This article will focus on what the app is, the general story behind it, and an overview of the codebase.

2. The Product: Ice Cubes

Last Christmas, I began using Mastodon seriously, and because I don’t do things lightly, I also started working on an iOS client for the service. I’ve always wanted to make my own Twitter client, but the state of the API was never that great, and I do love the iOS Twitter app, so I never actually did it. Firs thing first, the Mastodon API is just plain awesome. The documentation is great, and there is endpoint for about everything. And as Mastodon itself use their own API to make their core apps (web frontend, iOS, Android, etc…), there is not really any secret API or gatekeeper.

I also want to note and thanks the official iOS app repository, as it was a great resource while working on some part of Ice Cubes, especially push notifications.

Also if you’re wondering why Ice Cubes? Where does the name come from? Here is answer to all your questions, I promise:

https://mastodon.cloud/web/statuses/109706370798319716

Also the app is coming with a ton of icons, some made by midjourney and some made by amazing designer fromt the community and are now featured in the app!

With Mastodon it was time, I could finally make my own social network app, and with iOS 16 and all the great new SwiftUI API that came with it, it was the perfect timing. And here we go, I’ve created a GitHub and an Xcode project. As usual, it’s fully open source:

About a month later, after a lot of feedbacks and testing from thousands of testers on the public TestFlight, it was released on the App Store.

I decided to release early, very early. It was barely an MVP, but it was an MVP I have happy with. The 1.0 was working, and you could already do a lot of things that you couldn’t do in other clients, including the official iOS app. And I had one killer feature: pinning and reading remote timelines.

Mastodon is a decentralized network. You can have an account on just about any server, and there are tons of topic-specific and focused communities. The idea behind the Ice Cubes app is that you can add any server URL to the app and easily switch to its local timeline to read and share content from there.

The pinning and reading remote timelines feature was shipped as part of the initial release, and I received positive feedback about it every day. I know I’m not the first to do it, as other apps were already doing it before. However, making it a core feature and placing it in front of the user, on top of being easy to use, really helped raise awareness about it quite a lot.

And now we’re at the release 1.5.X, I’m making updates, with improvements, features, bug fixes almost daily.

Short history of Ice Cubes App Store updates

The repository also have around 80 contributors, 400+PR merged, and the app is available in 15+ languages. The open source & Mastodon communities have been incredible. This is by far my best open source project do date, and I’m also quite happy with the codebase.

I want to use this post to thank all the great contributors we have on the Ice Cubes GitHub repository. This app could not be what it is today without all your help in fixing bugs, implementing features, and translating it into various languages!

The app have been downloaded around 50 000 times at the time of this writing, and hasan average of 4.8 on the App Store with 1300 reviews. I guess it’s pretty good, huh?!

And from me, to you, this is just the beginning. Ice Cubes now support most of Mastodon features, and also provide great push notifications, a share extension and a Safari extension to open any Mastodon link in Ice Cubes. It’s an iOS first class citizen, built entirely with SwiftUI and using all the native iOS components possible.

Like the great ToolbarTitleMenu, you can see it in action in the timeline tab navigation bar.

Or the List swipe actions, that are available in the any status row view.

It also use NavigationStack all around the app with programmatic navigation and a centralised router:

It’s just some example about all the SwiftUI and iOS features the app is showcasing. Feel free to take a look around the codebase or while directly running the app on your device!

It also works great on iPadOS and macOS (as an Apple Silicon iOS app for now). I hope one day to make a truly native macOS client. I’m using a custom sidebar and a secondary column for bigger screen:

Ice Cubes on macOS

3. The codebase

Project organisation

Let’s jump into the codebase now that you got an overview of the app. First things first, the app make an heavy use of Swift Packages:

The packages are split by domains and features. There is very little code in the app itself; everything is self-contained in its own package. This makes it easier to test (even if, for now, there are barely any tests) and faster to work at the package level with SwiftUI previews, faster build times, and so on.

Architecture

There is no crazy architecture in there. It uses a barebones MVVM with great separation of concerns and view encapsulation. Here’s an example of the most important view in the app, the StatusRowView, where a post is displayed:

It have one main view, one view model, and then it’s composed of small, targeted subviews. The main view hold the viewModel using a @StateObject and the viewModel is passed as an @ObservedObject or a simple let variable in the subviews where it’s needed. It have to be observed only if you need to listen to its published properties. The idea is to connect and do the minimum amount of update possible in those subviews to keep updates while scrolling at the minimum (actually next to none in the case of scrolling a list of statuses). This played a big part into improving performances while scrolling the timeline in the 1.5.X versions of the app.

Also big shoutout to Alex Grebenyuk for optimising parts of the app and make even further optimisation to Nuke, his library the app use for loading & displaying remote images.

EnvironmentObject

The app make heavy use the EnvironmentObject pattern:

WindowGroup {
appView
.applyTheme(theme)
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(pushNotificationsService)
}

There is no downside to injecting any number of them. It’s all about which view is connected to which environment object and how many updates you’re making in them. Feel free to split your shared data, etc., into any number of environment objects. The more targeted they are, the better it is for your views, as it means you can connect to only the desired properties.

You have to be aware including @EnvironmentObject at the top of your view don’t have any performance cost by itself. But updating any @Published property within this environment object will trigger a view update if the view is connected to it. Even if you’re not directly observing or using this property within your view. So be conscious about that.

I have an Env package which a bunch of objects and custom EnvironmentValues that can be injected and retrieved from the environment.

In my case, I have a variety of them, one of the user preferences, one for the theme, one for managing push notifications, a stream watcher, and something to access the current account and current instance information at anytime, anywhere in the app.

Navigation

Next let’s talk about navigation, navigation is always a concern in SwiftUI and was tackled very early on, I decided to go full NavigationStack, it support path manipulation for programmatic navigation and NavigationLink.

You’ll tell me “But it’s iOS 16 only!!!”, indeed it is, but I’m making a side project / hobby app here, and yes I’m using the latest shiny API because I can and because I want to.

So how the central app router works? Well I have defined a list of possible route:

public enum RouterDestinations: Hashable {
case accountDetail(id: String)
case accountDetailWithAccount(account: Account)
case accountSettingsWithAccount(account: Account, appAccount: AppAccount)
case statusDetail(id: String)
case statusDetailWithStatus(status: Status)
case remoteStatusDetail(url: URL)
case conversationDetail(conversation: Conversation)
case hashTag(tag: String, account: String?)
case list(list: Models.List)
case followers(id: String)
case following(id: String)
case favoritedBy(id: String)
case rebloggedBy(id: String)
case accountsList(accounts: [Account])
}

And I have the same for sheets:

public enum SheetDestinations: Identifiable {
case newStatusEditor(visibility: Models.Visibility)
case editStatusEditor(status: Status)
case replyToStatusEditor(status: Status)
case quoteStatusEditor(status: Status)
case mentionStatusEditor(account: Account, visibility: Models.Visibility)
case listEdit(list: Models.List)
case listAddAccount(account: Account)
case addAccount
case addRemoteLocalTimeline
case statusEditHistory(status: String)
case settings
case accountPushNotficationsSettings
case report(status: Status)
case shareImage(image: UIImage, status: Status)
}

And then I have an ObservableObject that I called… RouterPath

@MainActor
public class RouterPath: ObservableObject {
public var client: Client?
public var urlHandler: ((URL) -> OpenURLAction.Result)?

@Published public var path: [RouterDestinations] = []
@Published public var presentedSheet: SheetDestinations?

public init() {}

public func navigate(to: RouterDestinations) {
path.append(to)
}
}

It’ll be later injected anywhere you have a NavigationStack, so one for each tab of the app.

struct NotificationsTab: View {
@StateObject private var routerPath = RouterPath()

var body: some View {
NavigationStack(path: $routerPath.path) {
NotificationsListView()
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
}
.environmentObject(routerPath)
}

As you can see, I bind the path published property of the RouterPath StateObject to the NavigationStack. So NavigationStack will handle any programmatic manipulation done to it. And NavigationStack will also update it to reflect any change made from NavigationLink.I’m not using NavigationLink a lot, because it come with too many contrains, so I’m using path manipulation a lot and the navigate(to:) function a lot.

You’ll also notice that I created and add the withAppRouter and withSheetDestination modifiers to the view within the NavigationStack. To be able to use those routes with the NavigationStack you have to register them. Here is an extract of the implementation of those modifiers:

@MainActor
extension View {
func withAppRouter() -> some View {
navigationDestination(for: RouterDestinations.self) { destination in
switch destination {
case let .accountDetail(id):
AccountDetailView(accountId: id)
case let .accountDetailWithAccount(account):
AccountDetailView(account: account)
....
}
}
}

func withSheetDestinations(sheetDestinations: Binding<SheetDestinations?>) -> some View {
sheet(item: sheetDestinations) { destination in
switch destination {
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
.withEnvironments()
...
}
}

func withEnvironments() -> some View {
environmentObject(CurrentAccount.shared)
.environmentObject(UserPreferences.shared)
...
}
}

That’s it, you’re now ready to push or present anything from within this tab view using

  @EnvironmentObject private var routerPath: RouterPath

in your views.

Imagine you want to make an avatar touchable and navigate to the user profile? Well you would do it like so:

AvatarView(url: notification.account.avatar)
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account))
}

And it work the same for presenting any sheet. This is not really due to NavigationStack, but it’s because we register a global sheet router on our view hierarchy (withSheetDestinations). So to present a sheet you just have to :

Button {
routerPath.presentedSheet = .addAccount
} label: {
Image(systemName: "person.badge.plus")
}

Et voilà!

You now know about everything there is to know about how the router works within Ice Cubes. And the code is open source, you can take look at the whole code on GitHub.

I’ll stop there for this first article about Ice Cubes, but feel free to reach out in the comments, on GitHub, on Mastodon, etc.. I would be happy to hear about what you want to read next!

Thanks for reading, I hope you enjoyed this article, and I’ll see you for the next one where I’ll dig deeper into other parts of the codebase! 🚀

PS: Here is an invite link to signup on Mastodon.social instance, if you want to join me here: https://mastodon.social/invite/Tqz5hxRc

--

--

Thomas Ricouard

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