@teissler@hachyderm.io
@teissler@hachyderm.io avatar

teissler

@teissler@hachyderm.io

SwiftUI @ 🍏 | 🗺️Navigation 🧐 inspector 🎁 presentations

Hobbyist woodworker 🪵🪚🗜️

Mastodon just so happens to be a great place to meet great people in both of these areas.

This profile is from a federated server and may be incomplete. Browse more on the original instance.

teissler, to SwiftUI
@teissler@hachyderm.io avatar

WWDC24 is right around the corner. Before the excitement and rush of what's to come, I want to share some navigation fun facts and tips. None of this is new, having been discussed previously in sessions, etc. From talking with developers, these are parts of the system that are often overlooked, or forgotten about This is all running on macOS Sonoma 14.4, Xcode 15.3

teissler,
@teissler@hachyderm.io avatar

6 cont) This link takes a destination view and a label. It's referred to as a "view-destination" navigation link. It makes no state changes visible to the client, so it's also a fire-and-forget style link. The client's view of navigation state doesn't change. The onChange for the path never prints. You can track this with onAppear or other techniques, but that is a path😬 to stateful bugs. If tracking the presented state of a link is important from a navigational perspective, Day 7 is for you.

teissler,
@teissler@hachyderm.io avatar
  1. NavigationLink(_ title:value:) is a "value-destination" link. Conceptually, it navigates to a value. From talking with developers, it's a misconception that this must be paired with a .navigationDestination(for:…) modifier, mapping directly to a view. As we saw in Day 3, it can drive List selection, mapping indirectly to a view.
    This link often uses data-driven navigation (the opposite of fire-and-forget) — causing changes in client state like appending to a path seen here.

Xcode Previews showing a code sample and an iPhone. When the link in the iPhone is tapped, the stack pushes a pink view. Simultaneously, the console prints “New Path: [7]”. After popping the view back off, the console prints “New Path: []” Code: import SwiftUI struct Day7: View {

teissler,
@teissler@hachyderm.io avatar
teissler,
@teissler@hachyderm.io avatar

8 cont) There are two important concepts this demo shows: 1️⃣ A NavigationStack's path is a run of 0 or more values, followed by a run of 0 or more views. The video calls out "You can not push more value-destinations." once the first view-destination view has been pushed. This is why it's helpful to know the difference between view- and value-destination links. It's fun (IMO) to reason out why this is:

teissler,
@teissler@hachyderm.io avatar

8 cont) Imagine a path that allowed pushing values after pushing views, so a path was [1, 2, 3] ViewA, ViewB, [4]. In NavigationStack's API, the path would be [1, 2, 3, 4] with two Views somewhere along the path. Given just that state, it's not possible to construct the state of the app. We can't know when the view-destination views were pushed, and which view should be on top of the stack.

teissler,
@teissler@hachyderm.io avatar

8 cont) It takes more than a few toots to fully explore this concept, but it is problematic if app state depends on how we got there + the state itself. 2️⃣ The second concept we see here is the first example of picking the right navigation tool for the job. navigationDestination(item:destination) is often overlooked in favor of the deprecated NavigationLink(isActive:destination:). A get/set binding is used for the isActive binding that maps an item to the Boolean value under the hood.

teissler,
@teissler@hachyderm.io avatar

8 cont) When possible, it is advisable to use the item-bound navigationDestination, rather than the deprecated API. When using NavigationLink(isActive:destination), especially in a List, that makes your app state a function of the path AND whether or not that link is in existence (e.g. scrolled on screen). The best way to model this instead, is whether or not the item binding is populated, which is the contract of the modifier.

teissler,
@teissler@hachyderm.io avatar

8 almost done) Another reason the NavigationLink(isActive:destination:) is deprecated is that it leads to mini update cycles when used in this way. the cycle looks like this: (a) user taps link (b) link view updates attempting to set it's isActive binding to true (c) the get/set binding kicks in its side effect of setting some item binding elsewhere (d) link view updates again because the isActive binding depends on the item binding. The loop terminates there and is sometimes harmless

teissler,
@teissler@hachyderm.io avatar

8 last one) These mini update cycles are sometimes harmless, but the more complex and app gets, the more work is being done, bindings updating, environment propagating, the higher the chance that the mini-update cycle causes some state change to not propagate correctly. SwiftUI is robust against this and will cut off cycles when it detects them. But it is best avoided, thus, pick the right tool for the job

