SwiftUI List: Prevent Empty Selection - swift

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

Related

SwiftUI: How to update element in ForEach without necessity to update all elements?

Imagine that you have some parent view that generate some number of child views:
struct CustomParent: View {
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index)
}
}
}
}
struct CustomChild: View {
#State var index: Int
#State private var text: String = ""
var body: some View {
Button(action: {
// Here should be some update of background/text/opacity or whatever.
// So how can I update background/text/opacity or whatever for button with index for example 3 from button with index for example 1?
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Question is included in the code as comment.
Thanks!
UPDATE:
First of all really thanks for all of your answers, but now imagine that you use mentioned advanced approach.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
If I use let _ = Self._printChanges() method in CustomChildView, to catch UI updates/changes, it'll print that every element in ForEach was updated/changed on button action.
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
let _ = Self._printChanges() // This have been added to code
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}
And now imagine that you have for example 1000 custom elements which have some background, opacity, shadow, texts, fonts and so on. Now I change text in any of the elements.
Based on log from let _ = Self._printChanges() method, it goes through all elements, and all elements are updated/changed what can cause delay.
Q1: Why did update/change all elements, if I change text in only one element?
Q2: How can I prevent update/change all elements, if I change only one?
Q3: How to update element in ForEach without necessity to update all elements?
Simpler Approach:
Although child views cannot access things that the host views have, it's possible to declare the child states in the host view and pass that state as a binding variable to the child view. In the code below, I have passed the childTexts variable to the child view, and (for your convenience) initialized the text so that it binds to the original element in the array (so that your onAppear works properly). Every change performed on the text and childTexts variable inside the child view reflects on the host view.
I strongly suggest not to do this though, as more elegant approaches exist.
struct CustomParent: View {
#State var childTexts = [String](repeating: "", count: 10)
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, childTexts: $childTexts)
}
}
}
}
struct CustomChild: View {
let index: Int
#Binding private var text: String
#Binding private var childTexts: [String]
init(index: Int, childTexts: Binding<[String]>) {
self.index = index
self._childTexts = childTexts
self._text = childTexts[index]
}
var body: some View {
Button(action: {
//button behaviors goes here
//for example
childTexts[index + 1] = "A"
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Advanced Approach:
By using the Combine framework, all your logics can be moved into an ObservableObject view model. This is much better as the button logic is no longer inside the view. In simplest terms, the #Published variable in the ObservableObject will publish a change when it senses its own mutation, while the #StateObjectand the #ObservedObject will listen and recalculate the view for you.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}

Is it possible to make SwiftUI ListMenu with different behaviors?

Is it possible to make a list menu with swiftUI where the List items have different behaviors(and added to the view with foreach)?
The list items would be models.
EG. the first would open a Profile view, the second would open another different view, the third would just simply log out.
And fill the List with a ForEach of the models.
I'm making a MoreMenu with SwiftUI list.
Code:
var body: some View {
NavigationView {
List(viewModel.menuItems) { item in
//Here should show another view or call a function depending on the item type
//EG. if its a profile Menu item, show profile
// if its a logout Menu item, logout
}
}
}
Inheritance makes it easy to share properties with similar items and then routing Views depending on the type
import SwiftUI
class MenuOption: ObservableObject, Identifiable{
var id: UUID = UUID()
#Published var title: String
init(title: String){
self.title = title
}
}
class MOToggle: MenuOption{
#Published var value: Bool
init(title: String, value: Bool = false){
self.value = value
super.init(title: title)
}
}
class MOOptions: MenuOption{
#Published var selection: Options
enum Options: String, CaseIterable{
case first
case second
case third
case unknown
}
init(title: String, selection: Options = .unknown){
self.selection = selection
super.init(title: title)
}
}
//You can have Views that use each type
struct MenuOptionToggleView: View {
#ObservedObject var option: MOToggle
var body: some View {
Toggle(isOn: $option.value, label: {
Text(option.title)
})
}
}
struct MenuOptionOptionsView: View {
#ObservedObject var option: MOOptions
var body: some View {
Picker(selection: $option.selection, label:
Text(option.title)
, content: {
ForEach(MOOptions.Options.allCases, id:\.rawValue, content: { item in
Text(item.rawValue).tag(item)
})
}).pickerStyle(MenuPickerStyle())
}
}
//And show them all in one View
struct MenuListView: View {
//When they share a type they can be put in an array together
#State var options: [MenuOption] = [MenuOption(title: "say hello"),MOToggle(title: "toggle the option"), MOOptions(title: "show the menu")]
var body: some View {
List(options, id: \.id){option in
//Then when you have the item determine what type it is
if option is MOToggle{
//When you pass it to its designated View
//You convert it to its specifc type
MenuOptionToggleView(option: option as! MOToggle)
} else if option is MOOptions{
//When you pass it to its designated View
//You convert it to its specifc type
MenuOptionOptionsView(option: option as! MOOptions)
} else{
//And since they are if the same type you can have a catch all
Button(action: {
print(option.title)
}, label: {
Text(option.title)
})
}
}
}
}
struct MenuListView_Previews: PreviewProvider {
static var previews: some View {
MenuListView()
}
}

How to correctly handle Picker in Update Views (SwiftUI)

I'm quite new to SwiftUI and I'm wondering how I should use a picker in an update view correctly.
At the moment I have a form and load the data in with .onAppear(). That works fine but when I try to pick something and go back to the update view the .onAppear() gets called again and I loose the picked value.
In the code it looks like this:
import SwiftUI
struct MaterialUpdateView: View {
// Bindings
#State var material: Material
// Form Values
#State var selectedUnit = ""
var body: some View {
VStack(){
List() {
Section(header: Text("MATERIAL")){
// Picker for the Unit
Picker(selection: $selectedUnit, label: Text("Einheit")) {
ForEach(API().units) { unit in
Text("\(unit.name)").tag(unit.name)
}
}
}
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Does anyone has experience with that problem or am I doing something terribly wrong?
You need to create a custom binding which we will implement in another subview. This subview will be initialised with the binding vars selectedUnit and material
First, make your MaterialUpdateView:
struct MaterialUpdateView: View {
// Bindings
#State var material : Material
// Form Values
#State var selectedUnit = ""
var body: some View {
NavigationView {
VStack(){
List() {
Section(header: Text("MATERIAL")) {
MaterialPickerView(selectedUnit: $selectedUnit, material: $material)
}
.listStyle(GroupedListStyle())
}
.onAppear(){
prepareToUpdate()
}
}
}
}
func prepareToUpdate() {
self.selectedUnit = self.material.unit
}
}
Then, below, add your MaterialPickerView, as shown:
Disclaimer: You need to be able to access your API() from here, so move it or add it in this view. As I have seen that you are re-instanciating it everytime, maybe it is better that you store its instance with let api = API() and then refer to it with api, and even pass it to this view as such!
struct MaterialPickerView: View {
#Binding var selectedUnit: String
#Binding var material : Material
#State var idx: Int = 0
var body: some View {
let binding = Binding<Int>(
get: { self.idx },
set: {
self.idx = $0
self.selectedUnit = API().units[self.idx].name
self.material.unit = self.selectedUnit
})
return Picker(selection: binding, label: Text("Einheit")) {
ForEach(API().units.indices) { i in
Text(API().units[i].name).tag(API().units[i].name)
}
}
}
}
That should do,let me know if it works!

SwiftUI List selection always nil

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.

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