SwiftUI persist observable objects - swift

I am trying to understand data flows in swiftUI.
I have created a ViewModel which holds some data from a network request.
import SwiftUI
struct breakdown: Decodable {
var sms: Int
var im: Int
var total: Int
}
struct weeklyOverviewStruct: Decodable {
var data: [breakdown]
}
class WeeklyOverviewViewModel: ObservableObject {
#Published var overviewData: weeklyOverviewStruct?
func getBreakdown(){
semaphore.wait()
Network.Request(method: .GET , parameters: nil, endPoint: "rest/operator/stats/weekly/breakdown", completion: {
result,error in
if(error == nil){
do {
let breakdown = try JSONDecoder().decode(weeklyOverviewStruct.self, from: result!)
DispatchQueue.main.async {
self.overviewData = breakdown
}
}catch{
print("Json Error")
}
}else{
print("\(error!)")
}
})
}
}
It my understanding that I can then observe this ViewModel in a second view and the view will re-draw if the ViewModel changes:
struct SecondView: View {
#ObservedObject var WeeklyOverviewVM = WeeklyOverviewViewModel()
var body: some View {
Text(String.init(describing: WeeklyOverviewVM.overviewData?.data[0].total))
}
}
If however the second view is presented after the getBreakDown() is called the observedObject is nil.
Is there a way of persisting the data so that even if a view is presented after the getBreakdown() function is called, the data from the previous request is observable in the second view?

