View controllers tend to play a very central part in most apps built for Apple’s platforms. They manage key aspects of our UIs, provide a bridge to system functionality like device orientation and status bar appearance, and often respond to user interactions — like button taps, and text input.
Since they usually have such a key role, it’s therefore not that surprising that many view controllers end up suffering from the common Massive View Controller problem — when they end up taking on too many responsibilities, resulting in lots of intertwined logic, often mixed with view and layout code.
While we’ve already explored multiple ways of mitigating, and breaking up, large view controllers — such as using composition, moving navigation code to dedicated types, reusing data sources, and using logic controllers — this week, let’s take a look at a technique that lets us extract our view controllers’ core actions, without having to introduce any additional abstractions or architectural concepts.
Awkward awareness
A very common root cause of many kinds of architectural and structural problems is that some types simply become aware of too many domains and details. When a given type’s ”sphere of awareness” grows, so usually does its responsibilities, and — as a direct effect — the amount of code that it contains.
Let’s say that we’re building a composer view for a messaging app, and in order to be able to add recipients from the user’s contacts and to enable messages to be sent, we currently give our view controller direct access to our database and networking code:
class MessageComposerViewController: UIViewController {
private var message: Message
private let userDatabase: UserDatabase
private let networking: Networking
init(recipients: [Recipient],
userDatabase: UserDatabase,
networking: Networking) {
self.message = Message(recipients: recipients)
self.userDatabase = userDatabase
self.networking = networking
super.init(nibName: nil, bundle: nil)
}
}
The above might not seem like a big deal — we’re using dependency injection, and it’s not like our view controller has a large number of dependencies. However, while our view controller hasn’t turned into a massive one just yet, it does have quite a few number of actions that it needs to handle — such as adding recipients, cancelling, and sending messages — all of which it’s currently performing on its own:
private extension MessageComposerViewController {
func handleAddRecipientButtonTap() {
let picker = RecipientPicker(database: userDatabase)
picker.present(in: self) { [weak self] recipient in
self?.message.recipients.append(recipient)
self?.renderRecipientsView()
}
}
func handleCancelButtonTap() {
if message.text.isEmpty {
dismiss(animated: true)
} else {
dismissAfterAskingForConfirmation()
}
}
func handleSendButtonTap() {
let sender = MessageSender(networking: networking)
sender.send(message) { [weak self] error in
if let error = error {
self?.display(error)
} else {
self?.dismiss(animated: true)
}
}
}
}
Having view controllers perform their own actions can be really convenient, and for simpler view controllers it most likely won’t cause any problems — but as we can see by just looking at the above excerpt from MessageComposerViewController
, it often requires our view controllers to be aware of things they ideally shouldn’t be too concerned with — such as networking, creating logic objects, and making assumptions about how they are presented by their parent.
Since most view controllers are already quite busy with things like creating and managing views, setting up layout constraints, and detecting user interactions — let’s see if we can extract the above actions, and make our view controller much simpler (and less aware) in the process.
Actions
Actions often come in two distinct variants — synchronous and asynchronous. Some actions just require us to quickly handle or transform a given value, and return it directly, while others will take a bit more time to perform.
To model both of those two kinds of actions, let’s create a generic Action
enum — that doesn’t actually have any cases — but instead contains two type aliases, one for sync actions, and one for async ones:
enum Action<I, O> {
typealias Sync = (UIViewController, I) -> O
typealias Async = (UIViewController, I, @escaping (O) -> Void) -> Void
}
The reason we use an enum
for our Action
wrapper above is to prevent it from being instantiated as a type, and instead act only as an “abstract namespace”.
Using the above type aliases, we can now define a tuple that contains all of the actions that our MessageComposerViewController
can perform — like this:
extension MessageComposerViewController {
typealias Actions = (
addRecipient: Action<Message, Message>.Async,
finish: Action<Message, Error?>.Async,
cancel: Action<Message, Void>.Sync
)
}
With the above in place, we can now start to heavily simplify our view controller — starting by removing its awareness of our core networking and database types, and instead making it only be aware of the Actions
that were passed into it:
class MessageComposerViewController: UIViewController {
private var message: Message
private let actions: Actions
init(recipients: [Recipient], actions: Actions) {
self.message = Message(recipients: recipients)
self.actions = actions
super.init(nibName: nil, bundle: nil)
}
}
Next, to actually use our new collection of actions, let’s update all of our action performing code from before to now simply call upon one of the pre-defined actions that were passed as part of the view controller’s initializer — like this:
private extension MessageComposerViewController {
func handleAddRecipientButtonTap() {
actions.addRecipient(self, message) { [weak self] newMessage in
self?.message = newMessage
self?.renderRecipientsView()
}
}
func handleCancelButtonTap() {
actions.cancel(self, message)
}
func handleSendButtonTap() {
let loadingVC = add(LoadingViewController())
actions.finish(self, message) { [weak self] error in
loadingVC.remove()
error.map { self?.display($0) }
}
}
}
Worth noting is that as part of this refactor, we’ve also improved the way that recipients are added to a message. Rather than having the view controller itself perform the mutation of its model, we instead simply return a new Message
value as a result of its addRecipient
action.
The beauty of the above approach is that our view controller can now focus on what view controllers do best — control views — and let the context that created it deal with details like networking and presenting a RecipientPicker
. Here’s how we could now present a message composer on top of another view controller, for example within a coordinator or navigator:
func presentMessageComposerViewController(
for recipients: [Recipient],
in presentingViewController: UIViewController
) {
let composer = MessageComposerViewController(
recipients: recipients,
actions: (
addRecipient: { [userDatabase] vc, message, handler in
let picker = RecipientPicker(database: userDatabase)
picker.present(in: vc) { recipient in
var message = message
message.recipients.append(recipient)
handler(message)
}
},
cancel: { vc, message in
if message.text.isEmpty {
vc.dismiss(animated: true)
} else {
vc.dismissAfterAskingForConfirmation()
}
},
finish: { [networking] vc, message, handler in
let sender = MessageSender(networking: networking)
sender.send(message) { error in
handler(error)
if error == nil {
vc.dismiss(animated: true)
}
}
}
)
)
presentingViewController.present(composer, animated: true)
}
Pretty sweet! Since all of our view controller’s actions are now simply functions, our code becomes both more flexible, and easier to test — since we can easily mock behaviors and verify that the correct actions are called under various circumstances.
An actionable overview
Another big benefit of extracting actions out from private methods and into a dedicated collection, is that becomes much easier to get an overview of what kind of actions that a given view controller performs — such as this ProductViewController
, which has a very clear list of four synchronous and asynchronous actions:
extension ProductViewController {
typealias Actions = (
load: Action<Product.ID, Result<Product, Error>>.Async,
purchase: Action<Product.ID, Error?>.Async,
favorite: Action<Product.ID, Void>.Sync,
share: Action<Product, Void>.Sync
)
}
Adding support for new actions also usually becomes quite trivial, since instead of having to inject new dependencies and write specific implementations for each view controller, we can more easily utilize shared logic and simply add a new member to our Actions
tuple — and then invoke it when the corresponding user interaction happened.
Finally, actions enable things like customization of types, and easier refactors, without the usual “ceremony” that is often required in order to unlock such features — for example when using protocols, or when switching to a new, more strict, architectural design pattern.
For example, say that we wanted to go back to our MessageComposerViewController
from before and add support for saving drafts of unfinished messages. We could now implement that entire feature without even touching our actual view controller code — all we’d have to do is to update its cancel
action:
let composer = MessageComposerViewController(
recipients: recipients,
actions: (
...
cancel: { [draftManager] vc, message in
if message.text.isEmpty {
vc.dismiss(animated: true)
} else {
vc.presentConfirmation(forReason: .saveDraft) {
outcome in
switch outcome {
case .accepted:
draftManager.saveDraft(message)
vc.dismiss(animated: true)
case .rejected:
vc.dismiss(animated: true)
case .cancelled:
break
}
}
}
},
...
)
)
While the above kind of capabilities give us a lot of flexibility, we also need to be careful not too put too much complex logic inside free closures, since that can make a code base quite difficult to navigate and debug. Thankfully, once our logic is decoupled from our UI and our view controllers, moving it to dedicated types — such as MessageSender
and DraftManager
— is often quite easy.
Conclusion
There are no “silver bullets” when it comes to dealing with complex view controllers — especially ones that have outgrown their original sphere of awareness and responsibility. Like always, having a multitude of different techniques at our disposal — and deploying them where most appropriate — is often key to creating really robust systems in an efficient and pragmatic way.
While more sophisticated techniques (like using logic controllers or view models, or separating concerns using protocols) are great for when we want to make a more structured, fundamental change throughout a code base — extracting actions can go a long way towards making our view controllers much simpler, without requiring any big changes or new abstractions.
What do you think? Have you ever tried to extract actions out of your view controllers, or is it something you’ll try out? Let me know — along with your questions, comments, or feedback — by contacting me, or by tweeting @johnsundell.
Thanks for reading! 🚀