My Swift UI code currently calls a function to display calculations upon a button call. I'd like to display the function's output without the button call (in other words, the function is "live" and constantly calculating anytime a necessary variable is changed). Basically, I'm looking to get rid of the button that triggers this function call calculation, and always have the function's display shown. It has default values so it should have info even before the user inputs or something is changed.
The first screenshot shows the code currently, and the second shows where I'd like the time calculation string to always be. Note: this uses a Create ML file, so if you're inputting this code into your editor, it's not necessary to have the model use to calculate. Any use and output of the variables will do and I've left some commented code that might help.
I'm thinking there might be a calculate on change of X, Y, Z variable needed here. I'm not sure the best way to approach this and would love any ideas. Thanks!
import CoreML
import SwiftUI
struct ContentView: View {
#State var wakeUpTime = defaultWakeTime
#State var coffeeAmount = 1.0
#State var sleepAmount = 8.0
#State var alertTitle = ""
#State var alertMessage = ""
#State var showAlert = false
static var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date.now
}
var body: some View {
NavigationView {
Form {
Section {
DatePicker("Please enter a time", selection: $wakeUpTime, displayedComponents: .hourAndMinute)
.labelsHidden()
} header: {
Text("When do you want to wake up?")
.font(.headline)
}
VStack(alignment: .leading, spacing: 0) {
Text("Hours of sleep?")
.font(.headline)
Stepper(sleepAmount == 1 ? "1 hour" : "\(sleepAmount.formatted()) hours", value: $sleepAmount, in: 1...12, step: 0.25)
}
VStack(alignment: .leading, spacing: 0) {
Text("Cups of coffee?")
.font(.headline)
Stepper(coffeeAmount == 1 ? "1 cup" : "\(coffeeAmount.formatted()) cups", value: $coffeeAmount, in: 1...12, step: 0.25)
}
Section {
Text("Head to bed at: IDEAL TIME HERE")
}
}
.navigationTitle("BetterRest")
.toolbar {
Button("Calculate", action: calculateBedtime)
}
.alert(alertTitle, isPresented: $showAlert) {
Button("Ok") { }
} message: {
Text(alertMessage)
}
}
}
func calculateBedtime() {
do {
let config = MLModelConfiguration()
let model = try SleepCalculator(configuration: config)
let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUpTime)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60
let predicition = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
let sleepTime = wakeUpTime - predicition.actualSleep
alertTitle = "Your ideal bedtime is..."
alertMessage = sleepTime.formatted(date: .omitted, time: .shortened)
}
catch {
alertTitle = "Error"
alertMessage = "Sorry. There was a problem calculating your bedtime."
}
showAlert = true
// IF TRYING WITHOUT CREATE ML MODEL, comment out all of above^
// let alertTitle = "Showing calculated title"
// let alertMessage = "7:15 am"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
you could try this approach, where you create a class BedTimeModel: ObservableObject to
monitor changes in the various variables that is used to calculate (dynamically)
your sleepTime using func calculateBedtime().
EDIT-1: using Optional sleepTime
class BedTimeModel: ObservableObject {
#Published var sleepTime: Date? = Date() // <-- here optional
#Published var wakeUpTime = defaultWakeTime {
didSet { calculateBedtime() }
}
#Published var coffeeAmount = 1.0 {
didSet { calculateBedtime() }
}
#Published var sleepAmount = 8.0 {
didSet { calculateBedtime() }
}
// can also change this to return the calculated value and use it to update the `sleepTime`
func calculateBedtime() {
// do {
// let config = MLModelConfiguration()
// let model = try SleepCalculator(configuration: config)
// let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUpTime)
// let hour = (components.hour ?? 0) * 60 * 60
// let minute = (components.minute ?? 0) * 60
// let predicition = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
//
// sleepTime = wakeUpTime - predicition.actualSleep // <-- here
// }
// catch {
// sleepTime = nil // <-- here could not be calculated
// }
// for testing, adjust the real calculation to update sleepTime
sleepTime = wakeUpTime.addingTimeInterval(36000 * (sleepAmount + coffeeAmount))
}
static var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? Date.now
}
}
struct ContentView: View {
#StateObject private var vm = BedTimeModel() // <-- here
var body: some View {
NavigationView {
Form {
Section {
DatePicker("Please enter a time", selection: $vm.wakeUpTime, displayedComponents: .hourAndMinute)
.labelsHidden()
} header: {
Text("When do you want to wake up?").font(.headline)
}
VStack(alignment: .leading, spacing: 0) {
Text("Hours of sleep?").font(.headline)
Stepper(vm.sleepAmount == 1 ? "1 hour" : "\(vm.sleepAmount.formatted()) hours", value: $vm.sleepAmount, in: 1...12, step: 0.25)
}
VStack(alignment: .leading, spacing: 0) {
Text("Cups of coffee?").font(.headline)
Stepper(vm.coffeeAmount == 1 ? "1 cup" : "\(vm.coffeeAmount.formatted()) cups", value: $vm.coffeeAmount, in: 1...12, step: 0.25)
}
Section {
// -- here
if let stime = vm.sleepTime {
Text("Head to bed at: \(stime.formatted(date: .omitted, time: .shortened))")
} else {
Text("There was a problem calculating your bedtime.")
}
}
}
.navigationTitle("BetterRest")
}
}
}
Related
I'm trying to make a timer in swiftui but whenever I run it, it auto adds 30 minutes to the countdown. For example, when I set the countdown time to 5 minutes and click the "Start" button, it will show up as 35 minutes instead but when I click the button again, it will then just keep switching to random times. Above is the random times it will switch to.
I got this timer from a tutorial on youtube by Indently but changed some things to fit what I wanted it to do. I tried to set a custom time so the timer will always countdown from 5 minutes. From my understanding, the timer works by taking the difference between the current date and the end date then using the amount of time difference as the countdown. Below is the code for the TimerStruct (ViewModel) and the TimerView.
TimerStruct:
import Foundation
extension TimerView {
final class ViewModel: ObservableObject {
#Published var isActive = false
#Published var showingAlert = false
#Published var time: String = "5:00"
#Published var minutes: Float = 5.0 {
didSet {
self.time = "\(Int(minutes)):00"
}
}
var initialTime = 0
var endDate = Date()
// Start the timer with the given amount of minutes
func start(minutes: Float) {
self.initialTime = 5
self.endDate = Date()
self.isActive = true
self.endDate = Calendar.current.date(byAdding: .minute, value: Int(minutes), to: endDate)!
}
// Reset the timer
func reset() {
self.minutes = Float(initialTime)
self.isActive = false
self.time = "\(Int(minutes)):00"
}
// Show updates of the timer
func updateCountdown(){
guard isActive else { return }
// Gets the current date and makes the time difference calculation
let now = Date()
let diff = endDate.timeIntervalSince1970 - now.timeIntervalSince1970
// Checks that the countdown is not <= 0
if diff <= 0 {
self.isActive = false
self.time = "0:00"
self.showingAlert = true
return
}
// Turns the time difference calculation into sensible data and formats it
let date = Date(timeIntervalSince1970: diff)
let calendar = Calendar.current
let minutes = calendar.component(.minute, from: date)
let seconds = calendar.component(.second, from: date)
// Updates the time string with the formatted time
self.minutes = Float(minutes)
self.time = String(format:"%d:%02d", minutes, seconds)
}
}
}
TimerView:
import SwiftUI
struct TimerView: View {
#ObservedObject var vm = ViewModel()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
let width: Double = 250
var body: some View {
VStack {
Text("Timer: \(vm.time)")
.font(.system(size: 50, weight: .medium, design: .rounded))
.alert("Timer done!", isPresented: $vm.showingAlert) {
Button("Continue", role: .cancel) {
}
}
.padding()
HStack(spacing:50) {
Button("Start") {
vm.start(minutes: Float(vm.minutes))
}
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
//.disabled(vm.isActive)
if vm.isActive == true {
Button("Pause") {
vm.isActive = false
//self.timer.upstream.connect().cancel()
}
.padding()
.foregroundColor(.black)
.background(.red)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
} else {
Button("Resume") {
vm.isActive = true
}
.padding()
.foregroundColor(.black)
.background(.green)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
}
}
.frame(width: width)
}
.onReceive(timer) { _ in
vm.updateCountdown()
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView()
}
}
I noticed and fixed a number of things in your code:
start() is being called with the current value of vm.minutes, so it is going to start from that value and not 5. I changed it to use self.initialTime which means it's currently not using the value passed in. You need to decide if start() really wants to take a value and how to use it.
reset() wasn't being called. I call it from start().
Pause was only pausing the screen update. I changed it to keep track of the start time of the pause and to compute the amount of time paused so that it could accurately update the displayed time.
I made the Pause/Resume button one button with conditional values for title and color based upon vm.active.
Here is the updated code:
extension TimerView {
final class ViewModel: ObservableObject {
#Published var isActive = false
#Published var showingAlert = false
#Published var time: String = "5:00"
#Published var minutes: Float = 5.0 {
didSet {
self.time = "\(Int(minutes)):00"
}
}
var initialTime = 0
var endDate = Date()
var pauseDate = Date()
var pauseInterval = 0.0
// Start the timer with the given amount of minutes
func start(minutes: Float) {
self.initialTime = 5
self.reset()
self.endDate = Date()
self.endDate = Calendar.current.date(byAdding: .minute, value: self.initialTime, to: endDate)!
self.isActive = true
}
// Reset the timer
func reset() {
self.isActive = false
self.pauseInterval = 0.0
self.minutes = Float(initialTime)
self.time = "\(Int(minutes)):00"
}
func pause() {
if self.isActive {
pauseDate = Date()
} else {
// keep track of the total time we're paused
pauseInterval += Date().timeIntervalSince(pauseDate)
}
self.isActive.toggle()
}
// Show updates of the timer
func updateCountdown(){
guard isActive else { return }
// Gets the current date and makes the time difference calculation
let now = Date()
let diff = endDate.timeIntervalSince1970 + self.pauseInterval - now.timeIntervalSince1970
// Checks that the countdown is not <= 0
if diff <= 0 {
self.isActive = false
self.time = "0:00"
self.showingAlert = true
return
}
// Turns the time difference calculation into sensible data and formats it
let date = Date(timeIntervalSince1970: diff)
let calendar = Calendar.current
let minutes = calendar.component(.minute, from: date)
let seconds = calendar.component(.second, from: date)
// Updates the time string with the formatted time
//self.minutes = Float(minutes)
self.time = String(format:"%d:%02d", minutes, seconds)
}
}
}
struct TimerView: View {
#ObservedObject var vm = ViewModel()
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
let width: Double = 250
var body: some View {
VStack {
Text("Timer: \(vm.time)")
.font(.system(size: 50, weight: .medium, design: .rounded))
.alert("Timer done!", isPresented: $vm.showingAlert) {
Button("Continue", role: .cancel) {
}
}
.padding()
HStack(spacing:50) {
Button("Start") {
vm.start(minutes: Float(vm.minutes))
}
.padding()
.background((Color(red: 184/255, green: 243/255, blue: 255/255)))
.foregroundColor(.black)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
//.disabled(vm.isActive)
Button(vm.isActive ? "Pause" : "Resume") {
vm.pause()
//vm.isActive = false
//self.timer.upstream.connect().cancel()
}
.padding()
.foregroundColor(.black)
.background(vm.isActive ? .red : .green)
.cornerRadius(10)
.font(Font.system(size: UIFontMetrics.default.scaledValue(for: 16)))
}
.frame(width: width)
}
.onReceive(timer) { _ in
vm.updateCountdown()
}
}
}
I have a view model, that has multiple child view models. I am fairly new to watchOS, SwiftUI and Combine - taking this opportunity to learn.
I have a watchUI where it has
Play Button (View) - SetTimerPlayPauseButton
Text to show Time (View) - TimerText
View Model - that has 1 WatchDayProgramViewModel - N: ExerciseTestClass - N: SetInformationTestClass. For each ExerciseSets, there is a watchTimer & watchTimerSubscription and I have managed to run the timer to update remaining rest time.
ContentView - that has been injected the ViewModel as EnvironmentObject
If I tap SetTimerPlayPauseButton to start the timer, timer is running, working and changing the remainingRestTime(property within the child view model SetInformationTestClass) correctly, but the updates/changes are not being "published" to the TimerText View.
I have done most, if not all, the recommendation in other SO answers, I even made all my WatchDayProgramViewModel and ExerciseTestClass,SetInformationTestClass properties #Published, but they are still not updating the View, when the view model properties are updated as shown in the Xcode debugger below.
Please review my code and give me some advice on how to improve it.
ContentView
struct ContentView: View {
#State var selectedTab = 0
#StateObject var watchDayProgramVM = WatchDayProgramViewModel()
var body: some View {
TabView(selection: $selectedTab) {
SetRestDetailView().id(2)
}
.environmentObject(watchDayProgramVM)
.tabViewStyle(PageTabViewStyle())
.indexViewStyle(.page(backgroundDisplayMode: .automatic))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(watchDayProgramVM: WatchDayProgramViewModel())
}
}
}
SetRestDetailView
import Foundation
import SwiftUI
import Combine
struct SetRestDetailView: View {
#EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
var setCurrentHeartRate: Int = 120
#State var showingLog = false
var body: some View {
HStack {
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
TimerText(elapsedRestTime: elapsedRestTime, totalRestTime: totalRestTime, rect: rect)
.border(Color.yellow)
}
HStack {
SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
playImage: "play.fill",
pauseImage: "pause.fill",
bgColor: Color.clear,
fgColor: Color.white.opacity(0.5),
rect: rect) {
print("playtimer button tapped")
self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
}
.border(Color.yellow)
}
}
}
TimerText
struct TimerText: View {
var elapsedRestTime: Int
var totalRestTime: Int
var rect: CGRect
var body: some View {
VStack {
Text(counterToMinutes())
.font(.system(size: 100, weight: .semibold, design: .rounded))
.kerning(0)
.fontWeight(.semibold)
.minimumScaleFactor(0.25)
.padding(-1)
}
}
func counterToMinutes() -> String {
let currentTime = totalRestTime - elapsedRestTime
let seconds = currentTime % 60
let minutes = Int(currentTime / 60)
if currentTime > 0 {
return String(format: "%02d:%02d", minutes, seconds)
}
else {
return ""
}
}
}
ViewModel
import Combine
final class WatchDayProgramViewModel: ObservableObject {
#Published var exerciseVMList: [ExerciseTestClass] = [
(static/hard-coded values for testing)
]
class ExerciseTestClass: ObservableObject {
init(exercise: String, sets: [SetInformationTestClass]) {
self.exercise = exercise
self.sets = sets
}
var exercise: String
#Published var sets: [SetInformationTestClass]
}
class SetInformationTestClass: ObservableObject {
init(totalRestTime: Int, elapsedRestTime: Int, remainingRestTime: Int, isTimerRunning: Bool) {
self.totalRestTime = totalRestTime
self.elapsedRestTime = elapsedRestTime
self.remainingRestTime = remainingRestTime
self.isTimerRunning = isTimerRunning
}
#Published var totalRestTime: Int
#Published var elapsedRestTime: Int
#Published var remainingRestTime: Int
#Published var isTimerRunning = false
#Published var watchTimer = Timer.publish(every: 1.0, on: .main, in: .default)
#Published var watchTimerSubscription: AnyCancellable? = nil
#Published private var startTime: Date? = nil
func startTimer() {
print("startTimer initiated")
self.watchTimerSubscription?.cancel()
if startTime == nil {
startTime = Date()
}
self.isTimerRunning = true
self.watchTimerSubscription = watchTimer
.autoconnect()
.sink(receiveValue: { [weak self] _ in
guard let self = self, let startTime = self.startTime else { return }
let now = Date()
let elapsedTime = now.timeIntervalSince(startTime)
self.remainingRestTime = self.totalRestTime - Int(elapsedTime)
self.elapsedRestTime = self.totalRestTime - self.remainingRestTime
guard self.remainingRestTime > 0 else {
self.pauseTimer()
return
}
self.objectWillChange.send()
print("printing elapsedRest Time \(self.elapsedRestTime) sec")
print("printing remaining Rest time\(self.remainingRestTime)sec ")
})
}
func pauseTimer() {
//stop timer and retain elapsed rest time
print("pauseTimer initiated")
self.watchTimerSubscription?.cancel()
self.watchTimerSubscription = nil
self.isTimerRunning = false
self.startTime = nil
}
Managed to resolve the issue with help of #lorem ipsum and his feedback. As per his comment, the problem lied with the fact that
it is more than likely not working because you are chaining ObservableObjects #Published will only detect a change when the object is changed as a whole now when variables change. One way to test is to wrap each SetInformationTestClass in an #ObservbleObject by using a subview that takes the object as a parameter.
After which, I managed to find similar SO answers on changes in nested view model (esp child), and made the child view model an ObservedObject. The changes in child view model got populated to the view. Please see the changed code below.
SetRestDetailView
import Foundation
import SwiftUI
import Combine
struct SetRestDetailView: View {
#EnvironmentObject var watchDayProgramVM: WatchDayProgramViewModel
var setCurrentHeartRate: Int = 120
#State var showingLog = false
var body: some View {
HStack {
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
let setInformatationVM = self.watchDayProgramVM.exerciseVMList[0].sets[2]
TimerText(setInformationVM: setInformatationVM, rect: rect)
.border(Color.yellow)
}
HStack {
SetTimerPlayPauseButton(isSetTimerRunningFlag: false,
playImage: "play.fill",
pauseImage: "pause.fill",
bgColor: Color.clear,
fgColor: Color.white.opacity(0.5),
rect: rect) {
print("playtimer button tapped")
self.watchDayProgramVM.exerciseVMList[0].sets[2].startTimer()
let elapsedRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].elapsedRestTime
let totalRestTime = watchDayProgramVM.exerciseVMList[0].sets[2].totalRestTime
print("printing elapsedRestTime from SetRestDetailView \(elapsedRestTime)")
print("printing elapsedRestTime from SetRestDetailView \(totalRestTime)")
}
.border(Color.yellow)
}
}
}
TimerText
struct TimerText: View {
#ObservedObject var setInformationVM: SetInformationTestClass
// #State var elapsedRestTime: Int
// #State var totalRestTime: Int
var rect: CGRect
var body: some View {
VStack {
Text(counterToMinutes())
.font(.system(size: 100, weight: .semibold, design: .rounded))
.kerning(0)
.fontWeight(.semibold)
.minimumScaleFactor(0.25)
.padding(-1)
}
}
func counterToMinutes() -> String {
let currentTime = setInformationVM.totalRestTime - setInformationVM.elapsedRestTime
let seconds = currentTime % 60
let minutes = Int(currentTime / 60)
if currentTime > 0 {
return String(format: "%02d:%02d", minutes, seconds)
}
else {
return ""
}
}
}
My App calculate for divers some important Values like max. depth etc in meter or in feet. If the user changes the slider (ValueMOD2) to metric, it should be possible for the imperial value to calculated automatically the correct value and displayed in the slider (ValueMODft2).
For example:
The picker is set to metric. The user changes the value to 60m MOD. Now the user changes to Imperial and the slider should automatically be on 197 (rounded up) (60 * 3.281 = 196.85). The same should happen from imperial to metric.
How can I do that?
import SwiftUI
import Combine
struct ContentView: View {
#State var unitSelection = UserDefaults.standard.integer(forKey: "Picker")
#State var value_meter_initialized = 40.0
#State var value_feet_initialized = 100.0
var body: some View {
VStack {
HStack (alignment: .top) {
Spacer()
Picker("", selection: $unitSelection) {
Text("Metric").tag(0)
Text("Imperial").tag(1)
}
.pickerStyle(SegmentedPickerStyle()).padding(.horizontal, 89)
.onReceive(Just(unitSelection)) {
UserDefaults.standard.set($0, forKey: "Picker")
}
Spacer()
}
HStack {
if unitSelection == 0 {
ValueMOD2(value_meter: $value_meter_initialized)
} else {
ValueMODft2(value_feet: $value_feet_initialized)
}
}
}
}
}
struct ValueMOD2: View {
#Binding var value_meter: Double
var body: some View {
VStack {
HStack {
Text(" ")
Slider(value: $value_meter, in: 3...60, step: 1)
Text(" ")
}
HStack {
Text("\(value_meter, specifier: "%.0f")m")
Text("MOD")
}
}
}
}
struct ValueMODft2: View {
#Binding var value_feet: Double
var body: some View {
VStack {
HStack {
Text(" ")
Slider(value: $value_feet, in: 0...180, step: 10)
Text(" ")
}
HStack {
Text("\(value_feet, specifier: "%.0f")ft.")
Text("MOD")
}
}
}
}
You should use Swift generic Measurement structure. Just set it as UnitLength type and use it to convert the value from meter to feet. When displaying the value to the user you need to use MeasurementFormatter and set the unitOptions to providedUnit. So if the user set it to feet pass the feet measurement otherwise pass the meters:
extension Formatter {
static let measurement: MeasurementFormatter = {
let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
formatter.numberFormatter.maximumFractionDigits = 0
return formatter
}()
}
var value = 60.0
var meters: Measurement<UnitLength> { .init(value: value, unit: .meters) }
var feet: Measurement<UnitLength> { meters.converted(to: .feet) }
meters.value // 60
feet.value // 196.8503937007874
Formatter.measurement.string(from: meters) // "60 m"
Formatter.measurement.string(from: feet) // "197 ft"
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!
I'm adapting my iPad app to Mac with Mac Catalyst and am having a problem with the datePicker (it has a datePickerMode of time). On iPad the datePicker is a wheel and whenever the user scrolls on the date picker the dateChanged action is fired. But on Mac the date picker is not a scroller and is instead a type of text input. I can type and change all the time values on Mac, but the dateChanged action won't be fired until I press the return key.
I would like to get the dateChange action fired whenever a user is entering in a time. How can I do this? I tried adding different targets to the datePicker but nothing work.
I actually prefer to have the date scroller on the Mac so if anyone knows how to do this instead I would greatly appreciate it (I looked all over the internet for this and found nothing)!
Here's my code:
class DateVC: UIViewController {
#IBOutlet weak var datePicker: UIDatePicker!
override func viewDidLoad() {
super.viewDidLoad()
//Just show the time
datePicker.datePickerMode = .time
}
//Action connected to datePicker. This is not called until I press enter on Mac
#IBAction func datePickerChanged(_ sender: Any) {
//do actions
}
}
I have filed a bug report with Apple about 1 week ago.
For now I a doing the following to force the datepicker to use the wheel format. This fires the onchangedlistener as the wheels are spun.
if #available(macCatalyst 13.4, *) {
datePickerView.preferredDatePickerStyle = .wheels
}
Because your function linked with #IBAction which is to be called upon action, like 'button press'
you should follow different approach.
let datePicker = UIDatePicker()
datePicker.datePickerMode = .date
dateTextField.inputView = datePicker
datePicker.addTarget(self, action: #selector(datePickerChanged(picker:)), for: .valueChanged)
and here is your function:
#objc func datePickerChanged(picker: UIDatePicker) {
//do your action here
}
Here is a SwiftUI solution that displays a date and time scroller picker on iPad, iPhone and Mac Catalyst. Works without pressing the return key. You can easily display just the HoursMinutesPicker if desired.
import SwiftUI
struct ContentView: View {
#State var date = Date()
var body: some View {
NavigationView {
NavigationLink(destination: DateHMPicker(date: self.$date)) {
VStack {
Text("Show time")
Text("\(self.date)")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DateHMPicker: View {
var titleKey: LocalizedStringKey = ""
#Binding var date: Date
var body: some View {
HStack {
Spacer()
DatePicker(titleKey, selection: self.$date, displayedComponents: .date).datePickerStyle(WheelDatePickerStyle())
HoursMinutesPicker(date: self.$date)
Spacer()
}
}
}
struct HoursMinutesPicker: View {
#Binding var date: Date
#State var hours: Int = 0
#State var minutes: Int = 0
var body: some View {
HStack {
Spacer()
Picker("", selection: Binding<Int>(
get: { self.hours},
set : {
self.hours = $0
self.update()
})) {
ForEach(0..<24, id: \.self) { i in
Text("\(i) hours").tag(i)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 90).clipped()
Picker("", selection: Binding<Int>(
get: { self.minutes},
set : {
self.minutes = $0
self.update()
})) {
ForEach(0..<60, id: \.self) { i in
Text("\(i) min").tag(i)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 90).clipped()
Spacer()
}.onAppear(perform: loadData)
}
func loadData() {
self.hours = Calendar.current.component(.hour, from: date)
self.minutes = Calendar.current.component(.minute, from: date)
}
func update() {
if let newDate = Calendar.current.date(bySettingHour: self.hours, minute: self.minutes, second: 0, of: date) {
date = newDate
}
}
}
How about using DatePicker for the date part, and the following textfields for the time input part.
import SwiftUI
import Combine
struct ContentView: View {
#State var date = Date()
var body: some View {
NavigationView {
NavigationLink(destination: DateHMPicker(date: self.$date)) {
VStack {
Text("Show time")
Text("\(self.date)")
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DateHMPicker: View {
#State var labelText = ""
#Binding var date: Date
var body: some View {
HStack {
Spacer()
DatePicker(labelText, selection: self.$date, displayedComponents: .date)
Spacer()
HoursMinutesPicker(date: self.$date).frame(width: 90)
}.fixedSize()
}
}
struct TextFieldTime: View {
let range: ClosedRange<Int>
#Binding var value: Int
var handler: () -> Void
#State private var isGood = false
#State private var textValue = ""
#State private var digits = 2
var body: some View {
TextField("", text: $textValue)
.font(Font.body.monospacedDigit())
.onReceive(Just(textValue)) { txt in
// must be numbers
var newTxt = txt.filter {"0123456789".contains($0)}
if newTxt == txt {
// restrict the digits
if newTxt.count > self.digits {
newTxt = String(newTxt.dropLast())
}
// check the number
self.isGood = false
if let number = NumberFormatter().number(from: newTxt) {
if self.range.contains(number.intValue) {
self.textValue = newTxt
self.value = number.intValue
self.isGood = true
} else {
self.textValue = self.textValue.count == 1
? String(self.range.lowerBound) : String(self.textValue.dropLast())
}
}
if self.value >= 0 && self.isGood {
self.handler()
}
} else {
self.textValue = newTxt.isEmpty ? String(self.range.lowerBound) : newTxt
}
}.onAppear(perform: {
self.textValue = String(self.value)
self.digits = String(self.range.upperBound).count
})
.fixedSize()
}
}
struct HoursMinutesPicker: View {
#Binding var date: Date
#State var separator = ":"
#State var hours: Int = 0
#State var minutes: Int = 0
var body: some View {
HStack (spacing: 1) {
TextFieldTime(range: 0...23, value: self.$hours, handler: self.update)
Text(separator)
TextFieldTime(range: 0...59, value: self.$minutes, handler: self.update)
}.onAppear(perform: loadData).padding(5)
}
func loadData() {
self.hours = Calendar.current.component(.hour, from: date)
self.minutes = Calendar.current.component(.minute, from: date)
}
func update() {
let baseDate = Calendar.current.dateComponents([.year, .month, .day], from: date)
var dc = DateComponents()
dc.year = baseDate.year
dc.month = baseDate.month
dc.day = baseDate.day
dc.hour = self.hours
dc.minute = self.minutes
if let newDate = Calendar.current.date(from: dc), date != newDate {
date = newDate
}
}
}
I didn't find a solution to this using the built in UIDatePicker. I ended up moving to using JBCalendarDatePicker which accomplished the same look/feel.
If you prefer the wheel format of the DatePicker in Mac Catalyst, then change your Date Picker style to Wheels. It will display correctly on the Mac.
I converted my iPhone/iPad app to run on Mac Catalyst. I'm using Xcode 11.4.1 on MacOS Catalina 10.15.5. I was having a problem displaying the DatePicker as a wheel on the Mac version of the app. On the Mac emulator, it displayed as a text field, but the calendar would display when clicking on one of the date fields. I felt it would be a better user experience to stay with the wheel display.
A very simple workaround is to select the DatePicker in Storyboard. Display the Attributes Inspector. Change your Style from "Automatic" to "Wheels", and the display will go back to displaying as a wheel on the Mac Catalyst version.