SwiftUI row binding using ForEach? - swift

I'm trying to bind an array of strings into their corresponding text fields in a scrolling list. The number of rows is variable, and corresponds to the number of elements in the string array. The user can add or delete rows, as well as changing the text within each row.
The following Playground code is a simplified version of what I'm trying to achieve.
import SwiftUI
import PlaygroundSupport
struct Model {
struct Row : Identifiable {
var textContent = ""
let id = UUID()
}
var rows: [Row]
}
struct ElementCell: View {
#Binding var row: Model.Row
var body: some View {
TextField("Field",text: $row.textContent)
}
}
struct ElementList: View {
#Binding var model: Model
var body: some View {
List {
ForEach($model.rows) {
ElementCell(row: $0)
}
}
}
}
struct ContentView: View {
#State var model = Model(rows: (1...10).map({ Model.Row(textContent:"Row \($0)") }))
var body: some View {
NavigationView {
ElementList(model: $model)
}
}
}
PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())
The issue is that I can't seem to get the "cell" to bind correctly with its corresponding element. In the example code above, Xcode 11.1 failed to compile it with error in line 26:
error: Text-Field Row.xcplaygroundpage:26:13: error: cannot invoke initializer for type 'ForEach<_, _, _>' with an argument list of type '(Binding<[Model.Row]>, #escaping (Binding<Model.Row>) -> ElementCell)'
ForEach($model.rows) {
^
Text-Field Row.xcplaygroundpage:26:13: note: overloads for 'ForEach<_, _, _>' exist with these partially matching parameter lists: (Data, content: #escaping (Data.Element) -> Content), (Range<Int>, content: #escaping (Int) -> Content)
ForEach($model.rows) {
^
What would be the recommended way to bind elements that are a result of ForEach into its parent model?

Related

SwiftUI: Cannot convert value of type 'String' to expected argument type 'Model' – Okay, but what is the expected argument?

I'm still new to SwiftUI and hope I will be able to explain my problem well.
I followed a tutorial on reading, writing, and deleting data from the Firestore Database in SwiftUI and it all worked. In the tutorial, there is only 1 view used, but I wanted to take it a step further and use further views:
List View (to show all items)
Form View (form with fields and function to add items)
Detail View (shows details of each item and function to delete item)
While I can add new items to my database through Form View and they also show up after going back to List View, when I try to implement the function to delete items in Detail View, XCode throws the error: Cannot convert value of type 'String' to expected argument type 'Model'
I understand I am not using the right parameter in my function, but failing to understand what the right one here is.
Model
import Foundation
struct Model: Identifiable {
var id: String
var name: String
var itemName: String
}
ItemListModel
import Foundation
import FirebaseCore
import FirebaseFirestore
class ItemListModel: ObservableObject {
#Published var list = [Model]()
#Published var name = [Model]()
…
func deleteItem(itemDelete: Model) {
// Get a reference to the database
let database = Firestore.firestore()
// Specifiy the document to delete. Data passed through the method.
database.collection("xxx").document(itemDelete.id).delete { error in
// Check for errors
if error == nil {
// No errors
// Update the UI from the main thread
DispatchQueue.main.async {
// Remove the item that was just deleted
self.list.removeAll { items in
// Check for the item to remove
return items.id == itemDelete.id
}
}
}
}
}
…
}
ItemList
My thinking was to include an ID to tell the function what to delete exactly, so I included it in this file and pass it to the Detail View. Not sure if I need to pass the ID at this point though.
import SwiftUI
struct ItemList: View {
#EnvironmentObject var model: ItemListModel
#State var name = ""
#State var itemName = ""
#State var itemId = ""
var body: some View {
…
ForEach(model.list) { item in
NavigationLink(destination: Detail(name: item.name, itemName: item.itemName, itemId: item.id)) {
HStack {
Text(item.name)
Text(item.itemName)
}
}
}
}
}
Detail
And this is the view where XCode throws the error message Cannot convert value of type 'String' to expected argument type 'Model'.
import SwiftUI
struct Detail: View {
#EnvironmentObject var model: ItemListModel
var name: String
var itemName: String
var itemId: String
var body: some View {
VStack {
Text(name)
Text(itemName)
Button {
// delete (hopefully) the current item
model.deleteItem(itemDelete: itemId) // XCode throws error Cannot convert value of type 'String' to expected argument type 'Model'.
} label: {
Text("Delete")
}
…
}
}
}
Thank you for reading through all of this and I truly hope someone can help me and my confused brain here!
The expected type is Model. -> deleteItem(itemDelete: Model)
The easy solution is to pass a Model instance rather than the three strings
struct Detail: View {
#EnvironmentObject var model: ItemListModel
let item: Model
var body: some View {
VStack {
Text(item.name)
Text(item.itemName)
Button {
model.deleteItem(itemDelete: item)
} label: {
Text("Delete")
}
…
}
}
}
and in ItemList replace
NavigationLink(destination: Detail(name: item.name, itemName: item.itemName, itemId: item.id)) {
with
NavigationLink(destination: Detail(item: item)) {

SwiftUI memory leak

I'm getting a weird memory leak in SwiftUI when using List and id: \.self, where only some of the items are destroyed. I'm using macOS Monterey Beta 5.
Here is how to reproduce:
Create a new blank SwiftUI macOS project
Paste the following code:
class Model: ObservableObject {
#Published var objs = (1..<100).map { TestObj(text: "\($0)")}
}
class TestObj: Hashable {
let text: String
static var numDestroyed = 0
init(text: String) {
self.text = text
}
static func == (lhs: TestObj, rhs: TestObj) -> Bool {
return lhs.text == rhs.text
}
func hash(into hasher: inout Hasher) {
hasher.combine(text)
}
deinit {
TestObj.numDestroyed += 1
print("Deinit: \(TestObj.numDestroyed)")
}
}
struct ContentView: View {
#StateObject var model = Model()
var body: some View {
NavigationView {
List(model.objs, id: \.self) { obj in
Text(obj.text)
}
Button(action: {
var i = 1
model.objs.removeAll(where: { _ in
i += 1
return i % 2 == 0
})
}) {
Text("Remove half")
}
}
}
}
Run the app, and press the "Remove half" button. Keep pressing it until all the items are gone. However, if you look at the console, you'll see that only 85 items have been destroyed, while there were 99 items. The Xcode memory graph also supports this.
This seems to be caused by the id: \.self line. Removing it and switching it out for id: \.text fixes the problem.
However the reason I use id: \.self is because I want to support multiple selection, and I want the selection to be of type Set<TestObj>, instead of Set<UUID>.
Is there any way to solve this issue?
If you didn't have to use selection in your List, you could use any unique & constant id, for example:
class TestObj: Hashable, Identifiable {
let id = UUID()
/* ... */
}
And then your List with the implicit id: \.id:
List(model.objs) { obj in
Text(obj.text)
}
This works great. It works because now you are no longer identifying the rows in the list by a reference type, which is kept by SwiftUI. Instead you are using a value type, so there aren't any strong references causing TestObjs to not deallocate.
But you need selection in List, so see more below about how to achieve that.
To get this working with selection, I will be using OrderedDictionary from Swift Collections. This is so the list rows can still be identified with id like above, but we can quickly access them. It's partially a dictionary, and partially an array, so it's O(1) time to access an element by a key.
Firstly, here is an extension to create this dictionary from the array, so we can identify it by its id:
extension OrderedDictionary {
/// Create an ordered dictionary from the given sequence, with the key of each pair specified by the key-path.
/// - Parameters:
/// - values: Every element to create the dictionary with.
/// - keyPath: Key-path for key.
init<Values: Sequence>(_ values: Values, key keyPath: KeyPath<Value, Key>) where Values.Element == Value {
self.init()
for value in values {
self[value[keyPath: keyPath]] = value
}
}
}
Change your Model object to this:
class Model: ObservableObject {
#Published var objs: OrderedDictionary<UUID, TestObj>
init() {
let values = (1..<100).map { TestObj(text: "\($0)")}
objs = OrderedDictionary<UUID, TestObj>(values, key: \.id)
}
}
And rather than model.objs you'll use model.objs.values, but that's it!
See full demo code below to test the selection:
struct ContentView: View {
#StateObject private var model = Model()
#State private var selection: Set<UUID> = []
var body: some View {
NavigationView {
VStack {
List(model.objs.values, selection: $selection) { obj in
Text(obj.text)
}
Button(action: {
var i = 1
model.objs.removeAll(where: { _ in
i += 1
return i % 2 == 0
})
}) {
Text("Remove half")
}
}
.onChange(of: selection) { newSelection in
let texts = newSelection.compactMap { selection in
model.objs[selection]?.text
}
print(texts)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
EditButton()
}
}
}
}
}
Result:

Index out of range when overwriting array that UI depends on

I have a ForEach, that iterates over array of indices, accessing array's data by subscript, at some point in loading program from file process this array needs to be rewritten, thus its size is changed, but when the size of already rendered cells exceeds the new size the
Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range occurs.
ForEach(computer.program.commands.indices, id: \.self) { i in
GeometryReader { cell in
CommandCellView($computer.program[i],
computer.program[i].number == computer.commandCounter.getValue())
.renderIfWillBeSeen(preload, viewGeometry: cell, generalGeometry: g)
}.frame(height: cellHeight)
}
the computer is #StateObject, and everything inside of it is a struct
tried to call file method inside withAnimation() didn't work
works perfectly fine when rendered cell count is smaller than new array count
So, initial problem was to interate over binding of a collection in ForEach as if it was a collection of bindings. That functionality will be added in the new version of swift, however, I decided to post the solution that I found, in case someone will need that type of functionality in an older version of SwiftUI
API
//$computer.program - Binding<MutableCollection<Command>>
//command - Binding<Command>
BindingForEach($computer.program) { command in
CommandCellView(...,
command: command)
}
BindingForEach.swift
import SwiftUI
struct BindingForEach<Data: MutableCollection, Content: View>: DynamicViewContent where Data.Index: Hashable,
Data.Indices == Range<Int> {
#Binding public var data: Data
private var builder: (Binding<Data.Element>) -> Content
private var elementBindings = FunctionCache<Data.Index, Binding<Data.Element>>()
init(_ collection: Binding<Data>, #ViewBuilder _ content: #escaping (Binding<Data.Element>) -> Content) {
self._data = collection
self.builder = content
self.elementBindings = FunctionCache<Data.Index, Binding<Data.Element>> { [self] (i) in
Binding<Data.Element> { [self] in
self.data[i]
} set: { [self] in
self.data[i] = $0
}
}
}
var body: some View {
ForEach(data.enumerated().map({ (i, _) in i }), id: \.self) { i in
builder(elementBindings[i])
}
}
}
FunctionCache.swift
It it important to cache created bindings, to optimize the performance
import Foundation
class FunctionCache<TSource: Hashable, TResult> {
private let function: (TSource) -> (TResult)
private var cache = [TSource : TResult]()
init (_ function: #escaping (TSource) -> TResult = { _ in fatalError("Unspecified function") }) {
self.function = function
}
subscript(_ i: TSource) -> TResult {
get {
if !cache.keys.contains(i) {
cache[i] = function(i)
}
return cache[i]!
}
}
}

SwiftUI TextField Date Binding

Hmmm. I'm not doing very well with these bindings.
struct RecordForm: View
{
#State var record: Record
var body: some View
{
TextField("date", text: Binding($record.recordDate!)))
}
}
I want to convert this Date to a String. In regular Swift I would just call my extension
record.recordDate.mmmyyy()
but I cannot find the right syntax or even the right place to do the conversion.
If I try to put the code in the body or the struct I just get a pile of errors.
Is there any easy to read documentation on this subject?
The answer by nine stones worked nicely, although I had to tweak the code slightly to get it to work for me with an NSManagedObject:
struct RecordDate: View
{
#State var record: Record //NSManagedObject
var body: some View {
let bind = Binding<String>(
get: {self.$record.recordDate.wrappedValue!.dateString()},
set: {self.$record.recordDate.wrappedValue = dateFromString($0)}
)
return HStack {
Text("Date:")
TextField("date", text: bind)
}
}
}
//dateString is a date extension that returns a date as a string
//dateFromString is a function that returns a string from a date
Documentation is hard to find and Apple's really ucks.
Try to create a custom binding.
extension Date {
func mmyyy() -> String { "blah" }
static func yyymm(val: String) -> Date { Date() }
}
struct RecordForm: View {
struct Record {
var recordDate: Date
}
#State var record = Record(recordDate: Date())
var body: some View {
let bind = Binding(
get: { self.record.recordDate.mmyyy() },
set: { self.record.recordDate = Date.yyymm(val: $0)}
)
return VStack {
TextField("date", text: bind)
}
}
}

SwiftUI: ViewBuilder unable to create a collection of Views based on array

I am trying to build a simple List by using SwiftUI. However, I am not able to dynamically create the rows by using an array of data. This is the error message: Cannot convert value of type '(Setlist) -> SetlistRow' to expected argument type '(_) -> _'
I've tried at least the following syntaxes, but I always get the same error.
List(setlists) { }
List(setlists, rowContent: Setlist.init)
ForEach(self.setlists) { setlist in }
Here is my code:
struct Setlist {
var name: String = "New setlist"
var sets = [SongSet]()
}
struct SetlistManagerView : View {
private var setlists: [Setlist] {
// creates an array of dummy items
}
var body : some View {
List {
ForEach(setlists) {
SetlistRow(setlist: $0)
}
}
}
}
struct SetlistRow : View {
var setlist: Setlist
var body : some View {
let numberOfSongs = setlist.sets.map { $0.songs.count }.reduce(0, +)
return NavigationView {
NavigationButton (destination: SetListView(setlist: setlist)) {
// code for displaying the row
}
}
}
}
List items need to conform to Identifiable protocol in order for them to be used as collection data source without the identified(by:) argument.
Xcode error message here is misleading as the software is still in beta.