updating a view with changing variables - swift

I have this program with SwiftUI. The program is for calculating the bedtime using machine learning based on 3 user inputs. I have a Text("") showing users their updated bedtime.
I want the program to update the bedtime automatically and display it on my Text(""). I tried many methods and none seems to work. What I tried so far
onAppear - only updates once bedtime when the program first runs
onTapGesture - only updates the bedtime when tapping on the picker (scrolling the picker doesn't work), and it somehow hinders updating the stepper (clicking +/- doesn't change the hours)
using didSet with class conforming to observableObject, #Pulished vars in the class and #ObservedObject in the view struct. Didn't work as well but I tried it only when the class has default values
using didSet in the struct - it didn't update bedtime
Does anyone know if there's an easier way to have the bedtime updated however the user scrolls the picker and whenever a variable changes?
UI looks for detail
struct ContentView: View {
static var defaultWakeUpTime : Date {
var defaultTime = DateComponents()
defaultTime.hour = 7
defaultTime.minute = 0
return Calendar.current.date(from: defaultTime) ?? Date()
}
#State private var wakeUp = defaultWakeUpTime
#State private var sleepAmount = 8.0
#State private var coffeeAmount = 0 {
didSet {
calculateSleepTime()
}
}
#State private var showTime : String = " "
func calculateSleepTime() {**CONTENT**}
var body: some View {
NavigationView {
VStack {
Spacer(minLength: 20)
Text("Your optimum sleep time is \(showTime)")
Spacer(minLength: 10)
Section {
Text("When do you want to wake up?")
.font(.headline)
DatePicker("Please choose a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
.datePickerStyle(WheelDatePickerStyle())
}
Spacer()
Form {
Text("How many hours would you like to sleep?")
.font(.headline)
Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
Text("\(sleepAmount, specifier: "%g" ) hours")
}
}
Spacer()
Section {
Text("How many cups of coffee do you drink?")
.font(.headline)
Picker("Coffee Selector", selection: $coffeeAmount) {
ForEach (1..<21) {
Text("\($0) " + "Cup")
}
}
.labelsHidden()
}
}
.navigationBarTitle(Text("BetterSleep"))
.onAppear(perform: calculateSleepTime)
}
}
}

I would use a viewModel and use subscriptions to track values and calculate sleep time.
Change your ContentView at the top to this
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
Now precede any variables with viewModel.
Create a new .swift file I just called it ViewModel but you don't have to.
import Combine
final class ViewModel: ObservableObject {
#Published private(set) var bedTime: String = ""
#Published var wakeUp: Date = Date()
#Published var sleepAmount: Double = 8.0
#Published var coffeeAmount = 0
private var cancellables = Set<AnyCancellable>()
init() {
$wakeUp
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.calculateSleepTime()
}.store(in: &cancellables)
$sleepAmount
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.calculateSleepTime()
}.store(in: &cancellables)
$coffeeAmount
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.calculateSleepTime()
}.store(in: &cancellables)
}
private func calculateSleepTime() {
// Your Logic
self.bedTime =
}
}
Now anytime one of the values changes the suggested bedtime will update. Remember to add one to the coffeeAmount as it starts at 0.

Related

#EnvironmentObject not updating view when updating from Realm

