Swift and SwiftUI tutorials for Swift Developers

Hide Arrow in a NavigationLink in SwiftUI

If you are an iOS Developer, it is very likely that you have come across this scenario: you have an incredible design in Figma, you are building a custom list with beautiful cards, but when it comes time to implement navigation, Apple decides to automatically insert that little chevron icon (the famous gray arrow) to the right of your cell. Sometimes, the default system interface simply doesn’t fit with the creative vision of your app.

In this extensive Swift programming tutorial, we will dive into the depths of Apple’s declarative framework. We will learn step by step how to hide the arrow in a NavigationLink with SwiftUI, analyzing different techniques, from classic tricks (legacy) to the most modern approaches introduced in the latest versions of Swift. All this using Xcode and ensuring that our code is perfectly scalable and functional on iOS, as well as macOS and watchOS.


1. The “Disclosure Indicator” Problem

Before solving the problem, it is essential to understand why it happens. In the Apple ecosystem, User Interface (UI) and User Experience (UX) consistency are sacred. According to the Human Interface Guidelines (HIG), when an item within a list leads to a new detail screen, it must be visually indicated so the user knows that item is interactive.

That visual indication is the Disclosure Indicator (the arrow).

When you use the List component in SwiftUI and place a NavigationLink inside it, the framework automatically assumes you are building a standard navigation table and adds the arrow by default.

// Default behavior example
List {
    NavigationLink(destination: DetailView()) {
        Text("My Custom Cell")
    }
}

The code above will generate a cell with left-aligned text and a gray arrow on the right. But what happens if your cell is a complex card, with images, internal buttons, and an edge-to-edge design? The arrow completely breaks the aesthetics. Let’s see how to remove it.


2. Classic Solution: The ZStack and Opacity Trick (Compatible with iOS 13+)

If you are maintaining an older app or need to support versions prior to iOS 16, the oldest and most reliable technique in Swift programming to solve this is to trick the SwiftUI rendering system.

The concept is simple: we separate the visual component (what the user sees) from the interactive component (the NavigationLink), stack them on top of each other using a ZStack, and make the NavigationLink invisible using .opacity(0).

Implementation in Xcode

Open Xcode, create a new view, and try the following code:

import SwiftUI

struct LegacyHiddenArrowView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(0..<5) { item in
                    ZStack(alignment: .leading) {
                        // 1. Your custom visual design
                        CustomCardView(title: "Item \(item)")
                        
                        // 2. The invisible NavigationLink
                        NavigationLink(destination: Text("Detail for item \(item)")) {
                            EmptyView()
                        }
                        .opacity(0) // Hides the arrow and the link
                    }
                    // Optional: Remove list separators
                    .listRowSeparator(.hidden)
                    .listRowInsets(EdgeInsets())
                }
            }
            .navigationTitle("ZStack Hack")
        }
    }
}

struct CustomCardView: View {
    let title: String
    
    var body: some View {
        HStack {
            Image(systemName: "star.fill")
                .foregroundColor(.yellow)
            Text(title)
                .font(.headline)
            Spacer()
        }
        .padding()
        .background(Color.blue.opacity(0.1))
        .cornerRadius(10)
        .padding(.horizontal)
    }
}

Why does this work?

By wrapping the NavigationLink in a ZStack and passing an EmptyView() as its label, we reduce its visual size to the absolute minimum. By adding .opacity(0), we guarantee that the default arrow the system tries to draw is completely transparent. The magic lies in the fact that, even though it’s invisible, the NavigationLink continues to absorb user taps over the entire area of the ZStack.

Note for the iOS Developer: Although this trick is very popular, it can cause subtle accessibility issues (VoiceOver might read duplicate elements if not configured properly) and sometimes intercepts gestures unpredictably if your card has internal buttons.


3. Intermediate Solution: Style Modifiers with .buttonStyle

Another very elegant way with less view “hacking” is to take advantage of how SwiftUI interprets links under the hood. A NavigationLink inside a list acts, for all practical purposes, as a button.

If we apply a plain or unformatted button style, in some versions and platform contexts, we can remove the default list decorations.

import SwiftUI

