I'm building a SwiftUI screen that displays a list of notifications, using a ViewModel to handle state and API calls. The screen is fairly straightforward: it has a header and a paginated notification list that supports pull-to-refresh and infinite scrolling.
Recently, I came across a blog post suggesting that using ViewModels in SwiftUI might be an anti-pattern or unnecessary, especially in simple views (https://developer.apple.com/forums/thread/699003). This made me question whether my current architecture is overkill or misaligned with SwiftUI best practices.
Here is a simplified version of my implementation:
import SwiftUI
@MainActor
class NotificationListViewModel: ObservableObject, APIParamFiledType {
let router: InterCeptor<ProfileEndPoint>
enum NotificationState: Equatable {
case loading
case loaded([Notification])
case paginating([Notification])
case empty(String)
case error(String)
}
@Published var notificationList = [Notification]()
@Published private(set) var state = NotificationState.loading
var userModelController: UserModelController
var pagedObject = PageStruct(indxe: 1, size: 50)
init(router: InterCeptor<ProfileEndPoint>, userModelController: UserModelController) {
self.router = router
self.userModelController = userModelController
}
func loadMoreNotification() async {
let request = NotificationList.Request(country: userCountry, userInfoId: userInfoId, doctorID: doctorId, pageIndex: pagedObject.index, pageSize: pagedObject.size)
do {
let response: NotificationList.Response = try await router.request(endPoint: .notificationList(param: request, authToken: token))
if notificationList.isEmpty {
notificationList.append(contentsOf: response.result ?? [])
if notificationList.isEmpty {
state = .empty("No new notifications")
} else {
state = .loaded(notificationList)
}
} else {
notificationList.append(contentsOf: response.result ?? [])
state = .paginating(notificationList)
}
pagedObject.totalCount = response.totalCount
} catch let error {
state = .error(error.localizedDescription)
}
}
func resetNotification() async {
notificationList.removeAll()
pagedObject.resetPageIndex()
await loadMoreNotification()
}
func shouldLoadMore(currentOffset: Int) async {
if pagedObject.shouldLoadMore && currentOffset == notificationList.count - 1 {
pagedObject.increasePageIndex()
await loadMoreNotification()
}
}
}
here is my view
import SwiftUI
import JIGUIKit
struct NotificationListView: View {
var backButtonClick: (() -> Void)?
@ObservedObject var viewModel: NotificationListViewModel
var body: some View {
ZStack {
GradientBlueView()
.ignoresSafeArea()
VStack(spacing: 0) {
headerView
contentView
}.frame(maxHeight: .infinity, alignment: .top).onAppear {
UIRefreshControl.appearance().tintColor = .white
UIApplication.shared.applicationIconBadgeNumber = 0
Task {
await viewModel.loadMoreNotification()
}
}
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
}
}
private var headerView: some View {
HeaderViewWrapper(backButtonClick: backButtonClick)
.frame(height: 100)
}
@ViewBuilder
private var contentView: some View {
switch viewModel.state {
case .loading:
initalLoadingView
case .loaded(let notifications), .paginating(let notifications):
List {
showList(notifications: notifications)
if case .paginating = viewModel.state {
loaderView.listRowBackground(Color.clear)
}
}.refreshable(action: {
Task {
await viewModel.resetNotification()
}
})
.padding(.horizontal, 16)
.listStyle(.plain)
.applyScrollIndicatorHiddenIfAvailable()
case .empty(let emptyNotification), .error(let emptyNotification):
showError(error: emptyNotification)
}
}
private var initalLoadingView: some View {
VStack {
Spacer()
loaderView
Spacer()
}
}
private var loaderView: some View {
HStack {
Spacer()
BallPulseSync(ballSize: 20, ballColor: .buttonBackground)
Spacer()
}.frame(height: 100)
}
func showError(error: String) -> some View {
VStack {
Spacer()
HStack {
Spacer()
Text(error).font(.headline).foregroundStyle(Color.white)
Spacer()
}
Spacer()
}
}
func showList(notifications: [Notification]) -> some View {
ForEach(notifications.indices, id: \.self) { index in
let notification = notifications[index]
NotificationRow(notification: notification)
.padding(.vertical, 10)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.onAppear {
Task {
await viewModel.shouldLoadMore(currentOffset: index)
}
}
}
}
}
I experimented with managing all logic inside the View itself, including state management and API calls, without using a separate ViewModel. However, the view became cluttered and harder to test, so I moved the logic into a dedicated ObservableObject ViewModel for better separation of concerns.