Edited to remove unneccesary code...
I'm trying to build a view in my app which will display a schedule (for practicing musical instruments). The schedule can have multiple sessions, and each session multiple slots. I am retrieving a 2d array of these slots from Realm and then using a foreach inside another foreach to display each session with its contents. I'm using #EnvironmentObject to access Realm from each view and realm assembles a new [[Slot]] each time any information is changed (which it seems to be doing correctly).
The issue I'm having is that, although it is refreshing the sessions when I add/remove them, it is not updating the contents of each session. Realm is correctly working correctly, but the sub view is not being updated. If I exit and re-enter the ScheduleView it will show correctly again. I have tried various things to get it updating correctly, including changing an #State variable in each view after a delay, but nothing so far has worked and I'm starting to pull my hair out!
struct SessionRow: View {
#State var sessionNumber: Int
#State var session: [Slot]
var delete: () -> Void
var addSlot: () -> Void
#State var refreshToggle = false
var body: some View {
VStack{
HStack{
Text("Session \(sessionNumber + 1)")
Button {
self.delete()
} label: {
Label("", systemImage: "trash")
}
.buttonStyle(PlainButtonStyle())
Button {
self.addSlot()
} label: {
Label("", systemImage: "plus")
}
.buttonStyle(PlainButtonStyle())
}
ForEach(0..<self.session.count, id: \.self) { slotNumber in
SlotRow(slot: self.session[slotNumber], ownPosition: [self.sessionNumber, slotNumber])
}
.onDelete { indexSet in
//
}
}
.onAppear{
print("Session Number: \(sessionNumber) loaded.")
}
}
}
func addSlot(sessionNumber: Int){
DispatchQueue.main.async {
self.realmManager.addSlot(sessionNumber: sessionNumber)
self.refreshToggle.toggle()
schedule = realmManager.schedule
}
}
func deleteSession(at offsets: IndexSet){
DispatchQueue.main.async {
self.realmManager.deleteSession(sessionNumber: Int(offsets.first ?? 0))
schedule = realmManager.schedule
}
}
}
struct SlotRow: View {
//#EnvironmentObject var realmManager: RealmManager
#State var slot: Slot
//#Binding var isPresented: Bool
//#Binding var slotPosition: [Int]
#State var ownPosition: [Int]
// Add position/session to allow editing?
var body: some View {
VStack{
HStack{
Text("Own Position: [\(ownPosition[0]),\(ownPosition[1])]")
}
}
.padding()
.frame(height: 80.0)
.onAppear{
print("Slot \(ownPosition) loaded.")
}
}
func deleteIntervalFromSlot() {
//realmManager.updateSlot(session: ownPosition[0], position: ownPosition[1], interval: nil)
}
}
As you can see I've tried all sorts of hacks to get it to update, and loads of this code is unnecessary and will be removed once I have a solution. I thought I would leave it here to show the sort of things which have been tried.
I've found the solution, and apologies for the outrageous amount of detail above. I will go back and tidy the above when I get some time.
The issue is that I defined when passing the [Slot] to the session row the variable receiving it was #State and it should just be a var.
struct SessionRow: View {
#State var sessionNumber: Int
#State var session: [Slot]
Change it to
struct SessionRow: View {
var sessionNumber: Int
var session: [Slot]
and it works just fine.
A silly error from someone who is new to SwiftUI!

WidgetKit #StateObject not updating View

I'm having trouble understanding how to make my SwiftUI data model example work with my Widget. It's working in my test app just fine, I observe changes immediately. When I attempt the Widget, I can see the data being printed in console but no changes are happening in my View in WidgetKit. I'm using a ObservableObject class and #Published variables. I've attempted to use a #StateObject, #ObservedObject and an #EnvironmentObject and the same results. Always results in No game today. My data model is Combine related, not sure if that has anything to do with why I can't see data changes in my WidgetKit's View.
SwiftUI App example working
struct ContentView: View {
#ObservedObject var data = CombineData()
#State private var showSortSheet: Bool = false
#State private var nhlTeams: TeamOrder = .NewYorkIslanders
var body: some View {
NavigationView {
VStack(alignment: .leading) {
if data.schedule?.dates.first != nil {
Text("Game today")
} else {
Text("No game today")
}
}
.listStyle(PlainListStyle())
.navigationBarTitle(Text(""), displayMode: .inline)
.navigationBarItems(leading: HStack {
Button(action: {
self.showSortSheet.toggle()
}) {
HStack {
Image(systemName: "arrow.up.arrow.down")
}
}
})
.actionSheet(isPresented: $showSortSheet) {
ActionSheet(title: Text("Teams:"), buttons: [TeamOrder.NewYorkIslanders, TeamOrder.MontrealCanadiens].map { method in
ActionSheet.Button.default(Text("\(method.rawValue)")) {
self.nhlTeams = method
data.fetchSchedule(self.nhlTeams.rawValue)
print("\(self.nhlTeams.rawValue)")
}
})
}
.onAppear {
data.fetchSchedule(self.nhlTeams.rawValue)
}
}
}
}
Example Widget #StateObject (No data changes)
struct ExampleEntryView : View {
var entry: Provider.Entry
#Environment(\.widgetFamily) var widgetFamily
#StateObject var model = CombineData()
#ObservedObject var model = CombineData()
/*#EnvironmentObject private var model: CombineData*/
var body: some View {
VStack(alignment: .leading) {
if model.schedule?.dates.first != nil {
Text("Game today")
} else {
Text("No game today")
}
}
.onAppear {
model.fetchSchedule(entry.configuration.teams.rawValue) {
WidgetCenter.shared.reloadTimelines(ofKind: "NHL_Widget")
}
}
}
}
Example Widget 2 TimelineEntry (No data changes)
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
let model: CombineData
}
struct Provider: IntentTimelineProvider {
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: #escaping (Timeline<Entry>) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
let model = CombineData()
let entryDate = Calendar.current.date(byAdding: .minute, value: 1, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration, model: model)
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
model.fetchSchedule(entry.configuration.teams.rawValue) {
completion(timeline)
}
}
}
struct ExampleEntryView : View {
var entry: Provider.Entry
#Environment(\.widgetFamily) var widgetFamily
var body: some View {
VStack(alignment: .leading) {
if entry.model.schedule?.dates.first != nil {
Text("Game today")
} else {
Text("No game today")
}
}
}
}
Widgets are static, the view you provide on getTimeline is what will be displayed as is and won't be updated, so the call to fetchSchedule on onAppear won't even be executed. Once you execute the completion handler in getTimeline, no more code is executed.
To display data on your widgets you need to fetch your data in getTimeline, create a view that displays the data from the start, and return that entry in your completion handler.
In your last example I would suggest that you create your entry in the completion handler of model.fetchSchedule and pass the schedule object to your view.

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

Why doesn't code in a body property of a View run each time an #State variable of its parent View changes?