teissler,
@teissler@hachyderm.io avatar
  1. Today we look at navigationDestination(isActive:destination:), the boolean form of yesterday's modifier. The same guidelines apply, and here is one more: don't use them in lazy containers. Tying the existence of navigationDestination to whether or not contents of a List have been lazily loaded commits the error again (see Day8) of making navigation state a function of scroll state, AX/dynamic type size (larger type -> fewer rows scrolled on screen), size class, etc.
teissler,
@teissler@hachyderm.io avatar

9 cont) I've seen some code bases substantiate this construction with modularity. For example, the app's root view may be a List, each team that works on the app adds their feature like:

List {
Feature1()
Feature2()
}

And within those features exist .navigationDestination modifiers. Swift gives facilities for staying modular, and still moving those destinations outside the List. That's how to get robust deep linking, not subject to difficult to debug conditions like device size

teissler,
@teissler@hachyderm.io avatar
  1. onAppear and onDisappear are not your friends when it comes to tracking navigation state — is it technically possible to track state like this? Yes, but brittle because you've made two sources of truth. If you've ever wondered why onDisappear isn't called when a view in a stack has one pushed atop it, imagine ViewA has @State or some timer that updates a dependency of ViewB. (ViewB is pushed on top of ViewA). ViewA can't be removed from the graph – it is still a dependency of ViewB
teissler,
@teissler@hachyderm.io avatar

10 cont) onAppear/disappear are not code smells by any means, just be wary that you aren't using them to track state that might disagree with your true source of navigation state: List selection bindings, stack-paths, and fire-and-forget state like root replacements.

teissler,
@teissler@hachyderm.io avatar

10 cont) by the way, the system takes care to forward these dependencies efficiently. If ViewA has some observable property that updates over time, and ViewB captures that property in a navigationDestination, ViewB will update when the property of ViewA changes. Try putting an Observable at the root of a NavigationStack and updating the observable on a timer. Capture it in a NavigationDestination and watch the pushed view update too.

teissler,
@teissler@hachyderm.io avatar
  1. We dive deeper into navigationDestination. We learned yesterday the closure passed to navigationDestination is reevaluated when its dependencies change. This keeps the destination presented by a navigationDestination up to date with the values captured by the closure. Be aware of how much you are capturing inside this navigationDestination closure! When you build up a stack of views with a NavigationStack, you might be building up a complex dependency graph — thus, cycles.
teissler,
@teissler@hachyderm.io avatar

11 cont) Quick aside to say, there have been cycle bugs that needed to be fixed in the framework, but clients can cause them too.

teissler,
@teissler@hachyderm.io avatar

11 cont) Let's examine a navigation cycle. This example shows a navigation cycle that is a client bug. It's hard to spot why immediately. The Day11_Bad_Cycle view has an environmental dependency on MyEnvironmentKey. The value of MyEnvironmentKey is a closure. Closures are not equatable or comparable in Swift, the graph always has to assume they have changed value. Down below, the navigationDestination captures self by means of localState! When I tap push, the app freezes up 😮

A video of Xcode previews. The phone has a single button “Push”. When tapped, the “Push” gets a pressed appearance, and the app freezes, never pushing. import SwiftUI struct Day11_Bad_Cycle: View { private var localState: String = "loop" @Environment(.myCustomValue) var functional var body: some View { NavigationLink("Push", value: 10) .navigationDestination(for: Int.self) { _ in OtherView(string: localState) // Captures

teissler,
@teissler@hachyderm.io avatar

11 cont) When the navigation system tries to push, it evaluates the navigationDestination. The navigationDestination captured self, and established a dependency on self. When SwiftUI checks if the navigationDestination has changed, it has no choice but to assume that it has! Becuase Day11_Bad_Cycle has the non-Equatable functional property. So it reevaluates the destination view and updates the view. Which starts the cycle over again.

teissler,
@teissler@hachyderm.io avatar

11 cont) The potential for cycles exists in other places in SwiftUI. They come up often in navigation because you can inadvertently build large interconnected dependencies when stacking views up. The solution here is a general best practice: make your environment values equatable so SwiftUI can efficiently diff them. (or for a quick and dirty proof, use a capture list to capture only [localState] and avoid establish the dependency on the environment.)

teissler,
@teissler@hachyderm.io avatar

11 final) In this version, I've wrapped the closure up in a Equatable Box. For a proof-of-concept, I use the @Namespace property wrapper to establish identity, given that I know that closure values identity is tied to the Namespace.The cycle is solved. The takeaway, make your environment values Equatable when possible, and when you can't, don't capture them as part of .navigationDestinations

