SwiftUI Textfields inside Lists - ios

I want a list with rows, with each row having 2 Textfields inside of it. Those rows should be saved in an array, so that I can use the data in an other view for further functions. If the text in the Textfield is changed, the text should be saved inside the right entry in the array.
You also can add new rows to the list via a button, which should also change the array for the rows.
The goal is to have a list of key value pairs, each one editable and those entries getting saved with the current text.
Could someone help me and/or give me hint for fixing this problem?
So far I have tried something like this:
// the array of list entries
#State var list: [KeyValue] = [KeyValue()]
// the List inside of a VStack
List(list) { entry in
KeyValueRow(list.$key, list.$value)
}
// the single item
struct KeyValue: Identifiable {
var id = UUID()
#State var key = ""
#State var value = ""
}
// one row in the list with view elements
struct KeyValueRow: View {
var keyBinding: Binding<String>
var valueBinding: Binding<String>
init(_ keyBinding: Binding<String>, _ valueBinding: Binding<String>){
self.keyBinding = keyBinding
self.valueBinding = valueBinding
}
var body: some View {
HStack() {
TextField("key", text: keyBinding)
Spacer()
TextField("value", text: valueBinding)
Spacer()
}
}
}
Also, about the button for adding new entries.
Problem is that if I do the following, my list in the view goes blank and everything is empty again
(maybe related: SwiftUI TextField inside ListView goes blank after filtering list items ?)
Button("Add", action: {
self.list.append(KeyValue())
})

I am not sure what the best practice is keep a view up to date with state in an array like this, but here is one approach to make it work.
For the models, I added a list class that conforms to Observable object, and each KeyValue item alerts it on changes:
class KeyValueList: ObservableObject {
#Published var items = [KeyValue]()
func update() {
self.objectWillChange.send()
}
func addItem() {
self.items.append(KeyValue(parent: self))
}
}
class KeyValue: Identifiable {
init(parent: KeyValueList) {
self.parent = parent
}
let id = UUID()
private let parent: KeyValueList
var key = "" {
didSet { self.parent.update() }
}
var value = "" {
didSet { self.parent.update() }
}
}
Then I was able to simply the row view to just keep a single piece of state:
struct KeyValueRow: View {
#State var item: KeyValue
var body: some View {
HStack() {
TextField("key", text: $item.key)
Spacer()
TextField("value", text: $item.value)
Spacer()
}
}
}
And for the list view:
struct TextFieldList: View {
#ObservedObject var list = KeyValueList()
var body: some View {
VStack {
List(list.items) { item in
HStack {
KeyValueRow(item: item)
Text(item.key)
}
}
Button("Add", action: {
self.list.addItem()
})
}
}
}
I just threw an extra Text in there for testing to see it update live.
I did not run into the Add button blanking the view as you described. Does this solve that issue for you as well?

Working code example for iOS 15
In SwiftUI, Apple recommends passing the binding directly into the List constructor and using a #Binding in the ViewBuilder block to iterate through with each element.
Apple recommends this approach over using the Indices to iterate over the collection since this doesn't reload the whole list every time a TextField value changes (better efficiency).
The new syntax is also back-deployable to previous releases of SwiftUI apps.
struct ContentView: View {
#State var directions: [Direction] = [
Direction(symbol: "car", color: .mint, text: "Drive to SFO"),
Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"),
Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"),
Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"),
Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"),
Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"),
Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"),
Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"),
Direction(symbol: "key", color: .red, text: "Enter door code:"),
]
var body: some View {
NavigationView {
List($directions) { $direction in
Label {
TextField("Instructions", text: $direction.text)
}
}
.listStyle(.sidebar)
.navigationTitle("Secret Hideout")
}
}
}
struct Direction: Identifiable {
var id = UUID()
var symbol: String
var color: Color
var text: String
}

