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

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

Related

SwiftUI - Nested links within NavigationStack inside a NavigationSplitView not working

I'm playing around with the new navigation API's offered in ipadOS16/macOS13, but having some trouble working out how to combine NavigationSplitView, NavigationStack and NavigationLink together on macOS 13 (Testing on a Macbook Pro M1). The same code does work properly on ipadOS.
I'm using a two-column NavigationSplitView. Within the 'detail' section I have a list of SampleModel1 instances wrapped in a NavigationStack. On the List I've applied navigationDestination's for both SampleModel1 and SampleModel2 instances.
When I select a SampleModel1 instance from the list, I navigate to a detailed view that itself contains a list of SampleModel2 instances. My intention is to navigate further into the NavigationStack when clicking on one of the SampleModel2 instances but unfortunately this doesn't seem to work. The SampleModel2 instances are selectable but no navigation is happening.
When I remove the NavigationSplitView completely, and only use the NavigationStack the problem does not arise, and i can successfully navigate to the SampleModel2 instances.
Here's my sample code:
// Sample model definitions used to trigger navigation with navigationDestination API.
struct SampleModel1: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel1(), SampleModel1(), SampleModel1()]
}
struct SampleModel2: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel2(), SampleModel2(), SampleModel2()]
}
// The initial view loaded by the app. This will initialize the NavigationSplitView
struct ContentView: View {
enum NavItem {
case first
}
var body: some View {
NavigationSplitView {
NavigationLink(value: NavItem.first) {
Label("First", systemImage: "house")
}
} detail: {
SampleListView()
}
}
}
// A list of SampleModel1 instances wrapped in a NavigationStack with multiple navigationDestinations
struct SampleListView: View {
#State var path = NavigationPath()
#State var selection: SampleModel1.ID? = nil
var body: some View {
NavigationStack(path: $path) {
List(SampleModel1.samples, selection: $selection) { model in
NavigationLink("\(model.id)", value: model)
}
.navigationDestination(for: SampleModel1.self) { model in
SampleDetailView(model: model)
}
.navigationDestination(for: SampleModel2.self) { model in
Text("Model 2 ID \(model.id)")
}
}
}
}
// A detailed view of a single SampleModel1 instance. This includes a list
// of SampleModel2 instances that we would like to be able to navigate to
struct SampleDetailView: View {
var model: SampleModel1
var body: some View {
Text("Model 1 ID \(model.id)")
List (SampleModel2.samples) { model2 in
NavigationLink("\(model2.id)", value: model2)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I removed this unclear ZStack and all works fine. Xcode 14b3 / iOS 16
// ZStack { // << this !!
SampleListView()
// }
Apple just releases macos13 beta 5 and they claimed this was resolved through feedback assistant, but unfortunately this doesn't seem to be the case.
I cross-posted this question on the apple developers forum and user nkalvi posted a workaround for this issue. I’ll post his example code here for future reference.
import SwiftUI
// Sample model definitions used to trigger navigation with navigationDestination API.
struct SampleModel1: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel1(), SampleModel1(), SampleModel1()]
}
struct SampleModel2: Hashable, Identifiable {
let id = UUID()
static let samples = [SampleModel2(), SampleModel2(), SampleModel2()]
}
// The initial view loaded by the app. This will initialize the NavigationSplitView
struct ContentView: View {
#State var path = NavigationPath()
enum NavItem: Hashable, Equatable {
case first
}
var body: some View {
NavigationSplitView {
List {
NavigationLink(value: NavItem.first) {
Label("First", systemImage: "house")
}
}
} detail: {
SampleListView(path: $path)
}
}
}
// A list of SampleModel1 instances wrapped in a NavigationStack with multiple navigationDestinations
struct SampleListView: View {
// Get the selection from DetailView and append to path
// via .onChange
#State var selection2: SampleModel2? = nil
#Binding var path: NavigationPath
var body: some View {
NavigationStack(path: $path) {
VStack {
Text("Path: \(path.count)")
.padding()
List(SampleModel1.samples) { model in
NavigationLink("Model1: \(model.id)", value: model)
}
.navigationDestination(for: SampleModel2.self) { model in
Text("Model 2 ID \(model.id)")
.navigationTitle("navigationDestination(for: SampleModel2.self)")
}
.navigationDestination(for: SampleModel1.self) { model in
SampleDetailView(model: model, path: $path, selection2: $selection2)
.navigationTitle("navigationDestination(for: SampleModel1.self)")
}
.navigationTitle("First")
}
.onChange(of: selection2) { newValue in
path.append(newValue!)
}
}
}
}
// A detailed view of a single SampleModel1 instance. This includes a list
// of SampleModel2 instances that we would like to be able to navigate to
struct SampleDetailView: View {
var model: SampleModel1
#Binding var path: NavigationPath
#Binding var selection2: SampleModel2?
var body: some View {
NavigationStack {
Text("Path: \(path.count)")
.padding()
List(SampleModel2.samples, selection: $selection2) { model2 in
NavigationLink("Model2: \(model2.id)", value: model2)
// This also works (without .onChange):
// Button(model2.id.uuidString) {
// path.append(model2)
// }
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

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 one view from within another in SwiftUI

I am working on a SwiftUI view where I have it populates other subviews within the main view. My question is how can I call something in the main view from within a sub view?
Here is what the code looks like for my main view:
struct MovieList: View {
#ObservedObject var viewModel = MovieViewModel()
var body: some View {
VStack {
ScrollView(.vertical) {
VStack {
ForEach(self.viewModel.movie) { movie in
MovieView(movie: movie)
}
}
}
}
.navigationBarTitle("Movies")
.onAppear {
self.viewModel.fetchMovies() // Fetch all movies and cause entire view to refresh and populate movies
}
}
}
This main view populates a list of movies by adding multiple MovieView instances.
Here is some example code for the MovieView:
struct MovieView: View {
let movie: Movie
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(“Movie Title: \(movie.title)”)
}.padding([.top, .leading, .bottom])
Button("Do not show this movie") {
// Update user prefers to hide the movie.
// But also somehow from within here call viewModel.fetchMovies() in the other view to refresh the movies list
}
}
}
}
So for example, from within one of the MovieView views, how can I have fetchMovies() from within the main view called so that everything gets updated?
Essentially a list of items is being populated and I would like for any one of these to have the ability to refresh/ perform some action on the entire main view
In terms of updating the Movie itself, as was pointed out in the comments, you probably want to pass a Binding to it. See the changes to the ForEach.
In terms of calling fetchMovies again, you can either pass the entire ObservableObject to the child view or just pass a reference to the function you need (which I've shown below):
struct Movie : Identifiable {
var id = UUID()
var title : String
var isHidden: Bool
}
class MovieViewModel : ObservableObject {
#Published var movies = [Movie]()
func fetchMovies() {
//fetch
}
}
struct MovieList: View {
#ObservedObject var viewModel = MovieViewModel()
var body: some View {
VStack {
ScrollView(.vertical) {
VStack {
ForEach($viewModel.movies) { $movie in
MovieView(movie: $movie, fetchMovies: viewModel.fetchMovies)
}
}
}
}
.navigationBarTitle("Movies")
.onAppear {
self.viewModel.fetchMovies()
}
}
}
struct MovieView: View {
#Binding var movie: Movie
var fetchMovies : () -> Void
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Movie Title: \(movie.title)")
}.padding([.top, .leading, .bottom])
Button("Do not show this movie") {
movie.isHidden = true
fetchMovies()
}
}
}
}

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

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

