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)