No need to mess up with classes, Observable, Identifiable. You can do it all with structs.
Note, that version below will do fine for insertions, but fail if you try to delete array elements:
import SwiftUI
// the single item
struct KeyValue {
var key: String
var value: String
}
struct ContentView: View {
#State var boolArr: [KeyValue] = [KeyValue(key: "key1", value: "Value1"), KeyValue(key: "key2", value: "Value2"), KeyValue(key: "key3", value: "Value3"), KeyValue(key: "key4", value: "Value4")]
var body: some View {
NavigationView {
// id: \.self is obligatory if you need to insert
List(boolArr.indices, id: \.self) { idx in
HStack() {
TextField("key", text: self.$boolArr[idx].key)
Spacer()
TextField("value", text: self.$boolArr[idx].value)
Spacer()
}
}
.navigationBarItems(leading:
Button(action: {
self.boolArr.append(KeyValue(key: "key\(UInt.random(in: 0...100))", value: "value\(UInt.random(in: 0...100))"))
print(self.boolArr)
})
{ Text("Add") }
, trailing:
Button(action: {
self.boolArr.removeLast() // causes "Index out of range" error
print(self.boolArr)
})
{ Text("Remove") })
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Update:
A little trick to make it work with deletions as well.
import SwiftUI
// the single item
struct KeyValue {
var key: String
var value: String
}
struct KeyValueView: View {
#Binding var model: KeyValue
var body: some View {
HStack() {
TextField("Key", text: $model.key)
Spacer()
TextField("Value", text: $model.value)
Spacer()
}
}
}
struct ContentView: View {
#State var kvArr: [KeyValue] = [KeyValue(key: "key1", value: "Value1"), KeyValue(key: "key2", value: "Value2"), KeyValue(key: "key3", value: "Value3"), KeyValue(key: "key4", value: "Value4")]
var body: some View {
NavigationView {
List(kvArr.indices, id: \.self) { i in
KeyValueView(model: Binding(
get: {
return self.kvArr[i]
},
set: { (newValue) in
self.kvArr[i] = newValue
}))
}
.navigationBarItems(leading:
Button(action: {
self.kvArr.append(KeyValue(key: "key\(UInt.random(in: 0...100))", value: "value\(UInt.random(in: 0...100))"))
print(self.kvArr)
})
{ Text("Add") }
, trailing:
Button(action: {
self.kvArr.removeLast() // Works like a charm
print(self.kvArr)
})
{ Text("Remove") })
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Swift 5.5
This version of swift enables one line code for this by passing the bindable item directly from the array.
struct DirectionsList: View {
#Binding var directions: [Direction]
var body: some View {
List($directions) { $direction in
Label {
TextField("Instructions", text: $direction.text)
} icon: {
DirectionsIcon(direction)
}
}
}
}

Related

SwiftUI: Checkmarks disappear when changing from one view to another using NavigationLink

I'm trying to make an app that is displaying lists with selections/checkmarks based on clicked NavigationLink. The problem I encountered is that my selections disappear when I go back to main view and then I go again inside the NavigationLink. I'm trying to save toggles value in UserDefaults but it's not working as expected. Below I'm pasting detailed and main content view.
Second view:
struct CheckView: View {
#State var isChecked:Bool = false
#EnvironmentObject var numofitems: NumOfItems
var title:String
var count: Int=0
var body: some View {
HStack{
ScrollView {
Toggle("\(title)", isOn: $isChecked)
.toggleStyle(CheckToggleStyle())
.tint(.mint)
.onChange(of: isChecked) { value in
if isChecked {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
UserDefaults.standard.set(self.isChecked, forKey: "locationToggle")
}.onTapGesture {
}
.onAppear {
self.isChecked = UserDefaults.standard.bool(forKey: "locationToggle")
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
Main view:
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach(menu){
section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach(section.items) { item in
CheckView( title: item.name)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
ItemsSection:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria"
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola"
}
Model:
struct ItemsSection: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var items: [CountriesItem]
}
struct CountriesItem: Codable, Equatable, Identifiable,Hashable {
var id: UUID = UUID()
var name: String
}
As allready stated in the comment you have to relate the isChecked property to the CountryItem itself. To get this to work i have changed the model and added an isChecked property. You would need to add this to the JSON by hand if the JSON allread exists.
struct CheckView: View {
#EnvironmentObject var numofitems: NumOfItems
//use a binding here as we are going to manipulate the data coming from the parent
//and pass the complete item not only the name
#Binding var item: CountriesItem
var body: some View {
HStack{
ScrollView {
//use the name and the binding to the item itself
Toggle("\(item.name)", isOn: $item.isChecked)
.toggleStyle(.button)
.tint(.mint)
// you now need the observe the isChecked inside of the item
.onChange(of: item.isChecked) { value in
if value {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
}.onTapGesture {
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach($menu){ // from here on you have to pass a binding on to the decendent views
// mark the $ sign in front of the property name
$section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach($section.items) { $item in
//Pass the complete item to the CheckView not only the name
CheckView(item: $item)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
Example JSON:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria",
"isChecked": false
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola",
"isChecked": false
}
]
}
]
Remarks:
The aproach with JSON and storing this in the bundle will prevent you from persisting the isChecked property between App launches. Because you cannot write to the Bundle from within your App. The choice will persist as long as the App is active but will be back to default as soon as you either reinstall or force quit it.
As already mentioned in the comment, I don'r see where you read back from UserDefaults, so whatever gets stored there, you don't read it. But even if so, each Toggle is using the same key, so you are overwriting the value.
Instead of using the #State var isChecked, which is used just locally, I'd create another struct item which gets the title from the json and which contains a boolean that gets initialized with false.
From what I understood, I assume a solution could look like the following code. Just a few things:
I am not sure how your json looks like, so I am not loading from a json, I add ItemSections Objects with a title and a random number of items (actually just titles again) with a function.
Instead of a print with the number of checked toggles, I added a text output on the UI. It shows you on first page the number of all checked toggles.
Instead of using UserDefaults I used #AppStorage.
To make that work you have to make Array conform to RawRepresentable you achieve that with the following code/extension (just add it once somewhere in your project)
Maybe you should thing about a ViewModel (e.g. ItemSectionViewModel), to load the data from the json and provide it to the views as an #ObservableObject.
The code for the views:
//
// CheckItem.swift
// CheckItem
//
// Created by Sebastian on 24.08.22.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack() {
CheckItemView()
}
}
}
struct CheckItemView: View {
let testStringForTestData: String = "Check Item Title"
#AppStorage("itemSections") var itemSections: [ItemSection] = []
func addCheckItem(title: String, numberOfItems: Int) {
var itemArray: [Item] = []
for i in 0...numberOfItems {
itemArray.append(Item(title: "item \(i)"))
}
itemSections.append(ItemSection(title: title, items: itemArray))
}
func getSelectedItemsCount() -> Int{
var i: Int = 0
for itemSection in itemSections {
let filteredItems = itemSection.items.filter { item in
return item.isOn
}
i = i + filteredItems.count
}
return i
}
var body: some View {
NavigationView{
VStack() {
List(){
ForEach(itemSections.indices, id: \.self){ id in
NavigationLink(destination: ItemSectionDetailedView(items: $itemSections[id].items)) {
Text(itemSections[id].title)
}
.padding()
}
}
Text("Number of checked items: \(self.getSelectedItemsCount())")
.padding()
Button(action: {
self.addCheckItem(title: testStringForTestData, numberOfItems: Int.random(in: 0..<4))
}) {
Text("Add Item")
}
.padding()
}
}
}
}
struct ItemSectionDetailedView: View {
#Binding var items: [Item]
var body: some View {
ScrollView() {
ForEach(items.indices, id: \.self){ id in
Toggle(items[id].title, isOn: $items[id].isOn)
.padding()
}
}
}
}
struct ItemSection: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var items: [Item]
}
struct Item: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var isOn: Bool = false
}
Here the adjustment to work with #AppStorage:
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

SwiftUI: Sheet cannot show correct values in first time

I found strange behavior in SwiftUI.
The sheet shows empty text when I tap a list column first time.
It seems correct after second time.
Would you help me?
import SwiftUI
let fruits: [String] = [
"Apple",
"Banana",
"Orange",
]
struct ContentView: View {
#State var isShowintSheet = false
#State var selected: String = ""
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
isShowintSheet = true
}) {
Text(fruit)
}
}
}
.sheet(isPresented: $isShowintSheet, content: {
Text(selected)
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
list
first tap
after second tap
Use .sheet(item:) instead. Here is fixed code.
Verified with Xcode 12.1 / iOS 14.1
struct ContentView: View {
#State var selected: String?
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
}) {
Text(fruit)
}
}
}
.sheet(item: $selected, content: { item in
Text(item)
})
}
}
extension String: Identifiable {
public var id: String { self }
}
Thank you, Omid.
I changed my code from Asperi's code using #State like this.
import SwiftUI
struct Fruit: Identifiable, Hashable {
var name: String
var id = UUID()
}
let fruits: [Fruit] = [
Fruit(name: "Apple"),
Fruit(name: "Banana"),
Fruit(name: "Orange"),
]
struct ContentView: View {
#State var selected: Fruit?
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
}) {
Text(fruit.name)
}
}
}
.sheet(item: $selected, content: { item in
Text(item.name)
})
}
}

