Search code examples
swiftuicombine

Issue with Updating Items in SwiftUI VStack Using Timer Publisher


I'm encountering difficulty updating items within a VStack in my SwiftUI app. Each item (ListItemView()) displays a label showing the remaining time. I'm struggling to find an approach to achieve this for each item in a VStack (specifically a LazyVStack) on the screen.

Here's how my ViewModel is structured:

@MainActor
final class MyViewModel: ObservableObject {
    
    private var cancellables: Set<AnyCancellable> = []
    
    private (set) var models: [MyModel] = []
    @Published private(set) var listItemModels: [MyListItemModel] = []
    
    init() {
        
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                self?.updateModelsTimeLeft()
            }.store(in: &cancellables)
        
    }
   
    func fetchData(limit: Int) async {
        do {
            let models = try await APIService.fetchModels()
            self.models = models
            
            listItemModels = models.map { MyListItemModel(id: $0.id,
                                                               timeInMs: $0.timeInMs)}
        } catch {
            print("Error fetching models: \(error)")
        }
    }
}


private extension MyViewModel {
    
    func updateModelsTimeLeft() {
        
        for index in 0..<self.listItemModels.count {
             self.listItemModels[index].timeInMs = // changed based on some logic
            }
        }
    }
}

The MyListItemModel used in the ViewModel is defined as follows:

struct MyListItemModel: Identifiable {
    let id: Int
    var timeInMs: Int
}

I'm attempting to display these items on the screen using:

LazyVStack(alignment: .center, spacing: 16) {
    ForEach(viewModel.listItemModels, id: \.id) { model in
        ListItemView(model: model)
    }
}

Inside the ListItemView, I'm simply printing the value inside a Text() view.

The problem happens with Timer publisher I guess: the UI doesn't update at all.

Any suggestions on how to properly update the UI in SwiftUI when using Timer publisher for this scenario would be greatly appreciated!

Edit:

Not sure how this can be important, but this is how I fetch data:

// This is on my main screen's view.
.onAppear {
            Task {
                await viewModel.fetchData(limit: 20)
            }
        }

Solution

  • To fetch data it's like this:

    struct Fetcher {
    
        // in Swift, funcs should always return a result or throw
        func fetchData(limit: Int) async throws -> [Model] {
            return try await APIService.fetchModels() // normally you wouldn't use another object     
        }
    }
    
    @State var models: [MyModel] = []
    ...
    .task {
        let fetcher = Fetcher() // good idea to make this an @Environment so can be swapped out for Previews
        do {
            models = await fetcher.fetchData(limit: 20)
        }
        catch {
            // normally this error would be saved in a @State and shown in a Text
            print("Error fetching models: \(error)")
        }
    }
    

    .task removes the need for a @StateObject to manage the lifetime of the async work.

    To transform models into certain Views use a computed property like this:

    var listItemModels: [MyListItemModel] {
        models.map { model in
            MyListItemModel(id: model.id, timeInMs: model.timeInMs) 
        }
    }
    ...
    MyList(listItemModels: listItemModels)
    

    I'll think about your timer, maybe another .task with a while loop with Task.sleep.