skip to Main Content

I’m attempting to retrieve data for a user profile, and I’m not sure how to initialize the observable objects in my view model. I see a lot of examples of where an array of objects will be returned, but not one single object.

How can I initialize the profile variable in the view model with a null value before I attempt to fetch the profile data, or should I not use this method for retrieving this data?

Also on a side note, is it best practice to write a common class (like a RestManager class) to handle all API requests and have one method which invokes the URLSession dataTaskPublisher method?

Model:

struct Profile: Codable, Hashable, Identifiable {
    var id: UUID
    var address: String
    var city: String
    var emailAddress: String
    var firstName: String
    var lastName: String
    var smsNumber: String
    var state: String
    var userBio: String
    var username: String
    var zipCode: Int
}

ViewModel:

class ProfileViewModel: ObservableObject {
    // Cannot create Profile() as it needs data to be instantiated
    @Published var profile: Profile = Profile()

    init() {
        fetchProfile()
    }

    func fetchProfile() {
        guard let url = URL(string: "https://path/to/api/v1/profiles") else {
            print("Failed to create URL")
            return
        }
        
        let requestData = [
            "username": "testuser"
        ]
        var request = URLRequest(url: url)
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpMethod = "GET"
        request.httpBody = try? JSONEncoder().encode(requestData)
        
        URLSession.shared.dataTaskPublisher(for: request)
            .tryMap { output in
                guard let response = output.response as? HTTPURLResponse, response.statusCode == 200 else {
                    throw HTTPError.statusCode
                }

                return output.data
            }
            .decode(type: Profile.self, decoder: JSONDecoder())
            .retry(3)
            .replaceError(with: Profile()) // Same issue here - requires data to be instantiated
            .eraseToAnyPublisher()
            .receive(on: DispatchQueue.main)
            .assign(to: &$profile)
    }
}

View:

struct ProfileSummary: View {
    @ObservedObject var viewModel: ProfileViewModel

    var body: some View {
        NavigationView {
            VStack {
                Text(viewModel.profile.firstName)
                Text(viewModel.profile.lastName)
            }
            .navigationTitle("Profile")
            .onAppear {
                viewModel.fetchProfile()
            }
        }
    }
}

3

Answers


  1. you could try something like this, making "Profile" an optional:

    (Note, in your "ProfileViewModel", I can’t see where you actually assign a value to "profile". Seems you are missing the sink/receiveValue)

    class ProfileViewModel: ObservableObject {
        @Published var profile: Profile: Profile?  // <--- here
        ...
    }
       
    struct ProfileSummary: View {
        @ObservedObject var viewModel: ProfileViewModel
        
        var body: some View {
            NavigationView {
                VStack {
                    if let profile = viewModel.profile {  // <--- here
                        Text(profile.firstName)
                        Text(profile.lastName)
                    }
                }
                .navigationTitle("Profile")
                .onAppear {
                    viewModel.fetchProfile()
                }
            }
        }
    }
    

    Edit: to fix your other problems in "ProfileViewModel", you could try:

    class ProfileViewModel: ObservableObject {
        @Published var profile: Profile?
        
        var cancellable = Set<AnyCancellable>()
    
        func fetchProfile() {
            guard let url = URL(string: "https://path/to/api/v1/profiles") else {
                print("Failed to create URL")
                return
            }
            
            let requestData = ["username": "testuser"]
            
            var request = URLRequest(url: url)
            request.setValue("application/json", forHTTPHeaderField: "Accept")
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpMethod = "GET"
            request.httpBody = try? JSONEncoder().encode(requestData)
            
            URLSession.shared.dataTaskPublisher(for: URLRequest(url: url))
                .tryMap { output in
                    guard let response = output.response as? HTTPURLResponse, response.statusCode == 200 else {
                        throw HTTPError.statusCode
                    }
                    return output.data
                }
                .decode(type: Profile.self, decoder: JSONDecoder())
                .retry(3)
                .eraseToAnyPublisher()
                .receive(on: DispatchQueue.main)
                 .sink { completion in
                     print("-----> completion: (completion)")
                 } receiveValue: { profile in
                     self.profile = profile
                 }
                .store(in: &self.cancellable)
        }
    }
    
    Login or Signup to reply.
  2. A possible solution – to avoid optionals – is a static property with creates an empty sample instance

    struct Profile: Codable, Hashable, Identifiable {
        var id: UUID
        var address: String
        var city: String
        var emailAddress: String
        var firstName: String
        var lastName: String
        var smsNumber: String
        var state: String
        var userBio: String
        var username: String
        var zipCode: Int
    
        static let sample = Profile(id: UUID(), address: "", city: "", emailAddress: "", firstName: "", lastName: "", smsNumber: "", state: "", userBio: "", username: "", zipCode: 0)
    }
    

    and use it

    @Published var profile = Profile.sample
    

    and also in the replaceError operator line

    .replaceError(with: Profile.sample)
    

    Or create an enum for all possible states for example

    enum State {
        case undetermined
        case isLoading
        case loaded(Profile)
        case error(Error)
    }
    
    @Published var state : State = .undetermined
    

    In the view switch on the state and render appropriate UI

    Side notes:

    • If the struct members are never going to be modified declare them as constants (let)
    • .eraseToAnyPublisher() is pointless if the pipeline ends in the same scope.
    Login or Signup to reply.
  3. Instead of letting a view attempt to render content that does not exist (how the heck can that be implemented anyway??)

    specify clearly what to render at any time.

    So, instead of attempting to draw an optional whose value is .none:

    Your "view state" or a part of it:

    enum Content {
        case none(NoContent) 
        case some(Profile)
    }
    

    In your View Model:

    class ProfileViewModel: ObservableObject {
        @Published var viewState: Content = .none(NoContent())
        ...
    

    Your NoContent represents exactly what to render when the profile value is absent. This also enables you to render a nice representation for "no profile available (yet)" since you can render any view you (or UX) wants to look at when you are about to load the profile – or when an error occurred, or …

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search