How to store selection of multiple Pickers? - swiftui

I have 4 dogs - represented by 4 Pickers - and each one of them has a favourite treat. How can I store the selection of each Picker? I tried storing it in a dictionary, but when clicking on one of the treats nothing gets selected and the Picker view also does not get dismissed.
import SwiftUI
import OrderedCollections
enum Dog : String, CaseIterable, Identifiable {
var id: Self { self }
case Anton, Ben, Charlie, Didi
}
struct MainConstants {
let treats : OrderedDictionary <String, Int> = [
"Bone" : 123,
"Sausage" : 456,
"Cookies" : 789
]
}
struct FavouriteTreatsView: View {
let constants = MainConstants()
#State var favouriteTreats : [Dog : String] = [:]
var body: some View {
NavigationView {
Form {
ForEach (Dog.allCases) {dog in
Picker(dog.rawValue ,selection: $favouriteTreats[dog]) {
ForEach (constants.treats.keys, id: \.self) {treat in
Text(treat)
}
}
}
}
}
}
}
struct FavoriteTreatsView_Previews: PreviewProvider {
static var previews: some View {
FavouriteTreatsView()
}
}

I don't know about treating dogs, however the example below works well with lions...
First, you can have a dedicated view for each single picker - that view will store the treat of each lion on a #State var. You use the .id() modifier to tell the picker what value needs to be stored.
The view will also have a #Binding var that will receive the dictionary, to store the treat selected on the right lion.
In that picker view, you listen to changes in the treat: when a treat is selected, you store that value in the dictionary passed with the binding var.
So, assuming they are lions, here's the picker view:
struct TreatMe: View {
let constants = MainConstants()
// treat for a single Lion
#State private var treat = ""
// The dictionary that will be updated after the treat is selected
#Binding var favouriteTreats: [Lion: String]
// the Lion that will receive the treat
let lion: Lion
var body: some View {
Picker(lion.rawValue ,selection: $treat) {
ForEach (constants.treats.keys, id: \.self) {treat in
Text(treat)
// This will ensure the right value is stored
.id(treat)
}
}
// Listen to changes in the treat, then store it in the dictionary
.onChange(of: treat) { value in
favouriteTreats[lion] = value
}
}
}
You call it from the FavouriteTreatsView:
struct FavouriteTreatsView: View {
#State var favouriteTreats : [Lion : String] = [:]
var body: some View {
NavigationView {
Form {
ForEach (Lion.allCases) {lion in
// Pass the variables
TreatMe(favouriteTreats: $favouriteTreats, lion: lion)
}
}
}
}
}

Related

Strange results with data input and nested Foreach loops

