Turbocharged SwiftUI Previews: Exploring Modularization with SPM
Hey Devs,
Ever notice SwiftUI previews slowing down 🐌 as your project balloons? It’s a common struggle! Modularize your Code. Less dependency faster preview updates. Break down your UI into separate modules. Each module handles a specific bunch of views.
Experience with Swift Generics is recommended.
Data, Protocols: It contains only raw data structures and protocols. It doesn't depend on anything.
Gallery UI/Profile UI: It contains only swiftUI views and uses the Data, Protocol from the dependency.
Core: It contains ViewModels, UseCases and all the business logic.
Note: For simplicity UI module is not the pure leaf module. Else we end up writing lots of protocols and conformances. That would be overkill.
Lets dive into code!
Create a simple iOS app project. Name it GalleryApp
We will be using Swift Package Manager for managing dependencies.
Now create a package, from Xcode -> File -> New -> Package, select empty
Select root GalleryApp directory click Create.
Create multiple folder inside Modules which will contains all the modules and files. GalleryCore, GalleryUI, GalleryData. Folder structure should look like this.
Update Package.swift
file defining dependencies. Double click it to open in Xcode.
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Modules",
platforms: [
.iOS(.v14)
],
products: [
.library(
name: "GalleryModules",
targets: ["GalleryCore",
"GalleryUI",
"GalleryData"])
],
targets: [
.target(
name: "GalleryCore",
dependencies: ["GalleryData"],
path: "GalleryCore"
),
.target(
name: "GalleryUI",
dependencies: ["GalleryData"],
path: "GalleryUI"
),
.target(
name: "GalleryData",
path: "GalleryData"
)
]
)
Create file GalleryData/GalleryBean.swift
. Lets add some dummy data for testing.
import Foundation
public struct GalleryBean: Codable, Hashable {
public let url: String
init(url: String) {
self.url = url
}
}
public extension GalleryBean {
static let dummyList: [GalleryBean] = [
.init(url: "https://picsum.photos/id/17/200/133"),
.init(url: "https://picsum.photos/id/15/200/133"),
.init(url: "https://picsum.photos/id/11/200/133"),
.init(url: "https://picsum.photos/id/14/200/133")
]
}
Create file GalleryData/GalleryImageViewModel.swift
. This is view model protocol declaration. It just contains a GalleryBean
and the view can download the image and save it in the image
variable.
import Foundation
import UIKit
@MainActor
public protocol GalleryImageViewModel: ObservableObject {
var item: GalleryBean { get }
var image: UIImage? { get }
}
Next let create some UI with previews.
Create file GalleryUI/GalleryImageView.swift
import SwiftUI
import GalleryData
public struct GalleryImageView<Model: GalleryImageViewModel>: View {
@StateObject var model: Model
public var body: some View {
if let image = model.image {
Image(uiImage: image)
} else {
ProgressView()
.colorInvert()
}
}
}
class GalleryImageViewModelPreview: GalleryImageViewModel {
let item: GalleryBean
@Published var image: UIImage?
public init(item: GalleryBean) {
self.item = item
image = createDummyImage(size: CGSize(width: 100, height: 100))
}
func createDummyImage(size: CGSize) -> UIImage {
let color = [UIColor.red, .yellow, .green, .blue, .orange].randomElement()!
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
return image
}
}
#Preview {
GalleryImageView(model: GalleryImageViewModelPreview(item: GalleryBean.dummyList.first!))
}
Now comes to the tricky part where the we use this protocol. struct GalleryImageView<Model: GalleryImageViewModel>: View
we have to pass class information to use it. GalleryImageViewModelPreview
confronts to the required ViewModel protocol.
If there is still error, just add an empty swift file in GalleryCore/File.swift
and you can see the preview for GalleryImageView
.
Now we create the gallery view where multiple GalleryImageView
will be there.
Lets create the GalleryData/GalleryViewModel.swift
. For protocol to pass class info, we have to use associatedtype
. Here constrained by GalleryImageViewModel.
import Foundation
@MainActor
public protocol GalleryViewModel: ObservableObject {
associatedtype SubModel: GalleryImageViewModel
var items: [SubModel] { get set }
}
Create file GalleryUI/GalleryView.swift
import SwiftUI
import GalleryData
public struct GalleryView<Model: GalleryViewModel>: View {
@StateObject var model: Model
public init(model: Model) {
self._model = StateObject(wrappedValue: model)
}
public var body: some View {
List {
ForEach(model.items, id: \.self) { item in
GalleryImageView(model: item)
}
}
.listStyle(.plain)
}
}
class GalleryViewModelPreview: GalleryViewModel {
@Published var items: [GalleryImageViewModelPreview]
init() {
self.items = GalleryBean.dummyList.map { GalleryImageViewModelPreview(item: $0) }
}
}
#Preview {
GalleryView(model: GalleryViewModelPreview())
}
GalleryViewModelPreview
creates object ofGalleryImageViewModelPreview
array.
To use in ForEach
, add Hashable
requirements to GalleryImageViewModel
@MainActor
public protocol GalleryImageViewModel: ObservableObject, Hashable {
var item: GalleryBean { get }
var image: UIImage? { get }
}
and in GalleryImageView.swift
extension GalleryImageViewModelPreview {
func hash(into hasher: inout Hasher) {}
static func == (lhs: GalleryImageViewModelPreview, rhs: GalleryImageViewModelPreview) -> Bool {
lhs === rhs
}
}
Now you can add actual view model implementations for GalleryViewModel
, GalleryImageViewModel
in GalleryCore module
import GalleryData
import UIKit
public class GalleryImageViewModelImpl: GalleryImageViewModel { ...
public class GalleryViewModelImpl: GalleryViewModel { ...
Note that, GalleryCore
is not dependent on the GalleryUI
. Since all the protocol declaration are in the GalleryData
module.
Finally open GalleryApp.xcodeproj
and add local dependency to our Modules
.
You should be able to use the views and models from the module like this. You may need to make few items public as required.
import SwiftUI
import GalleryData
import GalleryCore
import GalleryUI
struct ContentView: View {
var body: some View {
GalleryView(model: GalleryViewModelImpl())
}
}
#Preview {
ContentView()
}
If you’re still with me, let’s take it up a notch — time to add some tests to the mix! 😆
Close the app project and double click Package.swift
. Append a testTarget
to Package.swift
. Also exclude Test.swift
from GalleryCore
module.
.target(
name: "GalleryCore",
dependencies: ["GalleryData"],
path: "GalleryCore",
exclude: ["tests/Test.swift"]
),
.testTarget(
name: "GalleryCoreTests",
dependencies: ["GalleryData", "GalleryCore"],
path: "GalleryCore/tests"
),
Add file GalleryCore/tests/Test.swift
import XCTest
import GalleryData
import GalleryCore
final class Test: XCTestCase {
@MainActor
func testExample1() throws {
let galleryViewModel = GalleryViewModelImpl()
XCTAssert(galleryViewModel.items.isEmpty == false)
}
@MainActor
func testExample2() throws {
let imageModel = GalleryImageViewModelImpl(item: GalleryBean.dummyList[0])
XCTAssert(imageModel.image != nil)
}
}
Project should look like this. Run tests from here.
The sample source code is available here.
That’s a wrap for now! Thanks for joining the ride 🙌🏼. Your time and attention mean the world. Would love to hear your thoughts — drop a comment or hit me up. Appreciate your feedback. Until next time, happy coding! 🚀💻