DEV Community

bhavesh chaudhari
bhavesh chaudhari

Posted on

Is it wrong to use a ViewModel in a simple SwiftUI screen with a list and API call? is there any better approach

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)