The SecondView now creates view model every time it is instantiated, so if you want previously fetched data persist, it needs to store that view model instance somewhere outside of SecondView and inject it on its creation, like
struct SecondView: View {
// only declare
#ObservedObject var WeeklyOverviewVM: WeeklyOverviewViewModel
and somewhere in parent view
...
// inject WeeklyOverviewViewModel from own property
SecondView(WeeklyOverviewVM: self.secondViewVM)
...

Related

How to trigger automatic SwiftUI Updates with #ObservedObject using MVVM

I have a question regarding the combination of SwiftUI and MVVM.
Before we start, I have read some posts discussing whether the combination of SwiftUI and MVVM is necessary. But I don't want to discuss this here, as it has been covered elsewhere. I just want to know if it is possible and, if yes, how. :)
So here comes the code. I tried to add the ViewModel Layer in between the updated Object class that contains a number that should be updated when a button is pressed. The problem is that as soon as I put the ViewModel Layer in between, the UI does not automatically update when the button is pressed.
View:
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#ObservedObject var numberStorage = NumberStorage()
var body: some View {
VStack {
// Text("\(viewModel.getNumberObject().number)")
// .padding()
// Button("IncreaseNumber") {
// viewModel.increaseNumber()
// }
Text("\(numberStorage.getNumberObject().number)")
.padding()
Button("IncreaseNumber") {
numberStorage.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published var number: NumberStorage
init() {
self.number = NumberStorage()
}
func increaseNumber() {
self.number.increaseNumber()
}
func getNumberObject() -> NumberObject {
self.number.getNumberObject()
}
}
Model:
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func getNumberObject() -> NumberObject {
return self.numberObject
}
public func increaseNumber() {
self.numberObject.number+=1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
} ```
Looking forward to your feedback!
I think your code is breaking MVVM, as you're exposing to the view a storage model. In MVVM, your ViewModel should hold only two things:
Values that your view should display. These values should be automatically updated using a binding system (in your case, Combine)
Events that the view may produce (in your case, a button tap)
Having that in mind, your ViewModel should wrap, adapt and encapsulate your model. We don't want model changes to affect the view. This is a clean approach that does that:
View:
struct ContentView: View {
#StateObject // When the view creates the object, it must be a state object, or else it'll be recreated every time the view is recreated
private var viewModel = ViewModel()
var body: some View {
VStack {
Text("\(viewModel.currentNumber)") // We don't want to use functions here, as that will create a new object , as SwiftUI needs the same reference in order to keep track of changes
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
ViewModel:
class ViewModel: ObservableObject {
#Published
private(set) var currentNumber: Int = 0 // Private set indicates this should only be mutated by the viewmodel
private let numberStorage = NumberStorage()
init() {
numberStorage.currentNumber
.map { $0.number }
.assign(to: &$currentNumber) // Here we're binding the current number on the storage to the published var that the view is listening to.`&$` basically assigns it to the publishers address
}
func increaseNumber() {
self.numberStorage.increaseNumber()
}
}
Model:
class NumberStorage {
private let currentNumberSubject = CurrentValueSubject<NumberObject, Never>(NumberObject())
var currentNumber: AnyPublisher<NumberObject, Never> {
currentNumberSubject.eraseToAnyPublisher()
}
func increaseNumber() {
let currentNumber = currentNumberSubject.value.number
currentNumberSubject.send(.init(number: currentNumber + 1))
}
}
struct NumberObject: Identifiable { // I'd not use this, just send and int directly
let id = UUID()
var number = 0
}
It's a known problem. Nested observable objects are not supported yet in SwiftUI. I don't think you need ViewModel+Model here since ViewModel seems to be enough.
To make this work you have to trigger objectWillChange of your viewModel manually when objectWillChange of your model is triggered:
class ViewModel: ObservableObject {
init() {
number.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}.store(in: &cancellables)
}
}
You better listen to only the object you care not the whole observable class if it is not needed.
Plus:
Since instead of injecting, you initialize your viewModel in your view, you better use StateObject instead of ObservedObject. See the reference from Apple docs: Managing model data in your app
One way you could handle this is to observe the publishers in your Storage class and send the objectWillChange publisher when it changes. I have done this in personal projects by adding a class that all my view models inherit from which provides a nice interface and handles the Combine stuff like this:
Parent ViewModel
import Combine
class ViewModel: ObservableObject {
private var cancellables: Set<AnyCancellable> = []
func publish<T>(on publisher: Published<T>.Publisher) {
publisher.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
}
}
Specific ViewModel
class ContentViewModel: ViewModel {
private let numberStorage = NumberStorage()
var number: Int { numberStorage.numberObject.number }
override init() {
super.init()
publish(on: numberStorage.$numberObject)
}
func increaseNumber() {
numberStorage.increaseNumber()
}
}
View
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
var body: some View {
VStack {
Text("\(viewModel.number)")
.padding()
Button("IncreaseNumber") {
viewModel.increaseNumber()
}
}
}
}
Model/Storage
class NumberStorage:ObservableObject {
#Published var numberObject: NumberObject
init() {
numberObject = NumberObject()
}
public func increaseNumber() {
self.numberObject.number += 1
}
}
struct NumberObject: Identifiable {
let id = UUID()
var number = 0
}
This results in the view re-rendering any time Storage.numberObject changes.

Using Combine's Publishers and Subscribers to publish real time HealthKit data?

I've always used delegation in UIKit and WatchKit to communicate between objects as far as passing around data from e.g. a WorkoutManager ViewModel that receives delegate callbacks from HealthKit during an HKworkout for calories, heart rates, to an InterfaceController.
I'm now trying to use Combine and SwiftUI to pass around the same data and am a little lost. I'm using a WorkoutManager class as an environment object that I initialize in my ContentView:
class WorkoutManager: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate, ObservableObject {
#Published var totalEnergyBurned: Double = 0
//How to subscribe to the changes?
//Omitted HealthKit code that queries and pushes data into totalEnergyBurned here
}
struct ContentView: View {
let healthStore = HKHealthStore()
#StateObject var workoutManager = WorkoutManager()
var sessionTypes = [SessionType.Game, SessionType.Practice, SessionType.Pickup]
var body: some View {
List {
ForEach(sessionTypes) { sessionType in
NavigationLink(destination: LiveWorkoutView(sessionType: sessionType)) {
SessionTypeRow(name: sessionType.stringValue)
}
}
}
.navigationTitle("Let's Go!")
.onAppear {
let authorizationStatus = healthStore.authorizationStatus(for: HKSampleType.workoutType())
switch authorizationStatus {
case .sharingAuthorized:
print("sharing authorized")
case .notDetermined:
print("not determined")
HealthKitAuthManager.authorizeHealthKit()
case .sharingDenied:
print("sharing denied")
HealthKitAuthManager.authorizeHealthKit()
default:
print("default in healthStore.authorizationStatus in ContentView")
HealthKitAuthManager.authorizeHealthKit()
}
}
}
}
My goal is to Publish the changes to all of the children of ContentView but I'm not sure how to subscribe to the changes?
import SwiftUI
struct LiveWorkoutView: View {
#State var sessionType: SessionType
#StateObject var workoutManager = WorkoutManager()
var body: some View {
VStack {
Text("\(workoutManager.totalEnergyBurned)")
Button(action: {
workoutManager.stopWorkout()
}) {
Text("End Workout")
}
}
.onAppear {
workoutManager.startWorkout()
workoutManager.sessionType = sessionType
}
.navigationTitle(sessionType.stringValue)
}
}
//How to subscribe to the changes?
You don't. #StateObject injects subscriber in view, so just use workoutManager. totalEnergyBurned property somewhere (where needed) in view body and view will be refreshed automatically once this property changed (eg. you assign new value to it from HealthKit callback.

Subview does not render Published value update in ObservableObject class within a child view

I have a subview which is a List containing a ForEach on a Published array variable from an ObservedObject. The subview is displayed within a TabView and NavigationView on the main ContentView.
However, the List does not render the updated array when it is updated from a network request using the onAppear function.
Placing the network call in another callback, for example, an on-tap gesture on another view element causes the view to update correctly.
I have tried using willSet and calling objectWillChange manually, to no success.
ServerListView.swift:
struct ServerListView: View {
#ObservedObject var viewModel: ViewModel
#State private var searchText = ""
var body: some View {
List {
TextField("Search", text: self.$searchText)
ForEach(self.viewModel.servers) { server in
Text(server.name)
}
}
.navigationBarTitle(Text("Your Servers"))
.onAppear(perform: fetchServers)
}
func fetchServers() {
self.viewModel.getServers()
}
}
ViewModel.swift:
class ViewModel: ObservableObject {
private let service: ApiService
#Published var servers: [Server] = []
init(service: ApiService) {
self.service = service
}
func getServers() {
self.service.getServers { [weak self] result in
switch result {
case .failure(let error):
print(error)
case .success(let serverListModel):
DispatchQueue.main.async {
self?.servers = serverListModel.servers
}
}
}
}
}
If this view is not placed within a parent view, then the functionality works as intended.
Edit for more information:
The ApiService class just handles a URLSession dataTask and the model conforms to Decodable to decode from JSON.
The parent View creates ServerListView as follows in the example, where service.authenticated is a Bool which is set true or false depending on whether the client is authenticated with the API. I have tested without any other code, just by creating the ServerListView inside the body and nothing else. The same outcome is seen.
struct ContentView: View {
var service: ApiService
#ViewBuilder
var body: some View {
if service.authenticated {
TabView {
NavigationView {
ServerListView(ServerListViewModel(service: service))
}
.tabItem {
Image(systemName: "desktopcomputer")
Text("Servers")
}
}
}
else {
LoginView(service: service)
}
}
}
After further testing, placing the API Call in the struct initializer in ServerViewList.swift means the code works as intended. But, this does mean the API call is placed twice. The struct appears to be constructed twice, but the onAppear is only called once.

Using Firebase Realtime Database with SwiftUI List - Clearing a list

I'm wondering what's the most effective way to clear a list's data that's presented in a View via an observable object? At the moment, everytime I reload the view the data gets duplicated (as we are checking the query for updates and parsing the responses). This is not the expected behaviour. The expected behaviour would be to only update the #Published property "if" the database indicates that a new notification has been received.
I know the culprit is the code within the .onAppear block - I'm just not sure architecturally how I might solve this. How could I use this listener while only parsing new data, not data that was previously parsed?
I attempted to clear the list .onAppear, but resulted in a crash indicated that I tried to delete a section while there were already 0 sections so that didn't work.
I've thought of possibly providing the Message object with a Static Unique ID to upload with the Message object when sending to Firebase (Or using the firebase key itself). That way I could use a set of dictionary objects using the unique ID to identify the object in the dictionary. This may help me avoid duplicate entries.
struct Updates: View {
#ObservedObject var dataController = DataController()
var body: some View {
ZStack {
VStack {
List {
ForEach(self.dataController.messages, id: \.id) { message in
Text(message.message)
}
}
}.onAppear {
self.dataController.query.observe(.childAdded) { snapshot in
let data = JSON(snapshot.value as Any)
if let message = Message.parseFirebaseQuery(dataJSON: data){
self.dataController.messages.append(message)
}
}
}
}
}
}
class DataController: ObservableObject {
#Published var query = ChatPathsAndReferences.refs.databaseMessages.queryLimited(toLast:100).queryOrdered(byChild: "date")
#Published var messages = [Message]()
}
I resolved this by adding an init block to my ObservableObject
class DataController: ObservableObject {
#Published var query = ChatPathsAndReferences.refs.databaseMessages.queryLimited(toLast:100).queryOrdered(byChild: "date")
#Published var messages = [Message]()
init() {
self.query.observe(.childAdded) { snapshot in
let data = JSON(snapshot.value as Any)
if let chatMessage = ChatMessage.parseFirebaseQuery(dataJSON: data){
self.messages.append(chatMessage)
}
}
}
}
So that now, when I create the view, the object initialises the observer in the data controller rather than the view.
struct Updates: View {
// As you can see, we initialise the data controller which inits the observer.
// Since the messages in the data controller are #Published,
// we don't need to worry about checking for updates again in the view
#ObservedObject var chatDataController: DataController = DataController()
var body: some View {
GeometryReader { geometry in
ZStack {
Color("Background")
VStack {
// messages
List {
ForEach(self.chatDataController.messages, id: \.id) { message in
Text(message.message)
}
}
}
}
}
}

Can you use a Publisher directly as an #ObjectBinding property in SwiftUI?

In SwiftUI, can you use an instance of a Publisher directly as an #ObjectBinding property or do you have to wrap it in a class that implements BindableObject?
let subject = PassthroughSubject<Void, Never>()
let view = ContentView(data:subject)
struct ContentView : View {
#ObjectBinding var data:AnyPublisher<Void, Never>
}
// When I want to refresh the view, I can just call:
subject.send(())
This doesn't compile for me and just hangs Xcode 11 Beta 2. But should you even be allowed to do this?
In your View body use .onReceive passing in the publisher like the example below, taken from Data Flow Through SwiftUI - WWDC 2019 # 21:23. Inside the closure you update an #State var, which in turn is referenced somewhere else in the body which causes body to be called when it is changed.
You can implement a BindableObject wich takes a publisher as initializer parameter.
And extend Publisher with a convenience function to create this BindableObject.
class BindableObjectPublisher<PublisherType: Publisher>: BindableObject where PublisherType.Failure == Never {
typealias Data = PublisherType.Output
var didChange: PublisherType
var data: Data?
init(didChange: PublisherType) {
self.didChange = didChange
_ = didChange.sink { (value) in
self.data = value
}
}
}
extension Publisher where Failure == Never {
func bindableObject() -> BindableObjectPublisher<Self> {
return BindableObjectPublisher(didChange: self)
}
}
struct ContentView : View {
#ObjectBinding var binding = Publishers.Just("test").bindableObject()
var body: some View {
Text(binding.data ?? "Empty")
}
}