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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SOLID Principles for iOS Apps
40 mins
- Getting Started
- Understanding SOLID’s Five Principles
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
- Auditing the Project
- Invoking the Single Responsibility Principle
- Using the New Persistence
- Implementing the Open-Closed Principle
- Creating the Enum
- Cleaning up the Reports
- Updating ContentView.swift
- Adding Weekly Reports
- Applying Dependency Inversion
- Removing the Core Data Dependency
- Seeing Your Changes in Action
- Simplifying the Reports Datasource Interface
- Refactoring ExpensesView
- Adding Interface Segregation
- Splitting up Protocols
- Implementing Liskov Substitution
- Auditing the App Again
- Where to Go From Here?
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.
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.
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.
Build and run, then check the reports you previously saved.