Turbocharged SwiftUI Previews: Exploring Modularization with SPM

Chandan Karmakar
6 min readJan 8, 2024

--

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! 🚀💻

--

--