The purpose of this fork is to show how the app looks like if we add ViewController's to the MVCS mix (the "VC" in MVCS π).
A key feature of @mergesort's setup is the
Boutique
Store
,
which makes it really easy to persist
Codable
objects.
Kinda like a "Nano SwiftData".
Links:
- The original MVCS demo project on GitHub
- Mergesort's blog entry describing his MVCS architecture.
- My blog entry about using MVC ViewController's with SwiftUI
In @mergesort's
MVCS
setup, the "Controller" object is an arbitrary
ObservableObject
that performs common operations on behalf of SwiftUI Views,
and latches on to the
Store
for persistence.
The original demo has the
ImagesController
example which is instantiated (twice!) and used by both Views.
A ViewController is a very specific kind of a controller in Apple's "MVC" (really more like MWC), that deals with the needs of a specific View. Described well in The Role of View Controllers.
I think the example is a good usecase for ViewController's, as it has two distinct screen sections that manage certain aspects of the application (even though it doesn't have navigation in the traditional way).
While one can use
MVCS
with the more general controllers,
and
ViewController
without the specific
Store
,
they actually supplement each other quite well!
ViewController
exists to structure the View
and presentation setup
using the known VC pattern,
but doesn't do anything about model persistence.
Boutique adds an easy to use persistence framework for simpler setups.
Since the Boutique Store
attaches to arbitrary
ObservableObject
's
using the
@Stored
property wrapper,
it works w/ a
ViewController
out of the box!
Note: I didn't change of the original layout or functionality, just reordered things to fit the VC setup.
The app has two screen sections:
- The
FavoritesCarousel
on the top. It shows the Panda's that have been saved to the store, and can remove them from the store. - The
RedPandaCard
which can fetch new Panda's from the Interwebs and save them to the store.
Both of which we turn into
ViewController's.
And we add another one, Main
, which controls the scene view
(i.e. what has been the ContentView
before).
The Main
VC is
attached directly in the scene:
@main
struct MVCSDemoApp: App {
var body: some Scene {
WindowGroup {
MainViewController(Main())
}
}
}
And looks like this (Main.swift): :
class Main: ViewController {
let carouselFocusController = ScrollFocusController<String>()
let carousel = FavoritesCarousel()
lazy var card = RedPandaCard(focusController: carouselFocusController)
var view: some View {
VStack(spacing: 0.0) {
carousel
.controlledContentView // essentially the "presentInline"
.environmentObject(carouselFocusController)
.padding(.bottom, 8.0)
Divider()
Spacer()
card
.controlledContentView
}
.padding(.horizontal, 16.0)
.background(Color.palette.background)
}
}
Note how the
View
(like in UIKit) becomes owned by theViewController
, i.e. the "other way around" to what people often come up with in SwiftUI. That is the key premise in our ViewController setup, regardless whether it is done using the micro framework or using plain ObservableObject's.
The main controller has two child view controllers, which are added as contained controllers.
Let's have a look at the
FavoritesCarousel
.
Before it was a View
, now it is a ViewController
and a nested View
:
class FavoritesCarousel: ViewController {
@Stored(in: .imagesStore) var images
@Published private var animation: Animation? = nil
init() {
...
self.animation = .easeInOut(duration: 0.35)
}
private func removeImage(image: RemoteImage) {
Task { try await self.$images.remove(image) }
}
private func clearAllImages() {
Task { try await self.$images.removeAll() }
}
var view: some View {
VStack {
HStack {
Text("Favorites")
.bold()
...
Button(action: clearAllImages) {
Image(systemName: "xmark.circle.fill")
...
}
}
if images.isEmpty { ... }
else {
HStack {
CarouselView(items: images.sorted(by: { $0.createdAt > $1.createdAt })) {
image in
ZStack(alignment: .topTrailing) {
RemoteImageView(image: image)
..
Button(action: { self.removeImage(image: image) }) {
Image(systemName: "xmark.circle.fill")
..
}
}
}
}
}
}
}
}
The first things to note is that we have the
@Stored(in: .imagesStore) var images
This is the "magic" coming in from Boutique. It sets up the store which auto-persists and auto-restores. If any VC or View in the stack modifies the store, the VC will update accordingly.
The second thing to note is the @Published var animation
.
That was a @State
of the View
before.
In our VC setup, state generally does not belong into View's.
Then there comes an init
, another important part in the VC setup.
It makes the task
(aka onAppear
) modifier superfluous.
Because ViewController's are setup explicitly and have identity (they are
objects in the presentation hierarchy), the flow is always clear and
initialization can be done when necessary.
Initialization shouldn't be a side effect.
What follows are the removeImage
and clearAllImages
actions that
are hooked up directly to the Button
s and modify the store.
Also note that proper encapsulation is in play, i.e. all the methods of a VC
can usually be marked private
.
And finally the view
of the ViewController
.
Nothing really special about it.
The View doesn't need to be done inline. Like in UIKit,
more complex Views can be moved outside of the VC class.
The
RedPandaCard
ViewController
works the same. Feel free to have a look
in the sample.
Note that even with VC's, a more general controller like the
ImagesController
can still be used.
For further structuring of the application (they are often called a "Service"
in MVC setups).
To hookup a VC with another controller (or any other ObservableObject),
willChange(with:)
is available. It could looks like this:
class FavoritesCarousel: ViewController {
let imagesControllers = ImagesController()
init() {
willChange(with: imagesController)
}
}
This makes the view refresh if the nested controller changes.
ViewController diverges from the controller idea in MVCS. Though IMO they go along really well and provide each other the cherry on top π
Having a specific controller for views does provide some structure, especially when it comes to (especially nested) navigation. Though we need to revisit that with the NavigationStack changes coming in SwiftUI 4 / iOS 16 π¬
Have fun!
Welcome to a demo of the Model View Controller Store architecture. If you'd like to familiarize yourself with Model View Controller Store you can read about the philosophy, along with a very technical walkthrough in this post.
SwiftUI has never been granted a blessed architecture by Apple, and many developers have spent countless hours filling the gaps with their own ideas. The most common approach is to take the MVVM pattern and translate it to the needs of SwiftUI, which works well but has gaps exposed given SwiftUI's View-centric nature. Others have taken the path of integrating well thought out and powerful libraries such as The Composable Architecture to have the tools to reason about your entire application, but come with a very high learning curve.
Model View Controller Store is a rethinking of an architecture familiar to iOS developers MVC, for SwiftUI. You can build apps across many platforms using this pattern, in fact the C from MVCS is inspired by Rails Controllers rather than ViewControllers, but in this demo we'll focus on SwiftUI. The Model and View require no changes to your mental model when you think about a Model or View in a SwiftUI app, but what's new is the concept of a Store
. You can think the Store
as your storage for your model objects, in SwiftUI this would be your single source of truth. The combination of MVC and a Store bound together by a simple API allows a developer to give their app a straightforward and well-defined data architecture, with no learning curve, to create an app that's incredibly easy to reason about.
The best way to explain Model View Controller Store is to show you what it is. The idea is so small that I'm convinced you can look at the code in this repo and know how it works almost immediately, there's actually very little to learn. Model View Controller Store doesn't require you to change your apps, and this demo app is powered by Boutique, a library I've developed to provide a batteries-included Store
. It requires no tricks to use, does no behind the scenes magic, and doesn't resort to shenanigans like runtime hacking to achieve a great developer experience. Boutique's Store is a dual-layered memory and disk cache which lets you build apps that update in real time with full offline storage with three lines of code and an incredibly simple API. That may sound a bit fancy but all it means is that when you save an object into the Store
, it also saves that object to disk. This persistence is powered under the hood by Bodega, an actor-based library I've developed for saving data or Codable objects to disk.