iosuser-interfacearchitectural-patterns

Good strategy for replacing parts of functionality in iOS ViewControllers


I have VCs in an iOS app which have quite a lot of UI controls. I would now need to replace or "mock" some of these controls when in a specific state. In some cases this would be just disabling button actions, but in some cases the actions that happen need to be replaced with something completely different.

I don't really like the idea of having this sort of check littered all around the codebase.

if condition {
  ...Special/disabled functionality
} else {
  ...Normal functionality
}

In Android, I can just subclass each Fragment/Activity and build the functionality there, and then doing the if/else when inserting Fragments or launching activities.

But on iOS with Storyboards/IBActions and Segues, UIs and VCs are really tightly coupled. You either end up duplicating UI views or adding a lot of finicky code to already large VCs.

What would be the best way to handle this in iOS?

Sample code of what I want to avoid doing:

//Before:
class SomeViewController : UIViewController {
  @IBAction onSomeButton() {
    checkSomeState()
    doANetworkRequest(() -> {
       someCompletionHandler()
       updatesTheUI()
    }
    updateTheUIWhileLoading()
  }

  @IBAction onSomeOtherButton() {
    checkAnotherState()
    updateUI()
  }
}
//After:
class SomeViewController : UIViewController {
  @IBAction onSomeButton() {
    if specialState {
      doSomethingSimpler()
    } else {
      checkSomeState()
      doANetworkRequest(() -> {
         someCompletionHandler()
         updatesTheUI()
      }
      updateTheUIWhileLoading()
    }
  }

  @IBAction onSomeOtherButton() {
    if specialState {
      return // Do nothing
    } else {
      checkAnotherState()
      updateUI()
    }
  }
}

Solution

  • I'd suggest using the MVVM (Model - View - ViewModel) pattern. You pass the ViewModel to your controller and delegate all actions to it. You can also use it to style your views and decide if some of them should be hidden or disabled, etc.

    Let's image a shopping app in which your pro users get a 10% discount and can use a free-shipping option.

    protocol PaymentScreenViewModelProtocol {
        var regularPriceString: String { get }
        var discountedPriceString: String? { get }
        var isFreeShippingAvailable: Bool { get }
    
        func userSelectedFreeShipping()
        func buy()
    }
    
    class StandardUserPaymentScreenViewModel: PaymentScreenViewModelProtocol {
        let regularPriceString: String = "20"
        let discountedPriceString: String? = nil
        let isFreeShippingAvailable: Bool = false
    
        func userSelectedFreeShipping() {
            // standard users cannot use free shipping!
        }
    
        func buy() {
            // process buying
        }
    }
    
    class ProUserPaymentScreenViewModel: PaymentScreenViewModelProtocol {
        let regularPriceString: String = "20"
        let discountedPriceString: String? = "18"
        let isFreeShippingAvailable: Bool = true
    
        func userSelectedFreeShipping() {
            // process selection of free shipping
        }
    
        func buy() {
            // process buying
        }
    }
    
    class PaymentViewController: UIViewController {
    
        @IBOutlet weak var priceLabel: UILabel!
        @IBOutlet weak var discountedPriceLabel: UILabel!
        @IBOutlet weak var freeShippingButton: UIButton!
    
        var viewModel: PaymentScreenViewModelProtocol
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            priceLabel.text = viewModel.regularPriceString
            discountedPriceLabel.text = viewModel.discountedPriceString
            freeShippingButton.isHidden = !viewModel.isFreeShippingAvailable
        }
    
        @IBAction func userDidPressFreeShippingButton() {
            viewModel.userSelectedFreeShipping()
        }
    
        @IBAction func userDidPressBuy() {
            viewModel.buy()
        }
    }
    

    This approach let's you decouple your logic from your views. It's also easier to test this logic.
    One thing to consider and decide is the approach as to how to inject the view model into the view controller. I can see three possibilities :

    1. Via init - you provide a custom initializer requiring to pass the view model. This will mean you won't be able to use segue's or storyboards (you will be able to use xibs). This will let your view model be non-optional.
    2. Via property setting with default implementation - if you provide some form of default/empty implementation of your view model you could use it as a default value for it, and set the proper implementation later (for example in prepareForSegue). This enables you to use segues, storyboards and have the view model be non-optional (it just adds the overhead of having an extra empty implementation).
    3. Via property setting without default implementation - this basically means that your view model will need to be an optional and you will have to check for it almost everytime you access it.