Getting 200% Out of SwiftUI & Preview with Protocols
A goodbye letter to simulator and device builds ๐จโ๐ป
Is everyone coding happily? ๐๏ธโโ๏ธ
Are you all staying productive..?
Or are you suffering through UI development..?
We all know SwiftUI has Previews, and word on the street is UIKit supports them now too...
But when you actually try to use them, do you find yourself tangled in dependencies, unable to use Previews at all, and just spinning up device builds instead?
Well, the diagnosis is in. You need to use protocols.
Why Preview? ๐ค
As you all know, Preview is a feature provided by SwiftUI. With Previews, you can literally "preview" your code without building to a device or simulator. The days of waiting a full minute for a build just to check a single font change are long gone.
This allows engineers to cut down on design iteration time and prototype much faster. And that translates into massive productivity. The time saved gets reinvested into architecture and design, which yields even greater productivity. It creates a tremendous gap compared to how productive we used to be.
According to recent announcements, Xcode 15 brought the amazing news that Previews are now supported in UIKit as well as SwiftUI. Amazing!! This means we no longer have to suffer through long build times when modifying legacy code! ๐
But Preview ๐ฅฒ
However, I often see cases where people struggle to fully utilize these fantastic Previews. Sure, Previews work fine for simple Views, but as the project grows and layers get more complex, Previews often become practically useless. This dilemma deepens especially when you first introduce architectures like MVVM and start working on dependency separation.
The typical "Giving up on Previews" tech tree goes something like this:
1. Create a file and inject data.
'Hmm~ If I pass the data during initialization, I can see the UI right away~ Previews are awesome~'
struct AlarmyView: View {
let title: String
var body: some View {
Text(title)
}
}
struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(title: "Hello Alarmy")
}
}2. Introduce a ViewModel.
'Still good! I just need to inject the data the View needs through the ViewModel!'
struct AlarmyView: View {
@ObservedObject private var viewModel: AlarmyViewModel
init(viewModel: AlarmyViewModel) {
self.viewModel = viewModel
}
...
}
struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(viewModel: .init())
}
}
public class AlarmyViewModel: ObservableObject {
...
}3. Dependencies start piling up one by one.
But as dependencies increase, you'll start hitting the limits of initializing the ViewModel inside the Preview.
'Ah, external dependencies at initialization are getting a bit annoying...?'
public class AlarmyViewModel: ObservableObject {
init(
alarmyRepository: AlarmyRepository,
alarmyAPIProvider: AlarmyAPIProvider,
) {
...
}
}It gets even worse if you adopt Clean Architecture and need to inject UseCases.
public class AlarmyViewModel: ObservableObject {
init(
fetchAlarmyDataUseCase: FetchAlarmyDataUseCase,
deleteAlarmyDataUseCase: DeleteAlarmyDataUseCase,
requestWeatherUseCase: RequestAlarmyHistoryUseCase,
requestNewsUseCase: RequestNewsUseCase
) {
...
}
}
struct FetchAlarmyDataUseCase {
private let alarmyRepository: AlarmyRepository
init(alarmyRepository: AlarmyRepository) {
self.alarmyRepository = alarmyRepository
}
func execute() -> AlarmyData {
alarmyRepository.fetchAlarmyData()
}
}
...4. Give up and turn back.
Once external dependencies pile up like this, drawing data on the View hits a wall, and you just give up.
Since data has to be fetched externally (Local DB, Server, etc.), loading it every single time in a Preview state is way too expensive. So, the now-useless Preview code gets deleted, and you go back to validating Views via full builds just like before.
Magical Protocol ๐ง
So, how can we easily solve this problem?!
As mentioned in the title, using protocols allows us to solve this problem much more easily and elegantly.
More accurately, it's the concept of an interface!
An interface is the shared boundary or connection point where two different systems or devices exchange information or signals.
That's what Wikipedia says, but simply put, an interface is a communication protocol between two objects.
Let's look at some example code to help you understand!
From the previous example, I declared the externally injected object, AlarmyRepository, as a protocol.
public protocol AlarmyRepository {
func save(alarmyData: AlarmyData)
func delete(alarmyData: AlarmyData)
func fetchAlarmyData() -> AlarmyData
}The AlarmyRepository protocol above establishes a promise to perform the following functions. It promises to save, delete, and fetch AlarmyData.
Once the protocol is declared like this, we can adopt it to create an actual implementation!
class AlarmRepositoryImpl: AlarmyRepository {
func save(alarmyData: AlarmyData) {
...
}
func delete(alarmyData: AlarmyData) {
...
}
func fetchAlarmyData() -> AlarmyData {
...
}
}AlarmRepositoryImpl adopts the AlarmyRepository protocol and implements each function to actually work.
Saving, deleting, and fetching data can be implemented via CoreData, UserDefaults, Realm, etc.
And when you actually create the ViewModel, you just initialize AlarmRepositoryImpl and pass it in.
// AlarmyViewModel ๋ด๋ถ
public class AlarmyViewModel: ObservableObject {
init(
alarmyRepository: AlarmyRepository
) {
...
}
}
// AlarmyView ์์ฑ
let alarmyView = AlarmyView(viewModel: .init(alarmyRepository: AlarmRepositoryImpl()))๐ค But that's exactly the same as the previous implementation?! If I don't save anything, nothing shows up on the screen..! How am I supposed to use this in SwiftUI?!
You might be wondering exactly that!
๐ฅ๐ฅ๐ฅ๐ฅ๐ฅ๐ฅ๐ฅ๐ฅ
Well! Let me add one more keyword that exponentially increases the charm of interfaces. That is Fake.
Fake is a concept from TDD, meaning to implement a fake object as if it were real. But there's no rule saying we can only use Fakes in TDD, right!? And honestly, in the context of UI testing, isn't this also a form of TDD..? Haha ๐
Coming back to our point, we can use the already declared AlarmyRepository protocol to create a Fake object. We can also make the functions required for drawing the UI return exactly the objects we want.
class FakeAlarmyRepository: AlarmyRepository {
func save(alarmyData: AlarmyData) { }
func delete(alarmyData: AlarmyData) { }
func fetchAlarmyData() -> AlarmyData {
AlarmyData()
}
}After that, you just pass FakeAlarmyRepository when initializing the ViewModel in the Preview, and you're done!
BAM-!
struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(
viewModel: .init(alarmyRepository: FakeAlarmyRepository())
)
}
}Now we can check exactly how the data we want will look in SwiftUI! ๐
And on top of that, this newly created FakeAlarmyRepository can even be used for TDD!
It secures app stability through dependency separation,
lets you instantly insert the necessary data in SwiftUI,
and is super useful in TDD..!
Protocols, what a cheeky little thing..!! You just can't help but use something this attractive, right?
To summarize as a quick reminder before we forget the process:
Create a protocol (interface).
Create the implementation needed for the actual product (AlarmRepositoryImpl) and the implementation needed for SwiftUI (FakeAlarmyRepository).
Inject the different implementations during initialization as needed.
Pro Tip ๐ง
How was that? Did it help? It'd be a shame to end it right here, so let me drop one more pro tip. When working on UIs, you often run into situations where you need to handle multiple states.
Beyond just a simple "data exists / data doesn't exist," there are times when you need to show different screens based on specific data cases.
For example, in our actual product, we encountered a situation where we had to check for 7 different cases.
base, // ๊ธฐ๋ณธ
recentLaunched, // ์ต๊ทผ ์ค์น
longTimeNoSee, // ์ค๋๋ง
everyDay, // ๋งค์ผ
onlyWeek, // ์ฃผ์ค์ฌ์ฉ
alternate, // ๊ฒฉ์ผ
once // ํ๋ฃจAnd naturally, we needed different data to test each one. If we actually had to create and delete data repeatedly for all these cases, the cost would be astronomical, right?
How can we solve this easily?
There are many solutions, of course, but I decided to create the data in advance and return it differently based on the case at initialization.
For instance, if you needed to test the following cases in FakeAlarmyRepository, you could express it like this:
class FakeAlarmRepository: AlarmyRepository {
private let dataType: DataType
init(
dataType: DataType
) {
self.dataType = dataType
}
func save(alarmyData: AlarmyData) { }
func delete(alarmyData: AlarmyData) { }
func fetchAlarmyData() -> AlarmyData {
dataType.data
}
}
extension FakeAlarmRepository {
enum DataType {
case base, // ๊ธฐ๋ณธ
recentLaunched, // ์ต๊ทผ ์ค์น
longTimeNoSee, // ์ค๋๋ง
everyDay, // ๋งค์ผ
onlyWeek, // ์ฃผ์ค์ฌ์ฉ
alternate, // ๊ฒฉ์ผ
once // ํ๋ฃจ
var data: AlarmyData {
switch self {
...
}
}
}
}How about that?
Now we've incredibly easily created an object that returns different data for every case we need.
Conclusion
We want to reliably deliver better products and features to our users in less time. To do that, we need to make better use of the tools we have!
The ways to use protocols and interfaces are truly limitless. The method I shared is just one example. And the pro tip is also just another example of how to utilize them better.
If you haven't been able to use Previews well because of external objects or networks, try creating a protocol and making a Fake object that adopts it! And now, say your goodbyes to simulators and devices! Haha
Thank you for reading to the end ๐