3

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?

Willeke
  • 14,578
  • 4
  • 19
  • 47
Stepan Parunashvili
  • 2,627
  • 5
  • 30
  • 51

4 Answers4

5

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()
            }
        }
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
4

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)
    }
}
rraphael
  • 10,041
  • 2
  • 25
  • 33
  • The benefits of single source of truth make sense to me, but what is a good way to handle cancelling/discarding the edits? Won't that require a separate piece of state to be able to revert to? – Ben Feb 04 '20 at 17:49
  • 1
    @Ben Indeed, I updated the answer to provide an example of how I would do it. – rraphael Feb 04 '20 at 18:49
2

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:

  1. The value we want to track
  2. 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
        }
    }
}
arsenius
  • 12,090
  • 7
  • 58
  • 76
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

WelcomeNewUsers
  • 247
  • 4
  • 7