DatePicker on Mac not saving date until return key is pressed - swift

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.

Related

Display function output live without Button press

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")
}
}
}

How can I use TimelineView in Swift 5 to refresh a text every second?

I'm pretty new at swift coding and I'm trying to do a very simple project: just a clock that shows the time. I'm using TimelineView to refresh the time every second, but it's not working. This is my code:
import SwiftUI
struct ContentView: View {
#State var hour: Int = Calendar.current.component(.hour, from: Date())
#State var minute: Int = Calendar.current.component(.minute, from: Date())
#State var second: Int = Calendar.current.component(.second, from: Date())
var body: some View {
ZStack {
VStack{
Spacer()
HStack {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(String(hour))
Text(String(minute))
Text(String(second))
}
}
Spacer()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.previewInterfaceOrientation(.portrait)
}
}
}
Since my hour, minute and second variables are #State and I'm using the TimelineView, they should refresh every second, shouldn't they?
I'm very confused and I would appreciate some help. Thank you very much.
You have to observe changes in the timeline.
Here I used onChange and update the value of min, sec, and hour.
struct TimerView: View {
var date: Date
#State var hour: Int = Calendar.current.component(.hour, from: Date())
#State var minute: Int = Calendar.current.component(.minute, from: Date())
#State var second: Int = Calendar.current.component(.second, from: Date())
var body: some View {
VStack {
Text(String(hour))
Text(String(minute))
Text(String(second))
}
.onChange(of: date) { _ in
second += 1
if second > 59 {
minute += 1
second = 0
if minute > 59 {
hour += 1
minute = 0
if hour > 23 {
hour = 0
}
}
}
}
}
}
struct ContentView: View { var body: some View {
ZStack {
VStack{
Spacer()
HStack {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
TimerView(date: timeline.date)
}
}
Spacer()
}
}
}
}
As the documentation says (https://developer.apple.com/documentation/swiftui/timelineview):
A timeline view acts as a container with no appearance of its own. Instead, it redraws the content it contains at scheduled points in time
The content it contains is defined in the closure you provide:
TimelineView(...) { timeline in
// content which gets redrawn
}
Inside this closure you have access to a TimelineView.Context (https://developer.apple.com/documentation/swiftui/timelineview/context). With the help of this context, you can access the date which triggered the update / redraw like so:
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text("\(timeline.date)")
}
This will produce the following output:
To improve formatting, you could use a DateFormatter (https://developer.apple.com/documentation/foundation/dateformatter):
struct ContentView: View {
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .medium
return dateFormatter
}()
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text("\(dateFormatter.string(from: timeline.date))")
}
}
}
Just make your hour-minute-second as computed property not #State
struct ContentView: View {
var hour: Int {
Calendar.current.component(.hour, from: Date())
}
var minute: Int {
Calendar.current.component(.minute, from: Date())
}
var second: Int {
Calendar.current.component(.second, from: Date())
}
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
HStack {
Text(String(hour))
Text(String(minute))
Text(String(second))
}
}
}
}

SwiftUI - show view during Digital Crown rotation

