SwiftUI: Published string changes inside view model are not updating view - swift

I have a timer inside my view model class which every second changes two #Published strings inside the view model. View model class is an Observable Object which Observed by the view but the changes to these string objects are not updating my view.
I have a very similar structure in many other views(Published variables inside a ObservableObject which is observed by view) and it always worked. I can't seem to find what am I doing wrong?
ViewModel
final class QWMeasurementViewModel: ObservableObject {
#Published var measurementCountDownDetails: String = ""
#Published var measurementCountDown: String = ""
private var timer: Timer?
private var scheduleTime = 0
func setTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
DispatchQueue.main.async {
self.scheduleTime += 1
if self.scheduleTime == 1 {
self.measurementCountDownDetails = "Get ready"
self.measurementCountDown = "1"
}
else if self.scheduleTime == 2 {
self.measurementCountDownDetails = "Relax your arm"
self.measurementCountDown = "2"
}
else if self.scheduleTime == 3 {
self.measurementCountDownDetails = "Breathe"
self.measurementCountDown = "3"
}
else if self.scheduleTime == 4 {
self.measurementCountDownDetails = ""
self.measurementCountDown = ""
timer.invalidate()
}
}
}
}
}
View
struct QWMeasurementView: View {
#ObservedObject var viewModel: QWMeasurementViewModel
var body: some View {
VStack {
Text(viewModel.measurementCountDownDetails)
.font(.body)
Text(viewModel.measurementCountDown)
.font(.title)
}
.onAppear {
viewModel.setTimer()
}
}
}
Edit
After investigation, this seems to be related to how it is being presented. Cause if it's a single view this code works but I am actually presenting this as a sheet. (Still cannot understand why would it make a difference..)
struct QWBPDStartButtonView: View {
#ObservedObject private var viewModel: QWBPDStartButtonViewModel
#State private var startButtonPressed: Bool = false
init(viewModel: QWBPDStartButtonViewModel) {
self.viewModel = viewModel
}
var body: some View {
Button(action: {
self.startButtonPressed = true
}) {
ZStack {
Circle()
.foregroundColor(Color("midGreen"))
Text("Start")
.font(.title)
}
}
.buttonStyle(PlainButtonStyle())
.sheet(isPresented: $startButtonPressed) {
QWMeasurementView(viewModel: QWMeasurementViewModel())
}
}
}

You’re passing in a brand new viewmodel to the sheet’s view.
Try passing in the instance from line 3

Related

NavigationLink causing ChildView to reinitialize whenever ParentView is visible again (SwiftUI)