struct ButtonStyleView: View {
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: Text("Detail")) {
                    CustomCardView(title: "Cell with ButtonStyle")
                }
                .buttonStyle(PlainButtonStyle()) // Removes default highlight behavior
                // Note: In the latest versions of iOS, PlainButtonStyle doesn't always remove the arrow in a List,
                // but it is crucial to prevent the whole cell from flashing blue when tapped.
            }
            .navigationTitle("Button Style")
        }
    }
}

It is important to note that the behavior of .buttonStyle(PlainButtonStyle()) can vary greatly between iOS, macOS, and watchOS. Because of this, many developers prefer to abandon the List component entirely when extreme customization is required.


4. The Ultimate Solution for Custom Designs: ScrollView + LazyVStack

As an experienced iOS Developer, you will realize that fighting against the system’s default behavior is usually a bad idea. The List component in SwiftUI is specifically designed to look and behave like native iOS tables (the classic UITableView from UIKit). If your design doesn’t look like a native table, you shouldn’t use a List.

To build completely custom interfaces with no traces of arrows, separators, or weird margins, the winning combination in Swift programming is using a ScrollView along with a LazyVStack.

Practical Example in Swift

import SwiftUI

struct CustomScrollViewNavigation: View {
    var body: some View {
        NavigationView {
            ScrollView {
                LazyVStack(spacing: 16) {
                    ForEach(0..<10) { index in
                        NavigationLink(destination: Text("Detail \(index)")) {
                            // Your cell is now the NavigationLink's Label
                            CustomCardView(title: "Custom card \(index)")
                        }
                        .buttonStyle(PlainButtonStyle()) // Prevents default blue highlight
                    }
                }
                .padding()
            }
            .navigationTitle("ScrollView Custom")
        }
    }
}

Advantages of this approach:

  • Absolute Control: Goodbye to how to hide the arrow in a NavigationLink with SwiftUI. By not being inside a List context, the arrow (disclosure indicator) is never rendered.
  • Performance: LazyVStack only loads into memory the views that are visible on the screen, offering performance similar to List.
  • No hacks: The code is clean, semantic, and easy to maintain. There are no hidden views or zero opacities.

5. The Modern Standard (iOS 16+): NavigationStack and Programmatic Navigation

At WWDC 2022, Apple revolutionized SwiftUI navigation by deprecating NavigationView and introducing NavigationStack. This change brought a data-driven navigation paradigm (value-based navigation).

This is, without a doubt, the most professional and modern way to solve our problem in Xcode today. Instead of using NavigationLink to wrap our visual view, we use normal buttons that update the navigation state, and we define the destination at a higher level.

Step-by-Step Code with NavigationStack

import SwiftUI

// 1. We define a data model for our navigation
struct ItemData: Hashable {
    let id = UUID()
    let name: String
}

struct ModernNavigationView: View {
    // 2. Optional: We can control the navigation path if we want
    @State private var items = [
        ItemData(name: "MacBook Pro"),
        ItemData(name: "iPhone 15"),
        ItemData(name: "Apple Watch Ultra")
    ]
    
    var body: some View {
        // 3. We use the new NavigationStack
        NavigationStack {
            List(items, id: \.id) { item in
                // 4. Instead of a NavigationLink, we use a normal Button or an .onTapGesture
                Button(action: {
                    // This is where you would normally do something before navigating
                    // But value-based navigation is handled with NavigationLink(value:label:)
                }) {
                    CustomCardView(title: item.name)
                }
                .listRowSeparator(.hidden)
                // 5. The master trick: a NavigationLink with a value, without a visual label
                .overlay(
                    NavigationLink(value: item) {
                        EmptyView()
                    }
                    .opacity(0)
                )
            }
            .listStyle(.plain)
            .navigationTitle("iOS 16+ Navigation")
            // 6. The destination is defined outside the cell
            .navigationDestination(for: ItemData.self) { selectedItem in
                Text("Welcome to the detail of: \(selectedItem.name)")
                    .font(.largeTitle)
            }
        }
    }
}

Another even cleaner alternative with NavigationStack if you decide not to use List (and use the ScrollView approach mentioned earlier), is to use purely state-driven navigation:

