Search code examples
swiftswiftuiviewviewmodel

Changes to my model/viewmodel are not charging what's on my view


I'm making a simple news app with swiftUI. the problem is that I want to add a view model to my project but now when I run the app nothing is shown. When I first added the viewmodel everything was perfect, but now the viewmodel can't see the changes I make or it doesn't communicate them. I tried everything that I could think of, so I need some help.

What shows when I run the app

App Entry

@main
struct GameNewsApp: App {
    
    @State var newsModel = NewsViewModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(newsModel)
        }
    }
}

Model

struct ArticleSearch: Decodable {
        var results = [Articles]()
}
struct Articles: Decodable, Identifiable {
    let id = UUID()
    var publish_date: String?
    var authors: String?
    var title: String?
    var image: Images?
    var deck: String?
    var body: String
    var categories: [Category]
}

struct Images: Decodable {
    var square_tiny: String?
    var screen_tiny: String?
    var square_small: String?
    var original: String?
}

struct Category: Decodable, Identifiable {
    var id: Int?
    var name: String?
}

Views

struct ContentView: View {
    var body: some View {
        
        TabView {
            NewsView()
                .tabItem {
                    Label("Game News", systemImage: "gamecontroller")
                }
            
            VideoView()
                .tabItem {
                    Label("Game Videos", systemImage: "airplayvideo")
                }
        }
        .onAppear {
            UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance()
        }
        .preferredColorScheme(.dark)
    }
}

#Preview {
    ContentView()
}
struct NewsView: View {
    
    @Environment (NewsViewModel.self) var newsModel
    
    var body: some View {
        NavigationStack {
            ScrollView(showsIndicators: false) {
                VStack {
                    ForEach(newsModel.articles) { a in
                        NavigationLink {
                            DetailNews()
                        } label: {
                            NewsCard()
                        }
                        .onTapGesture {
                            newsModel.selectedNews = a
                        }
                    }
                }
            }
            .navigationTitle("Game News")
        }
        .onAppear {
            newsModel.getNewsData()
        }
        .refreshable {
            newsModel.getNewsData()
        }
    }
}

#Preview {
    NewsView()
}
import SwiftUI
import CachedAsyncImage

struct NewsCard: View {
    
    @Environment(NewsViewModel.self) var newsModel
    
    var body: some View {
        
        let articles = newsModel.selectedNews
        
        VStack(alignment: .leading, spacing: 0) {
            CachedAsyncImage(url: URL(string: articles?.image?.original ?? "")) { image in
                switch image {
                case .empty:
                    HStack {
                        Spacer()
                        ProgressView()
                        Spacer()
                    }
                case .success(let image):
                    image
                        .resizable()
                        .clipShape(.rect(topLeadingRadius: 10, topTrailingRadius: 10))
                        .frame(height: 150)
                        .padding(.bottom, 10)
                        .overlay {
                            LinearGradient(stops: [
                                Gradient.Stop(color: .clear, location: 0.6),
                                Gradient.Stop(color: .black, location: 1)
                            ], startPoint: .top, endPoint: .bottom)
                        }
                case .failure:
                    HStack {
                        Spacer()
                        Image(systemName: "photo")
                            .imageScale(.large)
                        Spacer()
                    }
                @unknown default:
                    fatalError()
                }
            }
            .frame(maxHeight: 150)
            .background(Color.gray.opacity(0.3))
            .clipped()
            
            
                Spacer()

                Text(articles?.title ?? "")
                    .font(.title3)
                    .fontWeight(.bold)
                    .lineLimit(3)
                    .padding(.bottom, 10)
                    .padding(.horizontal)
                    .multilineTextAlignment(.leading)
            
                Text(articles?.deck ?? "")
                    .font(.subheadline)
                    .lineLimit(4)
                    .padding(.bottom, 10)
                    .padding(.horizontal)
                    .multilineTextAlignment(.leading)
            
            Spacer()
            
            HStack {
                ForEach(articles?.categories ?? []) { category in
                    Text(category.name ?? "")
                }
            }
            .padding(.horizontal)
            .padding(.bottom, 5)
            
                Spacer()
            
            }
            .frame(height: 350)
            .overlay {
                RoundedRectangle(cornerRadius: 10)
                    .stroke(.white)
            }
            .padding(.all, 10)
            .foregroundStyle(.white)
    }
}


#Preview {
    NewsCard()
}

Viewmodel

@Observable
class NewsViewModel {
    
    var articles = [Articles]()
    var dataService = DataService()
    var selectedNews: Articles?
    
    func getNewsData() {
        Task {
            articles = await dataService.articleSearch()
        }
    }
}

When changes happen I want to show them in the view of the app.


Solution

  • The structure is not quite right, async funcs must return something and it's .task not Task{} in SwiftUI for the correct lifetime, try something like this:

    struct DataService {
        // background thread
        func articleSearch() async -> [News] { // usually this would also be throws
            return ...
        }
    
        func articleContent(for articleID: String) async -> String {
            return 
        }
    }
    
    struct NewsView: View {
        
        @Environment(\.dataService) private var dataService // have to learn EnvironmentKey
        @State private var articles: [Articles] = []
    
        var body: some View {
            if articles.empty {
                 ContentUnavailableView(...)
                 .task {
                     await load()
                 } 
            }
            else {
                List(articles) { article in
                    ArticleContent(articleID: article.id)
                }
                .refreshable { 
                    await load()
                }
            }
        }
    
        func load() -> async {
           articles = await dataService.articleSearch() // usually you would catch an error and set it on a state to show it
        }
    }
    
    struct ArticleContent: View {
        let articleID: String
    
        @Environment(\.dataService) private var dataService
        @State private var content = ""
    
        var body: Some View {
            Text(content)
            .task(id: articleID) {
                content = await dataService.articleContent(for articleID: articleID)
            }
        }
    }
    

    For selection use another @State.