Shout out to a particular follower who knows where they are who filed quality FBs about this.

The same example as before, but conforming MyEnvironmentKey.Value to Equatable stops the cycle. The view pushes and pops successfully import SwiftUI struct Day11: View {

teissler,
@teissler@hachyderm.io avatar

Day 12) A lot of what I've covered so far I only thought to cover through speaking with developers online and at last year's DubDub. Or via the sub-toots 🙃 For day 12, if any of the preceding tips have been useful to you, would you like more detail on a specific topic? Is your team running into issues with navigation? Are there constructions you're wondering are undefined behavior?

teissler,
@teissler@hachyderm.io avatar

12 cont) days 1-11 were more successful than I anticipated! Not a single question today. I'll leave day 12 as an open invitation, I can certainly scale up from zero. The new developer forums are also ready for action. The show must go on: .toolbar(removing: .sidebarToggle) is not a tool to fix the sidebar open, or worse, closed. You remove the sidebar toggle to add a custom one, modify the tool tip, or position it in a spot that suits your app's design better

teissler,
@teissler@hachyderm.io avatar

12 final) Given the size classes and interactions supported, fixing the sidebar is a rare, and currently unsupported (FBs welcome). On iPad in certain size classes users can drag from the leading edge to reveal the sidebar. Lest we forget the iPad mini in portrait where a fixed sidebar would feel cramped. And lest we really not forget this peek interaction on macOS

An Xcode Previews window showing a macOS preview of a NavigationSplitView. Hovering the mouse some 5-10 points from the leading edge of the window reveal a peek of the sidebar. The user can grab the peeking sidebar and drag it open from a collapsed state. import SwiftUI struct Day12: View { var body: some View { NavigationSplitView { List { ForEach(0...10, id: .self) { i in NavigationLink("Option (i)", value: i) } } } detail:{ ContentUnavailableView( "Make your selection", systemImage: "circle.hexagongrid.fill") .symbolRenderingMode(.multicolor) .symbolEffect(.pulse, options: .repeating) } } } { Day12() }

teissler,
@teissler@hachyderm.io avatar
  1. macOS supports Cmd-click deselect out of the box. This works with value-destination and view destination links. You'll notice the view-destination link’s (Settings) selection is cleared when the environmental action dismiss is called from the detail view

macOS Xcode Previews showing a basic NavigationSplitView. The 3 options in the sidebar as successively clicked and Command-clicked, which dismisses the presented view. Additionally, a button that calls the dismiss action form the detail column will also dismiss the presented view and clear selection. import SwiftUI struct Day13: View { var body: some View { NavigationSplitView { List { NavigationLink(value: "House") { Label("Home", systemImage: "house") } NavigationLink(value: "Profile") { Label("Profile", systemImage: "person") } NavigationLink { DismissibleView() } label: { Label( "Settings", systemImage: "gear") } } .navigationDestination(for: String.self) { str in if str == "House" { Color.accentColor } else { Color.mint } } } detail: { Text("No selection") } } } struct DismissibleView: View { @Environment(.dismiss) var dismiss var body: some View { ZStack { Color.gray Button("Dismiss") { dismiss() } } } } { Day13() }

teissler,
@teissler@hachyderm.io avatar
  1. NavigationSplitView has opinionated behavior when collapsing from a regular to compact size class. In the absence of a preferredCompactColumn argument, NavigationSplitView will show the most "interesting" trailing column. A column is interesting if it's been root-replaced, its previous column has a non-nil List selection binding, or if it contains a NavigationStack with a non-empty path. Given no interesting columns, the sidebar will be on top.
  • All
  • Subscribed
  • Moderated
  • Favorites
  • JUstTest
  • mdbf
  • everett
  • osvaldo12
  • magazineikmin
  • thenastyranch
  • rosin
  • normalnudes
  • Youngstown
  • Durango
  • slotface
  • ngwrru68w68
  • kavyap
  • DreamBathrooms
  • tester
  • InstantRegret
  • ethstaker
  • GTA5RPClips
  • tacticalgear
  • Leos
  • anitta
  • modclub
  • khanakhh
  • cubers
  • cisconetworking
  • megavids
  • provamag3
  • lostlight
  • All magazines