import SwiftUI

struct PureStateNavigationView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            ScrollView {
                LazyVStack {
                    ForEach(1...5, id: \.self) { index in
                        // A simple button. Zero arrows. Zero default styles.
                        Button(action: {
                            // We add the value to the path to trigger navigation
                            navigationPath.append(index)
                        }) {
                            CustomCardView(title: "Interactive item \(index)")
                        }
                        .buttonStyle(.plain)
                    }
                }
            }
            .navigationTitle("State Navigation")
            .navigationDestination(for: Int.self) { value in
                Text("Destination screen \(value)")
            }
        }
    }
}

This is the holy grail for any iOS Developer. We have completely decoupled the user interface from the navigation logic. The visual component is just a button that pushes a value to an array (NavigationPath), and the NavigationStack reacts to that change by showing the new screen.


6. Cross-Platform Considerations: macOS and watchOS

The beauty of SwiftUI is its promise of learn once, apply anywhere. However, as you will know when using Xcode, each platform has its quirks.

macOS

On macOS, applications typically use a NavigationSplitView architecture (sidebars). If you try to use the ZStack and opacity trick on macOS, you might find that the mouse selection area behaves strangely.

For macOS, the recommended approach is always to use data-driven navigation (NavigationStack or NavigationSplitView on macOS 13+) or the ScrollView approach. The use of List on macOS is closely tied to the system’s row selection behavior (the blue or gray highlight that covers the entire row), and altering that behavior breaks the native Mac user experience.

watchOS

On the small screen of the Apple Watch, space is critical. In watchOS, elements in a List almost always have a capsule-shaped background. Here, the disclosure indicator sometimes isn’t even shown by default depending on the list style.

If you develop for watchOS using Swift programming, use native buttons (Button) that navigate programmatically via NavigationStack. Avoid using ZStack with opacity(0) on watchOS, as the rendering engine on this device is highly optimized and adding invisible views can slightly affect performance and the haptic feedback of the Digital Crown.


7. Accessibility and Human Interface Guidelines (HIG)

Before we conclude, let’s put on our product designer hats. You have learned how to hide the arrow in a NavigationLink with SwiftUI, but the real question is: Should you do it?

Apple includes the chevron for a very specific reason: discoverability. Users have been neurologically trained since 2007 (with the first iPhone) to know that this arrow means “tap here to see more details.”

If you decide to remove the arrow, it is your responsibility as an iOS Developer to provide alternative visual cues indicating that the item is interactive. Some ways to do this are:

  1. Tap Feedback: Make sure your card changes color, scales down slightly, or reduces its opacity when the user presses on it.
  2. Shadows and Depth: Use shadow(radius:) to make the card look like it’s floating above the background, which in modern design language (Material Design and Apple’s own) suggests interactivity.
  3. Explicit Calls to Action (CTA): You can include a button that says “View more” or “Details” within the card itself.
  4. VoiceOver Support: If you use the ZStack trick with an EmptyView(), test your app with VoiceOver enabled. Be sure to group accessibility elements (.accessibilityElement(children: .combine)) so the screen reader doesn’t get confused reading overlapping elements and frustrate visually impaired users.

8. Conclusion

Mastering navigation in SwiftUI is a rite of passage for any iOS Developer. Throughout this extensive tutorial, we have explored how Apple’s framework handles UI creation and why it insists on placing native UI elements like the disclosure indicator.

We have seen that there is no single correct answer to hiding the arrow, but rather several tools in our Swift programming arsenal:

  • The ZStack and Opacity(0) trick for legacy projects using List.
  • Using ScrollView and LazyVStack to completely escape the system’s restrictive styles.
  • Powerful state-based navigation with NavigationStack introduced in iOS 16, ideal for clean and modern architectures in Xcode.

As a final piece of advice, keep your code as simple and declarative as possible. As SwiftUI evolves year after year, “hacky” solutions tend to break with new iOS updates. Adopting new APIs like NavigationStack and NavigationPath will not only solve your current aesthetic problems but will also future-proof your app across all Apple devices.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to Print to the Xcode Console in SwiftUI

Related Posts