Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI DatePicker Binding optional Date, valid nil

I'm experimenting code from https://alanquatermain.me/programming/swiftui/2019-11-15-CoreData-and-bindings/

my goal is to have DatePicker bind to Binding< Date? > which allow for nil value instead of initiate to Date(); this is useful, if you have Date attribute in your core data model entity which accept nil as valid value.

Here is my swift playground code:

extension Binding {
    init<T>(isNotNil source: Binding<T?>, defaultValue: T) where Value == Bool {
        self.init(get: { source.wrappedValue != nil },
                  set: { source.wrappedValue = $0 ? defaultValue : nil})
    }
}

struct LiveView: View {
    @State private var testDate: Date? = nil
    var body: some View {
        VStack {
            Text("abc")

            Toggle("Has Due Date",
                   isOn: Binding(isNotNil: $testDate, defaultValue: Date()))

            if testDate != nil {
                DatePicker(
                    "Due Date",
                    selection: Binding($testDate)!,
                    displayedComponents: .date
                )
            }
        }
    }
}

let liveView = LiveView()
PlaygroundPage.current.liveView = UIHostingController(rootView: liveView)

I can't find solution to fix this code. It works when the toggle first toggled to on, but crash when the toggle turned back off.

The code seems to behave properly when I removed the DatePicker, and change the code to following:

extension Binding {
    init<T>(isNotNil source: Binding<T?>, defaultValue: T) where Value == Bool {
        self.init(get: { source.wrappedValue != nil },
                  set: { source.wrappedValue = $0 ? defaultValue : nil})
    }
}

struct LiveView: View {
    @State private var testDate: Date? = nil
    var body: some View {
        VStack {
            Text("abc")

            Toggle("Has Due Date",
                   isOn: Binding(isNotNil: $testDate, defaultValue: Date()))

            if testDate != nil {
                Text("\(testDate!)")
            }
        }
    }
}

let liveView = LiveView()
PlaygroundPage.current.liveView = UIHostingController(rootView: liveView)

I suspect it's something to do with this part of the code

DatePicker("Due Date", selection: Binding($testDate)!, displayedComponents: .date )

or

problem when the source.wrappedValue set back to nil (refer to Binding extension)

like image 786
wk.experimental Avatar asked Dec 10 '19 17:12

wk.experimental


3 Answers

The problem is that DatePicker grabs binding and is not so fast to release it even when you remove it from view, due to Toggle action, so it crashes on force unwrap optional, which becomes nil ...

The solution for this crash is

DatePicker(
    "Due Date",
    selection: Binding<Date>(get: {self.testDate ?? Date()}, set: {self.testDate = $0}),
    displayedComponents: .date
)
like image 177
Asperi Avatar answered Nov 04 '22 02:11

Asperi


An alternative solution that I use in all my SwiftUI pickers...

I learned almost all I know about SwiftUI Bindings (with Core Data) by reading that blog by Jim Dovey. The remainder is a combination of some research and quite a few hours of making mistakes.

So when I use Jim's technique to create Extensions on SwiftUI Binding then we end up with something like this for a deselection to nil...

public extension Binding where Value: Equatable {
    init(_ source: Binding<Value>, deselectTo value: Value) {
        self.init(get: { source.wrappedValue },
                  set: { source.wrappedValue = $0 == source.wrappedValue ? value : $0 }
        )
    }
}

Which can then be used throughout your code like this...

Picker("Due Date", 
       selection: Binding($testDate, deselectTo: nil),
       displayedComponents: .date
) 

OR when using .pickerStyle(.segmented)

Picker("Date Format Options", // for example 
       selection: Binding($selection, deselectTo: -1)) { ... }
    .pickerStyle(.segmented)

... which sets the index of the segmented style picker to -1 as per the documentation for UISegmentedControl and selectedSegmentIndex.

The default value is noSegment (no segment selected) until the user touches a segment. Set this property to -1 to turn off the current selection.

like image 2
andrewbuilder Avatar answered Nov 04 '22 00:11

andrewbuilder


Here is my solution, I added a button to remove the date and add a default date. All it's wrapped in a new component

https://gist.github.com/Fiser12/62ef54ba0048e5b62cf2f2a61f279492

import SwiftUI

struct NullableBindedValue<T>: View {
    var value: Binding<T?>
    var defaultView: (Binding<T>, @escaping (T?) -> Void) -> AnyView
    var nullView: ( @escaping (T?) -> Void) -> AnyView

    init(
        _ value: Binding<T?>,
        defaultView: @escaping (Binding<T>, @escaping (T?) -> Void) -> AnyView,
        nullView: @escaping ( @escaping (T?) -> Void) -> AnyView
    ) {
        self.value = value
        self.defaultView = defaultView
        self.nullView = nullView
    }

    func setValue(newValue: T?) {
        self.value.wrappedValue = newValue
    }

    var body: some View {
        HStack(spacing: 0) {
            if value.unwrap() != nil {
                defaultView(value.unwrap()!, setValue)
            } else {
                nullView(setValue)
            }
        }
    }
}

struct DatePickerNullable: View {
    var title: String
    var selected: Binding<Date?>
    @State var defaultToday: Bool = false

    var body: some View {
        NullableBindedValue(
            selected,
            defaultView: { date, setDate in
                let setDateNil = {
                    setDate(nil)
                    self.defaultToday = false
                }

                return AnyView(
                    HStack {
                        DatePicker(
                            "",
                            selection: date,
                            displayedComponents: [.date, .hourAndMinute]
                        ).font(.title2)
                        Button(action: setDateNil) {
                            Image(systemName: "xmark.circle")
                                .foregroundColor(Color.defaultColor)
                                .font(.title2)
                        }
                        .buttonStyle(PlainButtonStyle())
                        .background(Color.clear)
                        .cornerRadius(10)
                    }
                )
            },
            nullView: { setDate in
                let setDateNow = {
                    setDate(Date())
                }

                return AnyView(
                    HStack {
                        TextField(
                            title,
                            text: .constant("Is empty")
                        ).font(.title2).disabled(true).textFieldStyle(RoundedBorderTextFieldStyle())
                        Button(action: setDateNow) {
                            Image(systemName: "plus.circle")
                                .foregroundColor(Color.defaultColor)
                                .font(.title2)
                        }
                        .buttonStyle(PlainButtonStyle())
                        .background(Color.clear)
                        .cornerRadius(10)
                    }.onAppear(perform: {
                        if self.defaultToday {
                            setDateNow()
                        }
                    })
                )
            }
        )
    }
}
like image 1
Fiser Avatar answered Nov 04 '22 00:11

Fiser