Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI: Preview with data in ViewModel

Tags:

swift

swiftui

I load my data from a viewModel which is loading data from web. Problem: I want to set some preview sample data to have content in preview window. Currently my preview contains an empty list as I do not provide data.

How can I achieve this?

struct MovieListView: View {

    @ObservedObject var viewModel = MovieViewModel()

    var body: some View {
       List{
        ForEach(viewModel.movies) { movie in
                MovieRow(movie: movie)
                    .listRowInsets(EdgeInsets())
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MovieListView()
    }
}

class MovieViewModel: ObservableObject{

    private let provider = NetworkManager()

    @Published var movies = [Movie]()

    init() {
       loadNewMovies()
    }

    func loadNewMovies(){
         provider.getNewMovies(page: 1) {[weak self] movies in
                   print("\(movies.count) new movies loaded")
                   self?.movies.removeAll()
            self?.movies.append(contentsOf: movies)}
    }
}
like image 394
netshark1000 Avatar asked Feb 04 '20 12:02

netshark1000


3 Answers

Here is possible approach (based on dependency-injection of view model members instead of tight-coupling)

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        // create Movie to be previewed inline, say from bundled data
        MovieListView(viewModel: MovieViewModel(provider: nil, movies: [Movie(...)]))
    }
}

class MovieViewModel: ObservableObject {

    private var provider: NetworkManager?

    @Published var movies: [Movie]

    // same as before by default, but allows to modify if/when needed explicitly
    init(provider: NetworkManager? = NetworkManager(), movies: [Movie] = []) {
        self.provider = provider
        self.movies = movies

        loadNewMovies()
    }

    func loadNewMovies(){
         provider?.getNewMovies(page: 1) {[weak self] movies in
                print("\(movies.count) new movies loaded")
                self?.movies.removeAll()
                self?.movies.append(contentsOf: movies)
        }
    }
}
like image 183
Asperi Avatar answered Sep 19 '22 14:09

Asperi


Further to the answer above, and if you want to keep your shipping codebase clean, I've found that extending the class captured in PreProcessor flags to add a convenience init works.

#if DEBUG
extension MovieViewModel{
   convenience init(forPreview: Bool = true) {
      self.init()
      //Hard code your mock data for the preview here
      self.movies = [Movie(...)]
   }
}
#endif

Then modify your SwiftUI structs using preprocessor flags as well:

struct MovieListView: View {

   #if DEBUG
   let viewModel: MovieViewModel

   init(viewModel: MovieViewModel = MovieViewModel()){
      self.viewModel = viewModel
   }
   #else
    @StateObject var viewModel = MovieViewModel()
   #endif

    var body: some View {
       List{
        ForEach(viewModel.movies) { movie in
                MovieRow(movie: movie)
                    .listRowInsets(EdgeInsets())
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MovieListView(viewModel: MovieViewModel(forPreview: true)
    }
}
like image 32
Kramer Avatar answered Sep 22 '22 14:09

Kramer


This question was written before @StateObject was introduced at WWDC 2020. I believe these days you'd want to use @StateObject instead of @ObservedObject because otherwise your view model can be re-initialized numerous times (which would result in multiple network calls in this case).

I wanted to do the exact same thing as OP, but with @StateObject. Here's my solution that doesn't rely on any build configurations.

struct MovieListView: View {

    @StateObject var viewModel = MovieViewModel()

    var body: some View {
        MovieListViewInternal(viewModel: viewModel)
    }
}

private struct MovieListViewInternal<ViewModel: MovieViewModelable>: View {

    @ObservedObject var viewModel: ViewModel

    var body: some View {
       List {
           ForEach(viewModel.movies) { movie in
               MovieRow(movie: movie)
           }
       }
       .onAppear {
           viewModel.fetchMovieRatings()
       }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MovieListViewInternal(viewModel: PreviewMovieViewModel())
    }
}

The View model protocols and implementations:

protocol MovieViewModelable: ObservableObject {
    var movies: [Movie] { get }
    func fetchMovieRatings()
    // Define vars or funcs for anything else your view accesses in your view model
}


class MovieViewModel: MovieViewModelable {

    @Published var movies = [Movie]()

    init() {
       loadNewMovies()
    }

    private func loadNewMovies() {
        // do the network call
    }

    func fetchMovieRatings() {
        // do the network call
    }
}

class PreviewMovieViewModel: MovieViewModelable {
    @Published var movies = [fakeMovie1, fakeMovie2]
    
    func fetchMovieRankings() {} // do nothing while in a Preview
}

This way your external interface to MovieListView is exactly the same, but for your previews you can use the internal view definition and override the view model type.

like image 23
Jeremy Avatar answered Sep 20 '22 14:09

Jeremy