ObservableObject text not updating even with objectWillChange - multiple classes - swift

I am currently writing a utility, and as part of this, a string is received (through WebSockets and the Starscream library) and the string value is then displayed in the SwiftUI view (named ReadingsView).
The structure of the code is as follows - there are two classes, the WSManager class which manages the WebSocket connections, and the GetReadings class which has the ObservableObject property, which manages and stores the readings.
When the string is received using the didReceive method in the WSManager class, it is decoded by the decodeText method in the WSManager class, which then calls the parseReceivedStrings method in the GetReadings class.
class WSManager : WebSocketDelegate {
func didReceive(event: WebSocketEvent, client: WebSocket) {
case .text(let string):
// Decode the text
DispatchQueue.main.async {
self.decodeText(recvText: string)
print("Received text: \(string)")
}
recvString = string
}
func decodeText(recvText: String) {
// If the message is allowed, then pass it to getReadings
print("Decoding")
if recvText.hasPrefix("A=") {
getReadings.parseReceivedStrings(recvText: recvText, readingType: .allreadings)
print("All readings received")
} else if recvText.hasPrefix("T = ") {
getReadings.parseReceivedStrings(recvText: recvText, readingType: .temperature)
} else if recvText.hasPrefix("P = ") {
getReadings.parseReceivedStrings(recvText: recvText, readingType: .pressure)
} else if recvText.hasPrefix("H = ") {
getReadings.parseReceivedStrings(recvText: recvText, readingType: .humidity)
} else {
print("Unrecognised string.")
}
}
}
enum ReadingType {
case allreadings
case temperature
case pressure
case humidity
}
class GetReadings: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
#Published var temp: Float = 0.0 {
willSet {
print("Temp new = " + String(temp))
objectWillChange.send()
}
}
#Published var pressure: Float = 0.0 {
willSet {
print("Pressure new = " + String(pressure))
objectWillChange.send()
}
}
#Published var humidity: Float = 0.0 {
willSet {
print("Humidity new = " + String(humidity))
objectWillChange.send()
}
}
func getAll() {
//print(readings.count)
//print(readings.count)
wsManager.socket.write(string: "get_all")
}
func parseReceivedStrings (recvText: String, readingType: ReadingType) {
if readingType == .allreadings {
// Drop first two characters
let tempText = recvText.dropFirst(2)
// Split the string into components
let recvTextArray = tempText.components(separatedBy: ",")
// Deal with the temperature
temp = (recvTextArray[0] as NSString).floatValue
// Pressure
pressure = (recvTextArray[1] as NSString).floatValue
// Humidity
humidity = (recvTextArray[2] as NSString).floatValue
}
}
}
When the values are parsed, I would expect the values in the ReadingsView to update instantly, as I have marked the variables as #Published, as well as using the objectWillChange property to manually push the changes. The print statements within the willSet parameters reflect the new values, but the text does not update. In the ReadingsView code, I have compensated for this by manually calling the parseReceivedString method when the refresh button is pressed (this is used as part of the WebSocket protocol to send the request), but this causes the readings to be one step behind where they should be. Ideally, I would want the readings to update instantly once they have been parsed in the method described in the previous paragraph.
struct ReadingsView: View {
#ObservedObject var getReadings: GetReadings
var body: some View {
VStack {
Text(String(self.getReadings.temp))
Text(String(self.getReadings.pressure))
Text(String(self.getReadings.humidity))
Button(action: {
print("Button clicked")
self.getReadings.getAll()
self.getReadings.parseReceivedStrings(recvText: wsManager.recvString, readingType: .allreadings)
}) {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.system(size: 30))
}
.padding()
}
}
}
I am wondering whether I have used the right declarations or whether what I am trying to do is incompatible with using multiple classes - this is my first time using SwiftUI so I may be missing a few nuances. Thank you for your help in advance.
Edited - added code
ContentView
struct ContentView: View {
#State private var selection = 0
var body: some View {
TabView(selection: $selection){
ReadingsView(getReadings: GetReadings())
.tabItem {
VStack {
Image(systemName: "thermometer")
Text("Readings")
}
} .tag(0)
SetupView()
.tabItem {
VStack {
Image(systemName: "slider.horizontal.3")
Text("Setup")
}
}
.tag(1)
}
}
}