SwiftUI ObservedObject makes view pop on update

Why is my view going back to the parent when the ObservedObject updates? I understand that the update to it redraws the view (and presumably also the ParentView) but frankly atm that I am looking at the ChildView I don't care about the look of the ParentView.
I would understand the behaviour if the displayed stuff was coming directly from the ParentView but it is tied to a class. Is this intended behaviour from Apple?
How am I supposed to manipulate Data in a View that got called by a NavigationLink without popping the view?
Why is this pop forced?
Is there a way to "hold it back"?
I've tried something like this:
SwiftUI: #ObservedObject redraws every view but it didn't help. It just made it worse as I tap remove items anymore.
I have a code construct like this:
Parent View:
struct ContentView: View {
#ObservedObject var data = Data()
var body: some View {
NavigationView {
VStack() {
ForEach(self.data.myArray, id: \.self) { subArray in
NavigationLink(destination: Child(data: self.data, mySubArray: subArray)) {
Text(subArray[0]).padding().background(Color.yellow)
}
}
}
}
}
}
Child:
struct Child: View {
#ObservedObject var data: Data
var mySubArray: Array<String>
var body: some View {
ForEach(self.mySubArray, id: \.self) { str in
Text(str).padding().background(Color.yellow)
.onTapGesture {
self.data.removeFromSubArray(index: self.data.myArray.firstIndex(of: self.mySubArray) ?? 0, remove: str)
}
}
}
}
And an ObservableObject:
class Data: ObservableObject {
#Published var myArray: Array<Array<String>> = []
init() {
self.myArray.append(["A", "B", "C"])
self.myArray.append(["D", "E", "F"])
self.myArray.append(["G", "H", "I"])
}
func removeFromSubArray(index: Int, remove: String) {
if self.myArray.count-1 >= index {
if let subIndex = self.myArray[index].firstIndex(of: remove) {
self.myArray[index].remove(at: subIndex)
}
}
}
}