Fran

Boilerplate Wars: Episode I - Swift Key Paths, A new hope.

Not so long time ago, in a company not that far away, there was an ongoing war against boilerplate and code duplication...

In Karumi, we are always looking for new paradigms and tools to make our code as simple as possible without losing expressiveness. Recently we found that we could use Swift 4 and its new KeyPath feature to reduce the boilerplate required to link our presentation logic and our views.

Let's analyze what's the "problem"

Usually, we link our ViewControllers to their presenters using a protocol that will represent all the view capabilities so the presenter never would know how its view it's implemented.

Spoiler alert: here you have a GitHub repository with all the code.

protocol JoinRebelionView {  
    func setDescriptionHidden(_ isHidden: Bool)
    func setDescription(color: JoinRebelionPresenter.DescriptionColor)
    func setDescription(message: String)
    func setJoinVisible(_ visible: Bool, animated: Bool)
    func clearFighterName()
    func disableFighterName()
}
class JoinRebelionPresenter {  
    ...

    let view: JoinRebelionView

    var fighterName: String?
    var joinAttemps = 0
    let maxJoinAttemps = 3

    let errorMessages = [
        "Sorry, we can't use that fighter. Try with another one",
        ...
    ]

    init(_ view: JoinRebelionView) {
        self.view = view
    }

    ...

    func figherNameChanged(_ newName: String?) {
        fighterName = newName

        guard let fighterName = fighterName else { return }
        view.setJoinVisible(fighterName.count >= 5, animated: true)
        view.setDescriptionHidden(true)
    }

    func joinRequested() {
        joinAttemps += 1

        if fighterName?.uppercased() == "XWing".uppercased() {
            view.setDescriptionHidden(false)
            view.setDescription(message: "Welcome on board Luke! We count on you to destroy The Empire.")
            view.setDescription(color: .Success)
            view.disableFighterName()
        } else {
            view.setDescriptionHidden(false)
            view.setDescription(message: errorMessages[joinAttemps - 1])
            view.clearFighterName()

            if joinAttemps - 1 == maxJoinAttemps{
                view.disableFighterName()
                view.setDescription(color: .Error)
            }
        }
        view.setJoinVisible(false, animated: true)
    }
}

The code is pretty clear, what we are modeling here is a form where you can enter your spaceship name and join The Rebellion. The only spaceship name allowed is "X-Wing" and anyone trying to join the forces against The Empire more than four times should be automatically rejected. Besides that, there are some presentation rules, as cleaning the form entry after an error or hiding the join button if your spaceship has less than 5 characters. As you can see, everything has been made up, but could fit lots of rules we have to deal with in our daily basis.

If you keep adding presentation rules to our JoinRebellionPresenter you soon will realize that a repeating pattern starts to arise: the state is changed and then, based on those changes, the view gets updated. What's the problem with that pattern you may ask? The state and view changes are mixed in our code, the more state you have, the more convoluted your code gets in order to update the view properly to reflect that state.

A different approach.

What's the value of the View protocol in here? We are just using it as an abstraction level to decouple its capabilities from its current implementation. What if we can connect our views and our presenters in a different manner without coupling them?. Since its announcement, in Karumi we thought that KeyPath in Swift 4 combined with KVO would be a nice approach to solve that model-view binding problem.

Davide was always talking about how C# had awesome tools to do MVVM, binding view to code and vice-versa in a really easy manner, so he decided to give it a go and try to get some model-view bindings using KVO and KeyPath.

After having a small ViewController being updated using KeyPaths and KVO to bind viewmodel changes to the view, we decided that we could abstract that solution to make it more "fluent" and reuse it all around our code.

Refactor it, Luke!
There is NO protocol JoinRebellionView 

¯\_(ツ)_/¯
class JoinRebellionPresenter {

    class VisibleWithAnimation: NSObject {
        let visible: Bool
        let animation: Bool
        init(_ visible: Bool, animation: Bool) {
            self.visible = visible
            self.animation = animation
        }
    }

    class DescriptionColor: NSObject {
        enum Color {
            case Default
            case Error
            case Success
        }
        let color: Color
        private init(_ color: Color) {
            self.color = color
        }

        static let Default = DescriptionColor(Color.Default)
        static let Error = DescriptionColor(Color.Error)
        static let Success = DescriptionColor(Color.Success)
    }

    class JoinRebellionViewModel: NSObject {
        fileprivate var fighterName: String? {
            didSet {
                if let fighterName = fighterName {
                    isJoinButtonVisible = VisibleWithAnimation(fighterName.count >= 5, animation: true)
                    isDescriptionHidden = true
                }
            }
        }
        private var joinAttemps = 0
        private let maxJoinAttemps = 3
        let errorMessages = [
            ...
        ]

        fileprivate enum State {
            case Started
            case Joined
            case Rejected
            case Failed
        }

        fileprivate var state: State = .Started {
            didSet {
                switch state {
                case .Started:
                    isDescriptionHidden = true
                    isJoinButtonVisible = VisibleWithAnimation(false, animation: false)
                    isFighterNameEnabled = true
                case .Joined:
                    isDescriptionHidden = false
                    isFighterNameEnabled = false
                    descriptionColor = .Success
                    descriptionText = "Welcome on board Luke! We count on you to destroy The Empire."
                    isJoinButtonVisible = VisibleWithAnimation(false, animation: true)
                case .Rejected:
                    isDescriptionHidden = false
                    descriptionText = errorMessages[joinAttemps - 1]
                    isFighterNameEnabled = false
                    fighterNameText = ""
                    descriptionColor = .Error
                    isJoinButtonVisible = VisibleWithAnimation(false, animation: true)
                case .Failed:
                    isDescriptionHidden = false
                    descriptionText = errorMessages[joinAttemps - 1]
                    fighterNameText = ""
                    descriptionColor = .Default
                    isJoinButtonVisible = VisibleWithAnimation(false, animation: true)
                }
            }
        }