If you are using ObservableObject you don't need to write objectWillChange.send() in willSet of your Published properties.
Which means you can as well remove:
let objectWillChange = ObservableObjectPublisher()
which is provided by default in ObservableObject classes.
Also make sure that if you're updating your #Published properties you do it in the main queue (DispatchQueue.main). Asynchronous requests are usually performed in background queues and you may try to update your properties in the background which will not work.
You don't need to wrap all your code in DispatchQueue.main - just the part which updates the #Published property:
DispatchQueue.main.async {
self.humidity = ...
}
And make sure you create only one GetReadings instance and share it across your views. For that you can use an #EnvironmentObject.
In the SceneDelegate where you create your ContentView:
// create GetReadings only once here
let getReadings = GetReadings()
// pass it to WSManager
// ...
// pass it to your views
let contentView = ContentView().environmentObject(getReadings)
Then in your ReadingsView you can access it like this:
#EnvironmentObject var getReadings: GetReadings
Note that you don't need to create it in the TabView anymore:
TabView(selection: $selection) {
ReadingsView()
...
}

Related

Is there a way to pass mutating closures as arguments to a SwiftUI View?

The Idea
In one of the views of my application I need to mutate some data. To make the code clear, testable and just to test how far I can get without logic in ViewModels, I've moved the mutating logic to the Model layer.
Say this is my Model
struct Model {
var examples: [Example] = []
/* lots of other irrelevant properties and a constructor here */
}
struct Example: Identifiable {
var id = UUID()
var isEnabled: Bool = true
/* other irrelevant properties and a constructor here */
}
And the function that mutates stuff is
// MARK: mutating methods
extension Model {
mutating func disableExamples(with ids: Set<UUID>) {
// do whatever, does not matter now
}
mutating func enableExamples(with ids: Set<UUID>) {
// do whatever, does not matter now
}
}
Now, let's display it in views, shall we?
struct ContentView: View {
#State private var model = Model()
var body: some View {
VStack {
Text("That's the main view.")
// simplified: no navigation/sheets/whatever
ExampleMutatingView(examples: $model.examples)
}
}
}
struct ExampleMutatingView: View {
#Binding var examples: [Example]
var body: some View {
VStack {
Text("Here you mutate the examples.")
List($examples) {
// TODO: do stuff
}
}
}
}
The attempt
Since I don't want to put the whole Model into the ExampleMutatingView, both because I don't need the whole thing and due to performance reasons, I tried to supply the view with necessary methods.
I've also added the ability to select examples by providing a State variable.
struct ContentView: View {
#State private var model = Model()
var body: some View {
VStack {
Text("That's the main view.")
// simplified: no navigation/sheets/whatever
ExampleMutatingView(examples: $model.examples,
operationsOnExamples: (enable: model.enableExamples, disable: model.disableExamples))
}
}
}
struct ExampleMutatingView: View {
#Binding var examples: [Example]
let operationsOnExamples: (enable: ((Set<UUID>) -> Void, disable: (Set<UUID>) -> Void)
#State private var multiSelection = Set<UUID>()
var body: some View {
VStack {
Text("Here you mutate the examples.")
List($examples, selection: $multiSelection) { example in
Text("\(example.id)")
}
HStack {
Button { operationsOnExamples.enable(with: multiSelection) } label: { Text("Enable selected") }
Button { operationsOnExamples.disable(with: multiSelection) } label: { Text("Disable selected") }
}
}
}
}
The problem
The thing is, with such setup the ContentView greets me with Cannot reference 'mutating' method as function value error. Not good, but mysterious for me for the very reason that fixes it: supplying the actual Model into the view.
The (non ideal) solution
Showing only the parts that changed
// ContentView
1. ExampleMutatingView(model: $model)
// ExampleMutatingView
1. #Binding var model: Model
2. List($model.examples/*...*/)
3. Button { model.enableExamples(with: multiSelection) } /*...*/
4. Button { model.disableExamples(with: multiSelection) } /*...*/
The discussion
Why is it the case? The only difference I see and cannot explain accurately between these two is that supplying the model might give the method access to its self, which is, otherwise, not available. If that's the case, maybe wrapping the methods in some kind of closure with an [unowned self] would help?
I'm fresh to the topic of self in Swift, so I honestly have no idea.
TL;DR: why does it work when I supply the object defining the methods, but does not when I supply only the methods?

Code composition of SwiftUI when the model is passed from parent and dependend on parents setup

How to structure the #ObservedObject, #State, #StateObject for a situation like this
i have a box which displays next and previous appointment (NextPreviousAppointmentBoxUI)
this NextPreviousAppointmentBoxUI uses a generic appointment box (SingleAppointmentBoxUI), which loads and appointment by the configuration of the model (FAppointmentsStore) provided by the parent
this model is however dependent on the appointment given to NextPreviousAppointmentBoxUI from above
i restructred the code several times, but still not sure how the SwiftUI composition should look like in such case
how to avoid setupAndLoadModels be called .onChange and .onAppear and be called just once? Is there a more suitabl composition?
class FAppointmentsStore: ObservableObject {
#Published state: State = .loading
...
}
class FAppointment: RealmObject {
...
}
struct SingleAppointmentBoxUI: View {
#ObservedObject var model: FAppointmentsStore
var body: some View {
Group {
switch model.loadingState {
case .loading:
Text("Loading")
case .loaded:
if let appointment = model.appointment {
Text("Display appointment \(appointment.title)")
}
case .error:
Text("Error")
}
}
}
}
struct NextPreviousAppointmentBoxUI: View {
var appointment: FAppointment
#State var nextModel: FAppointmentsStore?
#State var previousModel: FAppointmentsStore?
var body: some View {
VStack {
if let nextModel = nextModel, let previousModel = previousModel {
Text("Previous")
SingleAppointmentBoxUI(model: previousModel)
Divider()
Text("Next")
SingleAppointmentBoxUI(model: nextModel)
}
}
.onAppear {
setupAndLoadModels(appointment)
}
.onChange(of: appointment, perform: { newValue in
setupAndLoadModels(newValue)
})
}
func setupAndLoadModels(_ appointment: FAppointment?) {
guard let user = appointment.user, let start = appointment.start, let end = appointment.end else {
return
}
nextModel = FAppointmentsStore(user: user, startFrom: end, limit: 1, sort: .startAsc)
previousModel = FAppointmentsStore(user: user, startTo: start, limit: 1, sort: .startDesc)
nextModel?.loadContent()
previousModel?.loadContent()
}
}

How can I make a State wrapper outside of View in SwiftUI?

I know that State wrappers are for View and they designed for this goal, but I wanted to try build and test some code if it is possible, my goal is just for learning purpose,
I have 2 big issues with my code!
Xcode is unable to find T.
How can I initialize my state?
import SwiftUI
var state: State<T> where T: StringProtocol = State(get: { state }, set: { newValue in state = newValue })
struct ContentView: View {
var body: some View {
Text(state)
}
}
Update: I could do samething for Binding here, Now I want do it for State as well with up code
import SwiftUI
var state2: String = String() { didSet { print(state2) } }
var binding: Binding = Binding.init(get: { state2 }, set: { newValue in state2 = newValue })
struct ContentView: View {
var body: some View {
TextField("Enter your text", text: binding)
}
}
If I could find the answer of my issue then, i can define my State and Binding both outside of View, 50% of this work done and it need another 50% for State Wrapper.
New Update:
import SwiftUI
var state: State<String> = State.init(initialValue: "Hello") { didSet { print(state.wrappedValue) } }
var binding: Binding = Binding.init(get: { state.wrappedValue }, set: { newValue in state = State(wrappedValue: newValue) })
struct ContentView: View {
var body: some View {
Text(state) // <<: Here is the issue!
TextField("Enter your text", text: binding)
}
}
Even if you create a State wrapper outside a view, how will the view know when to refresh its body?
Without a way to notify the view, your code will do the same as:
struct ContentView: View {
var body: some View {
Text("Hello")
}
}
What you can do next depends on what you want to achieve.
If all you need is a way to replicate the State behaviour outside the view, I recommend you take a closer look at the Combine framework.
An interesting example is CurrentValueSubject:
var state = CurrentValueSubject<String, Never>("state1")
It stores the current value and also acts as a Publisher.
What will happen if we use it in a view that doesn't observe anything?
struct ContentView: View {
var body: some View {
Text(state.value)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
state.value = "state2"
}
}
}
}
The answer is: nothing. The view is drawn once and, even if the state changes, the view won't be re-drawn.
You need a way to notify the view about the changes. In theory you could do something like:
var state = CurrentValueSubject<String, Never>("state1")
struct ContentView: View {
#State var internalState = ""
var body: some View {
Text(internalState)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
state.value = "state2"
}
}
.onReceive(state) {
internalState = $0
}
}
}
But this is neither elegant nor clean. In these cases we should probably use #State:
struct ContentView: View {
#State var state = "state1"
var body: some View {
Text(state)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
state = "state2"
}
}
}
}
To sum up, if you need a view to be refreshed, just use the native SwiftUI property wrappers (like #State). And if you need to declare state values outside the view, use ObservableObject + #Published.
Otherwise there is a huge Combine framework which does exactly what you want. I recommend you take a look at these links:
Combine: Getting Started
Using Combine

SwiftUI: How to only run code when the user stops typing in a TextField?

so I'm trying to make a search bar that doesn't run the code that displays the results until the user stops typing for 2 seconds (AKA it should reset a sort of timer when the user enters a new character). I tried using .onChange() and an AsyncAfter DispatchQueue and it's not working (I think I understand why the current implementation isn't working, but I'm not sure I'm even attack this problem the right way)...
struct SearchBarView: View {
#State var text: String = ""
#State var justUpdatedSuggestions: Bool = false
var body: some View {
ZStack {
TextField("Search", text: self.$text).onChange(of: self.text, perform: { newText in
appState.justUpdatedSuggestions = true
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: {
appState.justUpdatedSuggestions = false
})
if justUpdatedSuggestions == false {
//update suggestions
}
})
}
}
}
The possible approach is to use debounce from Combine framework. To use that it is better to create separated view model with published property for search text.
Here is a demo. Prepared & tested with Xcode 12.4 / iOS 14.4.
import Combine
class SearchBarViewModel: ObservableObject {
#Published var text: String = ""
}
struct SearchBarView: View {
#StateObject private var vm = SearchBarViewModel()
var body: some View {
ZStack {
TextField("Search", text: $vm.text)
.onReceive(
vm.$text
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
) {
guard !$0.isEmpty else { return }
print(">> searching for: \($0)")
}
}
}
}
There are usually two most common techniques used when dealing with delaying search query calls: throttling or debouncing.
To implement these concepts in SwiftUI, you can use Combine frameworks throttle/debounce methods.
An example of that would look something like this:
import SwiftUI
import Combine
final class ViewModel: ObservableObject {
private var disposeBag = Set<AnyCancellable>()
#Published var text: String = ""
init() {
self.debounceTextChanges()
}
private func debounceTextChanges() {
$text
// 2 second debounce
.debounce(for: 2, scheduler: RunLoop.main)
// Called after 2 seconds when text stops updating (stoped typing)
.sink {
print("new text value: \($0)")
}
.store(in: &disposeBag)
}
}
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
TextField("Search", text: $viewModel.text)
}
}
You can read more about Combine and throttle/debounce in official documentation: throttle, debounce

