A test view has #State showTitle, title and items where the title value text is controlled by a closure assigned to a CTA show title.
When the showTitle state changes, the value presented in the body Content of test view changes accordingly:
Text({ self.showTitle ? "Yes, showTitle!" : "No, showTitle!" }())
While the case where the closure is a value in the array items does not change. Why isn't the closure triggering the title state?
NestedView(title: $0.title())
I've done tests with both Foobar as Struct and Class.
import SwiftUI
struct Foobar: Identifiable {
var id: UUID = UUID()
var title: () -> String
init (title: #escaping () -> String) {
self.title = title
}
}
struct test: View {
#State var showTitle: Bool = true
#State var title: String
#State var items: [Foobar]
var body: some View {
VStack {
Group {
Text("Case 1")
Text({ self.showTitle ? "Yes, showTitle!" : "No, showTitle!" }())
}
Group {
Text("Case 2")
ForEach (self.items, id: \.id) {
NestedView(title: $0.title())
}
}
Button("show title") {
print("show title cb")
self.showTitle.toggle()
}
}.onAppear {
let data = ["hello", "world", "test"]
for title in data {
self.items.append(Foobar(title: { self.showTitle ? title : "n/a" }))
}
}
}
}
struct NestedView: View {
var title: String
var body: some View {
Text("\(title)")
}
}
What's expected is that "Case 2" to have a similar side-effect we have in "Case 1" that should display "n/a" on showTitle toggle.
Output:
From what I understand, the reason why the initial code does not work is related to the showTitle property that is passed to the Array and holds a copy of the value
You were right to blame the closure capturing the value at the onAppear time. Basically due to this, SwiftUI doesn't know to refresh the list when the showTitle value changes, as there's no Binding involved that SwiftUI can use to know when to re-render the list.
I can provide two alternative solutions, that don't require another class just to hold the bool value. Both solutions involve communicating to SwiftUI that you need the showTitle binding to refresh the titles.
Don't use a closure for title, defer the title computation to the list builder:
struct Foobar: Identifiable {
var id: UUID = UUID()
var title: String
init (title: String) {
self.title = title
}
}
...
ForEach (self.items, id: \.id) {
NestedView(title: self.showTitle ? $0.title : "n/a" )
}
...
.onAppear {
let data = ["hello", "world", "test"]
self.items = data.map { Foobar(title: $0) }
}
Convert the title closure to a (Binding<Bool>) -> String one, inject the $showTitle binding from the view:
struct Foobar: Identifiable {
var id: UUID = UUID()
var title: ((Binding<Bool>) -> String)
init (title: #escaping (Binding<Bool>) -> String) {
self.title = title
}
}
...
ForEach (self.items, id: \.id) {
// here we pass the $showTitle binding, thus SwiftUI knows to re-render
// the view when the binding value is updated
NestedView(title: $0.title(self.$showTitle))
}
...
.onAppear {
let data = ["hello", "world", "test"]
self.items = data.map { Foobar(title: { $0.wrappedValue ? title : "n/a" })) }
}
Personally, I'd go with the first solution, since it better transmit the intent.
From what I understand, the reason why the initial code does not work is related to the showTitle property that is passed to the Array and holds a copy of the value (creates a unique copy of the data).
I did think #State would make it controllable and mutable, and the closure would capture and store the reference (create a shared instance). In other words, to have had a reference, instead of a copied value! Feel free to correct me, if that's not the case, but that's what it looks like based on my analysis.
With that being said, I kept the initial thought process, I still want to pass a closure to the Array and have the state changes propagated, cause side-effects, accordingly to any references to it!
So, I've used the same pattern but instead of relying on a primitive type for showTitle Bool, created a Class that conforms to the protocol ObservableObject: since Classes are reference types.
So, let's have a look and see how this worked out:
import SwiftUI
class MyOption: ObservableObject {
#Published var option: Bool = false
}
struct Foobar: Identifiable {
var id: UUID = UUID()
var title: () -> String
init (title: #escaping () -> String) {
self.title = title
}
}
struct test: View {
#EnvironmentObject var showTitle: MyOption
#State var title: String
#State var items: [Foobar]
var body: some View {
VStack {
Group {
Text("Case 1")
Text(self.showTitle.option ? "Yes, showTitle!" : "No, showTitle!")
}
Group {
Text("Case 2")
ForEach (self.items, id: \.id) {
NestedView(title: $0.title())
}
}
Button("show title") {
print("show title cb")
self.showTitle.option.toggle()
print("self.showTitle.option: ", self.showTitle.option)
}
}.onAppear {
let data = ["hello", "world", "test"]
for title in data {
self.items.append(Foobar(title: { self.showTitle.option ? title : "n/a" }))
}
}
}
}
struct NestedView: View {
var title: String
var body: some View {
Text("\(title)")
}
}
The result as expected:
Related
I am trying to open a sheet when tapping on an item. I followed this questions Sheet inside ForEach doesn't loop over items SwiftUI answer. I get this Error: The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions, I don't understand what is causing it. I tried multiple solutions and they all lead to the same Error.
#State var selectedSong: Int? = nil
#State var songList: [AlbumSong] = []
VStack {
ForEach(songList.enumerated().reversed(), id: \.offset) { index, song in
HStack {
Text("\(index + 1).").padding(.leading, 8)
VStack {
Text(song.title)
Text(song.artist)
}
}.onTapGesture {
self.selectedSong = index
}
}
}
}
.sheet(item: self.$selectedSong) { selectedMovie in
SongPickerEdit(songList: $songList, songIndex: selectedMovie)
I also tried setting songIndex to being an AlbumSong and then implemented this sheet:
.sheet(item: self.$selectedSong) {
SongPickerEdit(songList: $songList, songIndex: self.songList[$0])
}
struct SongPickerEdit: View {
#Binding var songList: [AlbumSong]
#State var songIndex: Int?
var body: some View {
}
}
struct AlbumSong: Identifiable, Codable {
#DocumentID var id: String?
let title: String
let duration: TimeInterval
var image: String
let artist: String
let track: String
}
How about making selectedSong an AlbumSong?? The item: parameter needs to be an Identifiable binding, but Int is not Identifiable.
#State var selectedSong: AlbumSong? = nil
#State var songList: [AlbumSong] = []
var body: some View {
List {
ForEach(songList.enumerated().reversed(), id: \.offset) { index, song in
HStack {
Text("\(index + 1).").padding(.leading, 8)
VStack {
Text(song.title)
Text(song.artist)
}
}.onTapGesture {
self.selectedSong = song
}
}
}.sheet(item: $selectedSong) { song in
SongPickerEdit(songList: $songList, song: song)
}
}
Note that SongPickerEdit would look like this:
struct SongPickerEdit: View {
#State var song: AlbumSong
var body: some View {
Text("\(song.title), \(song.artist)")
}
}
If you really need the index for some reason, you can add the song list binding back in and use songList.index { $0.id == song.id } to find the index if the list is not too long.
Otherwise, you can make your own Identifiable type SongAndIndex that uses the same id as AlbumSong, but with an extra index property, and use that as the type of selectedSong.
A third way would be to use the sheet(isPresented:) overload, but this way you end up with 2 sources of truth:
#State var selectedSongIndex: Int? = nil {
didSet {
if selectedSongIndex != nil {
isSheetPresented = true
}
}
}
#State var isSheetPresented: Bool = false {
didSet {
if !isSheetPresented {
selectedSongIndex = nil
}
}
}
...
}.sheet(isPresented: $isSheetPresented) {
SongPickerEdit(songList: $songList, songIndex: selectedSongIndex)
}
selectedSongIndex also won't be set to nil when the user dismisses the sheet.
---- Updated to provide a reproducible example ----
Following is my View file. I'd like to have each navigation link destination linked to a view model stored in a dictionary (represented by a simple string in the example).
However, the following piece of code doesn't work and each item always displays nothing, even though I tried the solution in SwiftUI NavigationLink loads destination view immediately, without clicking
struct ContentView: View {
private var indices: [Int] = [1, 2, 3, 4]
#State var strings: [Int: String] = [:]
var body: some View {
NavigationView {
List {
ForEach(indices, id: \.self) { index in
NavigationLink {
NavigationLazyView(view(for: index))
} label: {
Text("\(index)")
}
}
}
.onAppear {
indices.forEach { index in
strings[index] = "Index: \(index)"
}
print(strings.keys)
}
}
}
#ViewBuilder
func view(for index: Int) -> some View {
if let str = strings[index] {
Text(str)
}
}
}
struct NavigationLazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
You're working against a couple of the principals of SwiftUI just enough that things are breaking. With a couple of adjustments, you won't even need the lazy navigation link.
First, generally in SwiftUI, it's advisable to not use indices in ForEach -- it's fragile and can lead to crashes, and more importantly, the view doesn't know to update if an item changes, since it only compares the indexes (which, if the array stays the same size, never changes).
Generally, it's best to use Identifiable items in a ForEach.
This, for example, works fine:
struct Item : Identifiable {
var id = UUID()
var index: Int
var string : String?
}
struct ContentView: View {
private var indices: [Int] = [1, 2, 3, 4]
#State var items: [Item] = []
var body: some View {
NavigationView {
List(items) { item in
NavigationLink {
view(for: item)
} label: {
Text("\(item.index)")
}
}
.onAppear {
items = indices.map { Item(index: $0, string: "Index: \($0)")}
}
}
}
#ViewBuilder
func view(for item: Item) -> some View {
Text(item.string ?? "Empty")
}
}
I can't say absolutely definitively what's going on with your first example, and why the lazy navigation link doesn't fix it, but my theory is that view(for:) and strings are getting captured by the #autoclosure and therefore not reflecting their updated values by the time the link is actually built. This is a side effect of the list not actually updating when the #State variable is set, due to the aforementioned issue with List and ForEach using non-identifiable indices.
I'm assuming that your real situation is complex enough that there are good reasons to be doing mutations in the onAppear and storying the indices separately from the models, but just in case, to be clear and complete, the following would be an even simpler solution to the issue, if it really were a simple situation:
struct ContentView: View {
private var items: [Item] = [.init(index: 1, string: "Index 1"),.init(index: 2, string: "Index 2"),.init(index: 3, string: "Index 3"),.init(index: 4, string: "Index 4"),]
var body: some View {
NavigationView {
List(items) { item in
NavigationLink {
view(for: item)
} label: {
Text("\(item.index)")
}
}
}
}
#ViewBuilder
func view(for item: Item) -> some View {
Text(item.string ?? "Empty")
}
}
I have a parent state that might exist:
class Model: ObservableObject {
#Published var name: String? = nil
}
If that state exists, I want to show a child view. In this example, showing name.
If name is visible, I'd like it to be shown and editable. I'd like this to be two-way editable, that means if Model.name changes, I'd like it to push to the ChildUI, if the ChildUI edits this, I'd like it to reflect back to Model.name.
However, if Model.name becomes nil, I'd like ChildUI to hide.
When I do this, via unwrapping of the Model.name, then only the first value is captured by the Child who is now in control of that state. Subsequent changes will not push upstream because it is not a Binding.
Question
Can I have a non-optional upstream bind to an optional when it exists? (are these the right words?)
Complete Example
import SwiftUI
struct Child: View {
// within Child, I'd like the value to be NonOptional
#State var text: String
var body: some View {
TextField("OK: ", text: $text).multilineTextAlignment(.center)
}
}
class Model: ObservableObject {
// within the parent, value is Optional
#Published var name: String? = nil
}
struct Parent: View {
#ObservedObject var model: Model = .init()
var body: some View {
VStack(spacing: 12) {
Text("Demo..")
// whatever Child loads the first time will retain
// even on change of model.name
if let text = model.name {
Child(text: text)
}
// proof that model.name changes are in fact updating other state
Text("\(model.name ?? "<waiting>")")
}
.onAppear {
model.name = "first change of optionality works"
loop()
}
}
#State var count = 0
func loop() {
async(after: 1) {
count += 1
model.name = "updated: \(count)"
loop()
}
}
}
func async(_ queue: DispatchQueue = .main,
after: TimeInterval,
run work: #escaping () -> Void) {
queue.asyncAfter(deadline: .now() + after, execute: work)
}
struct OptionalEditingPreview: PreviewProvider {
static var previews: some View {
Parent()
}
}
Child should take a Binding to the non-optional string, rather than using #State, because you want it to share state with its parent:
struct Child: View {
// within Child, I'd like the value to be NonOptional
#Binding var text: String
var body: some View {
TextField("OK: ", text: $text).multilineTextAlignment(.center)
}
}
Binding has an initializer that converts a Binding<V?> to Binding<V>?, which you can use like this:
if let binding = Binding<String>($model.name) {
Child(text: binding)
}
If you're getting crashes from that, it's a bug in SwiftUI, but you can work around it like this:
if let text = model.name {
Child(text: Binding(
get: { model.name ?? text },
set: { model.name = $0 }
))
}
Bind your var like this. Using custom binding and make your child view var #Binding.
struct Child: View {
#Binding var text: String //<-== Here
// Other Code
if model.name != nil {
Child(text: Binding($model.name)!)
}
This question already has answers here:
How to change a value of struct that is in array?
(2 answers)
Closed 1 year ago.
I'm trying to achieve a two way binding-like functionality.
I have a model with an array of identifiable Items, var selectedID holding a UUID of selected Item, and var proxy which has get{} that looks for an Item inside array by UUID and returns it.
While get{} works well, I can't figure out how to make proxy mutable to change values of selected Item by referring to proxy.
I have tried to implement set{} but nothing works.
import SwiftUI
var words = ["Aaaa", "Bbbb", "Cccc"]
struct Item: Identifiable {
var id = UUID()
var word: String
}
class Model: ObservableObject {
#Published var items: [Item] = [Item(word: "One"), Item(word: "Two"), Item(word: "Three")]
#Published var selectedID: UUID?
var proxy: Item? {
set {
// how to set one property of Item?, but not the whole Item here?
}
get {
let index = items.firstIndex(where: { $0.id == selectedID })
return index != nil ? items[index!] : nil
}
}
}
struct ContentView: View {
#StateObject var model = Model()
var body: some View {
VStack {
// monitoring
MonitorkVue(model: model)
//selections
HStack {
ForEach(model.items.indices, id:\.hashValue) { i in
SelectionVue(item: $model.items[i], model: model)
}
}
}.padding()
}
}
struct MonitorkVue: View {
#ObservedObject var model: Model
var body: some View {
VStack {
Text(model.proxy?.word ?? "no proxy")
// 3rd: cant make item change by referring to proxy
// in order this to work, proxy's set{} need to be implemented somehow..
Button {
model.proxy?.word = words.randomElement()!
} label: {Text("change Proxy")}
}
}
}
struct SelectionVue: View {
#Binding var item: Item
#ObservedObject var model: Model
var body: some View {
VStack {
Text(item.word).padding()
// 1st: making selection
Button {
model.selectedID = item.id } label: {Text("SET")
}.disabled(item.id != model.selectedID ? false : true)
// 2nd: changing item affects proxy,
// this part works ok
Button {
item.word = words.randomElement()!
}label: {Text("change Item")}
}
}
}
Once you SET selection you can randomize Item and proxy will return new values.
But how to make it works the other way around when changing module.proxy.word = "Hello" would affect selected Item?
Does anyone knows how to make this two-way shortct?
Thank You
Here is a correction and some fix:
struct Item: Identifiable {
var id = UUID()
var word: String
}
class Model: ObservableObject {
#Published var items: [Item] = [Item(word: "One"), Item(word: "Two"), Item(word: "Three")]
#Published var selectedID: UUID?
var proxy: Item? {
get {
if let unwrappedIndex: Int = items.firstIndex(where: { value in (selectedID == value.id) }) { return items[unwrappedIndex] }
else { return nil }
}
set(newValue) {
if let unwrappedItem: Item = newValue {
if let unwrappedIndex: Int = items.firstIndex(where: { value in (unwrappedItem.id == value.id) }) {
items[unwrappedIndex] = unwrappedItem
}
}
}
}
}
import SwiftUI
enum ValueType {
case string(String)
case int(Int)
}
struct ParentView: View {
#State var value: ValueType?
var body: some View {
ChildView(boundValue: $value)
}
}
struct ChildView: View {
#Binding var boundValue: ValueType?
#State private var userInput: String = ""
var body: some View {
TextField("Enter some text", text: $userInput)
}
}
Here ChildView has a bound version of its parent's #State ... answer var. However the specific use case here involves a binding of an enum that can either have a String or Int value, whereas the ChildView has a TextField which involved a pure String value. How can the ChildView's userInput value be transferred into its boundValue.string(...)?
Thank you for reading. Apologies if this question is a duplicate, I did search but found nothing.
The TextField initializer also has two optional arguments for callbacks, onEditingChanged and onCommit, so another approach could be to put your update logic in there.
TextField("Enter some text", text: $userInput, onCommit: {
self.boundValue = .string(self.userInput)
})
You can use a second Binding to bridge the string to your custom enum, as long as you handle all of the cases. Here is an example:
import SwiftUI
enum ValueType {
case string(String)
case int(Int)
}
struct ParentView: View {
#State var value: ValueType?
var body: some View {
ChildView(boundValue: $value)
}
}
struct ChildView: View {
let boundValue: Binding<ValueType?>
let myCustomBinding: Binding<String>
init(boundValue: Binding<ValueType?>) {
myCustomBinding = Binding<String>.init(
get: {
switch boundValue.wrappedValue {
case .string(let string):
return string
case .int(let int):
return String(describing: int)
case .none:
return ""
}
},
set: { (string: String) -> Void in
boundValue.wrappedValue = Int(string).map{ ValueType.int($0)} ?? ValueType.string(string)
})
self.boundValue = boundValue.projectedValue
}
var body: some View {
TextField("Enter some text", text: myCustomBinding)
}
}