SwiftUI List selection always nil - swift

In my macOS app project, I have a SwiftUI List view of NavigationLinks build with a foreach loop from an array of items:
struct MenuView: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
List(selection: $settings.selectedWeek) {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings)
tag: week,
selection: $settings.selectedWeek)
{
Image(systemName: "circle")
Text("\(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
This view creates the sidebar menu for a overall NavigationView.
In this List view, I would like to use the selection mechanic together with tag from NavigationLink. Week is a custom model class:
struct Week: Identifiable, Hashable, Equatable {
var id = UUID()
var days: [Day] = []
var name: String
}
And UserSettings looks like this:
class UserSettings: ObservableObject {
#Published var weeks: [Week] = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
#Published var selectedWeek: Week? = UserDefaults.standard.object(forKey: "week.selected") as? Week {
didSet {
var a = oldValue
var b = selectedWeek
UserDefaults.standard.set(selectedWeek, forKey: "week.selected")
}
}
}
My goal is to directly store the value from List selection in UserDefaults. The didSet property gets executed, but the variable is always nil. For some reason the selected List value can't be stored in the published / bindable variable.
Why is $settings.selectedWeek always nil?

A couple of suggestions:
SwiftUI (specifically on macOS) is unreliable/unpredictable with certain List behaviors. One of them is selection -- there are a number of things that either completely don't work or at best are slightly broken that work fine with the equivalent iOS code. The good news is that NavigationLink and isActive works like a selection in a list -- I'll use that in my example.
#Published didSet may work in certain situations, but that's another thing that you shouldn't rely on. The property wrapper aspect makes it behave differently than one might except (search SO for "#Published didSet" to see a reasonable number of issues dealing with it). The good news is that you can use Combine to recreate the behavior and do it in a safer/more-reliable way.
A logic error in the code:
You are storing a Week in your user defaults with a certain UUID. However, you regenerate the array of weeks dynamically on every launch, guaranteeing that their UUIDs will be different. You need to store your week's along with your selection if you want to maintain them from launch to launch.
Here's a working example which I'll point out a few things about below:
import SwiftUI
import Combine
struct ContentView : View {
var body: some View {
NavigationView {
MenuView().environmentObject(UserSettings())
}
}
}
class UserSettings: ObservableObject {
#Published var weeks: [Week] = []
#Published var selectedWeek: UUID? = nil
private var cancellable : AnyCancellable?
private var initialItems = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
init() {
let decoder = PropertyListDecoder()
if let data = UserDefaults.standard.data(forKey: "weeks") {
weeks = (try? decoder.decode([Week].self, from: data)) ?? initialItems
} else {
weeks = initialItems
}
if let prevValue = UserDefaults.standard.string(forKey: "week.selected.id") {
selectedWeek = UUID(uuidString: prevValue)
print("Set selection to: \(prevValue)")
}
cancellable = $selectedWeek.sink {
if let id = $0?.uuidString {
UserDefaults.standard.set(id, forKey: "week.selected.id")
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(self.weeks) {
UserDefaults.standard.set(encoded, forKey: "weeks")
}
}
}
}
func selectionBindingForId(id: UUID) -> Binding<Bool> {
Binding<Bool> { () -> Bool in
self.selectedWeek == id
} set: { (newValue) in
if newValue {
self.selectedWeek = id
}
}
}
}
//Unknown what you have in here
struct Day : Equatable, Hashable, Codable {
}
struct Week: Identifiable, Hashable, Equatable, Codable {
var id = UUID()
var days: [Day] = []
var name: String
}
struct WeekView : View {
var week : Week
var body: some View {
Text("Week: \(week.name)")
}
}
struct MenuView: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
List {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings),
isActive: settings.selectionBindingForId(id: week.id)
)
{
Image(systemName: "circle")
Text("\(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
In UserSettings.init the weeks are loaded if they've been saved before (guaranteeing the same IDs)
Use Combine on $selectedWeek instead of didSet. I only store the ID, since it seems a little pointless to store the whole Week struct, but you could alter that
I create a dynamic binding for the NavigationLinks isActive property -- the link is active if the stored selectedWeek is the same as the NavigationLink's week ID.
Beyond those things, it's mostly the same as your code. I don't use selection on List, just isActive on the NavigationLink
I didn't implement storing the Week again if you did the onMove or onDelete, so you would have to implement that.

Bumped into a situation like this where multiple item selection didn't work on macOS. Here's what I think is happening and how to workaround it and get it working
Background
So on macOS NavigationLinks embedded in a List render their Destination in a detail view (by default anyway). e.g.
struct ContentView: View {
let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
#State var listSelection = Set<String>()
var body: some View {
NavigationView {
List(beatles, id: \.self, selection: $listSelection) { name in
NavigationLink(name) {
Text("Some details about \(name)")
}
}
}
}
}
Renders like so
Problem
When NavigationLinks are used it is impossible to select multiple items in the sidebar (at least as of Xcode 13 beta4).
... but it works fine if just Text elements are used without any NavigationLink embedding.
What's happening
The detail view can only show one NavigationLink View at a time and somewhere in the code (possibly NavigationView) there is piece of code that is enforcing that compliance by stomping on multiple selection and setting it to nil, e.g.
let selectionBinding = Binding {
backingVal
} set: { newVal in
guard newVal <= 1 else {
backingVal = nil
return
}
backingVal = newVal
}
What happens in these case is to the best of my knowledge not defined. With some Views such as TextField it goes out of sync with it's original Source of Truth (for more), while with others, as here it respects it.
Workaround/Fix
Previously I suggested using a ZStack to get around the problem, which works, but is over complicated.
Instead the idiomatic option for macOS, as spotted on the Lost Moa blog post is to not use NaviationLink at all.
It turns out that just placing sidebar and detail Views adjacent to each other and using binding is enough for NavigationView to understand how to render and stops it stomping on multiple item selections. Example shown below:
struct ContentView: View {
let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
#State var listSelection: Set<String> = []
var body: some View {
NavigationView {
SideBar(items: beatles, selection: $listSelection)
Detail(ids: listSelection)
}
}
struct SideBar: View {
let items: Array<String>
#Binding var selection: Set<String>
var body: some View {
List(items, id: \.self, selection: $selection) { name in
Text(name)
}
}
}
struct Detail: View {
let ids: Set<String>
var detailsMsg: String {
ids.count == 1 ? "Would show details for \(ids.first)"
: ids.count > 1 ? "Too many items selected"
: "Nothing selected"
}
var body: some View {
Text(detailsMsg)
}
}
}
Have fun.

Related

SwiftUI List: Prevent Empty Selection

I have a SwiftUI list, defined in a typical fashion:
struct SettingsView: View
{
#State private var selectedCategory: SettingsCategory? = .general
List(SettingsCategory.allCases, id: \.self, selection: $selectedCategory) { category in
[...]
}
}
In this case, the List is a table of "categories" for a settings area in my UI. The SettingsCategory is an enum that defines these categories, and the UI ends up looking like this:
It is not appropriate for this list to have an empty selection; a category should always be selected. In AppKit, it was trivially easy to disable an empty selection on NSTableView. But in SwiftUI, I've been unable to find a way to disable it. Anytime I click in the empty area of the list, the selection is cleared. How can I stop that?
selectedCategory must be an Optional or the compiler vomits all over itself.
I can't use willSet/didSet on selectedCategory because of the #State property wrapper. And I can't use a computed property that never returns nil because the List's selection has to be bound.
I also tried this approach: SwiftUI DatePicker Binding optional Date, valid nil
So, what magical incantation is required to disable empty selection in List?
One solution would be to set the selection back to the original one if the selection becomes nil.
Code:
struct ContentView: View {
#State private var selectedCategory: SettingsCategory = .general
var body: some View {
NavigationView {
SettingsView(selectedCategory: $selectedCategory)
Text("Category: \(selectedCategory.rawValue.capitalized)")
.navigationTitle("App")
}
}
}
enum SettingsCategory: String, CaseIterable, Identifiable {
case destination
case general
case speed
case schedule
case advanced
case scripts
var id: String { rawValue }
}
struct SettingsView: View {
#Binding private var selectedCategory: SettingsCategory
#State private var selection: SettingsCategory?
init(selectedCategory: Binding<SettingsCategory>) {
_selectedCategory = Binding<SettingsCategory>(
get: { selectedCategory.wrappedValue },
set: { newCategory in
selectedCategory.wrappedValue = newCategory
}
)
}
var body: some View {
List(SettingsCategory.allCases, selection: $selection) { category in
Text(category.rawValue.capitalized)
.tag(category)
}
.onChange(of: selection) { [oldCategory = selection] newCategory in
if let newCategory = newCategory {
selection = newCategory
selectedCategory = newCategory
} else {
selection = oldCategory
}
}
}
}
you could try adding .onChange to the List, such as:
.onChange(of: selectedCategory) { val in
if val == nil {
selectedCategory = .general // <-- make sure never nil
}
}

SwiftUI problems with Multiplatform NavigationView

I'm currently having all sorts of problems with a NavigationView in my multi-platform SwiftUI app.
My goal is to have a NavigationView with an item for each object in a list from Core Data. And each NavigationLink should lead to a view that can read and write data of the object that it's showing.
However I'm running into many problems, so I figured I'm probably taking the wrong approach.
Here is my code as of now:
struct InstanceList: View {
#StateObject private var viewModel = InstancesViewModel()
#State var selectedInstance: Instance?
var body: some View {
NavigationView {
List {
ForEach(viewModel.instances) { instance in
NavigationLink(destination: InstanceView(instance: instance), tag: instance, selection: $selectedInstance) {
InstanceRow(instance)
}
}
.onDelete { set in
viewModel.deleteInstance(viewModel.instances[Array(set)[0]])
for reverseIndex in stride(from: viewModel.instances.count - 1, through: 0, by: -1) {
viewModel.instances[reverseIndex].id = Int16(reverseIndex)
}
}
}
.onAppear {
selectedInstance = viewModel.instances.first
}
.listStyle(SidebarListStyle())
.navigationTitle("Instances")
.toolbar {
ToolbarItemGroup {
Button {
withAnimation {
viewModel.addInstance(name: "4x4", puzzle: "3x3") // temporary
}
} label: {
Image(systemName: "plus")
}
}
}
}
}
}
and the view model (which probably isn't very relevant but I'm including it just in case):
class InstancesViewModel: ObservableObject {
#Published var instances = [Instance]()
private var cancellable: AnyCancellable?
init(instancePublisher: AnyPublisher<[Instance], Never> = InstanceStorage.shared.instances.eraseToAnyPublisher()) {
cancellable = instancePublisher.sink { instances in
self.instances = instances
}
}
func addInstance(name: String, puzzle: String, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.add(
name: name,
puzzle: puzzle,
notes: notes,
id: id ?? (instances.map{ Int($0.id) }.max() ?? -1) + 1
)
}
func deleteInstance(_ instance: Instance) {
InstanceStorage.shared.delete(instance)
}
func deleteInstance(withId id: Int) {
InstanceStorage.shared.delete(withId: id)
}
func updateInstance(_ instance: Instance, name: String? = nil, puzzle: String? = nil, notes: String? = nil, id: Int? = nil) {
InstanceStorage.shared.update(instance, name: name, puzzle: puzzle, notes: notes, id: id)
}
}
and then the InstanceView, which just shows some simple information for testing:
struct InstanceView: View {
#ObservedObject var instance: Instance
var body: some View {
Text(instance.name)
Text(String(instance.id))
}
}
Some of the issues I'm having are:
On iOS and iPadOS, when the app starts, it will show a blank InstanceView, pressing the back button will return to a normal instanceView and pressing it again will show the navigationView
Sometime pressing on a navigationLink will only highlight it and won't go to the destination
On an iPhone in landscape, when scrolling through the NavigationView, sometimes the selected Item will get unselected.
When I delete an item, the InstanceView shows nothing for the name and 0 for the id, as if its showing a "ghost?" instance, until you select a different one.
I've tried binding the selecting using the index of the selected Instance but that still has many of the same problems.
So I feel like I'm making some mistake in the way that I'm using NavigationView, and I was wondering what the best approach would be for creating a navigationView from an Array that works nicely across all devices.
Thanks!

SwiftUI ObservedObject causes undesirable visible view updates

I am working on an app that applies a filter to an image. The filter has a number of parameters that the user can modify. I have created an ObservableObject that contain said parameters. Whenever one of the parameters changes, there is a visible update for views, even if the view displays the same value as before. This does not happen when I model the parameters as individual #State variables.
If this is to be expected (after all the observed object does change, so each view depending on it will update), is an ObservedObject the right tool for the job? On the other hand it seems to be very inconvenient to model the parameters as individual #State/#Binding variables, especially if a large number of parameters (e.g. 10+) need to be passed to multiple subviews!
Hence my question:
Am I using ObservedObject correctly here? Are the visible updates unintended, but acceptable, or is there a better solution to handle this in swiftUI?
Example using #ObservedObject:
import SwiftUI
class Parameters: ObservableObject {
#Published var pill: String = "red"
#Published var hand: String = "left"
}
struct ContentView: View {
#ObservedObject var parameters = Parameters()
var body: some View {
VStack {
// Using the other Picker causes a visual effect here...
Picker(selection: self.$parameters.pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
// Using the other Picker causes a visual effect here...
Picker(selection: self.$parameters.hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}
Example using #State variables:
import SwiftUI
struct ContentView: View {
#State var pill: String = "red"
#State var hand: String = "left"
var body: some View {
VStack {
Picker(selection: self.$pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
Picker(selection: self.$hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}
Warning: This answer is less than ideal. If the properties of parameters will be updated in another view (e.g. an extra picker), the picker view will not be updated.
The ContentView should not 'observe' parameters; a change in parameters will cause it to update its content (which is visible in case of the Pickers). To prevent the need for the observed property wrapper, we can provide explicit bindings for parameter's properties instead. It is OK for a subview of ContentView to use #Observed on parameters.
import SwiftUI
class Parameters: ObservableObject {
#Published var pill: String = "red"
#Published var hand: String = "left"
}
struct ContentView: View {
var parameters = Parameters()
var handBinding: Binding<String> {
Binding<String>(
get: { self.parameters.hand },
set: { self.parameters.hand = $0 }
)
}
var pillBinding: Binding<String> {
Binding<String>(
get: { self.parameters.pill },
set: { self.parameters.pill = $0 }
)
}
var body: some View {
VStack {
InfoDisplay(parameters: parameters)
Picker(selection: self.pillBinding, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
Picker(selection: self.handBinding, label: Text("Which hand?")) {
Text("left" ).tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}
struct InfoDisplay: View {
#ObservedObject var parameters: Parameters
var body: some View {
Text("I took the \(parameters.pill) pill from your \(parameters.hand) hand!")
}
}
Second attempt
ContentView should not observe parameters (this causes the undesired visible update). The properties of parameters should be ObservableObjects as well to make sure views can update when a specific property changes.
Since Strings are structs they cannot conform to ObservableObject; a small wrapper 'ObservableValue' is necessary.
MyPicker is a small wrapper around Picker to make the view update on changes. The default Picker accepts a binding and thus relies on a view up the hierarchy to perform updates.
This approach feels scalable:
There is a single source of truth (parameters in ContentView)
Views only update when necessary (no undesired visual effects)
Disadvantages:
Seems a lot of boilerplate code for something that feels so trivial it should be provided by the platform (I feel I am missing something)
If you add a second MyPicker for the same property, the updates are not instantaneous.
import SwiftUI
import Combine
class ObservableValue<Value: Hashable>: ObservableObject {
#Published var value: Value
init(initialValue: Value) {
value = initialValue
}
}
struct MyPicker<Value: Hashable, Label: View, Content : View>: View {
#ObservedObject var object: ObservableValue<Value>
let content: () -> Content
let label: Label
init(object: ObservableValue<Value>,
label: Label,
#ViewBuilder _ content: #escaping () -> Content) {
self.object = object
self.label = label
self.content = content
}
var body: some View {
Picker(selection: $object.value, label: label, content: content)
.pickerStyle(SegmentedPickerStyle())
}
}
class Parameters: ObservableObject {
var pill = ObservableValue(initialValue: "red" )
var hand = ObservableValue(initialValue: "left")
private var subscriber: Any?
init() {
subscriber = pill.$value
.combineLatest(hand.$value)
.sink { _ in
self.objectWillChange.send()
}
}
}
struct ContentView: View {
var parameters = Parameters()
var body: some View {
VStack {
InfoDisplay(parameters: parameters)
MyPicker(object: parameters.pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}
MyPicker(object: parameters.hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}
}
}
}
struct InfoDisplay: View {
#ObservedObject var parameters: Parameters
var body: some View {
Text("I took the \(parameters.pill.value) pill from your \(parameters.hand.value) hand!")
}
}

Issue when rearranging List item in detail view using SwiftUI Navigation View and Sorted FetchRequest

I have a NavigationView with a list showing tasks from a CoreData FetchRequest. The FetchRequest is sorted ascending on Task.dueDate. The TaskDetail view basically consists of a TextField for the title and a date picker for the date. Changing the values in the detail view works. Though I get some weird behaviour every time I try to change the date value. The date gets changed but the Navigation view automatically exits the detail view and goes back to the list view. It only happens when I change the date in such a way that the list gets rearranged due to the sorting.
How do I prevent this weird behaviour described above??
//
// ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(fetchRequest: Task.requestAllTasks()) var tasks: FetchedResults<Task>
var body: some View {
NavigationView {
List(tasks, id: \.id) { task in
NavigationLink(destination: TaskDetail(task: task)) {
Text("\(task.title)")
}
}.navigationBarTitle("Tasks").navigationBarItems(trailing: Button("new") {self.addTask()})
}
}
func addTask() -> Void {
let newTask = Task(context: self.moc)
newTask.id = UUID()
newTask.title = "task \(tasks.count)"
newTask.dueDate = Date()
print("created new Task")
if (self.moc.hasChanges) {
try? self.moc.save()
print("saved MOC")
}
print(self.tasks)
}
}
struct TaskDetail : View {
#ObservedObject var task: Task
var body: some View {
VStack{
TextField("name", text: $task.title)
DatePicker("dueDate", selection: $task.dueDate, displayedComponents: .date)
.labelsHidden()
}
}
}
//
// Task.swift
import Foundation
import CoreData
public class Task: NSManagedObject, Identifiable {
#NSManaged public var id: UUID?
#NSManaged public var dueDate: Date
#NSManaged public var title: String
static func requestAllTasks() -> NSFetchRequest<Task> {
let request: NSFetchRequest<Task> = Task.fetchRequest() as! NSFetchRequest<Task>
let sortDescriptor = NSSortDescriptor(key: "dueDate", ascending: true)
request.sortDescriptors = [sortDescriptor]
return request
}
}
To create a running minimal reproducible version of this...do:
Create new Xcode "Single View App" Project. Make sure to check the
CoreData checkbox.
Copy the code for ContentView above and paste/replace in ContentView.swift.
Create a new Swift file named Task. Copy the code for Task and paste in Task.swift.
Add the entities in the ProjectName.xcdatamodeld according to the image below.
Run
I am on Xcode 11.4.
Let me know if you need me to provide more information.
Any help is much appreciated! Thanks!
UPDATE 2 (iOS 14 beta 3)
The issue seems to be fixed in iOS 14 beta 3: the Detail view does no longer pop when making changes that affect the sort order.
UPDATE
It seems Apple sees this as a feature, not a bug; today they replied to my feedback (FB7651251) about this issue as follows:
We would recommend using isActive and managing the push yourself using
the selection binding if this is the behavior you desire. As is this
is behaving correctly.
This is because the identity of the pushed view changes when you
change the sort order.
As mentioned in my comment above I believe this is a bug in iOS 13.4.
A workaround could be to use a NavigationLink outside of the List and define the List rows as Buttons that
a) set the task to be edited (a new #State var selectedTask) and
b) trigger the NavigationLink to TaskDetail(task: selectedTask!).
This setup will uncouple the selected task from its position in the sorted list thus avoiding the misbehaviour caused by the re-sort potentially caused by editing the dueDate.
To achieve this:
add these two #State variables to struct ContentView
#State private var selectedTask: Task?
#State private var linkIsActive = false
update the body of struct ContentView as follows
var body: some View {
NavigationView {
ZStack {
NavigationLink(
destination: linkDestination(selectedTask: selectedTask),
isActive: self.$linkIsActive) {
EmptyView()
}
List(tasks) { task in
Button(action: {
self.selectedTask = task
self.linkIsActive = true
}) {
NavigationLink(destination: EmptyView()){
Text("\(task.title)")
}
}
}
}
.navigationBarTitle("Tasks").navigationBarItems(trailing: Button("new") {self.addTask()})
}
}
add the following struct to ContentView.swift
struct linkDestination: View {
let selectedTask: Task?
var body: some View {
return Group {
if selectedTask != nil {
TaskDetail(task: selectedTask!)
} else {
EmptyView()
}
}
}
}
I encountered the same problem and could not find a good solution.
But I have another workaround.
I made the Fetchrequest dynamic and changed the sortdescriptor, when the link is active.
This has the negative sideeffect that the list sorts itself with an animation every time you navigate back to the ContentView.
If you add the following struct for your list:
struct TaskList: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest var tasks: FetchedResults<Task>
#Binding var activeTaskLink: Int?
init(activeTaskLink: Binding<Int?>, currentSortKey: String) {
self._activeTaskLink = activeTaskLink
self._tasks = Task.requestAllTask(withSortKey: currentSortKey)
}
var body: some View {
List(tasks, id: \.id) { task in
NavigationLink(destination: TaskDetail(task: task), tag: task.objectId.hashValue, selection: self.$activeTaskLink) {
Text("\(task.title)")
}
}
}
}
Then change the requestAllTask function in Task.swift:
static func requestAllTasks(withSortKey key: String) -> NSFetchRequest<Task> {
let request: NSFetchRequest<Task> = Task.fetchRequest() as! NSFetchRequest<Task>
let sortDescriptor = NSSortDescriptor(key: key, ascending: true)
request.sortDescriptors = [sortDescriptor]
return request
}
Then add a state for the activeTask in the ContentView
#State var activeTaskLink: Int? = nil
and change the body to
var body: some View {
TaskList(activeTaskLink: self.$activeTaskLink, currentSortKey: self.activeNavLink != nil ? "id" : "dueDate")
.navigationBarTitle("Tasks")
.navigationBarItems(trailing: Button("new") {self.addTask()})
}

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