I use Xcode 14.2, macOS Monterey 12.6.2 and Minimum Deployments 15.5.
I want to display 4 TextFields in a 2x2 grid for integer input.
The data is stored in an extern struct Model as an ObservableObject.
The environment variable is injected in the #main struct.
The TextField must be equipped with an .id modifier. Otherwise the following error message appears:
LazyVGridLayout: the ID 0 is used by multiple child views, this will give undefined results!
LazyVGridLayout: the ID 1 is used by multiple child views, this will give undefined results!
Adding .id(row + col) results in a single error message:
LazyVGridLayout: the ID 1 is used by multiple child views, this will give undefined results!
Using UUID() for generating an ID .id(UUID()) results in a strange effect. Trying to enter a multi digit value in a Textfeld fails. The software keyboard vanishes after the input of the first digit.
Changing the id to .id(row + 7 * col) results in the expected behavior of the demo App. However, this "solution" shouldn't be the right way to solve the problem.
Has somebody an idea was is going wrong?
struct ContentView: View {
let colums = [GridItem(),GridItem()]
#EnvironmentObject var model: Model
var body: some View {
List {
LazyVGrid(columns: colums) {
ForEach(0...1, id:\.self) { row in
ForEach(0...1, id: \.self) { col in
TextField("", value: $model.rows[row].values[col], format: .number)
.id(UUID())
// .id(row + 7 * col)
}
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
class Model: ObservableObject {
#Published
var rows = [Values(), Values()]
struct Values {
var values = [0, 0]
}
}
#main
struct KeyboardFocusApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
Following my comment please let me suggest a different approach. The whole fun of VGrid is that you don't need the grid structure in your data. You define the column and just throw the data in.
Also as commented it would be preferable to have the model data itself identifiable.
struct ContentView: View {
// #EnvironmentObject var model: Model
#StateObject var model = Model()
let colums = [GridItem(),GridItem()]
var body: some View {
List {
LazyVGrid(columns: colums) {
// no need for .id, as items are identifiable
// $ init makes $item modifiable
ForEach($model.items) { $item in
TextField("", value: $item.value, format: .number)
}
}
}
}
}
class Model: ObservableObject {
init() { // initialize with 4 items
items = []
for _ in 0..<4 {
items.append(Item())
}
}
#Published var items: [Item]
struct Item: Identifiable {
let id = UUID()
var value = 0
}
}

Weird behavior with NavigationSplitView and #State

I have a NavigationSplitView in my app, I have an #State variable in my detail view that gets created in init.
When I select something from the sidebar and the detail view renders, at first everything looks ok. But when I select a different item on the sidebar, the contents of the #state variable don't get recreated.
Using the debugger I can see the init of the detail view get called every time I select a new item in the sidebar, and I can see the #State variable get created. But when it actually renders, the #State variable still contains the previous selection's values.
I've reduced this problem to a test case I'll paste below. The top text in the detail view is a variable passed in from the sidebar, and the second line of text is generated by the #State variable. Expected behavior would be, if I select "one" the detail view would display "one" and "The name is one". If I select "two" the detail view would display "two" and "The name is two".
Instead, if I select "one" first, it displays correctly. But when I select "two", it displays "two" and "The name is one".
Note that if I select "two" as the first thing I do after launching the app, it correctly displays "two" and "The name is two", but when I click on "one" next, it will display "one" and "the name is two". So the state variable is being set once, then never changing again,
Here's the sample code and screenshots:
import SwiftUI
struct Item: Hashable, Identifiable {
let id = UUID()
let name: String
}
struct ContentView: View {
#State private var selectedItem: Item.ID? = nil
private let items = [Item(name: "one"), Item(name: "two"), Item(name: "three")]
func itemForID(_ id: UUID?) -> Item? {
guard let itemID = id else { return nil }
return items.first(where: { item in
item.id == itemID
})
}
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name)
} else {
Text("Select an item")
}
}
}
}
struct DetailView: View {
#State var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
_detailItem = State(wrappedValue: DetailItem(name: name))
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}
struct DetailItem {
let name: String
var computedText: String {
return "The name is \(name)"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Question. What is the purpose of having detailItem as a #State? if you remove the #State, this test case works.
Will the way computedText change over time?
struct DetailView: View {
// #State var detailItem: DetailItem
var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
// _detailItem = State(wrappedValue: DetailItem(name: name))
detailItem = DetailItem(name: name)
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}
This has nothing to do with NavigationSplitView, but how you initialise #State property.
According to the Apple document on #State (https://developer.apple.com/documentation/swiftui/state):
Don’t initialise a state property of a view at the point in the view hierarchy where you instantiate the view, because this can conflict with the storage management that SwiftUI provides.
As well as the documentation of init(wrappedValue:) (https://developer.apple.com/documentation/swiftui/state/wrappedvalue):
Don’t call this initializer directly. Instead, declare a property with the State attribute, and provide an initial value:
#State private var isPlaying: Bool = false
From my understanding, if you force to initialise the state in the view init, it will persist through the lifetime of the view, and subsequence change of it won't take any effect on the view.
The recommended way in Apple documentation is to create the struct in the parent view and pass it to the child view, and if you need to change the struct in the child view, use #Binding to allow read and write access.
If you want to ignore the documentation and force it to work, you can give an id to your DetailView, forcing it to refresh the view when the item id has changed:
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name).id(selectedItem)
} else {
Text("Select an item")
}
}
}
Your Item struct is bad, if the name is unique it should be:
struct Item: Identifiable {
var id: String { name }
let name: String
}
Otherwise:
struct Item: Identifiable {
let id = UUID()
let name: String
}

