DEV Community

Cover image for iOS View Communication
Dillon McElhinney
Dillon McElhinney

Posted on • Originally published at dilloncodes.com on

iOS View Communication

I have gotten a few questions about my recent article on How I Organize Layout Code in Swift, so I thought I would follow up on that and address a couple of the common ones. In this article we're going to look at some examples of how the view communicates with the view controller, how the view controller communicates back to the view, and some ways you can segue or transition to the next screen in your flow if you want to organize your layout code like I do.

View -> ViewController Communication

In my last article I sort of brushed over this, saying that I would use delegation. I realize now that specific examples are helpful for people who are learning, so let's look at what that could look like in our login view from last time. I'm going to use the last configuration we looked at, with ComposedLayoutViewController because it is the most complex and the most similar to what I actually implement in production.

First, we need to define a delegate protocol for the LoginStackView. This is where we'll put any messages that we want to communicate from this view up to whatever needs to hear it. We constrain it to AnyObject so that we know it is a reference type and LoginStackView can hold a weak reference to it.

// in LoginStackView.swift
protocol LoginStackViewDelegate: AnyObject {
    func didTapSubmit()
}
Enter fullscreen mode Exit fullscreen mode

Then we add a delegate property.

class LoginStackView: ProgrammaticView {
    weak var delegate: LoginStackViewDelegate?

    // other stuff...
}
Enter fullscreen mode Exit fullscreen mode

Then we add a method for the button to call, and call it when the user lifts their finger off the button, within its bounds.

class LoginStackView: ProgrammaticView {
    // other stuff...

    // in configure()
    submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)

    @objc private func submitTapped() {
        delegate?.didTapSubmit()
    }
}
Enter fullscreen mode Exit fullscreen mode

That's all we need in the LoginStackView. You can see how dead simple the logic is, and that's what I'm shooting for. So now we move up a level and do the same thing in ComposedLayoutView.

// in ComposedLayoutView.swift
protocol ComposedLayoutViewDelegate: AnyObject {
    func didTapSubmit()
}

class ComposedLayoutView: ProgrammaticView {
    weak var delegate: ComposedLayoutViewDelegate?

    // in configure()
    loginStack.delegate = self
}

extension ComposedLayoutView: LoginStackViewDelegate {
    func didTapSubmit() {
        delegate?.didTapSubmit()
    }
}
Enter fullscreen mode Exit fullscreen mode

This is basically just passing the message along from the login stack to the composed layout view's delegate. Note that it is really important that you set loginStack's delegate somewhere, otherwise it will be sending messages that no one is receiving.

Finally, in our view controller, we just need to conform to ComposedLayoutViewDelegate and set the delegate on contentView:

class ComposedLayoutViewController: UIViewController {
    // in loadView
    contentView.delegate = self
}

extension ComposedLayoutViewController: ComposedLayoutViewDelegate {
    func didTapSubmit() {
        print("The user hit submit!")
        // handle submit
    }
}
Enter fullscreen mode Exit fullscreen mode

We're not actually doing anything when the user hits submit yet, but if you run the code you can see that our print statement is showing up in the console. We'll look at how to move to the next screen in a later section, but let's think about what we've got here before we move on. In the view controller, all we know is that the user hit the submit button. We don't have any access to the strings they typed in in the text fields. For logging in, those are pretty important, so we need to get that info up to the view controller. We'll do that by adding them as parameters to our delegate method.

// in LoginStackView.swift
protocol LoginStackViewDelegate: AnyObject {
    func didTapSubmit(username: String?, password: String?)
}

// in LoginStackView.submitTapped()
delegate?.didTapSubmit(username: usernameField.text, password: passwordField.text)

// in ComposedLayoutView.swift
protocol ComposedLayoutViewDelegate: AnyObject {
    func didTapSubmit(username: String?, password: String?)
}

// in ComposedLayoutView extension
func didTapSubmit(username: String?, password: String?) {
    delegate?.didTapSubmit(username: username, password: password)
}

// in ComposedLayoutViewController extension
func didTapSubmit(username: String?, password: String?) {
    guard let username = username,
          !username.isEmpty,
          let password = password,
          !password.isEmpty else {
        return print("Don't have all the required info!")
    }
    print("The user hit submit with \(username) and \(password)!")
    // handle submit
}
Enter fullscreen mode Exit fullscreen mode

Again, the logic to get the necessary information up to the view controller is very simple. That's what we want. Then all the logic for handling what to do will live in the view controller. Here we're just checking that we actually have all the information we need to make a decision. Let's see if we can clean that up a bit.

Cleaning Up

