How to programmatically trigger NavigationLink? - swiftui

I have a NavigationView with a bunch of NavigationLinks in it. When the user hits the "Add" button, I want to create a new item in the view and automatically send them to it. Can I do that?

SwiftUI triggers View changes (the body) when its source of truth changes, so you just need to change the Binding<Bool> value to trigger a view update. Store the bindings for each link, and change its value when you click a button.
Let's say you have three different views you want to push. Then you would have three different bindings to store. You can store these bindings as three separate #State properties, or using a #StateObject view model as follows:
class RootViewModel: ObservableObject {
#Published var isLinkActive:[Int: Bool] = [:]
}
struct RootView: View {
#StateObject var viewModel = RootViewModel()
...
func binding(index: Int) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.viewModel.isLinkActive[index, default: false]
}) { (value) in
self.viewModel.isLinkActive[index] = value
}
}
...
func destination(index: Int) -> some View {
switch index {
case 1:
return ContentViewOne()
case 2:
return ContentViewTwo()
case 3:
return ContentViewThree()
default:
return EmptyView()
}
}
var body: some View {
return
NavigationView {
VStack {
ForEach(1..<4) { index in
NavigationLink(destination: self.destination(index: index), isActive: self.binding(index: index)) {
Text("Link to Content View \(index)")
}
}
Button("Add") {
self.contentViewModel.isLinkActive[2] = true // Change this index depending on which view you want to push.
}
}
}
}
}
So the button here simulates the second NavigationLink tap. I used StateObject to stop redrawing the whole RootView in the middle of a push.

Related

SwiftUI NavigationView cannot appears multiple NavigationLinks

I have a navigation view and each NavigationLink jumps to a color view:
struct ContentView: View {
private let navigationLinks: [NavigationItem] = [
NavigationItem("Red", AnyView(Color.red.edgesIgnoringSafeArea(.all))),
NavigationItem("Orange", AnyView(Color.orange.edgesIgnoringSafeArea(.all))),
NavigationItem("Yellow", AnyView(Color.yellow.edgesIgnoringSafeArea(.all))),
NavigationItem("Green", AnyView(Color.green.edgesIgnoringSafeArea(.all))),
NavigationItem("Blue", AnyView(Color.blue.edgesIgnoringSafeArea(.all))),
NavigationItem("Purple", AnyView(Color.purple.edgesIgnoringSafeArea(.all))),
NavigationItem("Pink", AnyView(Color.pink.edgesIgnoringSafeArea(.all))),
NavigationItem("Cyan", AnyView(Color.cyan.edgesIgnoringSafeArea(.all))),
NavigationItem("Teal", AnyView(Color.teal.edgesIgnoringSafeArea(.all))),
NavigationItem("Black", AnyView(Color.black.edgesIgnoringSafeArea(.all))),
NavigationItem("Gray", AnyView(Color.gray.edgesIgnoringSafeArea(.all))),
]
var body: some View {
NavigationView {
ForEach(self.navigationLinks, id:\.key) { item in
NavigationLink(destination: item.value) {
Text(item.key)
}
}
}
}
}
struct NavigationItem {
let key: String
let value: AnyView
init(_ key: String, _ value: AnyView) {
self.key = key
self.value = value
}
}
The result of running the program is just an Orange Item, and the other NavigationItems have disappeared.
When I click the back button in the upper left corner it will go back to the Red Item.
Is there any work around for this?
Your current ForEach will directly add all Text to NavigationView and this makes NavigationView confused. Wrap your ForEach to either List or VStack.
List { //Or VStack {
ForEach(self.navigationLinks, id:\.key) { item in
NavigationLink(destination: item.value) {
Text(item.key)
}
}
}
Note: With VStack you don't have scroll so if you are planning to add more items or a dynamic list then it's better to use List View only.

Environment dismiss provokes ObservableObject not publishing changes

I have a NavigationView which navigates to a first detail view. From this first detail view it navigates to a second detail view. In this second detail view I compose the UI observing a detailViewModel enum that stores the state: notRequested, loading and loaded. It works fine until I add the #Environment(.dismiss) var dismiss in the first detail view, when this occurs the view model enum changes but the UI is not repainted. Here is the sample code:
import SwiftUI
struct TestParentView: View {
var body: some View {
NavigationView {
Button {
} label: {
NavigationLink {
TestDetailView()
} label: {
Text("Go to detail")
}
}
}
}
}
struct TestDetailView: View {
// THIS LINE PROVOKES TestDetail2View NOT UPDATING VIEW
#Environment(\.dismiss) var dismiss
var body: some View {
Button {
} label: {
NavigationLink {
TestDetail2View()
} label: {
Text("Go to detail 2")
}
}
}
}
struct TestDetail2View: View {
#ObservedObject var testDetail2VM = TestDetail2VM()
var body: some View {
content
}
#ViewBuilder
private var content: some View {
switch testDetail2VM.state {
case .notRequested:
Text("")
.onAppear {
Task {
await testDetail2VM.getData()
}
}
case .loading:
Text("Loading data...")
case .loaded:
Text("LOADED VIEW!")
}
}
}
enum TestDetailState {
case notRequested
case loading
case loaded
}
final class TestDetail2VM: ObservableObject {
#Published var state = TestDetailState.notRequested
#MainActor func getData() async {
state = .loading
try! await Task.sleep(nanoseconds: 2_000_000_000)
state = .loaded
}
}
I've observed several things:
The problem only occurs if the view is the second (or more) view navigated. This problem doesn't occur if there are two vies in the navigation stack
If the view model has the annotation #StateObject instead of #ObservedObject, the problem doesn't occur (not a solution due to maybe an observed object is needed in that view)

