Clear SwiftUI list in NavigationView does not properly go back to default - swift

The simple navigation demo below demonstrate an example of my issue:
A SwiftUI list inside a NavigationView is filled up with data from the data model Model.
The list items can be selected and the NavigationView is linking another view on the right (demonstrated here with destination)
There is the possibility to clear the data from the model - the SwiftUI list gets empty
The model data can be filled with new data at a later point in time
import SwiftUI
// Data model
class Model: ObservableObject {
// Example data
#Published var example: [String] = ["Data 1", "Data 2", "Data 3"]
#Published var selected: String?
}
// View
struct ContentView: View {
#ObservedObject var data: Model = Model()
var body: some View {
VStack {
// button to empty data set
Button(action: {
data.selected = nil
data.example.removeAll()
}) {
Text("Empty Example data")
}
NavigationView {
// data list
List {
ForEach(data.example, id: \.self) { element in
// navigation to "destination"
NavigationLink(destination: destination(element: element), tag: element, selection: $data.selected) {
Text(element)
}
}
}
// default view when nothing is selected
Text("Nothing selected")
}
}
}
func destination(element: String) -> some View {
return Text("\(element) selected")
}
}
What happens when I click the "Empty Example data" button is that the list will be properly cleared up. However, the selection is somehow persistent and the NavigationView will not jump back to the default view when nothing is selected:
I would expect the view Text("Nothing selected") being loaded.
Do I overlook something important?

When you change the data.example
in the button, the left panel changes because the List has changed.
However the right side do not, because no change has occurred there, only the List has changed.
The ForEach does not re-paint the "destination" views.
You have an "ObservedObject" and its purpose is to keep track of the changes, so using that
you can achieve what you want, like this:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class Model: ObservableObject {
#Published var example: [String] = ["Data 1", "Data 2", "Data 3"]
#Published var selected: String?
}
struct ContentView: View {
#StateObject var data: Model = Model()
var body: some View {
VStack {
Button(action: {
data.selected = nil
data.example.removeAll()
}) {
Text("Empty Example data")
}
NavigationView {
List {
ForEach(data.example, id: \.self) { element in
NavigationLink(destination: DestinationView(),
tag: element,
selection: $data.selected) {
Text(element)
}
}
}
Text("Nothing selected")
}.environmentObject(data)
}
}
}
struct DestinationView: View {
#EnvironmentObject var data: Model
var body: some View {
Text("\(data.selected ?? "")")
}
}

Related

SwiftUI macOS NavigationView - onChange(of: Bool) action tried to update multiple times per frame

I'm seeing onChange(of: Bool) action tried to update multiple times per frame warnings when clicking on NavigationLinks in the sidebar for a SwiftUI macOS App.
Here's what I currently have:
import SwiftUI
#main
struct BazbarApp: App {
#StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
class ModelData: ObservableObject {
#Published var myLinks = [URL(string: "https://google.com")!, URL(string: "https://apple.com")!, URL(string: "https://amazon.com")!]
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
NavigationLink(destination: DetailView(selected: $selected) ) {
Text(url.absoluteString)
}
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
Text("Choose a link")
}
}
}
struct DetailView: View {
#Binding var selected: URL?
var body: some View {
if let selected = selected {
Text("Currently selected: \(selected)")
}
else {
Text("Choose a link")
}
}
}
When I alternate clicking on the second and third links in the sidebar, I eventually start seeing the aforementioned warnings in my console.
Here's a gif of what I'm referring to:
Interestingly, the warning does not appear when alternating clicks between the first and second link.
Does anyone know how to fix this?
I'm using macOS 12.2.1 & Xcode 13.2.1.
Thanks in advance
I think the issue is that both the List(selection:) and the NavigationLink are trying to update the state variable selected at once. A List(selection:) and a NavigationLink can both handle the task of navigation. The solution is to abandon one of them. You can use either to handle navigation.
Since List look good, I suggest sticking with that. The NavigationLink can then be removed. The second view under NavigationView is displayed on the right, so why not use DetailView(selected:) there. You already made the selected parameter a binding variable, so the view will update if that var changes.
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
#State private var selected: URL?
var body: some View {
NavigationView {
List(selection: $selected) {
Section(header: Text("Bookmarks")) {
ForEach(modelData.myLinks, id: \.self) { url in
Text(url.absoluteString)
.tag(url)
}
}
}
.onDeleteCommand {
if let selected = selected {
modelData.myLinks.remove(at: modelData.myLinks.firstIndex(of: selected)!)
}
selected = nil
}
DetailView(selected: $selected)
}
}
}
I can recreate this problem with the simplest example I can think of so my guess is it's an internal bug in NavigationView.
struct ContentView: View {
var body: some View {
NavigationView {
List {
NavigationLink("A", destination: Text("A"))
NavigationLink("B", destination: Text("B"))
NavigationLink("C", destination: Text("C"))
}
}
}
}

