jmm
jmm

Reputation: 1064

SwiftUI ObservableObject not refreshing data on views

I'm trying to use SwiftUI's ObservableObject, but I can't seem to update my view's properties.

This is my ContentView.swift

import SwiftUI


final class JiraData: ObservableObject {
    init() {
        // TODO use real service
        self.worklog = self.jiraService.getCounter(tickets: 2, minutes: 120, status: "BELOW")
    }
    
    init(hours: Double, tickets: Int, status: Status) {
        self.worklog = Worklog(minutesLogged: Int(hours) * 60, totalTickets: tickets, status: status)
    }
    
    /// Refreshes the data in this object, by calling the underlying service again
    ///
    /// Despite SwiftUI's Observable pattern, I need the UI to toggle this interaction
    func refresh() {
        self.worklog = self.jiraService.getCounter(tickets: Int.random(in: 3..<5), minutes: 390, status: "OK")
        print("Now mocked is: \(self.worklog)")
    }
    
    func getTimeAndTickets() -> String {
        return "You logged \(String(format: "%.2f", Double(self.worklog.minutesLogged) / 60.0)) hours in \(self.worklog.totalTickets) tickets today"
    }
    
    func getStatus() -> String {
        // TODO removed hardcoded part
        return "You are on \"\(Status.below.rawValue)\" status"
    }
    
    var jiraService = MockedService()
    @Published var worklog: Worklog
}

struct ContentView: View {
    @ObservedObject var jiraData = JiraData()
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(jiraData.getTimeAndTickets())
                .font(Font.system(size: 20.0))
                .fontWeight(.semibold)
                .multilineTextAlignment(.leading)
                .padding(.horizontal, 16.0)
                .frame(width: 360.0, height: 80.0, alignment: .topLeading)
            Text(jiraData.getStatus())
                .font(Font.system(size: 20.0))
                .fontWeight(.semibold)
                .multilineTextAlignment(.leading)
                .padding(.horizontal, 16.0)
                .frame(width: 360.0, height: 80, alignment: .topLeading)
            Button(action: {
                jiraData.refresh()  // TODO unneeded?
            })
            {
                Text("Refresh")
                    .font(.caption)
                    .fontWeight(.semibold)
            }
            Button(action: {
                NSApplication.shared.terminate(self)
            })
            {
                Text("Quit")
                    .font(.caption)
                    .fontWeight(.semibold)
            }
            .padding(.trailing, 16.0)
            .frame(width: 360.0, alignment: .trailing)
        }
        .padding(0)
        .frame(width: 360.0, height: 360.0, alignment: .top)
    }
}

#if DEBUG
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(jiraData: JiraData(hours: 6.5, tickets: 2, status: Status.below))
    }
}
#endif

The data structures are:

import Foundation


/// This is a Worklog possible status
enum Status: String, Codable, Equatable {
    case below = "below"
    case ok = "ok"
    case overtime = "overtime"
    
    public init(from decoder: Decoder) throws {
        // If decoding fails, we default to "below"
        guard let rawValue = try? decoder.singleValueContainer().decode(String.self) else {
            self = .below
            return
        }
        self = Status(rawValue: rawValue) ?? .below
    }
}

/// This represents a Worklog on JIRA
struct Worklog: Codable {
    var minutesLogged: Int
    var totalTickets: Int
    var status: Status
}

/// Parses a JSON obtained from the JAR's stdout
func parse(json: String) -> Worklog {
    let decoder = JSONDecoder()
    
    let data = Data(json.utf8)
    if let jsonWorklogs = try? decoder.decode(Worklog.self, from: data) {
        return jsonWorklogs
    }
    // TODO maybe handle error differently
    return Worklog(minutesLogged: 0, totalTickets: 0, status: Status.below)
}

My Service is just a mock:

import Foundation


struct MockedService {
    func getCounter(tickets: Int, minutes: Int, status: String) -> Worklog {
        let json = "{\"totalTickets\":\(tickets),\"minutesLogged\":\(minutes),\"status\":\"\(status)\"}"
        print("At MockedService: \(json)")
        return parse(json: json)
    }
}

On startup, I'm getting this printed on the console

At MockedService: {"totalTickets":2,"minutesLogged":120,"status":"BELOW"}
2020-11-09 20:47:17.163710-0300 JiraWorkflows[2171:14431] Metal API Validation Enabled

At this point, my app looks like this (which is correct).

enter image description here

I know, the UI looks awful so far :(

But then, after I click on Refresh, the UI isn't updated, despite seeing this on the console

2020-11-09 20:47:17.163710-0300 JiraWorkflows[2171:14431] Metal API Validation Enabled
At MockedService: {"totalTickets":4,"minutesLogged":390,"status":"OK"}
Now mocked is: Worklog(minutesLogged: 390, totalTickets: 4, status: JiraWorkflows.Status.below)

Any ideas on what could be going on here?
Thanks in advance!


EDIT after @Asperi 's answer, my refresh() function looks like this

func refresh() {
        self.worklog = self.jiraService.getCounter(tickets: Int.random(in: 3..<5), minutes: 390, status: "OK")
        print("Now mocked is: \(self.worklog)")
        self.timeAndTicketsMsg = "You logged \(String(format: "%.2f", Double(self.worklog.minutesLogged) / 60.0)) hours in \(self.worklog.totalTickets) tickets today"
        print(self.timeAndTicketsMsg)
        self.statusMsg = "You are on \"\(self.worklog.status.rawValue)\" status"
        print(statusMsg)
    }

Which prints:

At MockedService: {"totalTickets":4,"minutesLogged":390,"status":"OK"}
Now mocked is: Worklog(minutesLogged: 390, totalTickets: 4, status: JiraWorkflows.Status.below)
You logged 6.50 hours in 4 tickets today
You are on "below" status

Which is correct. However, the UI isn't refreshed. I've also changed my ObservableObject, which now looks like this:

final class JiraData: ObservableObject {
    // rest of the class
    var jiraService = MockedService()
    var worklog: Worklog
    @Published var timeAndTicketsMsg: String
    @Published var statusMsg: String
}

and now, my ContentView looks like this:

@ObservedObject var jiraData = JiraData()
    
var body: some View {
    VStack(alignment: .leading, spacing: 0) {
        Text(jiraData.timeAndTicketsMsg)

    // rest of the Text
    Text(jiraData.statusMsg)
    // rest of the class
}

Upvotes: 1

Views: 205

Answers (1)

Asperi
Asperi

Reputation: 258365

The problem is in calling functions, like

   Text(jiraData.getTimeAndTickets())

The refresh happens when there is dependency between changed property and view body. Function cannot be tracked for changes.

So you need to refactor your code to the kind

   Text(jiraData.timeAndTickets)

where timeAndTickets is

@Published var timeAndTickets: String

and you modify it directly whenever refresh performed.... and then view will be updated correspondingly.

Upvotes: 2

Related Questions