I wish to run the function calculateBedtime() when the app first loads, and each time any of the #State variables of ContentView change, so that an updated bedtime is displayed constantly at the bottom of the app in the lowermost Section. However, the app acts as if variable bedtime just keeps its initial value all the time and never changes.
What I am expecting to happen is that when I change any #State variable, say using the DatePicker to change wakeUp, the body property is reinvoked, the first line of which is a call to calculateBedtime(), and so this function runs and updates bedtime as frequently as I want it to.
import SwiftUI
struct ContentView: View {
#State private var wakeUp = defaultWakeTime
#State private var bedtime = ""
#State private var sleepAmount = 8.0
#State private var coffeeAmount = 1
#State private var alertTitle = ""
#State private var alertMessage = ""
#State private var showingAlert = false
var body: some View {
bedtime = calculateBedtime()
return NavigationView
{
Form
{
Section(header: Text("When do you want to wake up?").font(.headline))
{
Text("When do you want to wake up?")
.font(.headline)
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
.datePickerStyle(WheelDatePickerStyle())
}
Section(header: Text("Desired amount of sleep")
.font(.headline))
{
Stepper(value: $sleepAmount, in: 4...12, step: 0.25)
{
Text("\(sleepAmount, specifier: "%g") hours")
}
}
Section(header: Text("Daily coffee intake")
.font(.headline))
{
Picker("\(coffeeAmount+1) cup(s)", selection: $coffeeAmount)
{
ForEach(1..<21)
{ num in
if num==1
{
Text("\(num) cup")
}
else
{
Text("\(num) cups")
}
}
}
.pickerStyle(MenuPickerStyle())
}
Section(header: Text("Your Ideal Bedtime")
.font(.headline))
{
Text("\(bedtime)")
.font(.largeTitle)
}
}
.navigationBarTitle("BetterRest")
}
/*.onAppear(perform: {
calculateBedtime()
})
.onChange(of: wakeUp, perform: { value in
calculateBedtime()
})
.onChange(of: sleepAmount, perform: { value in
calculateBedtime()
})
.onChange(of: coffeeAmount, perform: { value in
calculateBedtime()
})*/
}
static var defaultWakeTime: Date
{
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
func calculateBedtime() -> String
{
let model = SleepCalculator()
let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUp)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60
var sleepTime = ContentView.defaultWakeTime
do
{
let prediction = try
model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
sleepTime = wakeUp - prediction.actualSleep
let formatter = DateFormatter()
formatter.timeStyle = .short
alertMessage = formatter.string(from: sleepTime)
alertTitle = "Your ideal bedtime is..."
} catch {
alertTitle = "Error"
alertMessage = "Sorry, there was a problem calculating your bedtime."
}
showingAlert = true
return alertMessage
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
What is the problem here? I am new to SwiftUI and feel that I must have a crucial misunderstanding of how the #State wrapper works. And what would be a good way to get the behavior I desire?
#State variables can only be mutated from within the body of your view and methods invoked by it; for anything else, you need to use ObservableObject which I think will solve your problem here.
You should only access a state property from inside the view’s body, or from methods called by it. For this reason, declare your state properties as private, to prevent clients of your view from accessing them. It is safe to mutate state properties from any thread.
More or less the scaffolding of the code below should achieve the results you want:
class SleepTimerViewModel: ObservableObject {
#Published public var bedTimeMessage: String?
}
struct ContentView: View {
#ObservedObject public var sleepTimerViewModel: SleepTimerViewModel
var body: some View {
Text(sleepTimerViewModel.bedTimeMessage)
}
public func updateBedTimeMessage() {
sleepTimerViewModel.bedTimeMessage = "Hello World"
}
}
I do think it's kind of annoying that Swift just don't care to let you know that you're updating a #State variable incorrectly. It just silently ignores the value you're trying to set, which is super annoying!

Picker delegate scrolling method in SwiftUI

I have simple Picker object in my SwiftUI hierarchy:
Picker(selection: $pickerSelection, label: Text("Select your item")) {
ForEach(0 ..< items.count) {
Text("\(self.items[$0].valueCode)")
.tag($0)
}
}
I'm using a scrollable Picker in WatchOS app and it works just fine. I'm even getting a Digital Crown rotation capability for free.
What I want to do is to detect when the scrolling started and especially ended (to get last selected value and execute and action with it)
I figure I need to implement sort of Delegate method to read the changes happening to the Picker but I'm not sure how, nor I'm able to find any in the documentation for WKInterfacePicker or just Picker
Any suggestions on how to detect the beginning and end of the scrolling event?
If its about the last value you can use Combine and subscribe to pickerSelection.
class ViewModel: ObservableObject {
private var disposables = Set<AnyCancellable>()
#Published var pickerSelection = 0
init() {
let cc = $pickerSelection
.sink(receiveValue: { value in
print(value)
})
cc.store(in: &disposables)
}
}
struct ContentView: View {
#ObservedObject var mm = ViewModel()
var items = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
VStack {
Text("Hello, World!")
Picker(selection: self.$mm.pickerSelection, label: Text("Item:")) {
ForEach(0 ..< items.count) {
Text("Item \($0)")
.tag($0)
}
}
}
}
}