SwiftUI List/Form bad animation when adding/removing rows - swiftui

I have a really simple list with text that when the user taps on it, it expands with a datepicker inside.
The problem is that the animation looks really broken, not sure what I can do about this besides doing the entire thing from scratch, that at this point I'd rather just use UIKit.
If you have an idea of how this can be fixed I'd really appreciate.
Here's the code:
struct ContentView: View {
let items = ["123", "345", "678"]
#State private var selectedItems = Set<String>()
#State private var test = Date()
var body: some View {
Form {
ForEach(items.indices) { index in
Button(action: {
withAnimation {
if selectedItems.contains(items[index]) {
selectedItems.remove(items[index])
} else {
selectedItems.insert(items[index])
}
}
}, label: {
Text(items[index])
.foregroundColor(.primary)
})
if selectedItems.contains(items[index]) {
DatePicker(selection: $test, in: ...Date(), displayedComponents: .date) {
}
.datePickerStyle(WheelDatePickerStyle())
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

ForEach(content:) should only be used for static collections.
If you have a dynamic collection (such as in your example - you're adding/removing entries), you need to use ForEach(id:content:):
ForEach(items.indices, id: \.self) { index in
Note that if your collection can have duplicate items, then id: \.self will not work properly and you may need to create a struct conforming to Identifiable instead.

Use Section inside ForEach.
struct ContentView: View {
let items = ["123", "345", "678"]
#State private var selectedItems = Set<String>()
#State private var test = Date()
var body: some View {
Form {
ForEach(items.indices) { index in
Section(header: header(index), content: {
if selectedItems.contains(items[index]) {
DatePicker(selection: $test, in: ...Date(), displayedComponents: .date) {
}
.datePickerStyle(WheelDatePickerStyle())
}
})
}
}
}
private func header(_ index: Int) -> some View {
Button(action: {
withAnimation {
if selectedItems.contains(items[index]) {
selectedItems.remove(items[index])
} else {
selectedItems.insert(items[index])
}
}
}, label: {
Text(items[index])
.foregroundColor(.primary)
})
}
}

Related

Showing ProgressView during a search

NOTE: this question is not about how to use .searchable or how to filter a List.
I am using the following view to search an external database:
struct SearchDatabaseView: View {
#Environment(\.dismiss) private var dismiss
#Environment(\.isSearching) private var isSearching: Bool
#State private var searchText: String = ""
#State private var searchResults: [Record] = []
var body: some View {
NavigationStack {
List(searchResults, id: \.self) { record in
/// display results here
}
.navigationTitle("Search Database")
.toolbar {
Button(action: {
dismiss()
}) {
Text("Done")
}
}
.overlay {
if isSearching {
ProgressView("Searching Database...")
}
}
}
.searchable(text: $searchText)
.disableAutocorrection(true)
.onSubmit(of: .search) {
searchDatabase()
}
}
}
Everything works, except the progress view is not showing. I tried putting the .overlay modifier after .onSubmit, but still it doesn't show.
What am I missing, is that not the proper use of isSearching ?
Try this approach, where two views are used (like the docs examples) to perform
the search and dismissal using dismissSearch and display the ProgressView.
This is just an example code, see the docs at: https://developer.apple.com/documentation/swiftui/managing-search-interface-activation
for more comprehensive info and examples.
struct ContentView: View {
var body: some View {
SearchDatabaseView()
}
}
struct SearchDatabaseView: View {
#State private var searchText: String = ""
var body: some View {
NavigationStack {
ListView()
.searchable(text: $searchText)
.disableAutocorrection(true)
.navigationTitle("Search Database")
.onSubmit(of: .search) {
// searchDatabase()
print("----> onSubmit: \(searchText)")
}
}
}
}
struct ListView: View {
#Environment(\.dismissSearch) private var dismissSearch
#Environment(\.isSearching) private var isSearching
#State private var searchResults: [String] = ["a-record", "b-record", "c-record", "d-record"]
var body: some View {
List(searchResults, id: \.self) { record in
Text(record)
}
.toolbar {
Button("Done") {
dismissSearch()
}
.overlay {
if isSearching {
ProgressView("Searching Database...")
}
}
}
}
}
EDIT-1:
To cater for your new question, I would do away with the isSearching thing.
Use a "normal" variable and implement a simple but effective code structure, such as in this example code:
struct SearchDatabaseView: View {
#State private var searchText: String = ""
#State private var showSearching = false
#State private var searchResults: [String] = ["a-record", "b-record", "c-record", "d-record"]
var body: some View {
NavigationStack {
List(searchResults, id: \.self) { record in
Text(record)
}
.toolbar {
Button("Done") {
showSearching = false
}
.overlay {
if showSearching {
ProgressView("Searching Database...")
}
}
.searchable(text: $searchText)
.disableAutocorrection(true)
.navigationTitle("Search Database")
.onSubmit(of: .search) {
showSearching = true
// searchDatabase()
// simulation of searchDatabase(), could also pass showSearching to it
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// .....
showSearching = false // when finished searchDatabase()
}
}
}
}
}
}

Popover displaying inaccurate information inside ForEach

I'm having a problem where I have a ForEach loop inside a NavigationView. When I click the Edit button, and then click the pencil image at the right hand side on each row, I want it to display the text variable we are using from the ForEach loop. But when I click the pencil image for the text other than test123, it still displays the text test123 and I have absolutely no idea why.
Here's a video. Why is this happening?
import SwiftUI
struct TestPopOver: View {
private var stringObjects = ["test123", "helloworld", "reddit"]
#State private var editMode: EditMode = .inactive
#State private var showThemeEditor = false
#ViewBuilder
var body: some View {
NavigationView {
List {
ForEach(self.stringObjects, id: \.self) { text in
NavigationLink( destination: HStack{Text("Test!")}) {
HStack {
Text(text)
Spacer()
if self.editMode.isEditing {
Image(systemName: "pencil.circle").imageScale(.large)
.onTapGesture {
if self.editMode.isEditing {
self.showThemeEditor = true
}
}
}
}
}
.popover(isPresented: $showThemeEditor) {
CustomPopOver(isShowing: $showThemeEditor, text: text)
}
}
}
.navigationBarTitle("Reproduce Editing Bug!")
.navigationBarItems(leading: EditButton())
.environment(\.editMode, $editMode)
}
}
}
struct CustomPopOver: View {
#Binding var isShowing: Bool
var text: String
var body: some View {
VStack(spacing: 0) {
HStack() {
Spacer()
Button("Cancel") {
self.isShowing = false
}.padding()
}
Divider()
List {
Section {
Text(text)
}
}.listStyle(GroupedListStyle())
}
}
}
This is a very common issue (especially since iOS 14) that gets run into a lot with sheet but affects popover as well.
You can avoid it by using popover(item:) rather than isPresented. In this scenario, it'll actually use the latest values, not just the one that was present when then view first renders or when it is first set.
struct EditItem : Identifiable { //this will tell it what sheet to present
var id = UUID()
var str : String
}
struct ContentView: View {
private var stringObjects = ["test123", "helloworld", "reddit"]
#State private var editMode: EditMode = .inactive
#State private var editItem : EditItem? //the currently presented sheet -- nil if no sheet is presented
#ViewBuilder
var body: some View {
NavigationView {
List {
ForEach(self.stringObjects, id: \.self) { text in
NavigationLink( destination: HStack{Text("Test!")}) {
HStack {
Text(text)
Spacer()
if self.editMode.isEditing {
Image(systemName: "pencil.circle").imageScale(.large)
.onTapGesture {
if self.editMode.isEditing {
self.editItem = EditItem(str: text) //set the current item
}
}
}
}
}
.popover(item: $editItem) { item in //item is now a reference to the current item being presented
CustomPopOver(text: item.str)
}
}
}
.navigationBarTitle("Reproduce Editing Bug!")
.navigationBarItems(leading: EditButton())
.environment(\.editMode, $editMode)
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct CustomPopOver: View {
#Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
var text: String
var body: some View {
VStack(spacing: 0) {
HStack() {
Spacer()
Button("Cancel") {
self.presentationMode.wrappedValue.dismiss()
}.padding()
}
Divider()
List {
Section {
Text(text)
}
}.listStyle(GroupedListStyle())
}
}
}
I also opted to use the presentationMode environment property to dismiss the popover, but you could pass the editItem binding and set it to nil as well (#Binding var editItem : EditItem? and editItem = nil). The former is just a little more idiomatic.

NavigationLink dismisses after TextField changes

I have a navigation stack that's not quite working as desired.
From my main view, I want to switch over to a list view which for the sake of this example represents an array of strings.
I want to then navigate to a detail view, where I want to be able to change the value of the selected string.
I have 2 issues with below code:
on the very first keystroke within the TextField, the detail view is being dismissed
the value itself is not being changed
Also, I suppose there must be a more convenient way to do the binding in the detail view ...
Here's the code:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
TestMainView()
}
}
}
struct TestMainView: View {
var body: some View {
NavigationView {
List {
NavigationLink("List View", destination: TestListView())
}
.navigationTitle("Test App")
}
}
}
struct TestListView: View {
#State var strings = [
"Foo",
"Bar",
"Buzz"
]
#State var selectedString: String? = nil
var body: some View {
List(strings.indices) { index in
NavigationLink(
destination: TestDetailView(selectedString: $selectedString),
tag: strings[index],
selection: $selectedString) {
Text(strings[index])
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("List")
}
}
}
struct TestDetailView: View {
#Binding var selectedString: String?
var body: some View {
VStack {
if let _ = selectedString {
TextField("Placeholder",
text: Binding<String>( //what's a better solution here?
get: { selectedString! },
set: { selectedString = $0 }
)
)
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
}
Spacer()
}
.navigationTitle("Detail")
}
}
struct TestMainView_Previews: PreviewProvider {
static var previews: some View {
TestMainView()
}
}
I am quite obviously doing it wrong, but I cannot figure out what to do differently...
You're changing the NavigationLink's selection from inside the NavigationLink which forces the TestListView to reload.
You can try the following instead:
struct TestListView: View {
#State var strings = [
"Foo",
"Bar",
"Buzz",
]
var body: some View {
List(strings.indices) { index in
NavigationLink(destination: TestDetailView(selectedString: self.$strings[index])) {
Text(self.strings[index])
}
}
}
}
struct TestDetailView: View {
#Binding var selectedString: String // remove optional
var body: some View {
VStack {
TextField("Placeholder", text: $selectedString)
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
}
}
}

how to use a #EnvironmentObject in combination with a List

The code for the basic app from Anlil's answer works fine. If I edit the datamodel to be more like mine, with a multidimensional String array, I get something like:
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List {
NavigationLink(destination:AddView().environmentObject(self.dm)) {
Image(systemName: "plus.circle.fill").font(.system(size: 30))
}
ForEach(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item[0])
}
}
}
}
}
}
struct DetailView: View {
var item : [String] = ["", "", ""]
var body: some View {
VStack {
Text(item[0])
Text(item[1])
Text(item[2])
}
}
}
struct AddView: View {
#EnvironmentObject var dm: DataManager
#State var item0 : String = "" // needed by TextField
#State var item1 : String = "" // needed by TextField
#State var item2 : String = "" // needed by TextField
#State var item : [String] = ["", "", ""]
var body: some View {
VStack {
TextField("Write something", text: $item0)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
TextField("Write something", text: $item1)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
TextField("Write something", text: $item2)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button(action: {
self.item = [self.item0, self.item1, self.item2]
print(self.item)
self.dm.array.append(self.item)
}) {
Text("Save")
}
}
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
var array : [[String]] = [["Item 1","Item 2","Item 3"],["Item 4","Item 5","Item 6"],["Item 7","Item 8","Item 9"]] {
didSet {
willChange.send()
}
}
}
There are no errors and the code runs as expected. Before I'm going to rewrite my own code (with the lessons I've learned solar) it would be nice if the code could be checked.
I'm really impressed with SwiftUI!
If your "source of truth" is an array of some "model instances", and you just need to read values, you can pass those instance around like before:
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item)
}
}
}
}
}
struct DetailView: View {
var item : String
var body: some View {
Text(item)
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
let array = ["Item 1", "Item 2", "Item 3"]
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(DataManager())
}
}
#endif
You need to pass the EnvironmentObject only if some views are able to manipulate the data inside the instances... in this case you can easily update the EnvironmentObject's status and everything will auto-magically updated everywhere!
The code below shows a basic App with "list", "detail" and "add", so you can see 'environment' in action (the only caveat is that you have to manually tap < Back after tapped the Save button). Try it and you'll see the list that will magically update.
import SwiftUI
import Combine
struct ContentView: View {
#EnvironmentObject var dm: DataManager
var body: some View {
NavigationView {
List {
NavigationLink(destination:AddView().environmentObject(self.dm)) {
Image(systemName: "plus.circle.fill").font(.system(size: 30))
}
ForEach(dm.array, id: \.self) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item)
}
}
}
}
}
}
struct DetailView: View {
var item : String
var body: some View {
Text(item)
}
}
struct AddView: View {
#EnvironmentObject var dm: DataManager
#State var item : String = "" // needed by TextField
var body: some View {
VStack {
TextField("Write something", text: $item)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button(action: {
self.dm.array.append(self.item)
}) {
Text("Save")
}
}
}
}
class DataManager: BindableObject {
var willChange = PassthroughSubject<Void, Never>()
var array : [String] = ["Item 1", "Item 2", "Item 3"] {
didSet {
willChange.send()
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(DataManager())
}
}
#endif

Save selected item in List

This looks like a very simple thing, but I can't figure out how to do this:
I have a List embedded in a NavigationView, containing a NavigationLink to view the detail of the item.
I have a save bar button where I would like to save the selected item. But how can I access the selected item?
It isn't visible in the button's action closure.
struct ItemList : View {
#EnvironmentObject var items: ItemsModel
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: ItemDetail(item: item)) {
Text(item.name)
}
}
.navigationBarTitle(Text("Item"))
.navigationBarItems(trailing: Button(action: {
self.save(/*item: item */) // How can I access item here?
}, label: {
Text("Save")
}))
}
}
func save(item: Item) {
print("Saving...")
}
}
Navigation links are not obligatory to accomplish this.
import SwiftUI
struct ContentView: View {
struct Ocean: Identifiable, Hashable {
let name: String
var id: Self { self }
}
private var oceans = [
Ocean(name: "Pacific"),
Ocean(name: "Atlantic"),
Ocean(name: "Indian"),
Ocean(name: "Southern"),
Ocean(name: "Arctic")
]
#State private var selectedOceans = [Ocean]()
#State private var multiSelection = Set<Ocean.ID>()
var body: some View {
VStack {
Text("Oceans")
List(oceans, selection: $multiSelection) {
Text($0.name)
}
.navigationTitle("Oceans")
.environment(\.editMode, .constant(.active))
.onTapGesture {
// Walkaround: try how it works without `asyncAfter()`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
selectedOceans = Array(multiSelection)
print(selectedOceans)
})
}
Divider()
Text("Selected oceans")
List(selectedOceans, selection: $multiSelection) {
Text($0.name)
}
}
Text("\(multiSelection.count) selections")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}