'Fatal error: index out of range' when deleting bound object in view

I am having some trouble avoiding index out of range errors when modifying an array that a child view depends on a bound object from.
I have a parent view called WorkoutList. WorkoutList has an EnvironmentObject of ActiveWorkoutStore. ActiveWorkoutStore is an ObservableObject that has an array of Workout objects. I have a list of active workouts being retrieved from ActiveWorkoutStore. I'm using a ForEach loop to work with the indices of these active workouts and pass an object binding to a child view called EditWorkout as a destination for a NavigationLink. EditWorkout has a button to finish a workout, which removes it from ActiveWorkoutStore's array of workouts and adds it to WorkoutHistoryStore. I'm running into trouble when I remove this object from ActiveWorkoutStore's activeWorkouts array, immediately causing an index out of range error. I'm suspecting this is because the active view relies on a bound object that I've just deleted. I've tried a couple permutations of this, including passing a workout to EditWorkout, then using its id to reference a workout in ActiveWorkoutStore to perform my operations, but run into similar troubles. I've seen a lot of examples online that follow this pattern of leveraging ForEach to iterate over indices and I've mirrored it as best I can tell, but I suspect I may be missing a nuance to the approach.
I've attached code samples below. Let me know if you have any questions or if there's anything else I should include! Thanks in advance for your help!
WorkoutList (Parent View)
import SwiftUI
struct WorkoutList: View {
#EnvironmentObject var activeWorkoutsStore: ActiveWorkoutStore
#State private var addExercise = false
#State private var workoutInProgress = false
var newWorkoutButton: some View {
Button(action: {
self.activeWorkoutsStore.newActiveWorkout()
}) {
Text("New Workout")
Image(systemName: "plus.circle")
}
}
var body: some View {
NavigationView {
Group {
if activeWorkoutsStore.activeWorkouts.isEmpty {
Text("No active workouts")
} else {
List {
ForEach(activeWorkoutsStore.activeWorkouts.indices.reversed(), id: \.self) { activeWorkoutIndex in
NavigationLink(destination: EditWorkout(activeWorkout: self.$activeWorkoutsStore.activeWorkouts[activeWorkoutIndex])) {
Text(self.activeWorkoutsStore.activeWorkouts[activeWorkoutIndex].id.uuidString)
}
}
}
}
}
.navigationBarTitle(Text("Active Workouts"))
.navigationBarItems(trailing: newWorkoutButton)
}
}
}
EditWorkout (Child View)
//
// EditWorkout.swift
// workout-planner
//
// Created by Dominic Minischetti III on 11/2/19.
// Copyright © 2019 Dominic Minischetti. All rights reserved.
//
import SwiftUI
struct EditWorkout: View {
#EnvironmentObject var workoutHistoryStore: WorkoutHistoryStore
#EnvironmentObject var activeWorkoutStore: ActiveWorkoutStore
#EnvironmentObject var exerciseStore: ExerciseStore
#Environment(\.presentationMode) var presentationMode
#State private var addExercise = false
#Binding var activeWorkout: Workout
var currentDayOfWeek: String {
let weekdayIndex = Calendar.current.component(.weekday, from: Date())
return Calendar.current.weekdaySymbols[weekdayIndex - 1]
}
var chooseExercisesButton: some View {
Button (action: {
self.addExercise = true
}) {
HStack {
Image(systemName: "plus.square")
Text("Choose Exercises")
}
}
.sheet(isPresented: self.$addExercise) {
AddWorkoutExercise(exercises: self.$activeWorkout.exercises)
.environmentObject(self.exerciseStore)
}
}
var saveButton: some View {
Button(action: {
self.workoutHistoryStore.addWorkout(workout: self.$activeWorkout.wrappedValue)
self.activeWorkoutStore.removeActiveWorkout(workout: self.$activeWorkout.wrappedValue)
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Finish Workout")
}
.disabled(self.$activeWorkout.wrappedValue.exercises.isEmpty)
}
var body: some View {
Form {
Section(footer: Text("Choose which exercises are part of this workout")) {
chooseExercisesButton
}
Section(header: Text("Exercises")) {
if $activeWorkout.wrappedValue.exercises.isEmpty {
Text("No exercises")
} else {
ForEach(activeWorkout.exercises.indices, id: \.self) { exerciseIndex in
NavigationLink(destination: EditWorkoutExercise(exercise: self.$activeWorkout.exercises[exerciseIndex])) {
VStack(alignment: .leading) {
Text(self.activeWorkout.exercises[exerciseIndex].name)
Text("\(self.activeWorkout.exercises[exerciseIndex].sets.count) Set\(self.activeWorkout.exercises[exerciseIndex].sets.count == 1 ? "" : "s")")
.font(.footnote)
.opacity(0.5)
}
}
}
saveButton
}
}
}
.navigationBarTitle(Text("Edit Workout"), displayMode: .inline )
}
}
ActiveWorkoutStore
import Foundation
import Combine
class ActiveWorkoutStore: ObservableObject {
#Published var activeWorkouts: [Workout] = []
func newActiveWorkout() {
activeWorkouts.append(Workout())
}
func saveActiveWorkout(workout: Workout) {
let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id })!
activeWorkouts[workoutIndex] = workout
}
func removeActiveWorkout(workout: Workout) {
if let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id }) {
activeWorkouts.remove(at: workoutIndex)
}
}
}
Workout
import SwiftUI
struct Workout: Hashable, Codable, Identifiable {
var id = UUID()
var date = Date()
var exercises: [WorkoutExercise] = []
}
ForEach<Range> is constant range container (pay attention on below description of constructor), it is not allowed to modify it after construction.
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
If you want to modify container, you have to use ForEach(activeWorkout.exercises)