Unselecting an item on a SwiftUI list crashes the app - swift

I’m having an issue with SwiftUI with optional bindings, basically it’s a List on macOS, where I add a DetailView once an item is selected, if not selected just add a Text.
When I open the app it’s fine, the Text appears, then I add some items and select it, also works fine, DetailView appears, but once I click outside the table, unselecting it, it crashes. Even tough I have a conditional checking for nil, that’s why it works the first time.
I guess the DetailView is keeping a reference to the selectedItem and crashing once it’s set to nil, but I thought the entire body should be refreshed once a State property is changed, which would remove the previous DetailView from memory and not call a new one, right?
Here's the code:
import SwiftUI
struct DetailView: View {
#Binding var text: String
var body: some View {
TextField("123", text: self.$text)
}
}
struct ContentView: View {
#State var text = ""
#State var items = [String]()
#State var selectedItem: String? = nil
var body: some View {
VStack {
HStack {
VStack(alignment: .leading, spacing: 0) {
List(selection: $selectedItem) {
ForEach(items, id: \.self) { item in
Text(item)
}
}
HStack(spacing: 0) {
Button(action: {
self.items.append(UUID().uuidString)
}, label: {
Text("Add")
})
Button(action: {
if let item = self.selectedItem {
self.items.remove(at: self.items.firstIndex(of: item)!)
}
self.selectedItem = nil
}, label: {
Text("Remove")
}).disabled(selectedItem == nil)
}
}
if selectedItem != nil {
DetailView(text: Binding($selectedItem)!)
} else {
Text("Add an item")
}
}
.tabItem {
Text("Test")
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

I guess the DetailView is keeping a reference to the selectedItem and
crashing once it’s set to nil, but I thought the entire body should be
refreshed once a State property is changed, which would remove the
previous DetailView from memory and not call a new one, right?
The order of update is not defined, so I would answer no on above.
Here is a solution. Tested with Xcode 11.4 / iOS 13.4
if selectedItem != nil {
DetailView(text: Binding(get: {self.selectedItem ?? ""},
set: {self.selectedItem = $0}))
} else {
Text("Add an item")
}

Related

TextField in a list not working well in SwiftUI

This problem is with SwiftUI for a iPhone 12 app, Using xcode 13.1.
I build a List with TextField in each row, but every time i try to edit the contents, it is only allow me tap one time and enter only one character then can not keep enter characters anymore, unless i tap again then enter another one character.Did i write something code wrong with it?
class PieChartViewModel: ObservableObject, Identifiable {
#Published var options = ["How are you", "你好", "Let's go to zoo", "OKKKKK", "什麼情況??", "yesssss", "二百五", "明天見"]
}
struct OptionsView: View {
#ObservedObject var viewModel: PieChartViewModel
var body: some View {
NavigationView {
List {
ForEach($viewModel.options, id: \.self) { $option in
TextField(option, text: $option)
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
func addNewOption() {
viewModel.options.insert("", at: viewModel.options.count)
}
}
struct OptionsView_Previews: PreviewProvider {
static var previews: some View {
let pieChart = PieChartViewModel()
OptionsView(viewModel: pieChart)
}
}
Welcome to StackOverflow! Your issue is that you are directly updating an ObservableObject in the TextField. Every change you make to the model, causes a redraw of your view, which, of course, kicks your focus from the TextField. The easiest answer is to implement your own Binding on the TextField. That will cause the model to update, without constantly redrawing your view:
struct OptionsView: View {
// You should be using #StateObject instead of #ObservedObject, but either should work.
#StateObject var model = PieChartViewModel()
#State var newText = ""
var body: some View {
NavigationView {
VStack {
List {
ForEach(model.options, id: \.self) { option in
Text(option)
}
}
List {
//Using Array(zip()) allows you to sort by the element, but use the index.
//This matters if you are rearranging or deleting the elements in a list.
ForEach(Array(zip(model.options, model.options.indices)), id: \.0) { option, index in
// Binding implemented here.
TextField(option, text: Binding<String>(
get: {
model.options[index]
},
set: { newValue in
//You can't update the model here because you will get the same behavior
//that you were getting in the first place.
newText = newValue
}))
.onSubmit {
//The model is updated here.
model.options[index] = newText
newText = ""
}
}
}
.navigationTitle("Options")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button {
addNewOption()
} label: {
HStack {
Image(systemName: "plus")
Text("Create a new option")
}
}
}
}
}
}
}
func addNewOption() {
model.options.insert("", at: model.options.count)
}
}

NavigationLink with isActive creates unusual scrolling behavior in macOS SwiftUI app

I have a macOS app with a 3-column view. In the first column, there is a List of items that is lengthy -- perhaps a couple hundred items.
If my NavigationLink for each item contains a isActive parameter, when clicking on the NavigationLinks, I get unpredictable/unwanted scrolling behavior on the list.
For example, if I scroll down to Item 100 and click on it, the List may decide to scroll back up to Item 35 or so (where the active NavigationLink is out of the frame). The behavior seems somewhat non-deterministic -- it doesn't always scroll to the same place. It seems less likely to scroll to an odd location if I scroll through the list to my desired item and then wait for the system scroll bars to disappear before clicking on the NavigationLink, but it doesn't make the problem disappear completely.
If I remove the isActive parameter, the scroll position is maintained when clicking on the NavigationLinks.
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue { activeItem = item }
}
}
var body: some View {
List(items) { item in
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item) //<-- Remove this line and everything works as expected
) {
Text(item.name)
}
}.listStyle(SidebarListStyle())
}
}
struct InnerSidebar : View {
#State private var items = Array(0...100).map { Item(name: "Inner item \($0)") }
var body: some View {
List(items) { item in
NavigationLink(destination: Text("Detail")) {
Text(item.name)
}
}
}
}
I would like to keep isActive, as I have some programatic navigation that I'd like to be able to do that depends on it. For example:
Button("Go to item 10") {
activeItem = items[10]
}
Is there any way to use isActive on the NavigationLink and avoid the unpredictable scrolling behavior?
(Built and tested with macOS 11.3 and Xcode 13.0)
The observed effect is because body of your main view is refreshed and all internals rebuilt. To avoid this we can separate sensitive part of view hierarchy into standalone view, so SwiftUI engine see that dependency not changed and view should not be updated.
Here is a fixed parts:
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
var body: some View {
List(items) {
SidebarRowView(item: $0, activeItem: $activeItem) // << here !!
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
Tested with Xcode 13 / macOS 11.6

SwiftUI NavigationView adding extra view onto stack when sheet is dismissed

I have a List with ForEach using a NavigationLink that when tapped displays a detail view. The DetailsView includes a sheet to save the detail information into an array. After the save, the sheet is dismissed but an additional DetailsView is put on the navigation stack, so that I need to tap the back link twice to get back to the listing.
I'm likely doing something incorrect as I'm relatively new to swiftui, but can't determine what.
Three things of interest:
In the ListView, I use .navigationViewStyle(StackNavigationViewStyle()). When removed, the issue goes away but the iPad gets messy for the ListView.
I'm using insert(at: 0) to add data in my array because I want the most recent data at the top of the listing. If I use append instead, the issue does go away. Wanting the most recently saved item at the top of the list, I add a sort, however sorting causes the duplicate issue to reappear.
The issue only seems to occur when selecting the first item created in the list (the last in the array) and then saving a new item into the array.
steps:
click Tap Here First, then tap SAVE, enter a name then click Save.
click tab bar item Saved.
click on the list item from step 1 in the Saved Items listing (nav bar should show "< Saved Items").
click SAVE, enter another name then click Save. At this point, the duplicate view appears with "< Back" as the leading nav bar item, clicking it takes you to the original detail view, then clicking "< Saved Items" takes you to the list view.
What am I doing wrong or what should I be doing better?
xcode 12.4/iOS 14.1
Stripped down code to reproduce:
struct TestModel: Identifiable, Codable {
private(set) var id: UUID
var name: String
}
class AppData: ObservableObject {
#Published var testList = [TestModel]()
}
struct NewView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: DetailView(item: TestModel(id: UUID(), name: ""))) {
Text("Tap here first")
}.navigationBarTitle("Main View", displayMode: .inline)
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ListView: View {
#EnvironmentObject var appData: AppData
var body: some View {
NavigationView {
List {
ForEach(appData.testList) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
.navigationBarTitle("Saved Items", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle()) // remove this and issue goes away, but iPad gets "messy".
}
}
struct DetailView: View {
#State private var isSaveShowing = false
#State var item: TestModel
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .center, spacing: 20) {
Text(item.name)
Button(action: {
isSaveShowing = true
}) {
Text("Save".uppercased())
}.sheet(isPresented: $isSaveShowing) {
SaveView(currentItem: item)
}
}
}
}
}
struct SaveView: View {
var currentItem: TestModel
#State private var name = ""
#EnvironmentObject var appData: AppData
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter Name ".uppercased())
) {
TextField("Name (required)", text: $name)
}
}
.navigationBarItems(
trailing: Button(action: {
// appData.testList.append(TestModel(id: UUID(), name: name)) // using append instead of insert also resolves issue...
appData.testList.insert(TestModel(id: UUID(), name: name), at: 0)
presentationMode.wrappedValue.dismiss()
}) {
Text("Save")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ContentView: View {
var appData = AppData()
var body: some View {
TabView {
NewView().tabItem {
Image(systemName: "rectangle.stack.badge.plus")
Text("Calculate")
}
ListView().tabItem {
Image(systemName: "tray.and.arrow.down")
Text("Saved")
}
}
.environmentObject(appData)
}
}
Apparently, this must have been a bug in SwiftUI.
Running the same code using Xcode 12.5 beta 3 with iOS 14.5 the issue no longer occurs.

List scroll freeze on catalyst NavigationView

I've run in to an odd problem with NavigationView on macCatalyst. Here below is a simple app with a sidebar and a detail view. Selecting an item on the sidebar shows a detail view with a scrollable list.
Everything works fine for the first NavigationLink, the detail view displays and is freely scrollable. However, if I select a list item which triggers a link to a second detail view, scrolling starts, then freezes. The app still works, only the detail view scrolling is locked up.
The same code works fine on an iPad without any freeze. If I build for macOS, the NavigationLink in the detail view is non-functional.
Are there any known workarounds ?
This is what it looks like, after clicking on LinkedView, a short scroll then the view freezes. It is still possible to click on the back button or another item on the sidebar, but the list view is blocked.
Here is the code:
ContentView.swift
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
var body: some View {
NavigationView {
List() {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailListView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...4).map({NamedItem(name: "\($0)")})
var body: some View {
VStack {
List {
Text(item.name)
NavigationLink(destination: DetailListView(item: NamedItem(name: "LinkedView"))) {
listItem(" LinkedView", "Item")
.foregroundColor(Color.blue)
}
ForEach(sections) { section in
sectionDetails(section)
}
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
TestListApp.swift
import SwiftUI
#main
struct TestListApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
I had this very same problem with Mac Catalyst app. On real device (iPhone 7 with iOS 14.4.2) there was no problem but with Mac Catalyst (MacBook Pro with Big Sur 11.2.3) the scrolling in the navigation view stuck very randomly as you explained. I figured out that the issue was with Macbook's trackpad and was related to scroll indicators because with external mouse the issue was absent. So the easiest solution to this problem is to hide vertical scroll indicators in navigation view. At least it worked for me. Below is some code from root view 'ContentView' how I did it. It's unfortunate to lose scroll indicators with big data but at least the scrolling works.
import SwiftUI
struct TestView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: NewView()) {
Text("Navigation Link to new view")
}
}
.onAppear {
UITableView.appearance().showsVerticalScrollIndicator = false
}
}
}
}
OK, so I managed to find a workaround, so thought I'd post this for help, until what seems to be a macCatalyst SwiftUI bug is fixed. I have posted a radar for the list freeze problem: FB8994665
The workaround is to use NavigationLink only to the first level of the series of pages which can be navigated (which gives me the sidebar and a toolbar), and from that point onwards use the NavigationStack package to mange links to other pages.
I ran in to a couple of other gotcha's with this arrangement.
Firstly the NavigationView toolbar loses its background when scrolling linked list views (unless the window is defocussed and refocussed), which seems to be another catalyst SwiftUI bug. I solved that by setting the toolbar background colour.
Second gotcha was that under macCatalyst the onTouch view modifier used in NavigationStack's PushView label did not work for most single clicks. It would only trigger consistently for double clicks. I fixed that by using a button to replace the label.
Here is the code, no more list freezes !
import SwiftUI
import NavigationStack
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
init() {
// Ensure toolbar is allways opaque
UINavigationBar.appearance().backgroundColor = UIColor.secondarySystemBackground
}
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let id = UUID()
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
var body: some View {
NavigationStackView {
DetailListView(item: item)
}
}
}
struct DetailListView: View {
var item: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
var linked = NamedItem(name: "LinkedView")
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
List {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(item.name)
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
}
}
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: {
self.isSelected = linked.id
})
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}
I have continued to experiment with NavigationStack and have made some modifications which will allow it to swap in and out List rows directly. This avoids the problems I was seeing with the NavigationBar background. The navigation bar is setup at the level above the NavigationStackView and changes to the title are passed via a PreferenceKey. The back button on the navigation bar hides if the stack is empty.
The following code makes use of PR#44 of swiftui-navigation-stack
import SwiftUI
struct ContentView: View {
var names = [NamedItem(name: "One"), NamedItem(name: "Two"), NamedItem(name:"Three")]
#State private var isSelected: UUID? = nil
var body: some View {
NavigationView {
List {
ForEach(names.sorted(by: {$0.name < $1.name})) { item in
NavigationLink(destination: DetailStackView(item: item)) {
Text(item.name)
}
}
}
.listStyle(SidebarListStyle())
Text("Detail view")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.toolbar { Spacer() }
}
}
}
struct NamedItem: Identifiable {
let name: String
let depth: Int
let id = UUID()
init(name:String, depth: Int = 0) {
self.name = name
self.depth = depth
}
var linked: NamedItem {
return NamedItem(name: "Linked \(depth+1)", depth:depth+1)
}
}
// Preference Key to send title back down to DetailStackView
struct ListTitleKey: PreferenceKey {
static var defaultValue: String = ""
static func reduce(value: inout String, nextValue: () -> String) {
value = nextValue()
}
}
extension View {
func listTitle(_ title: String) -> some View {
self.preference(key: ListTitleKey.self, value: title)
}
}
// Embed the list view in a NavigationStackView
struct DetailStackView: View {
var item: NamedItem
#ObservedObject var navigationStack = NavigationStack()
#State var toolbarTitle: String = ""
var body: some View {
List {
NavigationStackView(noGroup: true, navigationStack: navigationStack) {
DetailListView(item: item, linked: item.linked)
.listTitle(item.name)
}
}
.listStyle(PlainListStyle())
.animation(nil)
// Updated title
.onPreferenceChange(ListTitleKey.self) { value in
toolbarTitle = value
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("\(toolbarTitle) \(self.navigationStack.depth)")
.toolbar(content: {
ToolbarItem(id: "BackB", placement: .navigationBarLeading, showsByDefault: self.navigationStack.depth > 0) {
Button(action: {
self.navigationStack.pop()
}, label: {
Image(systemName: "chevron.left")
})
.opacity(self.navigationStack.depth > 0 ? 1.0 : 0.0)
}
})
}
}
struct DetailListView: View {
var item: NamedItem
var linked: NamedItem
let sections = (0...10).map({NamedItem(name: "\($0)")})
// Use a Navigation Stack instead of a NavigationLink
#State private var isSelected: UUID? = nil
#EnvironmentObject private var navigationStack: NavigationStack
var body: some View {
Text(item.name)
PushView(destination: linkedDetailView,
tag: linked.id, selection: $isSelected) {
listLinkedItem(" LinkedView", "Item")
}
ForEach(sections) { section in
if section.name != "0" {
sectionDetails(section)
}
}
}
// Ensure that the linked view has a toolbar button to return to this view
var linkedDetailView: some View {
DetailListView(item: linked, linked: linked.linked)
.listTitle(linked.name)
}
let info = (0...12).map({NamedItem(name: "\($0)")})
func sectionDetails(_ section: NamedItem) -> some View {
Section(header: Text("Section \(section.name)")) {
Group {
listItem("ID", "\(section.id)")
}
Text("")
ForEach(info) { ch in
listItem("Item \(ch.name)", "\(ch.id)")
}
}
}
func buttonAction() {
self.isSelected = linked.id
}
// Use a button to select the linked view with a single click
func listLinkedItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Button(title, action: buttonAction)
.foregroundColor(Color.blue)
Text(value)
.padding(.leading, 10)
}
}
func listItem(_ title: String, _ value: String, tooltip: String? = nil) -> some View {
HStack {
Text(title)
.frame(width: 200, alignment: .leading)
Text(value)
.padding(.leading, 10)
}
}
}

SwiftUI deep linking with NavigationLink inside List onAppear with tag: and selection: doesn't activate link

Trying to build deep linking into a list of NavigationList items; I will be reading a value on the SwiftUI view's .onAppear and based on that value, navigate to a specific cell. There are three issues that come up with different setups I have tried: (1) with the below code, navigation doesn't happen at all, (2) if it does navigate, it will immediately pop back, (3) if programmatic navigation works and it doesn't pop back, the manual navigation doesn't work.
I have tried this with a Binding dictionary, and I get issue #2 above. Not only this, but in both solutions, user has to scroll to the cell in order to even read the binding/selection.
import SwiftUI
struct ContentViewTwo: View {
var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
}
#State var selected: String?
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, tag: data, selection: self.$selected) {
Text("Tap for Page 3 with Data: \(data):")
}.onAppear() {
print("link appeared.")
}
}.onAppear() {
if (self.shouldPushPage3) {
self.selected = "3" // Has no affect. 😢
self.shouldPushPage3 = false
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Tap for Page 2")
}
}
}
}
}
You need to dispatch the selection.
.onAppear {
guard shouldPushPage3 else { return }
shouldPushPage3 = false
DispatchQueue.main.async {
selection = "3"
}
}