How to run `task` or `onAppear` on NavigationSplitView detail for every selection change?

I'm trying to use NavigationSplitView with a DetailView that has a task or onAppear in it, but it seems it only runs once.
enum MenuItem: String, Hashable, Identifiable, CaseIterable {
case menu1
case menu2
case menu3
case menu4
case menu5
var id: String { rawValue }
}
struct ContentView: View {
#State var selection: MenuItem?
var body: some View {
NavigationSplitView {
List(MenuItem.allCases, selection: $selection) { item in
NavigationLink(value: item) {
Text(item.rawValue)
}
}
} detail: {
if let selection {
DetailView(menuItem: selection)
} else {
Text("Default")
}
}
}
}
struct DetailView: View {
let menuItem: MenuItem
#State var name = "Name"
var body: some View {
VStack {
Text(menuItem.id)
Text(name)
}
.task {
// This should be an async setup code
// but for the sake of simplicity
// I just made it like this
name = menuItem.id
}
}
}
Initial application load
Initial menu selection
2nd to 5th menu selection
I know I can use onChange(of: selection) as a workaround, and then have my setup code there. But is there any other way to make task or onAppear work inside my DetailView?
Basing the View's id on the selection is not a good idea. It will force an entire body rebuild every time the selection changes, which will result in sluggish performance as the view hierarchy grows.
Instead, you can use the alternate form of task(id:priority:_:) to initiate the task when the selection value changes, like so:
struct ContentView: View {
#State var selection: MenuItem?
var body: some View {
NavigationSplitView {
…
} detail: {
…
}
.task(id: selection, priority: .userInitiated) { sel in
print("selection changed:", sel)
}
}
}
It is SwiftUI optimization, it recreates only dependent parts.
A possible solution is to make entire body dependent on menu item, so it will be recreated completely and calls task again, like
struct DetailView: View {
let menuItem: MenuItem
#State var name = "Name"
var body: some View {
VStack {
Text(menuItem.id)
Text(name)
}
.task {
// This should be an async setup code
// but for the sake of simplicity
// I just made it like this
name = menuItem.id
}
.id(menuItem.id) // << here !!
}
}

SwiftUI 4: NavigationSplitView behaves strangely?

