SOLID Principles for iOS Apps

SOLID is a group of principles that lead you to write clear and organized code without additional effort. Learn how to apply it to your SwiftUI iOS apps. By Ehab Amer.

4.6 (28) · 3 Reviews

Download materials
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

Refactoring ExpensesView

Add a new structure definition inside ExpensesView_Previews:

struct PreviewExpenseEntry: ExpenseModelProtocol {
  var title: String?
  var price: Double
  var comment: String?
  var date: Date?
  var id: UUID? = UUID()
}

Similar to what you defined earlier inside ExpenseItemView, this is a basic model that you can use as a mock expense item.

Next, add a class right underneath the structure you just added:

class PreviewReportsDataSource: ReportReader {
  override init() {
    super.init()
    for index in 1..<6 {
      saveEntry(
        title: "Test Title \(index)",
        price: Double(index + 1) * 12.3,
        date: Date(timeIntervalSinceNow: Double(index * -60)),
        comment: "Test Comment \(index)")
    }
  }

  override func prepare() {
  }

  override func saveEntry(
    title: String,
    price: Double,
    date: Date,
    comment: String
  ) {
    let newEntry = PreviewExpenseEntry(
      title: title,
      price: price,
      comment: comment,
      date: date)
    currentEntries.append(newEntry)
  }
}

This is a simplified data source that keeps all records in memory. It starts with a few records instead of being empty, just like the ReportsDataSource, but it eliminates the need for Core Data and to initialize a preview context.

Finally, change the implementation of the preview to the following:

static var previews: some View {
  ExpensesView(dataSource: PreviewReportsDataSource())
}

Here, you tell the preview to use the data source you just created.

Finally, open Persistence.swift and remove the last trace of preview objects by removing preview. Your views are no longer tied to Core Data. This not only lets you delete the code you wrote here, but also allows you to easily provide a mock data source to your views inside your tests.

Build and run. You'll find everything is still intact and unaffected, and the preview now shows your mock expenses.

SwiftUI preview with mock expense items

Adding Interface Segregation

Look at AddExpenseView and you'll see that it expects a closure to save the entry. Currently, ExpensesView now provides this closure. All it does is call a method on ReportReader.

An alternative is to pass the data source to AddExpenseView so it can call the method directly.

The obvious difference between the two approaches is: ExpensesView has the responsibility of informing AddExpenseView how to perform a save.

If you modify the fields you're saving, you'll need to propagate this change to both views. However, if you pass the data source directly, the listing view won't be responsible for any of the details about how information is saved.

But this approach will make the other functionalities provided by ReportReader visible to AddExpenseView.

The SOLID principle of interface segregation recommends that you separate interfaces into smaller pieces. This keeps each client focused on its main responsibility and avoids confusion.

In this case, the principle indicates that you should separate saveEntry(title:price:date:comment:) into its own protocol, then have ReportsDataSource conform to that protocol.

Splitting up Protocols

In the Protocols group, create a new Swift file and name it SaveEntryProtocol.swift. Add the following protocol to the new file:

protocol SaveEntryProtocol {
  func saveEntry(
    title: String,
    price: Double,
    date: Date,
    comment: String)
}

Open ReportReader.swift and remove saveEntry(title:price:date:comment:).

Next, open ReportsDataSource.swift and change the declaration of the class to conform to your new protocol:

class ReportsDataSource: ReportReader, SaveEntryProtocol {

Since you're now implementing a protocol method and not overriding the method from a superclass, remove the override keyword from saveEntry(title:price:date:comment).

Do the same in PreviewReportsDataSource in ExpensesView.swift. First, add the conformance:

class PreviewReportsDataSource: ReportReader, SaveEntryProtocol {

Then, remove the override keyword as before.

Both of your data sources now conform to your new protocol, which is very specific about what it does. All that's left is to change the rest of your code to use this protocol.

Open AddExpenseView.swift and replace saveClosure with:

var saveEntryHandler: SaveEntryProtocol

Now, you're using the protocol instead of the closure.

In saveEntry(), replace the call to saveClosure with the new property you just added:

saveEntryHandler.saveEntry(
  title: title,
  price: numericPrice,
  date: time,
  comment: comment)

Change the SwiftUI preview code to match your changes:

struct AddExpenseView_Previews: PreviewProvider {
  class PreviewSaveHandler: SaveEntryProtocol {
    func saveEntry(title: String, price: Double, date: Date, comment: String) {
    }
  }
  static var previews: some View {
    AddExpenseView(saveEntryHandler: PreviewSaveHandler())
  }
}

Finally, open ExpensesView.swift and change the full screen cover for $isAddPresented to the following:

.fullScreenCover(isPresented: $isAddPresented) { () -> AddExpenseView? in
  guard let saveHandler = dataSource as? SaveEntryProtocol else {
    return nil
  }
  return AddExpenseView(saveEntryHandler: saveHandler)
}

Now, you're using the more explicit and specific protocol to save your expenses. If you continue working on this project, you will almost certainly want to change and add to the saving behavior. For example, you might want to want to change database frameworks, add synchronization across devices or add a server-side component.

Having specific protocols like this will make it easy to change features in the future and will make testing those new features much easier. It's better to do this now, when you have a small amount of code, than to wait until the project gets too big and crusty.

Implementing Liskov Substitution

Currently, AddExpenseView expects any saving handler to be able to save. Furthermore, it doesn't expect the saving handler to do anything else.

If you present AddExpenseView with another object that conforms to SaveEntryProtocol but performs some validations before storing the entry, it will affect the overall behavior of the app because AddExpenseView doesn't expect this behavior. This stands against the Liskov Substitution principle.

That doesn't mean your initial SaveEntryProtocol design was incorrect. This situation is likely to occur as your app grows and more requirements come in. But as it grows, you should understand how to refactor your code in a way that doesn't allow another implementation to violate the expectations of the object using it.

For this app, all you need to do is to allow saveEntry(title:price:date:comment:) to return a Boolean to confirm whether it saved the value.

Open SaveEntryProtocol.swift and add a return value to the method's definition:

func saveEntry(
  title: String,
  price: Double,
  date: Date,
  comment: String
) -> Bool

Update ReportsDataSource.swift to match the changes in the protocol. First, add the return type to saveEntry(title:price:date:comment:):

func saveEntry(
  title: String,
  price: Double,
  date: Date,
  comment: String
) -> Bool {

Next, return true at the end of the method.

return true

Perform those two steps again in the following places:

  1. AddExpenseView_Previews.PreviewSaveHandler in AddExpenseView.swift
  2. ExpensesView_Previews in ExpensesView.swift

Next, in AddExpenseView.swift, replace the saveEntryHandler method call in saveEntry() with the following:

guard saveEntryHandler.saveEntry(
  title: title,
  price: numericPrice,
  date: time,
  comment: comment)
else {
  print("Invalid entry.")
  return
}

If the entry validation fails, you'll exit from the method early, bypassing the dismissal of the view. This way, AddExpenseView won't dismiss if the save method returns false.

Eliminate the final warning in ExpensesView.swift by changing the line saveEntry( to:

_ = saveEntry(

This discards the unused return value.