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:
- We don't use view model objects in SwiftUI for view data, that's the job of the
View
struct and property wrappers. - When
ObservableObject
is being used for model data, it's usually a singleton (one for the app and another for previews) and passed in asenvironmentObject
. 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. - In an
ObservableObject
we.assign(to: &$propertyName)
the end of the pipeline to an@Published var
, we don't usesink
or needcancellables
in this case. This ties the pipeline's lifetime to the object's, if you usesink
you need to cancel it yourself when the object de-inits (Not required for singletons but it's good to learn the pattern). - Since your timer is a
let
it will be lost every time theQuestionView
is re-init it needs to be in@State
. ForEach
is a View not a for loop. You have either supplyIdentifiable
data or anid
param, you can't useid:\.self
for dynamic data or it'll crash when it changes.