        @objc dynamic var isDescriptionHidden: Bool = false
        @objc dynamic var descriptionText: String = ""
        @objc dynamic var isJoinButtonVisible: VisibleWithAnimation = VisibleWithAnimation(false, animation: false)
        @objc dynamic var isFighterNameEnabled: Bool = false
        @objc dynamic var fighterNameText: String = ""
        @objc dynamic var descriptionColor: DescriptionColor = DescriptionColor.Default

        fileprivate func joinAttempt() {
            joinAttemps += 1
            if fighterName?.uppercased() == "x-wing".uppercased() {
                state = .Joined
            } else {
                state = (joinAttemps - 1 == maxJoinAttemps) ? .Rejected : .Failed
            }
        }
    }

    var fighterName: String?
    var joinAttemps = 0
    let maxJoinAttemps = 3

    var viewModel: JoinRebellionViewModel

    init() {
        viewModel = JoinRebellionViewModel()
    }

    func viewDidLoad() {
        viewModel.state = .Started
    }

    func figherNameChanged(_ newName: String?) {
        viewModel.fighterName = newName
    }

    func joinRequested() {
        viewModel.joinAttempt()
    }
}
class JoinRebellionViewController: UIViewController {  
    ...

    @IBOutlet weak var starFighterNameTextField: UITextField! {
        didSet {
            starFighterNameTextField.accessibilityLabel = JoinRebellionViewController.AccessibilityLabel.starFighterNameTextField
            observer.from(presenter.viewModel, \.fighterNameText).mapAsOptional().to(starFighterNameTextField, \.text)
            observer.from(presenter.viewModel, \.isFighterNameEnabled).to(starFighterNameTextField, \.isEnabled)
        }
    }

    ...

    @IBOutlet weak var descriptionLabel: UILabel! {
        didSet {

            ...

            observer.from(presenter.viewModel, \.descriptionText).mapAsOptional().to(descriptionLabel, \.text)
            observer.from(presenter.viewModel, \.descriptionColor).map({
                switch $0.color {
                case .Default: return UIColor.black
                case .Error: return UIColor(red: 153/255, green: 0, blue: 51/255, alpha: 1)
                case .Success: return UIColor(red: 255/255, green: 128/255, blue: 0, alpha: 1)
                }
            }).to(descriptionLabel, \.textColor)
        }
    }

    @IBOutlet var horizontalSpacingFighterAndJoin: NSLayoutConstraint!

    var presenter: JoinRebellionPresenter!

    var observer = Observer()

    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()

        if let navigationController = navigationController {
            navigationController.navigationBar.isHidden = true
        }

        // Everything related to animations cannot be in didSet
        observer.from(presenter.viewModel, \.isJoinButtonVisible).to { visibleWithAnimation in
            self.horizontalSpacingFighterAndJoin.isActive = visibleWithAnimation.visible
            if visibleWithAnimation.animation {
                UIView.animate(withDuration: 0.3) {
                    self.view.layoutIfNeeded()
                }
            }
        }
    }

    @IBAction func joinButtonTapped(_ sender: Any) {
        presenter.joinRequested()
    }

    @IBAction func editingFighterName(_ sender: UITextField) {
        presenter.figherNameChanged(sender.text)
    }
}

Wait! Are we having a JoinRebellionPresenter without a JoinRebelionView ? The answer here is: YES. If you "observe" (pun intended) the app, we are changing UIView properties, we are not dismissing any ViewController or anything like that, so our presenter can use a different mechanism to communicate those changes.

From now on, our JoinRebelionViewController will be notified about UI changes through an Observer.

What does an observer do?

It binds changes from an object property to another object property.
So everytime we change isFighterNameEnabled in our view model, isEnabled from starFighterNameTextField will be updated with the very same value.

How does it work?

It relies on KeyPaths and KVO, so we can observe changes that happen and apply them in some other object's property. The only restriction it has is that both properties need to have the same type.

Map, the force that will be with you

In order to overcome that limitation, we've added some methods to our Observer class that allows us to adapt that origin type into any other one we need.

For example:

observer.from(presenter.viewModel, \.descriptionColor).map({  
                switch $0.color {
                case .Default: return UIColor.black
                case .Error: return UIColor(red: 153/255, green: 0, blue: 51/255, alpha: 1)
                case .Success: return UIColor(red: 255/255, green: 128/255, blue: 0, alpha: 1)
                }
            }).to(descriptionLabel, \.textColor)

Will adapt a DescriptionColor instance into a UIColor one so we can use it as textColor in our descriptionLabel

Another option our Observable offers is, binding property changes to functions executions as you can see in:

observer.from(presenter.viewModel, \.isJoinButtonVisible).to { visibleWithAnimation in  
            self.horizontalSpacingFighterAndJoin.isActive = visibleWithAnimation.visible
            if visibleWithAnimation.animation {
                UIView.animate(withDuration: 0.3) {
                    self.view.layoutIfNeeded()
                }
            }
        }

Besides updating our constraint isActive property we need to trigger an animation block if required.

That's all for now, but as Chancellor Palpatine would say:

The dark side of JavaScript is a pathway to many abilities some consider to be unnatural.

(Blog post image)

Subscribe to Karumi Blog

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!