Im having trouble with dynamic lists in SwiftUI. I cant get my list to update dynamically using a picker

Basically as the title states. I have the picker called Ingredients and when I go into the list and click an element it should work as a button (or maybe not) and use the add function to append that element into the ingredients list which is a state variable which should then in turn update the list at the bottom and display its elements, but it doesnt. I have done other projects with a similar idea of an updating list but never with a picker. Any help appreciated. Also worth mentioning is that the TEST button works for what i want to achieve and the #ObservedObject can be ignored.
import SwiftUI
struct AddRecipe: View {
#ObservedObject var recipe: RecipeFinal
#State private var name = ""
#State private var time = 0
#State private var diff = ""
#State private var ingredients = [String]()
static var diffT = ["Easy", "Medium", "Difficult"]
static var ingred = ["Onion","Salt","Oil","Tomato", "Garlic",
"Peppers","Bread","Vinegar"]
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name)
Stepper(value: $time, in: 0...120, step: 15) {
Text("Time: \(time) minutes")
}
Picker ("Difficulty", selection: $diff) {
ForEach (AddRecipe.self.diffT, id: \.self) {
Text($0)
}
}
Button("TEST") {
self.ingredients.append("TEST")
}
Picker("Ingredients", selection: $ingredients) {
ForEach (AddRecipe.self.ingred, id: \.self) { ing in
Button(action: {
self.add(element: ing)
}) {
Text("\(ing)")
}
}
}
Section(header: Text("Ingredients")) {
List (self.ingredients, id: \.self) {
Text($0)
}
}
}
}
}
func add (element: String) {
self.ingredients.append(element)
}
}
struct AddRecipe_Previews: PreviewProvider {
static var previews: some View {
AddRecipe(recipe: RecipeFinal())
}
}