What I don't like about this is that we have to account for optionals and empty strings. And, it doesn't scale super well. We are only passing up two properties here, but what if we needed to pass up five? Or ten? Our delegate methods would get unwieldy. So we're going to wrap this data up in a struct.

struct LoginInfo {
    let username: String
    let password: String

    init?(username: String?, password: String?) {
        guard let username = username,
              !username.isEmpty,
              let password = password,
              !password.isEmpty else { return nil }
        self.username = username
        self.password = password
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we check for all of our required data in a failable initializer. This means that further down the line we won't have to make all the checks, either we get a LoginData and we have everything we need, or we get nil and we know we don't have everything we need.

To use it, we just need to update the delegate methods.

// in LoginStackView.swift
protocol LoginStackViewDelegate: AnyObject {
    func didTapSubmit(loginInfo: LoginInfo?)
}

// in LoginStackView.submitTapped()
let loginInfo = LoginInfo(username: usernameField.text, password: passwordField.text)
delegate?.didTapSubmit(loginInfo: loginInfo)

// in ComposedLayoutView.swift
protocol ComposedLayoutViewDelegate: AnyObject {
    func didTapSubmit(loginInfo: LoginInfo?)
}

// in ComposedLayoutView extension
func didTapSubmit(loginInfo: LoginInfo?) {
    delegate?.didTapSubmit(loginInfo: loginInfo)
}

// in ComposedLayoutViewController extension
func didTapSubmit(loginInfo: LoginInfo?) {
    guard let loginInfo = loginInfo else {
            return print("Don't have all the required info!")
        }
    print("The user hit submit with \(username) and \(password)!")
    // handle submit
}
Enter fullscreen mode Exit fullscreen mode

I like how clean that makes our logic, and it also nicely wraps up the information we'll need to pass to whatever form of authentication we'll be using in our app. But that leads us to the next question. What should we do if the user taps the submit button without the required info? Right now we are just printing a statement and doing nothing else. It is probably better UX to let the user know what the problem is. So let's see how we might handle that.

View Controller -> View Communication

First, I add a warning label to LoginStackView

private let warningLabel = UILabel()

// in configure()
warningLabel.text = "You must supply a username and password to log in."
warningLabel.numberOfLines = 0
warningLabel.textAlignment = .center
warningLabel.textColor = .systemRed
warningLabel.font = .preferredFont(forTextStyle: .callout)
// warningLabel.isHidden = true

// in constrain()
stackView.addArrangedSubviews(usernameField, passwordField, warningLabel, submitButton)

warningLabel.horizontalAnchors == horizontalAnchors
Enter fullscreen mode Exit fullscreen mode

We want it to be hidden by default, but I usually comment that line out until I've got it looking the way I want. Once I do, I uncomment warningLabel.isHidden = true and move on to adding a method to show it.

func showWarning() {
    if warningLabel.isHidden {
        UIView.animate(withDuration: 0.3) {
            self.warningLabel.isHidden = false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

First, I check to make sure that it is hidden and if it is, then I animate it to be shown. If it is already shown, we don't need to do anything.

So now we have our label and a function to call to show it. How do we get access to that in our view controller? There are a couple of ways and I've seen both used well, it just depends on what works best for your use case. The first is you could define a method on ComposedLayoutView (the middle man) that calls showWarning on its child LoginStackView and then call that method from ComposedLayoutViewController. That works, but it can get kind of messy if there are a lot of views involved. So here we're going to follow another method, one that Apple's frameworks use a ton. We're going to pass up a reference to the LoginStackView in the delegate method calls. Then, the view controller can just call showWarning on that. It is also a convenient way for the view controller to know which login stack called didTapSubmit, if there happen to be multiple. So first, we need to update our delegate methods again.

// in LoginStackView.swift
protocol LoginStackViewDelegate: AnyObject {
    func didTapSubmit(_ loginStack: LoginStackView, loginInfo: LoginInfo?)
}

// in LoginStackView.submitTapped()
let loginInfo = LoginInfo(username: usernameField.text, password: passwordField.text)
delegate?.didTapSubmit(self, loginInfo: loginInfo)

// in ComposedLayoutView.swift
protocol ComposedLayoutViewDelegate: AnyObject {
    func didTapSubmit(_ loginStack: LoginStackView, loginInfo: LoginInfo?)
}

// in ComposedLayoutView extension
func didTapSubmit(_ loginStack: LoginStackView, loginInfo: LoginInfo?) {
    delegate?.didTapSubmit(loginStack, loginInfo: loginInfo)
}

// in ComposedLayoutViewController extension
func didTapSubmit(_ loginStack: LoginStackView, loginInfo: LoginInfo?) {
    // same as before...
}
Enter fullscreen mode Exit fullscreen mode

Now we have everything we need to show the warning when the user didn't supply all the required info.

// ComposedLayoutViewController.didTapSubmit()
guard let loginInfo = loginInfo else {
    return loginStack.showWarning()
}
Enter fullscreen mode Exit fullscreen mode

warning-label.gif

That should give you a pretty good idea of how I work through the communication back and forth between the views and view controllers. There are always different scenarios and edge cases, but I pretty much always start with asking myself is this "view logic" or "business logic"? And then figure out how to get the responsibilities into their respective places.

Getting To The Next Screen

So that is a good look at how I organize the communications that goes on within a screen. But how do we get to the next one? Should we use segues? That is what most people who are used to using storyboards would reach for. And you can still use a segue if that is how you want to do it, but here I will show an easy (and somewhat naive) way to do it and then I'll show a simple example of Soroush Khalou's concept of a coordinator, which he wrote about in this blog post.

First, I'm going to add a few simple views for us to transition to. I also added a corresponding one called FailedLoginViewController, which is pretty much identical but has a different message. These aren't intended to be real views right now, just enough for us to illustrate the transition concept.

class LoggedInViewController: UIViewController {

    lazy var contentView: LoggedInView = .init()

    override func loadView() {
        view = contentView
    }
}

class LoggedInView: ProgrammaticView {
    private let label = UILabel()

    override func configure() {
        backgroundColor = .systemBackground

        label.text = "You're logged in!"
        label.textColor = .systemGreen
    }

    override func constrain() {
        addSubview(label)

        label.centerAnchors == centerAnchors
    }
}
Enter fullscreen mode Exit fullscreen mode

Then I'm going to add a mock authenticator. This is going to be an object that we can give a LoginInfo instance to and it will attempt to authenticate the user. In a real app, this will involve some networking and more complex code, but it is likely that the interface you'll use will be pretty similiar. Right now, we don't care how the authentication happens, we just care what the result is.

class Authenticator {
    static let shared = Authenticator()

    private init() {}

    func authenticateUser(with loginInfo: LoginInfo) -> Bool {
        return loginInfo.username.count >= 4 && loginInfo.password == "1234"
    }
}
Enter fullscreen mode Exit fullscreen mode

Obviously, there are a lot of problems with this Authenticator, but this isn't an article on authentication and it is good enough for us to use for now to test out our flows.

All we need to do now is use our authenticateUser method to determine which view to show:

// ComposedLayoutViewController.didTapSubmit()
guard let loginInfo = loginInfo else {
    return loginStack.showWarning()
}
let isValidUser = Authenticator.shared.authenticateUser(with: loginInfo)
if isValidUser {
    show(LoggedInViewController(), sender: self)
} else {
    show(FailedLoginViewController(), sender: self)
}
Enter fullscreen mode Exit fullscreen mode

show-presentation.gif

This works, but there are a couple of problems with it. First, we are using show, which will present the view controller modally here because our ComposedLayoutViewController isn't inside of a UINavigationController. It also means that the ComposedLayoutViewController is still in the view hierarchy underneath the new view. When we're logging in, we probably want to replace the window's root view controller, so that we can clear out the views used for logging in. To do that, we need access to the window. Unfortunately, there's not a great way to get access to that from the view controller (mostly because window manipulation should be done at a higher level). Let's look at how we could do this, and then quickly move to a better way.

guard let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate,
      let window = sceneDelegate.window else {
    return show(LoggedInViewController(), sender: self)
}
window.rootViewController = LoggedInViewController()
UIView.transition(with: window, duration: 0.3, options: .transitionFlipFromRight, animations: nil, completion: nil)
Enter fullscreen mode Exit fullscreen mode

flip-transition.gif

Again, this technically works. If you throw a print statement in the deint of ComposedLayoutViewController you can see that we are getting rid of that altogether, so it is no longer hanging around in memory. But it very tightly couples our ComposedLayoutViewController to the views that come next. If we keep it like this, we are likely to end up with a bunch of complex and confusing logic in this method as we come across edge cases of other places in the app that we want to authenticate the user. Imagine if the user tapped a deeplink into the app, that should take them to some specific content, but they need to log in first, or imagine if you want to A/B test different home screens, etc. Depending on the structure of your app, there might be any number of views that should come after this login view. So let's take a look at how we can decouple this a little bit.

Coordinators

The concept of coordinators was, as far as I know, first posited by Soroush Khanlou, at least in the iOS world. I'm not going to go super in depth on it here, because he's already already got some great material on what they are and how to use them. What I will do is show you how I typically use them and how it can clean up the code we're looking at here. First, we're going to define a Coordinator class. This is where the logic for the flows between individual screens will live.

class Coordinator {
    let window: UIWindow
    private var viewController: UIViewController?

    init(window: UIWindow) {
        self.window = window
    }

    func start() {
        if Authenticator.shared.isLoggedIn {
            viewController = LoggedInViewController()
        } else {
            viewController = ComposedLayoutViewController()
        }
        window.rootViewController = viewController
        window.makeKeyAndVisible()
    }
}
Enter fullscreen mode Exit fullscreen mode

I usually make my top level coordinator hold a reference to the window because it make the manipulations that you do at the beginning of the app lifecycle easier to do, but you could also just pass it a reference to your root tab bar controller or navigation controller, if that makes more sense in your use case. It'll also need to hold references to the view controllers or child coordinators that it is in charge of. Finally, has a start function. This is where the person who makes this coordinator will tell it to kick off its flow. In this case, the start method checks if the user is logged in and make the correct view controller the root.

Side note, I made a slight modification to our Authenticator to get this interface:

// in Authenticator
func authenticateUser(with loginInfo: LoginInfo) -> Bool {
    isLoggedIn = loginInfo.username.count >= 4 && loginInfo.password == "1234"
    return isLoggedIn
}

private(set) var isLoggedIn = false
Enter fullscreen mode Exit fullscreen mode

Then, we actually need to use our new coordinator, which we'll do in SceneDelegate:

// need to keep a reference to this so it isn't deinitialized
private var coordinator: Coordinator?

func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)

    coordinator = Coordinator(window: window)
    self.window = window

    coordinator?.start()
}
Enter fullscreen mode Exit fullscreen mode

Here, we just make a window for our scene, we hand it off to the coordinator, and we tell the coordinator to start. With just that little bit of code, our app will now check if the user is logged in during launch and only show them the view controller that is relevant to their current state. An added bonus is this means we can get rid of the Main.storyboard altogether. Then, with the exception of the launch sreen, our app is now totally initialized in code.

So what about the login logic? For that, we'll just define another delegate protocol and hand off the information from the view controller to the Coordinator.

protocol ComposedLayoutViewControllerDelegate: AnyObject {
    func attemptedLogin(with loginInfo: LoginInfo)
}

// in ComposedLayoutViewController
weak var delegate: ComposedLayoutViewControllerDelegate?

func didTapSubmit(_ loginStack: LoginStackView, loginInfo: LoginInfo?) {
    guard let loginInfo = loginInfo else {
        return loginStack.showWarning()
    }
    delegate?.attemptedLogin(with: loginInfo)
}
Enter fullscreen mode Exit fullscreen mode

Notice how simple didTapSubmit is now. We check to make sure we have all the information we need to attempt a login and if we don't, we respond within this view. This is the jurisdiciton of this view controller, so it just tells the view what to do. If we're ready to move beyond this view (i.e. we have all the information we need), we just pass it up to the delegate.

Finally, the coordinator needs to adopt the delegate protocol and make itself the view controller's delegate.

// in Coordinator.start()
let loginVC = ComposedLayoutViewController()
loginVC.delegate = self
viewController = loginVC

func attemptedLogin(with loginInfo: LoginInfo) {
    let isLoggedIn = Authenticator.shared.authenticateUser(with: loginInfo)
    if isLoggedIn {
        viewController = LoggedInViewController()
        window.rootViewController = viewController
        UIView.transition(with: window, duration: 0.3, options: .transitionFlipFromRight, animations: nil, completion: nil)
    } else {
        viewController?.show(FailedLoginViewController(), sender: viewController)
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, this is nice and clean because our coordinator already has a reference to the window, so we don't have to jump through hoops to get that. And we are totally free to do whatever we want in this function. We could present any view controller we want, based on a feature flag or an API call, we could spin up a child coordinator and have that make the decision, etc. In this case, if the login is successful I am replacing the root view controller with the logged in view, and giving it a little animation. If it isn't successful, I just pop up a message modally to let the user know something went wrong.

Wrap Up

We've covered a lot of ground here. We looked at how I have my views communicate with my view controllers, we've looked at how those view controllers communicate back down to their views, and we've looked at a couple ways you can organize the logic for the flows between screens. As always, I hope it has been helpful and maybe given you an opportunity to think about how you organize your own code. If you have any questions or suggestions, throw them in the comments!


Check out all the code from this article on github

If this has been helpful buy me a coffee!

Top comments (0)