How to update an element of an array in an Observable Object

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.
// model
struct Activity: Identifiable {
var id = UUID()
var name: String
var completeDescription: String
var completions: Int = 0
}
// view model
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
}
// view
struct ActivityView: View {
#StateObject var viewModel = ActivityViewModel()
#State private var showingAddEditActivityView = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.activities, id: \.id) {
activity in
NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
}
}
.navigationBarItems(trailing: Button("Add new"){
self.showingAddEditActivityView.toggle()
})
.navigationTitle(Text("Activity List"))
}
.sheet(isPresented: $showingAddEditActivityView) {
AddEditActivityView(copyViewModel: self.viewModel)
}
}
}
// detail view
struct ActivityDetailView: View {
#State var activity: Activity
#ObservedObject var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
}!
tempActivity.completions += 1
}
}
// Add new activity view (doesn't have anything to do with question)
struct AddEditActivityView: View {
#ObservedObject var copyViewModel : ActivityViewModel
#State private var activityName: String = ""
#State private var description: String = ""
var body: some View {
VStack {
TextField("Enter an activity", text: $activityName)
TextField("Enter an activity description", text: $description)
Button("Save"){
// I want this to be outside of my view
saveActivity()
}
}
}
func saveActivity() {
copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
print(copyViewModel.activities)
}
}
In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.
Thanks for any assistance or insight.
The problem is here:
struct ActivityDetailView: View {
#State var activity: Activity
...
This needs to be a #Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the #Binding, you can get rid of it.
// detail view
struct ActivityDetailView: View {
#Binding var activity: Activity /// here!
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
}
Text("\(activity.completeDescription)")
}
}
}
But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:
/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
NavigationLink(destination: ActivityDetailView(activity: $activity)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
And for iOS 14 or below, you'll need to loop over indices instead. But it works.
/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity) in
NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
You are changing and increment the value of tempActivity so it will not affect the main array or data source.
You can add one update function inside the view model and call from view.
The view model is responsible for this updation.
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
func updateCompletionCount(for id: UUID) {
if let index = activities.firstIndex(where: {$0.id == id}) {
self.activities[index].completions += 1
}
}
}
struct ActivityDetailView: View {
var activity: Activity
var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
self.viewModel.updateCompletionCount(for: activity.id)
}
}
Not needed #State or #ObservedObject for details view if don't have further action.

swiftui: detail view uses false List style (that of its parent) before it switches to the correct style (with delay)

I have a master-detail view with three levels. At the first level, a person is selected. At the second level, the persons' properties are shown using a grouped list with list style "InsetGroupedListStyle()"
My problem is: each time, the third level (here called "DetailView()") is displayed, it is displayed with the wrong style (the style of its parent), before it switches to the correct style with some delay.
This is a bad user experience. Any ideas?
Thanks!
import SwiftUI
struct Person: Identifiable, Hashable {
let id: Int
let name: String
}
class Data : ObservableObject {
#Published var persons: [Person] = [
Person(id: 0, name: "Alice"),
Person(id: 1, name: "Bob"),
]
}
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var data = Data()
var body: some View {
NavigationView {
List {
ForEach(data.persons, id: \.self) { person in
NavigationLink(
destination: EditPerson(data: data, psId: person.id),
label: {
Text(person.name)
})
}
}.environment(\.defaultMinListRowHeight, 10)
.navigationTitle("Persons")
}
}
}
struct EditPerson: View {
#ObservedObject var data: Data
var psId: Int
var body: some View {
List() {
Section(header:
Text("HEADER1 ")
) {
NavigationLink(
destination: DetailView(data: data),
label: {
Text("1st link")
}
)
}
}.navigationTitle("Person #" + String(psId))
.listStyle(InsetGroupedListStyle()) // <--- the style specified here
// is preliminarily used for the DetailView, too.
}
}
struct DetailView: View {
#ObservedObject var data : Data
var body: some View {
List { // <- this list is displayed with grouped list style before
// it is updated some split seconds later
Button(action: {
print("button1 pressed")
}) {
Text("Button1")
}
Button(action: {
print("button2 pressed")
}) {
Text("Button2")
}
}
}
}
It seems to work if you simply explicitly set it back to PlainListStyle in DetailView():
struct DetailView: View {
#ObservedObject var data : Data
var body: some View {
List { // <- this list is displayed with grouped list style before
// it is updated some split seconds later
Button(action: {
print("button1 pressed")
}) {
Text("Button1")
}
Button(action: {
print("button2 pressed")
}) {
Text("Button2")
}
}
.listStyle(PlainListStyle()) // Explicitly set it here
}
}

