How to get textField values in the list swiftui - ios

I used the list this way
I want to send all TextField values when the submit button is clicked.
My problem is that I can't get the TextField value in the list
I also use mvvm architecture in the project
my code in the project :
My Model :
import Foundation
import SwiftUI
struct MyModel : Identifiable {
var id:Int64
var title:String
#State var value:String
}
My ViewModel :
import Foundation
import SwiftUI
class MyViewModel: ObservableObject {
#Published var items:[MyModel] = []
init() {
populateItem()
}
func populateItem(){
self.items.append(MyModel(id: 0, title: "title #1", value: ""))
self.items.append(MyModel(id: 1, title: "title #2", value: ""))
self.items.append(MyModel(id: 2, title: "title #3", value: ""))
self.items.append(MyModel(id: 3, title: "title #4", value: ""))
self.items.append(MyModel(id: 4, title: "title #5", value: ""))
self.items.append(MyModel(id: 5, title: "title #6", value: ""))
}
}
My View :
import SwiftUI
struct ContentView: View {
#ObservedObject var myViewModel = MyViewModel()
var body: some View {
VStack {
List {
ForEach(self.myViewModel.items) {item in
HStack {
Text(item.title)
TextField("value", text: item.$value)
}
}
}
Button(action: {
print(self.myViewModel.items[0].value)
}){
Text("Submit")
}.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

First remove #State property wrapper from your model's value property.
And update your ForEach with the next code:
ForEach(self.myViewModel.items.indices, id:\.self) { index in
HStack {
Text(self.myViewModel.items[index].title)
TextField("value", text: Binding(
get: {
return self.myViewModel.items[index].value
},
set: { newValue in
return self.myViewModel.items[index].value = newValue
}))
}
}

Related

SwiftUI Expand Lists One-At-A-Time, Auto Collapse

I'm trying to build a view with several collapsed lists, only the headers showing at first. When you tap on a header, its list should expand. Then, with the first list is expanded, if you tap on the header of another list, the first list should automatically collapse while the second list expands. So on and so forth, so only one list is visible at a time.
The code below works great to show multiple lists at the same time and they all expand and collapse with a tap, but I can't figure out what to do to make the already open lists collapse when I tap to expand a collapsed list.
Here's the code (sorry, kind of long):
import SwiftUI
struct Task: Identifiable {
let id: String = UUID().uuidString
let title: String
let subtask: [Subtask]
}
struct Subtask: Identifiable {
let id: String = UUID().uuidString
let title: String
}
struct SubtaskCell: View {
let task: Subtask
var body: some View {
HStack {
Image(systemName: "circle")
.foregroundColor(Color.primary.opacity(0.2))
Text(task.title)
}
}
}
struct TaskCell: View {
var task: Task
#State private var isExpanded = false
var body: some View {
content
.padding(.leading)
.frame(maxWidth: .infinity)
}
private var content: some View {
VStack(alignment: .leading, spacing: 8) {
header
if isExpanded {
Group {
List(task.subtask) { subtask in
SubtaskCell(task: subtask)
}
}
.padding(.leading)
}
Divider()
}
}
private var header: some View {
HStack {
Image(systemName: "square")
.foregroundColor(Color.primary.opacity(0.2))
Text(task.title)
}
.padding(.vertical, 4)
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}
struct ContentView: View {
//sample data
private let tasks: [Task] = [
Task(
title: "Create playground",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
Task(
title: "Write article",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
Task(
title: "Prepare assets",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
Task(
title: "Publish article",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
]
var body: some View {
NavigationView {
VStack(alignment: .leading) {
ForEach(tasks) { task in
TaskCell(task: task)
.animation(.default)
}
Spacer()
}
}
}
}
Thanks ahead for any help!
EDIT: Here's the collapse functionality to go with the accepted solution below:
Update the onTapGesture in private var header: some View to look like this:
.onTapGesture {
withAnimation {
if task.isExpanded {
viewmodel.collapse(task)
} else {
viewmodel.expand(task)
}
}
}
Then add the collapse function to class Viewmodel
func collapse(_ taks: TaskModel) {
var tasks = self.tasks
tasks = tasks.map {
var tempVar = $0
tempVar.isExpanded = false
return tempVar
}
self.tasks = tasks
}
That's it! Fully working as requested!
I think the best way to achieve this is to move the logic to a viewmodel.
struct TaskModel: Identifiable {
let id: String = UUID().uuidString
let title: String
let subtask: [Subtask]
var isExpanded: Bool = false // moved state variable to the model
}
struct Subtask: Identifiable {
let id: String = UUID().uuidString
let title: String
}
struct SubtaskCell: View {
let task: Subtask
var body: some View {
HStack {
Image(systemName: "circle")
.foregroundColor(Color.primary.opacity(0.2))
Text(task.title)
}
}
}
struct TaskCell: View {
var task: TaskModel
#EnvironmentObject private var viewmodel: Viewmodel //removed state here and added viewmodel from environment
var body: some View {
content
.padding(.leading)
.frame(maxWidth: .infinity)
}
private var content: some View {
VStack(alignment: .leading, spacing: 8) {
header
if task.isExpanded {
Group {
List(task.subtask) { subtask in
SubtaskCell(task: subtask)
}
}
.padding(.leading)
}
Divider()
}
}
private var header: some View {
HStack {
Image(systemName: "square")
.foregroundColor(Color.primary.opacity(0.2))
Text(task.title)
}
.padding(.vertical, 4)
.onTapGesture {
withAnimation {
viewmodel.expand(task) //handle expand / collapse here
}
}
}
}
struct ContentView: View {
#StateObject private var viewmodel: Viewmodel = Viewmodel() //Create viewmodel here
var body: some View {
NavigationView {
VStack(alignment: .leading) {
ForEach(viewmodel.tasks) { task in //use viewmodel tasks here
TaskCell(task: task)
.animation(.default)
.environmentObject(viewmodel)
}
Spacer()
}
}
}
}
class Viewmodel: ObservableObject{
#Published var tasks: [TaskModel] = [
TaskModel(
title: "Create playground",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
TaskModel(
title: "Write article",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
TaskModel(
title: "Prepare assets",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
TaskModel(
title: "Publish article",
subtask: [
Subtask(title: "Cover image"),
Subtask(title: "Screenshots"),
]
),
]
func expand(_ task: TaskModel){
//copy tasks to local variable to avoid refreshing multiple times
var tasks = self.tasks
//create new task array with isExpanded set
tasks = tasks.map{
var tempVar = $0
tempVar.isExpanded = $0.id == task.id
return tempVar
}
// assign array to update view
self.tasks = tasks
}
}
Notes:
Renamed your task model as it is a very bad idea to name somthing with a name that is allready used by the language
This only handles expanding. But implementing collapsing shouldn´t be to hard :)
Edit:
if you dont want a viewmodel you can use a binding as alternative:
Add to your containerview:
#State private var selectedId: String?
change body to:
NavigationView {
VStack(alignment: .leading) {
ForEach(tasks) { task in
TaskCell(task: task, selectedId: $selectedId)
.animation(.default)
}
Spacer()
}
}
and change your TaskCell to:
struct TaskCell: View {
var task: TaskModel
#Binding var selectedId: String?
var body: some View {
content
.padding(.leading)
.frame(maxWidth: .infinity)
}
private var content: some View {
VStack(alignment: .leading, spacing: 8) {
header
if selectedId == task.id {
Group {
List(task.subtask) { subtask in
SubtaskCell(task: subtask)
}
}
.padding(.leading)
}
Divider()
}
}
private var header: some View {
HStack {
Image(systemName: "square")
.foregroundColor(Color.primary.opacity(0.2))
Text(task.title)
}
.padding(.vertical, 4)
.onTapGesture {
withAnimation {
selectedId = selectedId == task.id ? nil : task.id
}
}
}
}

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

Navigation Bar Items with #EnvironmentObject

I would like to create a Settings view using SwiftUI. I mainly took the official example from Apple about SwiftUI to realize my code. The settings view should have a toggle to whether display or not my favorites items.
For now I have a landmarks list and a settings view.
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var imageName: String
var title: String
var isFavorite: Bool
var description: String
enum CodingKeys: String, CodingKey {
case id, imageName, title, description
}
}
final class UserData: ObservableObject {
#Published var showFavoriteOnly: Bool = false
#Published var items: [Landmark] = landmarkData
#Published var showProfile: Bool = false
}
struct ItemList: View {
#EnvironmentObject var userData: UserData
#State var trailing: Bool = false
init() {
UITableView.appearance().separatorStyle = .none
}
var body: some View {
NavigationView {
List {
VStack {
CircleBadgeView(text: String(landmarkData.count), thickness: 2)
Text("Tutorials available")
}.frame(minWidth:0, maxWidth: .infinity)
ForEach(userData.items) { landmark in
if !self.userData.showFavoriteOnly || landmark.isFavorite {
ZStack {
Image(landmark.imageName)
.resizable()
.frame(minWidth: 0, maxWidth: .infinity)
.cornerRadius(10)
.overlay(ImageOverlay(text: landmark.title), alignment: .bottomTrailing)
Text(String(landmark.isFavorite))
NavigationLink(destination: TutorialDetailView(landmark: landmark)) {
EmptyView()
}.buttonStyle(PlainButtonStyle())
}
}
}
}.navigationBarTitle("Tutorials")
.navigationBarItems(trailing: trailingItem())
}
}
}
extension ItemList {
func trailingItem () -> some View {
return HStack {
if userData.showProfile {
NavigationLink(destination: ProfileView()) {
Image(systemName: "person.circle")
.imageScale(.large)
.accessibility(label: Text("Profile"))
}
}
NavigationLink(destination: SettingsView().environmentObject(userData)) {
Image(systemName: "gear")
.imageScale(.large)
.accessibility(label: Text("Settings"))
}
}
}
}
As you can see my SettingsView is accessible from navigationBarItems of my NavigationView. I don't know if it's the problem or not but when I put the Toggle inside the ListView it works as expected. But now when I trigger the toggle to enable only favorite my application crash instantly.
I've tried to trigger the Show profile toggle from SettingsView and it works.
struct SettingsView: View {
#EnvironmentObject var userData: UserData
var body: some View {
Form {
Section(header: Text("General")) {
Toggle(isOn: $userData.showProfile) {
Text("Show profile")
}
Toggle(isOn: $userData.showFavoriteOnly) {
Text("Favorites only")
}
}
Section(header: Text("UI")) {
Toggle(isOn: .constant(false)) {
Text("Dark mode")
}
NavigationLink(destination: Text("third")) {
Text("Third navigation")
}
}
}.navigationBarTitle(Text("Settings"), displayMode: .inline)
}
}
In brief, the crash appears in my SettingsView when I trigger the Show only favorite Toggle and then I try to go back to the previous view which is ItemListView
The only information I can get about the error is Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)
You can find the whole project on my GitHub : https://github.com/Hurobaki/swiftui-tutorial
Some help would be really appreciated :)
Here is a minimal version of your example code, that works:
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var imageName: String
var title: String
var isFavorite: Bool
var description: String
}
final class UserData: ObservableObject {
#Published var showFavoriteOnly: Bool = false
#Published var items: [Landmark] = [
Landmark(id: 1, imageName: "a", title: "a", isFavorite: true, description: "A"),
Landmark(id: 2, imageName: "b", title: "b", isFavorite: false, description: "B")
]
}
struct ContentView: View {
#EnvironmentObject var userData: UserData
var body: some View {
NavigationView {
List(userData.items.filter { !userData.showFavoriteOnly || $0.isFavorite }) { landmark in
Text(String(landmark.isFavorite))
}
.navigationBarTitle("Tutorials")
.navigationBarItems(trailing: trailingItem())
}
}
func trailingItem () -> some View {
return HStack {
NavigationLink(destination: SettingsView()) {
Text("Settings")
}
}
}
}
struct SettingsView: View {
#EnvironmentObject var userData: UserData
var body: some View {
Form {
Section(header: Text("General")) {
Toggle(isOn: $userData.showFavoriteOnly) {
Text("Favorites only")
}
}
}.navigationBarTitle(Text("Settings"), displayMode: .inline)
}
}

SwiftUI Textfields inside Lists

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

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

Resources