I currently have an app where the user goes through pages of lists to make multiple selections from. (using NavigationLinks)
PROBLEM: The functionality is fine if the user simply makes their selection then moves on, however the issue is when the user goes back THEN forward to a page. I.e. ViewA -> ViewB -> View->A -> ViewB.
Doing this causes ViewB to reinitialize and delete all previous selections on that page, even if ViewA didn't update.
Note that using the back button preserves selections as expected.
EXPECTED BEHAVIOR:
I want to preserve states through navigation of these pages.
ViewA:
struct YouthEventCheckInView: View {
#StateObject var trackable = TrackableMetricsManager(metricType: TrackableMetricType.Event, isCheckin: true)
#StateObject var event = CustomMetricManager()
#StateObject var checkInViewModel = CheckInViewModel()
#State private var moveToDailyStressorsView = false
#State private var newEvent = false
var body: some View {
NavigationView {
ZStack {
ScrollView {
VStack(alignment: .leading) {
NavigationLink(destination: YouthStressorCheckInView(checkInViewModel: checkInViewModel), isActive: $moveToDailyStressorsView) {
EmptyView()
}
Button {
moveToDailyStressorsView = true
} label: {
HStack {
Text("Next")
}
.navigationTitle("Major Life Events")
.onAppear {
trackable.observeEvents()
}
}
}
ViewB (ViewC is same setup as this one):
struct YouthStressorCheckInView: View {
#StateObject var trackable = TrackableMetricsManager(metricType: TrackableMetricType.Stressor, isCheckin: true)
#StateObject var stressor = CustomMetricManager()
#ObservedObject var checkInViewModel: CheckInViewModel
#State private var moveToCopingStrategiesView = false
#State private var newStressor = false
var body: some View {
ZStack {
ScrollView {
VStack(alignment: .leading) {
NavigationLink(destination: YouthStrategyCheckInView(checkInViewModel: checkInViewModel), isActive: $moveToCopingStrategiesView) {
EmptyView()
}
Button( action: {
moveToCopingStrategiesView = true
}, label: {
HStack {
Text("Next")
})
}
}
.navigationTitle("Daily Stressors")
.onAppear {
trackable.observeStressors()
}
}
ViewModel for these views:
class ViewCheckInViewModel: ObservableObject {
struct Item: Hashable {
let name: String
let color: String
let image: String
}
#Published var loading = false
#Published var majorLifeEvents: [Item] = []
#Published var dailyStressors: [Item] = []
#Published var copingStrategies: [Item] = []
#Published var date: String = ""
func loadData(withDataStore dataStore: AWSAppSyncDataStore, checkInId: String) {
self.checkInId = checkInId
loadDate(withDataStore: dataStore)
loadMajorLifeEvents(withDataStore: dataStore)
loadDailyStressors(withDataStore: dataStore)
loadCopingStrategies(withDataStore: dataStore)
}
private func loadMajorLifeEvents(withDataStore dataStore: AWSAppSyncDataStore) {
...
}
private func loadDailyStressors(withDataStore dataStore: AWSAppSyncDataStore) {
...
}
private func loadCopingStrategies(withDataStore dataStore: AWSAppSyncDataStore) {
...
}
NOTE: Obviously some code is taken out, I left the things that I thought were necessary for this issue

Blinking symbol with didSet in SwiftUI

This is synthesized from a much larger app. I'm trying to blink an SF symbol in SwiftUI by activating a timer in a property's didSet. A print statement inside timer prints the expected value but the view doesn't update.
I'm using structs throughout my model data and am guessing this will have something to do with value vs. reference types. I'm trying to avoid converting from structs to classes.
import SwiftUI
import Combine
#main
struct TestBlinkApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class Model: ObservableObject {
#Published var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Item1"), Item("Item2"), Item("Item3"), Item("Item4")]
return model
}
}
struct Item {
static let ledBlinkTimer: TimeInterval = 0.5
private let ledTimer = Timer.publish(every: ledBlinkTimer, tolerance: ledBlinkTimer * 0.1, on: .main, in: .default).autoconnect()
private var timerSubscription: AnyCancellable? = nil
var name: String
var isLEDon = false
var isLedBlinking = false {
didSet {
var result = self
print("in didSet: isLedBlinking: \(result.isLedBlinking) isLEDon: \(result.isLEDon)")
guard result.isLedBlinking else {
result.isLEDon = true
result.ledTimer.upstream.connect().cancel()
print("Cancelling timer.")
return
}
result.timerSubscription = result.ledTimer
.sink { _ in
result.isLEDon.toggle()
print("\(result.name) in ledTimer isLEDon: \(result.isLEDon)")
}
}
}
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.foregroundColor(model.items[0].isLEDon ? .green : color)
Button("Toggle") {
model.items[0].isLedBlinking.toggle()
}
}
.foregroundColor(color)
}
}
Touching the "Toggle" button starts the timer that's suppose to blink the circle. The print statement shows the value changing but the view doesn't update. Why??
You can use animation to make it blink, instead of a timer.
The model of Item gets simplified, you just need a boolean variable, like this:
struct Item {
var name: String
// Just a toggle: blink/ no blink
var isLedBlinking = false
init(_ name: String) {
self.name = name
}
}
The "hard work" is done by the view: changing the variable triggers or stops the blinking. The animation does the magic:
struct ContentView: View {
#StateObject var model = Model.loadData
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
.padding()
// Change based on isLedBlinking
Image(systemName: model.items[0].isLedBlinking ? "circle.fill" : "circle")
.font(.largeTitle)
.foregroundColor(model.items[0].isLedBlinking ? .green : color)
// Animates the view based on isLedBlinking: when is blinking, blinks forever, otherwise does nothing
.animation(model.items[0].isLedBlinking ? .easeInOut.repeatForever() : .default, value: model.items[0].isLedBlinking)
.padding()
Button("Toggle: \(model.items[0].isLedBlinking ? "Blinking" : "Still")") {
model.items[0].isLedBlinking.toggle()
}
.padding()
}
.foregroundColor(color)
}
}
A different approach with a timer:
struct ContentView: View {
#StateObject var model = Model.loadData
let timer = Timer.publish(every: 0.25, tolerance: 0.1, on: .main, in: .common).autoconnect()
let color = Color(UIColor.label)
public var body: some View {
VStack {
Text(model.items[0].name)
if model.items[0].isLedBlinking {
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.onReceive(timer) { _ in
model.items[0].isLEDon.toggle()
}
.foregroundColor(model.items[0].isLEDon ? .green : color)
} else {
Image(systemName: model.items[0].isLEDon ? "circle.fill" : "circle")
.foregroundColor(model.items[0].isLEDon ? .green : color)
}
Button("Toggle: \(model.items[0].isLedBlinking ? "Blinking" : "Still")") {
model.items[0].isLedBlinking.toggle()
}
}
.foregroundColor(color)
}
}

SwiftUI: Singleton class not updating view

New to SwiftUI... I have the following simplified code. The intended functionality is to be able to navigate between View1() and View2(), using a singleton class to keep track of this navigation.
I suspect that maybe I needed to add #Published to my show_view_2 variable, but I want to keep it in App Storage. Also, I understand this isn't the best way to switch between views, but I only am using this method because it's a minimally reproduced example.
Why isn't the below code working, and how can I make it work?
class Player {
static var player = Player()
#AppStorage("show_view_2") var show_view_2: Bool = false
}
struct ContentView: View {
var body: some View {
if(Player.player.show_view_2) {
View2()
} else {
Text("Go to View2")
.onTapGesture {
Player.player.show_view_2 = true
}
}
}
}
struct View2: View {
var body: some View {
Text("Back to View1")
.onTapGesture {
Player.player.show_view_2 = false
}
}
}
For SwiftUI to know to update, Player should be an ObservableObject. Also, it'll need to be accessed using a property wrapper (either #StateObject or #ObservedObject) on the View:
class Player : ObservableObject {
static var player = Player()
#AppStorage("show_view_2") var show_view_2: Bool = false
}
struct ContentView: View {
#StateObject private var player = Player.player
var body: some View {
if player.show_view_2 {
View2()
} else {
Text("Go to View2")
.onTapGesture {
player.show_view_2 = true
}
}
}
}
struct View2: View {
#StateObject private var player = Player.player
var body: some View {
Text("Back to View1")
.onTapGesture {
player.show_view_2 = false
}
}
}
In general, I'd recommend not using a singleton and instead passing an instance of the object explicitly between views -- this will be more testable and flexible over time:
class Player : ObservableObject {
#AppStorage("show_view_2") var show_view_2: Bool = false
}
struct ContentView: View {
#StateObject private var player = Player()
var body: some View {
if player.show_view_2 {
View2(player: player)
} else {
Text("Go to View2")
.onTapGesture {
player.show_view_2 = true
}
}
}
}
struct View2: View {
#ObservedObject var player : Player
var body: some View {
Text("Back to View1")
.onTapGesture {
player.show_view_2 = false
}
}
}

How to update an element of an array in an Observable Object

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.
// model
struct Activity: Identifiable {
var id = UUID()
var name: String
var completeDescription: String
var completions: Int = 0
}
// view model
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
}
// view
struct ActivityView: View {
#StateObject var viewModel = ActivityViewModel()
#State private var showingAddEditActivityView = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.activities, id: \.id) {
activity in
NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
}
}
.navigationBarItems(trailing: Button("Add new"){
self.showingAddEditActivityView.toggle()
})
.navigationTitle(Text("Activity List"))
}
.sheet(isPresented: $showingAddEditActivityView) {
AddEditActivityView(copyViewModel: self.viewModel)
}
}
}
// detail view
struct ActivityDetailView: View {
#State var activity: Activity
#ObservedObject var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
}!
tempActivity.completions += 1
}
}
// Add new activity view (doesn't have anything to do with question)
struct AddEditActivityView: View {
#ObservedObject var copyViewModel : ActivityViewModel
#State private var activityName: String = ""
#State private var description: String = ""
var body: some View {
VStack {
TextField("Enter an activity", text: $activityName)
TextField("Enter an activity description", text: $description)
Button("Save"){
// I want this to be outside of my view
saveActivity()
}
}
}
func saveActivity() {
copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
print(copyViewModel.activities)
}
}
In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.
Thanks for any assistance or insight.
The problem is here:
struct ActivityDetailView: View {
#State var activity: Activity
...
This needs to be a #Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the #Binding, you can get rid of it.
// detail view
struct ActivityDetailView: View {
#Binding var activity: Activity /// here!
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
}
Text("\(activity.completeDescription)")
}
}
}
But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:
/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
NavigationLink(destination: ActivityDetailView(activity: $activity)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
And for iOS 14 or below, you'll need to loop over indices instead. But it works.
/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity) in
NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
You are changing and increment the value of tempActivity so it will not affect the main array or data source.
You can add one update function inside the view model and call from view.
The view model is responsible for this updation.
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
func updateCompletionCount(for id: UUID) {
if let index = activities.firstIndex(where: {$0.id == id}) {
self.activities[index].completions += 1
}
}
}
struct ActivityDetailView: View {
var activity: Activity
var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
self.viewModel.updateCompletionCount(for: activity.id)
}
}
Not needed #State or #ObservedObject for details view if don't have further action.