Picker drops selected segment when data changed

I got a problem with Picker. When I try to change state of data in selected segment it's drops it (gif attached). How I can have stay selected segment in Picker when displaying data is changed (when I press "love" button, selected segment should stay without changes)
Single item has option:
struct Landmark: Identifiable, Codable, Hashable {
var id: Int
var name: String
var imageName: String
var mainImage: Image {
Image(imageName)
var liked: Bool
var popular: Bool
var recommended: Bool
}
I got class with array of objects:
final class ModelData: ObservableObject {
#Published var landmarks: [Landmark]
}
And trying use picker like that:
struct tempSegmentControl: View {
#EnvironmentObject var modelData: ModelData
#State private var selected = ModelData().landmarks
var popularLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(landmark.popular)
}
}
var rocommendedLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(landmark.recommended)
}
}
var body: some View {
VStack {
Picker("Selection", selection: $selected) {
Text("All").tag(modelData.landmarks)
Text("Popular").tag(popularLandmarks)
Text("Recommended").tag(rocommendedLandmarks)
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: UIScreen.main.bounds.width * 0.95)
ForEach(selected, id: \.id) { land in
NavigationLink(
destination: DetailView2(landmark: land)) {
MainItem3(landmark: land)
}
}
}
}
}
Drop selected segment
selection: isn't going to work properly with an array type like this ([Landmark]).
Instead, you'll probably want to do something simple like:
#State private var selection = 0
//...
Picker("Selection", selection: $selection) {
Text("All").tag(0)
Text("Popular").tag(1)
Text("Recommended").tag(2)
And then use a computed property for your array that you iterate through later:
var selected : [Landmark] {
switch selection {
case 0:
return modelData.landmarks
case 1:
//etc
}
}
You could do this with an enum : Int as well, which would probably be a little safer later on if you cases expand (and it would give you a limited set in your computed property).

How to update view when value inside Enum changes with SwiftUI?

Here is my code.
Run the MainView struct, and click on the button which should update the word first to the word hello.
It does not update at all even though the logs show that the data is correctly updated. Therefore is there no way to get the view to update when a value changes inside an enum?
The only way I got it to work was a nasty hack. To try the hack just uncomment the 3 lines of commented code and try it. Is there a better way?
I looked at this similar question, but the same problem is there -> SwiftUI two-way binding to value inside ObservableObject inside enum case
struct MainView: View {
#State var selectedEnum = AnEnum.anOption(AnObservedObject(string: "first"))
// #State var update = false
var body: some View {
switch selectedEnum {
case .anOption(var value):
VStack {
switch selectedEnum {
case .anOption(let val):
Text(val.string)
}
TestView(object: Binding(get: { value }, set: { value = $0 }),
callbackToVerifyChange: callback)
}
// .id(update)
}
}
func callback() {
switch selectedEnum {
case .anOption(let value):
print("Callback function shows --> \(value.string)")
// update.toggle()
}
}
}
class AnObservedObject: ObservableObject {
#Published var string: String
init(string: String) {
self.string = string
}
}
enum AnEnum {
case anOption(AnObservedObject)
}
struct TestView: View {
#Binding var object: AnObservedObject
let callbackToVerifyChange: ()->Void
var body: some View {
Text("Tap here to change the word 'first' to 'hello'")
.border(Color.black).padding()
.onTapGesture {
print("String before tapping --> \(object.string)")
object.string = "hello"
print("String after tapping --> \(object.string)")
callbackToVerifyChange()
}
}
}
You need to declare your enum Equatable.

Why does a SwiftUI TextField inside a navigation bar only accept input one character at a time

I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}