List reload animation glitches

So I have a list that changes when user fill in search keyword, and when there is no result, all the cells collapse and somehow they would fly over to the first section which looks ugly. Is there an error in my code or is this an expected SwiftUI behavior? Thanks.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(photoLibraryService: PhotoLibraryService.shared)
var body: some View {
NavigationView {
List {
Section {
TextField("Enter Album Name", text: $viewModel.searchText)
}
Section {
if viewModel.libraryAlbums.count > 0 {
ForEach(viewModel.libraryAlbums) { libraryAlbum -> Text in
let title = libraryAlbum.assetCollection.localizedTitle ?? "Album"
return Text(title)
}
}
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(
Text("Albums")
).navigationBarItems(trailing: Button("Add Album", action: {
PhotoLibraryService.shared.createAlbum(withTitle: "New Album \(Int.random(in: 1...100))")
}))
}.animation(.default)
}
}
1) you have to use some debouncing to reduce the needs to refresh the list, while typing in the search field
2) disable animation of rows
The second is the hardest part. the trick is to force recreate some View by setting its id.
Here is code of simple app (to be able to test this ideas)
import SwiftUI
import Combine
class Model: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
#Published var data = ["art", "audience", "association", "attitude", "ambition", "assistance", "awareness", "apartment", "artisan", "airport", "atmosphere", "actor", "army", "attention", "agreement", "application", "agency", "article", "affair", "apple", "argument", "analysis", "appearance", "assumption", "arrival", "assistant", "addition", "accident", "appointment", "advice", "ability", "alcohol", "anxiety", "ad", "activity"].map(DataRow.init)
var filtered: [DataRow] {
data.filter { (row) -> Bool in
row.txt.lowercased().hasPrefix(debouncedText.lowercased())
}
}
var id: UUID {
UUID()
}
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct DataRow: Identifiable {
let id = UUID()
let txt: String
init(_ txt: String) {
self.txt = txt
}
}
struct ContentView: View {
#ObservedObject var search = Model(delay: 0.5)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
TextField("filter", text: $search.text)
.padding(.vertical)
.padding(.horizontal)
List(search.filtered) { (e) in
Text(e.txt)
}.id(search.id)
}.navigationBarTitle("Navigation")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and i am happy with the result