SwiftUI - How to change/access #Published var value via toggle from View?

I'm making a simple password generation app. The idea was simple.
There are 2 toggles in the View that are bound to ObservableObject class two #Published bool vars. The class should return a different complexity of generated password to a new View dependently on published vars true/false status after clicking generate button.
Toggles indeed change published var status to true/false (when I print it on toggle) and the destination view does show the password for false/false combination but for some reason, after clicking generate, they always stay false unless I manually change their value to true. Can toggles change permanently the value of #Published var values somehow?
I can't seem to find a suitable workaround. Any solutions how to make this work?
MainView
import SwiftUI
struct MainView: View {
#ObservedObject var manager = PasswordManager()
var body: some View {
NavigationView() {
VStack {
ZStack {
Toggle(isOn: $manager.includeNumbers) {
Text("Include numbers")
.italic()
}
}
ZStack {
Toggle(isOn: $manager.includeCharacters) {
Text("Include special characters")
.italic()
}
}
NavigationLink(destination: PasswordView(), label: {
Text("Generate")
})
}
.padding(80)
}
}
PasswordManager
import Foundation
class PasswordManager: ObservableObject {
#Published var includeNumbers = false
#Published var includeCharacters = false
let letters = ["A", "B", "C", "D", "E"]
let numbers = ["1", "2", "3", "4", "5"]
let specialCharacters = ["!", "#", "#", "$", "%"]
var password: String = ""
func generatePassword() -> String {
password = ""
if includeNumbers == false && includeCharacters == false {
for _ in 1...5 {
password += letters.randomElement()!
}
}
else if includeNumbers && includeCharacters {
for _ in 1...3 {
password += letters.randomElement()!
password += numbers.randomElement()!
password += specialCharacters.randomElement()!
}
}
return password
}
}
View that shows password
import SwiftUI
struct PasswordView: View {
#ObservedObject var manager = PasswordManager()
var body: some View {
Text(manager.generatePassword())
}
}
The problem is caused by the fact that your PasswordView creates its own PasswordManager. Instead, you need to inject it from the parent view.
You should never initialise an #ObservedObject inside the View itself, since whenever the #ObservedObject's objectWillChange emits a value, it will reload the view and hence create a new object. You either need to inject the #ObservedObject or declare it as #StateObject if you are targeting iOS 14.
PasswordView needs to have PasswordManager injected from MainView, since they need to use the same instance to have shared state. In MainView, you can use #StateObject if targeting iOS 14, otherwise you should inject PasswordManager even there.
import SwiftUI
struct PasswordView: View {
#ObservedObject private var manager: PasswordManager
init(manager: PasswordManager) {
self.manager = manager
}
var body: some View {
Text(manager.generatePassword())
}
}
struct MainView: View {
#StateObject private var manager = PasswordManager()
var body: some View {
NavigationView() {
VStack {
ZStack {
Toggle(isOn: $manager.includeNumbers) {
Text("Include numbers")
.italic()
}
}
ZStack {
Toggle(isOn: $manager.includeCharacters) {
Text("Include special characters")
.italic()
}
}
NavigationLink(destination: PasswordView(manager: manager), label: {
Text("Generate")
})
}
.padding(80)
}
}
}