In SwiftUI 4, there is now a NavigationSplitView. I played around with it and detected some strange behaviour.
Consider the following code: When the content function returns the plain Text, then there is the expected behaviour - tapping a menu item changes the detail view to the related text.
However, when commenting out the first four cases, and commenting in the next four, then a tap on "Edit Profile" does not change the detail view display. (Using #ViewBuilder does not change this behaviour.)
Any ideas out there about the reasons for that? From my point of view, this may just be a simple bug, but perhaps there are things to be considered that are not documented yet?!
struct MainScreen: View {
#State private var menuItems = MenuItem.menuItems
#State private var menuItemSelection: MenuItem?
var body: some View {
NavigationSplitView {
List(menuItems, selection: $menuItemSelection) { course in
Text(course.name).tag(course)
}
.navigationTitle("HappyFreelancer")
} detail: {
content(menuItemSelection)
}
.navigationSplitViewStyle(.balanced)
}
func content(_ selection: MenuItem?) -> some View {
switch selection {
case .editProfile:
return Text("Edit Profile")
case .evaluateProfile:
return Text("Evaluate Profile")
case .setupApplication:
return Text("Setup Application")
case .none:
return Text("none")
// case .editProfile:
// return AnyView(EditProfileScreen())
//
// case .evaluateProfile:
// return AnyView(Text("Evaluate Profile"))
//
// case .setupApplication:
// return AnyView(Text("Setup Application"))
//
// case .none:
// return AnyView(Text("none"))
}
}
}
struct MainScreen_Previews: PreviewProvider {
static var previews: some View {
MainScreen()
}
}
enum MenuItem: Int, Identifiable, Hashable, CaseIterable {
var id: Int { rawValue }
case editProfile
case evaluateProfile
case setupApplication
var name: String {
switch self {
case .editProfile: return "Edit Profile"
case .evaluateProfile: return "Evaluate Profile"
case .setupApplication: return "Setup Application"
}
}
}
extension MenuItem {
static var menuItems: [MenuItem] {
MenuItem.allCases
}
}
struct EditProfileScreen: View {
var body: some View {
Text("Edit Profile")
}
}
After playing around a bit in order to force SwiftUI to redraw the details view, I succeeded in this workaround:
Wrap the NavigationSplitView into a GeometryReader.
Apply an .id(id) modifier to the GeometryReader (e.g., as #State private var id: Int = 0)
In this case, any menu item selection leads to a redraw as expected.
However, Apple should fix the bug, which it is obviously.
I've found that wrapping the Sidebar list within its own view will fix this issue:
struct MainView: View {
#State var selection: SidebarItem? = .none
var body: some View {
NavigationSplitView {
Sidebar(selection: $selection)
} content: {
content(for: selection)
} detail: {
Text("Detail")
}
}
#ViewBuilder
func content(for item: SidebarItem?) -> some View {
switch item {
case .none:
Text("Select an Item in the Sidebar")
case .a:
Text("A")
case .b:
Text("B")
}
}
}

How do I efficiently filter a long list in SwiftUI?

I've been writing my first SwiftUI application, which manages a book collection. It has a List of around 3,000 items, which loads and scrolls pretty efficiently. If use a toggle control to filter the list to show only the books I don't have the UI freezes for twenty to thirty seconds before updating, presumably because the UI thread is busy deciding whether to show each of the 3,000 cells or not.
Is there a good way to do handle updates to big lists like this in SwiftUI?
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
ForEach(userData.bookList) { book in
if !self.userData.showWantsOnly || !book.own {
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
Have you tried passing a filtered array to the ForEach. Something like this:
ForEach(userData.bookList.filter { return !$0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}
Update
As it turns out, it is indeed an ugly, ugly bug:
Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing") view. The result is the same, it takes 30 secs to do so!
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if self.userData.showWantsOnly {
Text("Nothing")
} else {
ForEach(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
}
Workaround
I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered and NotFiltered. Below is a full demo with 3000 rows.
import SwiftUI
class UserData: ObservableObject {
#Published var showWantsOnly = false
#Published var bookList: [Book] = []
init() {
for _ in 0..<3001 {
bookList.append(Book())
}
}
}
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if userData.showWantsOnly {
Filtered()
} else {
NotFiltered()
}
}
}.navigationBarTitle(Text("Books"))
}
}
struct Filtered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList.filter { $0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct NotFiltered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct Book: Identifiable {
let id = UUID()
let own = Bool.random()
}
struct BookRow: View {
let book: Book
var body: some View {
Text("\(String(book.own)) \(book.id)")
}
}
struct BookDetail: View {
let book: Book
var body: some View {
Text("Detail for \(book.id)")
}
}
Check this article https://www.hackingwithswift.com/articles/210/how-to-fix-slow-list-updates-in-swiftui
In short the solution proposed in this article is to add .id(UUID()) to the list:
List(items, id: \.self) {
Text("Item \($0)")
}
.id(UUID())
"Now, there is a downside to using id() like this: you won't get your update animated. Remember, we're effectively telling SwiftUI the old list has gone away and there's a new list now, which means it won't try to move rows around in an animated way."
I think we have to wait until SwiftUI List performance improves in subsequent beta releases. I’ve experienced the same lag when lists are filtered from a very large array (500+) down to very small ones. I created a simple test app to time the layout for a simple array with integer IDs and strings with Buttons to simply change which array is being rendered - same lag.
Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.
List(listArray){item in
...
}
self.listArray = []
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.listArray = newList
}
Looking for how to adapt Seitenwerk's response to my solution, I found a Binding extension that helped me a lot. Here is the code:
struct ContactsView: View {
#State var stext : String = ""
#State var users : [MockUser] = []
#State var filtered : [MockUser] = []
var body: some View {
Form{
SearchBar(text: $stext.didSet(execute: { (response) in
if response != "" {
self.filtered = []
self.filtered = self.users.filter{$0.name.lowercased().hasPrefix(response.lowercased()) || response == ""}
}
else {
self.filtered = self.users
}
}), placeholder: "Buscar Contactos")
List{
ForEach(filtered, id: \.id){ user in
NavigationLink(destination: LazyView( DetailView(user: user) )) {
ContactCell(user: user)
}
}
}
}
.onAppear {
self.users = LoadUserData()
self.filtered = self.users
}
}
}
This is the Binding extension:
extension Binding {
/// Execute block when value is changed.
///
/// Example:
///
/// Slider(value: $amount.didSet { print($0) }, in: 0...10)
func didSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
execute($0)
self.wrappedValue = $0
}
)
}
}
The LazyView is optional, but I took the trouble to show it, as it helps a lot in the performance of the list, and prevents swiftUI from creating the NavigationLink target content of the whole list.
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
This code will work correctly provided that you initialize your class in the 'SceneDelegate' file as follows:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var userData = UserData()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView:
contentView
.environmentObject(userData)
)
self.window = window
window.makeKeyAndVisible()
}
}