SwiftUI: asynchronously making a calculating and show its result in a sheet - swift

I'm making a CrossFit app that has, as one of the features, a stopwatch or timer that the user will start and stop, and when it's stopped, it'll log the exercise to Health Kit, it will calculate the approximate calories based on the exercise intensity and time to complete the exercise, and will finally show a sheet telling the user the number of calories consumed and how much time it took.
The view that contains the stopwatch and the buttons is the following:
struct FooterTimerView: View {
#State private var completedSheetPresented = false
var workout: Workout? = nil
var subworkout: Subworkout? = nil
#Environment(\.managedObjectContext) private var viewContext
#EnvironmentObject var healthViewModel: HealthViewModel
fileprivate func getConsumedCaloriesAndShowSheet(_ completable: Completable, timeInMinutes minutes: Int) async -> Double {
return await healthViewModel.logCompletable(completable, timeInMinutes: minutes, inContext: viewContext) ?? 0.0
}
var body: some View {
var loggedTime = ("", "", "")
var consumedCalories = 0.0
return TimerView { (timeString: String) in
loggedTime = converTimeStringToTimeInterval(timeString)
if let minutes = Int(loggedTime.0), let seconds = Int(loggedTime.1), let miliseconds = Int(loggedTime.2) {
let totalMinutes = minutes + seconds / 60 + (miliseconds / (60 * 1000))
if let workout = workout {
Task {
consumedCalories = await getConsumedCaloriesAndShowSheet(workout, timeInMinutes: totalMinutes)
completedSheetPresented = true
}
} else if let subworkout = subworkout {
Task {
consumedCalories = await getConsumedCaloriesAndShowSheet(subworkout, timeInMinutes: totalMinutes)
completedSheetPresented = true
}
}
}
}.sheet(isPresented: $completedSheetPresented) {
CompletedWorkoutSheet(workoutTime: loggedTime, workoutCalories: consumedCalories)
}
}
private func converTimeStringToTimeInterval(_ timeString: String) -> (String, String, String) {
let timeString = "59:56:23"
let components = timeString.components(separatedBy: ":")
let millis = components[2]
let seconds = components[1]
let minutes = components[0]
return (minutes, seconds, millis)
}
}
To notice in the code, the two variables that the sheet will need (loggedTime and consumedCalories) are defined in the body of the view, so the sheet can use their values, and I'm not sure if there's a better way to do this.
Also, my view TimerView which is created in the code above has the following code:
struct TimerView: View {
let stopWatchFrameHeight = CGFloat(70)
let stopWatchFontSize = CGFloat(70)
#StateObject var stopWatch = StopWatch()
let onTimerFinished: (String) -> Void
init(onTimerFinishedAction: #escaping (String) -> Void) {
self.onTimerFinished = onTimerFinishedAction
}
so when it is called in the FooterTimerView, the lambda passed to it is a function to be executed when the timer is finished - which is when the consumed calories can be calculated based on the time spent exercising.
The problem I'm facing is that in the two assignments to the consumedCalories variable, I get the error Mutation of captured var 'consumedCalories' in concurrently-executing code and I'm not sure how to fix the code so that:
The amount of calories is calculated first in an async way.
Once the 1. is finished, the sheet is shown, and it takes the consumedCalories value to it is shown to the user.
I've been trying to fix it for a couple of days and as soon as I think I'm heading in the right direction and I start fixing it, I always end up at a dead end.
Thanks a lot in advance!

Related

How do I get the #Published variable to update properly in Swift? [duplicate]

This question already has answers here:
Returning data from async call in Swift function
(13 answers)
Closed 25 days ago.
Background
I'm trying to build a class that will easily convert string like addresses into a CLLocationCoordinate2D for later use that will be saved to a database.
I have a class that is similar to below:
final class PublishMapData: ObservableObject {
#Published var userAddressLat: Double = 0.0
#Published var userAddressLong: Double = 0.0
func saveMapData(address: String){
let address = "One Apple Park Way, Cupertino, CA 95014" //simulating the call from a save button for instance
convertAddress(address: address)
print(String(userAddressLat)) //this prints 0.0
print(String(userAddressLong)) //this print 0.0
//{...extra code here...}
//this is where I would be storing the coordinates into something like Firestore for later use
}
func convertAddress(address: String) {
getCoordinate(addressString: address) { (location, error) in
if error != nil {
return
}
DispatchQueue.main.async {
self.userAddressLat = location.latitude
print(self.userAddressLat) //this prints the correct value
self.userAddressLong = location.longitude
print(self.userAddressLong) //this prints the correct value
}
}
}
private func getCoordinate(addressString : String, completionHandler: #escaping(CLLocationCoordinate2D, NSError?) -> Void ) {
let geocoder = CLGeocoder()
geocoder.geocodeAddressString(addressString) { (placemarks, error) in
if error == nil {
if let placemark = placemarks?[0] {
let location = placemark.location!
completionHandler(location.coordinate, nil)
return
}
}
completionHandler(kCLLocationCoordinate2DInvalid, error as NSError?)
}
}
}
For some reason, I'm not getting the lat, long values from the convertAddress function to properly get stored within the #Published variables. What am I doing wrong?
I'm still learning Swift. Thanks in advance for any assistance.
According to https://developer.apple.com/documentation/corelocation/clgeocoder/1423509-geocodeaddressstring
geocodeAddressString(_:completionHandler:)
is an asynchronous function, which means its completion handler will get executed at a later point in time and the called function returns immediately.
Thus when you call
convertAddress(address: address)
it returns immediately scheduling the dispatchQueue closure to be called later.
print(String(userAddressLat)) //this prints 0.0
print(String(userAddressLong))
are executed next which prints 0.0
DispatchQueue.main.async {
self.userAddressLat = location.latitude
print(self.userAddressLat) //this prints the correct value
self.userAddressLong = location.longitude
print(self.userAddressLong) //this prints the correct value
}
are executed later.
#lastbreath Thank you for highlighting the asynchronous nature of geocodeAddressString(_:completionHandler:). Because of that I found that I could use an asynchronous geocodeAddressString call in lieu of my previous approach.
This was noted in the link you provided:
func geocodeAddressString(_ addressString: String) async throws -> [CLPlacemark]
This is the fixed code...much more simplistic to achieve sending the values to #Published variable.
final class PublishMapData: ObservableObject {
#Published var userAddressLat: Double = 0.0
#Published var userAddressLong: Double = 0.0
func saveMapData(address: String){
Task {
do {
let address = "One Apple Park Way, Cupertino, CA 95014" //simulating the call from a save button for instance
try await getCoordinate(addressString: address)
print(String(userAddressLat)) //this now prints correct value
print(String(userAddressLong)) //this now prints correct value
//{...extra code here...}
//this is where I would be storing the coordinates into something like Firestore for later use
}
catch {
}
}
}
func getCoordinate(addressString : String) async throws {
let geocoder = CLGeocoder()
let placemark = try await geocoder.geocodeAddressString(addressString)
await MainActor.run(body: {
userAddressLat = placemark[0].location!.coordinate.latitude
userAddressLong = placemark[0].location!.coordinate.longitude
})
}
}
Thank you for your assistance in getting the answer.

Re-initialize instance of class with new argument every time when argument is changed

How do I re-initialize / instantiate a model that takes one parameter every time that parameter changes its value? The model works, but with the old value of the parameter, but it needs to be reinitialized every time this parameter changes.
I found didSet and willSet, but i don't know how to use this correctly
var test = countDownPicker.countDownDuration {
willSet {
self.model.workTimeInterval = PomodoroModel.TimeInterval(newValue)
}
}
self.model = PomodoroModel(workTimeInterval: PomodoroModel.TimeInterval(test))
PomodoroModel fields
public class PomodoroModel {
public typealias TimeInterval = UInt
weak var delegate: PomodoroModelDelegate?
var workTimeInterval: TimeInterval
let breakTimeInterval: TimeInterval
let restTimeInterval: TimeInterval
let numberOfCycles: TimeInterval
init(workTimeInterval: TimeInterval = 1500,
breakTimeInterval: TimeInterval = 300,
restTimeInterval: TimeInterval = 700,
numberOfCycles: TimeInterval = 4) {
self.workTimeInterval = workTimeInterval
self.breakTimeInterval = breakTimeInterval
self.restTimeInterval = restTimeInterval
self.numberOfCycles = numberOfCycles
self.state = StateNeutral()
self.state.model = self
}

Swift: return value every x seconds

I'm trying to generate random string every 10 seconds. I put this function in a class in another file and will call the function in another view controller. But now I'm not getting any output when I call it. How to can I fix this code
class Data{
static let instance = Data()
func randomString(of length: Int){
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var s = ""
for _ in 0 ..< length {
s.append(letters.randomElement()!)
print("\(s) = I'm in randomString Func")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
self?.randomString(of: 5)
}
}
}
in a view controller I put it under a button action and call it with this code
Button(action: {
info = Data.instance.randomString(of:5)
print(info)
}, label: {
Text ("PRINT")
.font(.callout)
.foregroundColor(Color.primary)
})
A possible solution is to use the Timer from the Combine framework:
struct ContentView: View {
#State private var text = "initial text"
#State private var timer: AnyCancellable?
var body: some View {
Text(text)
Button(action: startTimer) { // start timer on button tap, alternatively put it in `onAppear`
Text("Start timer")
}
}
func startTimer() {
// start timer (tick every 10 seconds)
timer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.sink { _ in
text = DataGenerator.instance.randomString(of: 5)
}
}
}
You also need to return the String from the randomString function. A good thing would also be to rename Data to avoid collisions:
class DataGenerator { // rename the class
static let instance = DataGenerator()
func randomString(of length: Int) -> String { // return `String`
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var s = ""
for _ in 0 ..< length {
s.append(letters.randomElement()!)
print("\(s) = I'm in randomString Func")
}
return s // return String, don't call `DispatchQueue.main.async` here
}
}
You may also consider moving the timer logic out of the view like in:
How to make the View update instant in SwiftUI?

How to pass data between views in swiftui?

I have a view, in which I want the user to enter data. Then I want the user, to be able to press a button which directs the user to another view. And depending on the value, which the user entered previously I want a link to be changed. The link is at top-level because I need it more than once. So for example if the user enters "89", I want the link to be "https://www.example.com/89".
this is the code of the first view:
class lClass: ObservableObject {
#Published var longitude: String = ""
}
struct Data: View {
#State var showGraph = false
#ObservedObject var longitude2 = lClass()
var body: some View {
Button(action: {
self.showGraph.toggle()
}) {
Text("Show Graph")
}.sheet(isPresented: $showGraph){
GraphView()
}
if showGraph {
GraphView()
}
else {
HStack {
TextField("Longitude:", text: $longitude2.longitude)
}.frame(width: 400, height: 100, alignment: .center)
}
}
}
And this is the shortened code of the second view:
var month = 1
var day = 1
var year = 2020
var latitude = 59.911232
var longitude = 10.757933
var offset = "1.0"
class test {
#ObservedObject var longitude2 = lClass()
}
class test2 {
var car = test.init().longitude2
}
private let url = URL(string: "https://midcdmz.nrel.gov/apps/spa.pl?syear=\(year)&smonth=\(month)&sday=\(day)&eyear=\(year)&emonth=\(month)&eday=\(day)&otype=0&step=60&stepunit=1&hr=12&min=0&sec=0&latitude=\(latitude)&longitude=\(longitude)&timezone=\(offset)&elev=53&press=835&temp=10&dut1=0.0&deltat=64.797&azmrot=180&slope=0&refract=0.5667&field=0")
private func loadData(from url: URL?) -> String {
guard let url = url else {
return "nil"
}
let html = try! String(contentsOf: url, encoding: String.Encoding.utf8)
return html
}
let html = loadData(from: url)
private func dbl1() -> Double {
let leftSideOfTheValue = "0:00:00,"
let rightSideOfTheValue = "\(month)/\(day)/\(year),1:00:00,"
guard let leftRange = html.range(of: leftSideOfTheValue) else {
print("cant find left range")
return 0
}
guard let rightRange = html.range(of: rightSideOfTheValue) else {
print("cant find right range")
return 0
}
let rangeOfTheValue = leftRange.upperBound..<rightRange.lowerBound
return Double(html[rangeOfTheValue].dropLast()) ?? 90
}
struct elevationGraph: View {
var body: some View {
Text(String(dbl1())
}
}
In the end, I want the user to be able to select the vars, month, day, year, longitude, latitude, offset in the first view. And then manipulate the url so that it uses the data entered by the user.
So the question is how do I update and share information across the application's views.
One way to do this is through the use of one or more common objects that do not get destroyed when the view is updated. To do this, the code either needs to create the object outside of the View structure. Or to use #StateObject in the View where the object is being instantiated.
Once created, the instance can then either be passed as a parameter to child views as an #ObservedObject parameter. Or if it used in many places and levels in the the app, it can be placed in the environment and extracted through the use of the #EnvironmentObject to avoid "prop drilling"
These two StackOverflow links should help clarify:
#ObservedObject vs #StateObject
#EnvironmentObject
Good luck.

Bind properties in Swift to NSTextFields

I'm working on a unit converter written in Swift that will automatically display the updated units within the appropriate NSTextFields. For this example, if the user inputs minutes into the minutesField, the secondsField and hoursField should automatically update to display the converted values from the properties. Below is an example of the code in my view controller:
import Cocoa
class ViewController: NSViewController {
#IBOutlet weak var secondsField: NSTextField!
#IBOutlet weak var minutesField: NSTextField!
#IBOutlet weak var hoursField: NSTextField!
var sec: Double = 1
var min: Double = 1
var hr: Double = 1
let conv = [[1, 0.0166666666666667, 0.000277777777777778],
[60, 1, 0.0166666666666667],
[3600, 60, 1]]
func updateTextFields() {
secondsField.stringValue = "\(sec)"
minutesField.stringValue = "\(min)"
hoursField.stringValue = "\(hr)"
}
}
extension ViewController: NSTextFieldDelegate {
override func controlTextDidChange(obj: NSNotification) {
let tag = obj.object?.tag() ?? 0
let text = obj.object?.stringValue ?? "1"
let value = Double(text) ?? 1.0
switch tag {
case 0:
// base unit = seconds
sec = value
min = value * conv[0][1]
hr = value * conv[0][2]
case 1:
// base unit = minutes
sec = value * conv[1][0]
min = value
hr = value * conv[1][2]
case 2:
// base unit = hours
sec = value * conv[2][0]
min = value * conv[2][1]
hr = value
default:
"none"
}
updateTextFields()
}
}
The code above updates the text fields but the active field with the input freezes after the first key stroke. For example, if you try to enter 60 into the seconds field, the next key stroke does not do anything. The number 6.0 appears after the first key stroke (see image below). This is likely due to the function that is called after the properties are updated. The properties are updated based on the tag of the active text field.
Is it possible to have a function that will update all the other text fields other than the currently active field?
Is it possible to have a function that will update all the other text
fields other than the currently active field?
Yes. You can connect all text field to the same action.
First connect all your text fields accordingly to your view controller:
#IBOutlet weak var hourField: NSTextField!
#IBOutlet weak var minuteField: NSTextField!
#IBOutlet weak var secondField: NSTextField!
Second create a single var to represent your time interval. You can also add a setter / getter to store it automatically to USerDefaults:
var timeInterval: TimeInterval {
get {
return UserDefaults.standard.double(forKey: "timeInterval")
}
set {
UserDefaults.standard.set(newValue, forKey: "timeInterval")
}
}
Third create a TimeInterval extension to convert the time from seconds to hour/minute
extension TimeInterval {
var second: Double { return self }
var minute: Double { return self / 60 }
var hour: Double { return self / 3600 }
var hourToMinute: Double { return self * 60 }
var hourToSecond: Double { return self * 3600 }
var minuteToHour: Double { return self / 60 }
var minuteToSecond: Double { return self * 60 }
}
Fourth create the action that will update the other text fields:
#IBAction func timeAction(sender: NSTextField) {
// use guard to convert the field string to Double
guard let time = Double(sender.stringValue) else { return }
// switch the field
switch sender {
case hourField:
// convert / update secondary fields
minuteField.stringValue = time.hourToMinute.description
secondField.stringValue = time.hourToSecond.description
// make it persist through launches
timeInterval = time * 3600
case minuteField:
hourField.stringValue = time.minuteToHour.description
secondField.stringValue = time.minuteToSecond.description
timeInterval = time * 60
default:
hourField.stringValue = time.hour.description
minuteField.stringValue = time.minute.description
timeInterval = time
}
}
Last but not least make sure you load the values next time your view will appear:
override func viewWillAppear() {
hourField.stringValue = timeInterval.hour.description
minuteField.stringValue = timeInterval.minute.description
secondField.stringValue = timeInterval.second.description
}
Sample Project
You need to move the setting of the text fields' values out of viewDidLoad() to a separate method. Call that method from viewDidLoad() and also from a didSet property observer on your seconds property.
You also need to create action methods for when the seconds, minutes, or hours fields are changed. In the action method for the seconds field, set the seconds property to secondsField.floatValue. Similarly for the other two fields. In the NIB/storyboard, connects the text fields' actions to the corresponding action method on the view controller.