Home > Software design >  Body of View is not rerendered
Body of View is not rerendered

Time:01-17

I am making QuizApp. Currently I have viewModel in which questions are fetched and stored in @Published array.

class HomeViewModel: ObservableObject {
    let repository: QuestionRepository

    @Published var questions: [Question] = []
    @Published var isLoading: Bool = true

    private var cancellables: Set<AnyCancellable> = .init()

    init(repository: QuestionRepository){
        self.repository = repository
    
        getQuestions()
    }

    private func getQuestions(){
        repository
            .getQuestions()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { [weak self] questions in
                    self?.isLoading = false
                    self?.questions = questions
                }
            )
            .store(in: &cancellables)
    }

    func updateQuestions(){
        questions.removeFirst()
    
        if questions.count < 2 {
            getQuestions()
        }
    }
}

In QuestionContainerView HomeViewModel is created as @StateObject and from it, first data from questions array is used and passed to QuestionView.

@StateObject private var viewModel: HomeViewModel = HomeViewModel(repository: QuestionRepositoryImpl())

var body: some View {
    if viewModel.isLoading {
        ProgressView()
    } else {
        VStack(alignment: .leading, spacing: 16) {
            if let question = viewModel.questions.first {
                QuestionView(question: question){
                    viewModel.updateQuestions()
                }
            } else {
                Text("No more questions")
                    .font(.title2)
            }
        }
        .padding()
    }
}

QuestionView has two properties, Question and showNextQuestion callback.

let question: Question
let showNextQuestion: () -> Void

And when some button is pressed in that view, callBack is called after 2.5s and after that viewModel function updateQuestions is called.

struct QuestionView: View {
    let question: Question
    let showNextQuestion: () -> Void

    @State private var showCorrectAnswer: Bool = false
    @State private var timeRemaining = 10

    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

var body: some View {
    VStack(alignment: .leading, spacing: 20) {
        Text("\(timeRemaining)")
            .font(.title2)
        
        Text(question.question)
            .font(.title2)
            .padding()
        
        ForEach(question.allAnswers, id: \.self){ answer in
            Button(
                action: {
                    showCorrectAnswer.toggle()
                    
                    DispatchQueue.main.asyncAfter(deadline: .now()   2.5) {
                        showNextQuestion()
                    }
                },
                label: {
                    Text(answer)
                        .font(.title2)
                        .padding()
                        .background(getBackgroundColor(answer: answer))
                        .clipShape(Capsule())
                }
            )
        }
        
        Spacer()
    }
    .onReceive(timer) { _ in
        if timeRemaining > 0 {
            timeRemaining -= 1
        } else {
            showNextQuestion()
        }
    }
}

My idea was to pass first item from viewModel array to QuestionView and after some Button action in QuestionView I wanted to remove firstItem from array and pass next firstItem.

But problem is that QuestionView is not updated (it is not rerendered) and it contains some data from past item - I added timer in QuestionView which is counting down and when question is changed, timer value is still same as for before question, it is not reseted.

I thought that marking viewModel array property with @Published will trigger whole QuestionContainerView render with new viewModel first item from array, but it is not updated as I wanted.

CodePudding user response:

There are several mistakes in the SwiftUI code, one or all could contribute to the problem, here are the ones I noticed:

  1. We don't use view model objects in SwiftUI for view data, that's the job of the View struct and property wrappers.
  2. When ObservableObject is being used for model data, it's usually a singleton (one for the app and another for previews) and passed in as environmentObject. We don't usually use the reference version of @State, i.e. @StateObject for holding the model since we don't want model lifetime tied to any view on screen, it has to be tied to the app executable's lifetime. Also, @StateObject are disabled for previews since usually those are used for network downloads.
  3. In an ObservableObject we .assign(to: &$propertyName) the end of the pipeline to an @Published var, we don't use sink or need cancellables in this case. This ties the pipeline's lifetime to the object's, if you use sink you need to cancel it yourself when the object de-inits (Not required for singletons but it's good to learn the pattern).
  4. Since your timer is a let it will be lost every time the QuestionView is re-init it needs to be in @State.
  5. ForEach is a View not a for loop. You have either supply Identifiable data or an id param, you can't use id:\.self for dynamic data or it'll crash when it changes.
  • Related