How to add something to a favorite list in SwiftUI?

I'm trying to create a Favorite list where I can add different items but it doesn't work. I made a simple code to show you what's going on.
// BookData gets data from Json
struct BookData: Codable {
var titolo: String
var descrizione: String
}
class FavoriteItems: ObservableObject {
#Published var favItems: [String] = []
}
struct ContentView: View {
#ObservedObject var bookData = BookDataLoader()
#ObservedObject var favoriteItems = FavoriteItems()
var body: some View {
NavigationView {
List {
NavigationLink(destination: FavoriteView()) {
Text("Go to favorites")
}
ForEach(0 ..< bookData.booksData.count) { num in
HStack {
Text("\(self.bookData.booksData[num].titolo)")
Button(action: {
self.favoriteItems.favItems.append(self.bookData.booksData[num].titolo)
}) {
Image(systemName: "heart")
}
}
}
}
}
}
}
struct FavoriteView: View {
#ObservedObject var favoriteItems = FavoriteItems()
var body: some View {
List {
ForEach (0 ..< favoriteItems.favItems.count) { num in
Text("\(self.favoriteItems.favItems[num])")
}
}
}
}
When I launch the app I can go to the Favorite View but after adding an Item I cannot.
My aim is to add an Item to Favorites and be able to save it once I close the app
The view model favoriteItems inside ContentView needs to be passed into FavoriteView because you need a reference of favoriteItems to reload FavoriteView when you add a new data.
Change to
NavigationView(destination: FavoriteView(favoriteItems: favoriteItems)) #ObservedObject var favoriteItems: FavoriteItems
It will be fine.
Thanks, X_X

Pushing a new List from a NavigationLink within a List freezes the simulator/canvas (SwiftUI)

When building a List View that can push a secondary List of items, the XCode simulator and SwiftUI canvas freezes without throwing an error. The code below describes the view hierarchy I am using to recreate this issue:
class Listable: Identifiable {
var id: UUID = UUID()
var name: String = "Name"
var title: String = "Title"
var description: String = "This is a description"
}
struct ContentView: View {
var items: [Listable]
var body: some View {
NavigationView {
List(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(items: testList)
}
}
struct SecondaryListView: View {
var items: [Listable]
var body: some View {
List(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
struct ListCell: View {
var item: Listable
var body: some View {
NavigationLink(destination: SecondaryListView(items: testSecondaryList)) {
Image(systemName: "photo")
VStack(alignment: .leading) {
Text(item.name)
.font(.title)
.fontWeight(.light)
Text(item.description)
}
}
}
}
let testList = [
Listable(),
Listable(),
Listable()
]
let testSecondaryList = [
Listable(),
Listable(),
Listable(),
Listable(),
Listable(),
Listable()
]
Note: If I replace the List object within SecondaryListView with a ForEach (as seen below), the code compiles and runs with no issue, and I can navigate as far down the stack as I'd like.
struct SecondaryListView: View {
var items: [Listable]
var body: some View {
ForEach(items) { item in
ListCell(item: item)
}
.navigationBarTitle(self.items[0].title)
}
}
Is pushing a List View from within a List View not allowed or is this a bug? It appears to cause a memory leak possibly - CPU on my main thread hits 99%.
The root cause ended up being that I was modifying the UITableView.appearance().backgroundView in the SceneDelegate. See below code:
SceneDelegate.swift:
UITableView.appearance().backgroundView = UIImageView(image: UIImage(named:"Background"), highlightedImage: nil)
My goal was to have a background image across all UITableViews within the app (Lists in SwiftUI still leverage UIKit components). The workaround I settled on was instead setting a background color with a UIColor init'd with a pattern image, as shown below:
UITableView.appearance().backgroundColor = UIColor(patternImage: UIImage(named:"Background") ?? UIImage())