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 2 of 5 of this article. Click here to view the first page.

Using the New Persistence

Now that you've separated the Core Data setup from AppMain.swift, there are five locations you need to fix.

In DailyReportsDataSource.swift and MonthlyReportsDataSource.swift, change the default parameter in init(viewContext:) to PersistenceController.shared.container.viewContext, like so:

init(viewContext: NSManagedObjectContext
  = PersistenceController.shared.container.viewContext
) {
  self.viewContext = viewContext
  prepare()
}

Then, in DailyExpensesView.swift and MonthlyExpensesView.swift locate the SwiftUI preview code. Change the parameter you send to the report's data source in previews to PersistenceController.preview.container.viewContext, like so:

let reportsDataSource = DailyReportsDataSource(
  viewContext: PersistenceController.preview.container.viewContext)

and

let reportsDataSource = MonthlyReportsDataSource(
  viewContext: PersistenceController.preview.container.viewContext)

Finally, in ExpenseItemView.swift's previews, use the preview item PersistenceController.previewItem instead of the one that you removed from AppMain:

ExpenseItemView(expenseItem: PersistenceController.previewItem)

The previews of the DailyExpensesView and MonthlyExpensesView are identical and aren't affected by the refactor. The same applies for the preview of ExpenseItemView.

Build and run. Open a report to make sure that your changes didn't break anything.

SwiftUI Preview for DailyExpensesView and ExpenseItemView

Implementing the Open-Closed Principle

The second principle is about structuring your code in a way that doesn't require that you make deep modifications in classes to add new features. A perfect example of how not to do this is the implementation of the daily and weekly reports.

Looking at DailyReportsDataSource.swift and MonthlyReportsDataSource.swift, you can see they're identical except for the dates the fetch request uses.

The same goes for DailyExpensesView.swift and MonthlyExpensesView.swift. They're also identical except for which report data source class they use.

Both cases use a lot of duplicate code — there's got to be a better way! :]

One option is to define a single data source class that uses a range of dates to fetch entries, then has a single view to display those entries.

To make it even cleaner, use an enum to represent those ranges, then have ContentView loop over the values in the enum to populate the list of available options.

With this method, all you need to do to add a new report type is to create a new enum. Everything else will just work. You'll implement this solution next.

Creating the Enum

In your Project navigator, create a new group named Enums. Create a new file inside it named ReportRange.swift.

Enums group with ReportRange.swift inside it

In the new file, create a new enum type:

enum ReportRange: String, CaseIterable {
  case daily = "Today"
  case monthly = "This Month"
}

CaseIterable allows you to iterate over the possible values of the enum you just defined. You'll use this option when you clean up ContentView later.

Next, add the following within the definition of the enum:

func timeRange() -> (Date, Date) {
  let now = Date()
  switch self {
  case .daily:
    return (now.startOfDay, now.endOfDay)
  case .monthly:
    return (now.startOfMonth, now.endOfMonth)
  }
}

timeRange() returns two dates in a tuple that represent a range. The first is the lower boundary and the second is the upper boundary. Based on the value of the enum, it will return a range fitting either a day or a month.

Cleaning up the Reports

The next step is to merge the duplicate classes.

Completely delete MonthlyReportsDataSource.swift, then rename DailyReportsDataSource.swift to ReportsDataSource.swift. Also, rename the class inside it to match the file name.

To make Xcode do all the work, open DailyReportsDataSource.swift and right-click the class name. Choose Refactor ▸ Rename... from the pop-up menu. When you edit the name in one place, Xcode changes it everywhere else it occurs, including the filename. When you're finished editing the name, click Rename in the upper-right corner.

class ReportsDataSource: ObservableObject

Add a new property in the class to store the date range you want this instance to use:

let reportRange: ReportRange

Then, pass this value through the initializer by replacing the current initializer with the following one:

init(
  viewContext: NSManagedObjectContext =
    PersistenceController.shared.container.viewContext,
  reportRange: ReportRange
) {
  self.viewContext = viewContext
  self.reportRange = reportRange
  prepare()
}

Currently, the fetch request uses Date().startOfDay and Date().endOfDay. It should use the dates from the enum instead. Change the implementation of getEntries() to the following:

let fetchRequest: NSFetchRequest<ExpenseModel> =
  ExpenseModel.fetchRequest()
fetchRequest.sortDescriptors = [
  NSSortDescriptor(
    keyPath: \ExpenseModel.date,
    ascending: false)
]
let (startDate, endDate) = reportRange.timeRange()
fetchRequest.predicate = NSPredicate(
  format: "%@ <= date AND date <= %@",
  startDate as CVarArg,
  endDate as CVarArg)
do {
  let results = try viewContext.fetch(fetchRequest)
  return results
} catch let error {
  print(error)
  return []
}

You declare two new variables in the method, startDate and endDate that you return inside the date range enum. You then use those dates to filter all of the stored expenses inside the Core Data database. This way the displayed expenses adapt to the value of the date range you pass in the initializer of the class.

Similar to what you did for the data source files, delete the file MonthlyExpensesView.swift and rename DailyExpensesView.swift to ExpensesView.swift. Rename the class in the file to match the file name:

struct ExpensesView: View {

If you didn't choose to use Xcode's refactoring ability above, change the type of dataSource to ReportsDataSource:

@ObservedObject var dataSource: ReportsDataSource

Here, you use the more general data source you just created.

Finally, change all the SwiftUI preview code to the following:

struct ExpensesView_Previews: PreviewProvider {
  static var previews: some View {
    let reportsDataSource = ReportsDataSource(
      viewContext: PersistenceController.preview
        .container.viewContext,
      reportRange: .daily)
    ExpensesView(dataSource: reportsDataSource)
  }
}

You added a reportRange parameter to the data source's initializer, so you set it in the preview. For the SwiftUI preview, you'll always show the daily expenses.

Just by changing the data source type, you made the view more general. This shows how much code duplication there was in these two files.

Now, even though you created your general view, you're still not using it anywhere. You'll fix that soon.

Updating ContentView.swift

At this point, you only have a few remaining errors in ContentView.swift. Go to that file and start fixing them.

Completely remove the two calculated properties, dailyReport and monthlyReport, and add this new method instead:

func expenseView(for range: ReportRange) -> ExpensesView {
  let dataSource = ReportsDataSource(reportRange: range)
  return ExpensesView(dataSource: dataSource)
}

This will create the appropriate expense view for a given date range.

The SwiftUI list has two hard-coded NavigationLink views for the two report types. If you want to add a new type of report, e.g. a weekly report, you'll have to change your code both here and in ReportRange.

This is inefficient. You want to use all of ReportRange's possible values to populate the list, without having to change code elsewhere.

Remove the content of List and replace it with the following:

ForEach(ReportRange.allCases, id: \.self) { value in
  NavigationLink(
    value.rawValue,
    destination: expenseView(for: value)
      .navigationTitle(value.rawValue))
}

By making your enum conform to CaseIterable, you get access to the synthesized property allCases. It gives you an array of all the values present in ReportRange, thus allowing you to loop over them easily. For each enum case, you'll create a new navigation link.

Finally, check the previews for ContentView and ExpensesView to ensure your refactoring didn't break anything.

ContentView and ExpensesView previews after refactoring for open-closed

Build and run, then check the reports you previously saved.

Monthly report after open-closed refactoring