I'm looking to show Text when scrolling but hide the text when not scrolling using digitalCrownRotation (like the indicator shown when scrolling). Currently it's only working one way when I scroll and doesn't work at too well, would this be possible to accomplish?
extension View {
func hidden(_ shouldHide: Bool) -> some View {
opacity(shouldHide ? 0 : 1)
}
}
struct ContentView: View {
#State var date: Date = Date()
#State var scroll: Double = 0.0
#State var previous: Double = 0.0
#State var scrolling: Bool = false
var body: some View {
VStack {
Text("\(date.dateFormat("E, d MMM"))")
.focusable(true)
.hidden(!scrolling)
.digitalCrownRotation($scroll, from: 0, through: 365, by: 1, sensitivity: .low, isContinuous: false, isHapticFeedbackEnabled: true)
.onChange(of: scroll) { value in
scrolling = (value > previous)
previous = value
date = Calendar.current.date(byAdding: .day, value: Int(value), to: Date())!
}
}
.onAppear {
self.date = Date()
}
}
}
You need to show your view while user scrolls and hide when he ended doing so.
I suggest you using .debounce from Combine. What it does it waits for some time (1 sec in my example, should be fine for you) after each new value passed, and only pass it if no new value was sent during this time.
So in this case it'll wait 1 sec after last crown touch before hiding the view:
#State var date: Date = Date()
#State var scroll: Double = 0.0
#State var scrolling: Bool = false
private let relay = PassthroughSubject<Double, Never>()
private let debouncedPublisher: AnyPublisher<Double, Never>
init() {
debouncedPublisher = relay
.removeDuplicates()
.debounce(for: 1, scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
var body: some View {
VStack {
Text("\(date)")
.focusable(true)
.opacity(scrolling ? 1 : 0)
.digitalCrownRotation($scroll, from: 0, through: 365, by: 1, sensitivity: .low, isContinuous: false, isHapticFeedbackEnabled: true)
.onChange(of: scroll) { value in
withAnimation {
scrolling = true
}
relay.send(value)
date = Calendar.current.date(byAdding: .day, value: Int(value), to: Date())!
}
.onReceive(
debouncedPublisher,
perform: { value in
withAnimation {
scrolling = false
}
}
)
}
.onAppear {
self.date = Date()
}
}
Result:

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!

Handling derived state in SwiftUI

say I am creating an "Date Editor" view. The goal is:
- Take a default, seed date.
- It lets the user alter the input.
- If the user then chooses, they can press "Save", in which case the owner of the view can decide to do something with the data.
Here's one way to implement it:
struct AlarmEditor : View {
var seedDate : Date
var handleSave : (Date) -> Void
#State var editingDate : Date?
var body : some View {
let dateBinding : Binding<Date> = Binding(
get: {
return self.editingDate ?? seedDate
},
set: { date in
self.editingDate = date
}
)
return VStack {
DatePicker(
selection: dateBinding,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
Spacer()
Button(action: {
self.handleSave(dateBinding.wrappedValue)
}) {
Text("Save").font(.headline).bold()
}
}
}
}
The Problem
What if the owner changes the value of seedDate?
Say in that case, what I wanted to do was to reset the value of editingDate to the new seedDate.
What would be an idiomatic way of doing this?
I'm not sure that I have understand the purpose of the seedDate here. But I think you are relying on events (kind of UIKit way) a bit too much instead of the single source of truth principle (the SwiftUI way).
Update: Added a way to cancel the date edition.
In that case, the editor view should mutate the Binding only when saving. To do so, it uses a private State that will be used for the date picker. This way, the source of truth is preserved as the private state used will never leave the context of the editing view.
struct ContentView: View {
#State var dateEditorVisible = false
#State var date: Date = Date() // source of truth
var body: some View {
NavigationView {
VStack {
Text("\(date.format("HH:mm:ss"))")
Button(action: self.showDateEditor) {
Text("Edit")
}
.sheet(isPresented: $dateEditorVisible) {
// Here we provide a two way binding to the `date` state
// and a way to dismiss the editor view.
DateEditorView(date: self.$date, dismiss: self.hideDateEditor)
}
}
}
}
func showDateEditor() {
dateEditorVisible = true
}
func hideDateEditor() {
dateEditorVisible = false
}
}
struct DateEditorView: View {
// Only a binding.
// Updating this value will update the `#State date` of the parent view
#Binding var date: Date
#State private var editingDate: Date = Date()
private var dismiss: () -> Void
init(date: Binding<Date>, dismiss: #escaping () -> Void) {
self._date = date
self.dismiss = dismiss
// assign the wrapped value as default value for edition
self.editingDate = date.wrappedValue
}
var body: some View {
VStack {
DatePicker(selection: $editingDate, displayedComponents: .hourAndMinute) {
Text("Date")
}
HStack {
Button(action: self.save) {
Text("Save")
}
Button(action: self.dismiss) {
Text("Cancel")
}
}
}
}
func save() {
date = editingDate
dismiss()
}
}
With this way, you don't need to define a save action to update the parent view or keep in sync the current value with some default value. You only have a single source of truth that drives all of your UI.
Edit:
The Date extension to make it build.
extension Date {
private static let formater = DateFormatter()
func format(_ format: String) -> String {
Self.formater.dateFormat = format
return Self.formater.string(from: self)
}
}
I would prefer to do this via explicitly used ViewModel for such editor, and it requires minimal modifications in your code. Here is possible approach (tested & worked with Xcode 11.2.1):
Testing parent
struct TestAlarmEditor: View {
private var editorModel = AlarmEditorViewModel()
var body: some View {
VStack {
AlarmEditor(viewModel: self.editorModel, handleSave: {_ in }, editingDate: nil)
Button("Reset") {
self.editorModel.seedDate = Date(timeIntervalSinceNow: 60 * 60)
}
}
}
}
Simple view model for editor
class AlarmEditorViewModel: ObservableObject {
#Published var seedDate = Date() // << can be any or set via init
}
Updated editor
struct AlarmEditor : View {
#ObservedObject var viewModel : AlarmEditorViewModel
var handleSave : (Date) -> Void
#State var editingDate : Date?
var body : some View {
let dateBinding : Binding<Date> = Binding(
get: {
return self.editingDate ?? self.viewModel.seedDate
},
set: { date in
self.editingDate = date
}
)
return VStack {
DatePicker(
selection: dateBinding,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
.onReceive(self.viewModel.$seedDate, perform: {
self.editingDate = $0 }) // << reset here
Spacer()
Button(action: {
self.handleSave(dateBinding.wrappedValue)
}) {
Text("Save").font(.headline).bold()
}
}
}
}
Comment and warning
Basically, this question amounts to looking for a replacement for didSet on the OP's var seedDate.
I used one of my support requests with Apple on this same question a few months ago. The latest response from them was that they have received several questions like this, but they don't have a "good" solution yet. I shared the solution below and they answered "Since it's working, use it."
What follows below is quite "smelly" but it does work. Hopefully we'll see improvements in iOS 14 that remove the necessity for something like this.
Concept
We can take advantage of the fact that body is the only entrance point for view rendering. Therefore, we can track changes to our view's inputs over time and change internal state based on that. We just have to be careful about how we update things so that SwiftUI's idea of State is not modified incorrectly.
We can do this by using a struct that contains two reference values:
The value we want to track
The value we want to modify when #1 changes
If we want SwiftUI to update we replace the reference value. If we want to update based on changes to #1 inside the body, we update the value held by the reference value.
Implementation
Gist here
First, we want to wrap any value in a reference type. This allows us to save a value without triggering SwiftUI's update mechanisms.
// A class that lets us wrap any value in a reference type
class ValueHolder<Value> {
init(_ value: Value) { self.value = value }
var value: Value
}
Now, if we declare #State var valueHolder = ValueHolder(0) we can do:
Button("Tap me") {
self.valueHolder.value = 0 // **Doesn't** trigger SwiftUI update
self.valueHolder = ValueHolder(0) // **Does** trigger SwiftUI update
}
Second, create a property wrapper that holds two of these, one for our external input value, and one for our internal state.
See this answer for an explanation of why I use State in the property wrapper.
// A property wrapper that holds a tracked value, and a value we'd like to update when that value changes.
#propertyWrapper
struct TrackedValue<Tracked, Value>: DynamicProperty {
var trackedHolder: State<ValueHolder<Tracked>>
var valueHolder: State<ValueHolder<Value>>
init(wrappedValue value: Value, tracked: Tracked) {
self.trackedHolder = State(initialValue: ValueHolder(tracked))
self.valueHolder = State(initialValue: ValueHolder(value))
}
var wrappedValue: Value {
get { self.valueHolder.wrappedValue.value }
nonmutating set { self.valueHolder.wrappedValue = ValueHolder(newValue) }
}
var projectedValue: Self { return self }
}
And finally add a convenience method to let us efficiently update when we need to. Since this returns a View you can use it inside of any ViewBuilder.
extension TrackedValue {
#discardableResult
public func update(tracked: Tracked, with block:(Tracked, Value) -> Value) -> some View {
self.valueHolder.wrappedValue.value = block(self.trackedHolder.wrappedValue.value, self.valueHolder.wrappedValue.value)
self.trackedHolder.wrappedValue.value = tracked
return EmptyView()
}
}
Usage
If you run the below code, childCount will reset to 0 every time masterCount changes.
struct ContentView: View {
#State var count: Int = 0
var body: some View {
VStack {
Button("Master Count: \(self.count)") {
self.count += 1
}
ChildView(masterCount: self.count)
}
}
}
struct ChildView: View {
var masterCount: Int
#TrackedValue(tracked: 0) var childCount: Int = 0
var body: some View {
self.$childCount.update(tracked: self.masterCount) { (old, myCount) -> Int in
if self.masterCount != old {
return 0
}
return myCount
}
return Button("Child Count: \(self.childCount)") {
self.childCount += 1
}
}
}
following your code, I would do something like this.
struct AlarmEditor: View {
var handleSave : (Date) -> Void
#State var editingDate : Date
init(seedDate: Date, handleSave: #escaping (Date) -> Void) {
self._editingDate = State(initialValue: seedDate)
self.handleSave = handleSave
}
var body: some View {
Form {
DatePicker(
selection: $editingDate,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
Spacer()
Button(action: {
self.handleSave(self.editingDate)
}) {
Text("Save").font(.headline).bold()
}
}
}//body
}//AlarmEditor
struct AlarmEditor_Previews: PreviewProvider {
static var previews: some View {
AlarmEditor(seedDate: Date()) { editingDate in
print(editingDate.description)
}
}
}
And, use it like this elsewhere.
AlarmEditor(seedDate: Date()) { editingDate in
//do anything you want with editingDate
print(editingDate.description)
}
this is my sample output:
2020-02-07 23:39:42 +0000
2020-02-07 22:39:42 +0000
2020-02-07 23:39:42 +0000
2020-02-07 21:39:42 +0000
what do you think? 50 points