SwiftUI Picker in a Form doesn't show the selected row

I am trying to have a Picker that shows which option is currently selected.
Try out the following code which correctly selects the right option but the picker does not show which option is selected:
import SwiftUI
struct ContentView: View {
#State var selectedIndex: Int = 0
let strings: [String] = {
var strings: [String] = []
for i in 0..<10 {
strings.append("\(i)")
}
return strings
}()
var body: some View {
NavigationView {
VStack {
Form {
Picker(selection: $selectedIndex,
label: Text("Selected string: \(strings[selectedIndex])")) {
ForEach(0..<strings.count) {
Text(self.strings[$0]).tag($0)
}
}
}
}
.navigationBarTitle("Form Picker",
displayMode: NavigationBarItem.TitleDisplayMode.inline)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Anyone know what could be wrong? It's observed using Xcode 11.1 and iOS 13.1
I created the simple picker I call "ListPicker" which should fit the bill. I've written it so it works well in a Form; if you need it outside of a Form you will have to tinker with it. If you see any way to improve the code, please add a comment; this is still a learning experience for all of us.
// MARK: - LIST PICKER (PUBLIC)
struct ListPicker<Content: View>: View {
#Binding var selectedItem: Int
var label: () -> Content
var data: [Any]
var selectedLabel: String {
selectedItem >= 0 ? "\(data[selectedItem])" : ""
}
var body: some View {
NavigationLink(destination: ListPickerContent(selectedItem: self.$selectedItem, data: self.data)) {
ListPickerLabel(label: self.label, value: "\(self.selectedLabel)")
}
}
}
// MARK: - INTERNAL
private struct ListPickerLabel<Content: View>: View {
let label: () -> Content
let value: String
var body: some View {
HStack(alignment: .center) {
self.label()
Spacer()
Text(value)
.padding(.leading, 8)
}
}
}
private struct ListPickerContentItem: View {
let label: String
let index: Int
let isSelected: Bool
var body: some View {
HStack {
Text(label)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}.background(Color.white) // so the entire row is selectable
}
}
private struct ListPickerContent: View {
#Environment(\.presentationMode) var presentationMode
#Binding var selectedItem: Int
var data: [Any]
var body: some View {
List {
ForEach(0..<data.count) { index in
ListPickerContentItem(label: "\(self.data[index])", index: index, isSelected: index == self.selectedItem).onTapGesture {
self.selectedItem = index
self.presentationMode.wrappedValue.dismiss()
}
}
}
}
}
Then you can use it like this:
#State var selectedCar: Int = 0
let cars = ["Jaguar", "Audi", "BMW", "Land Rover"]
Form {
ListPicker(
selectedItem: self.$selectedCar,
label: {
Text("Cars")
},
data: self.cars
)
}

Resources