SwiftUI List disclosure indicator without NavigationLink
Asked Answered
D

9

28

I am searching for a solution to show the disclosure indicator chevron without having the need to wrap my view into an NavigationLink. For example I want to show the indicator but not navigate to a new view but instead show a modal for example.

I have found a lot solutions that hide the indicator button but none which explains how to add one. Is this even possible in the current SwiftUI version ?

struct MyList: View {
    var body: some View {
        NavigationView {
        List {
            Section {
                Text("Item 1")
                Text("Item 2")
                Text("Item 3")
                Text("Item 4")

            }
        }
    }
}

For example I want to add the disclosure indicator to Item 1 without needing to wrap it into an NavigationLink

I already tried to fake the indicator with the chevron.right SF Symbol, but the symbol does not match 100% the default iOS one. Top is default bottom is chevron.right.

Disclosure Button Image

Danais answered 6/6, 2020 at 22:41 Comment(2)
This looks similar: Image(systemName: "chevron.right").font(Font.system(.footnote).weight(.semibold))Duodecimal
@Duodecimal Thanks a lot, also looks pretty the same as the original. Now I have two solutions (y)Danais
R
15

Hopefully, this is what you are looking for. You can add the item to a HStack and with a Spacer in between fake it that its a Link:

HStack {
                    Text("Item 1")
                    Spacer()
                    Button(action: {

                    }){
                        Image(systemName: "chevron.right")
                            .font(.body)
                    }
                }
Revisionism answered 6/6, 2020 at 23:1 Comment(4)
Thanks a lot for your answer! - I already did that and unfortunately it did not solve my problem 100% because the chevron.right does not match the appearance for the default disclosure indicator. I should have written that in my question, sorry for that -> edited my question now.Danais
You could try to heavily edit the font into the right style. I got pretty close by doing: .font(Font.system(size: 13, weight: .semibold, design: .default)) .foregroundColor(Color(red: 0.771, green: 0.771, blue: 0.779))Revisionism
Oh you are right, I totally forgot that I could modify the image in that way. So many things to remember in SwiftUI. Thanks, it looks really like the original. Thanks !Danais
If you use the color .foregroundColor(Color(UIColor.tertiaryLabel)) then it correctly supports dark mode as well.Overcapitalize
P
20

The answers already submitted don't account for one thing: the highlighting of the cell when it is tapped. See the About Peek-a-View cell in the image at the bottom of my answer — it is being highlighted because I was pressing it when the screenshot was taken.

My solution accounts for both this and the chevron:

Button(action: { /* handle the tap here */ }) {
    NavigationLink("Cell title", destination: EmptyView())
}
.foregroundColor(Color(uiColor: .label))

The presence of the Button seems to inform SwiftUI when the cell is being tapped; simply adding an onTapGesture() is not enough.

The only downside to this approach is that specifying the .foregroundColor() is required; without it, the button text will be blue instead.

Screenshot of a SwiftUI List with one cell highlighted

Philomena answered 27/4, 2022 at 14:58 Comment(4)
I like your answer and it solves the chevron problem with the color and size.Laurasia
100 million times this answer. Thank youBony
This feels relatively clean and concise compared to other proposed methods. Thanks Casey! (Long time listener, first time caller)Dayna
This no longer works. Using a NavigationLink without a parent NavigationView will result in a greyed out NavigationLink.Catabolism
M
18

It is definitely possible.

You can use a combination of Button and a non-functional NavigationLink to achieve what you want.

Add the following extension on NavigationLink.

extension NavigationLink where Label == EmptyView, Destination == EmptyView {

   /// Useful in cases where a `NavigationLink` is needed but there should not be
   /// a destination. e.g. for programmatic navigation.
   static var empty: NavigationLink {
       self.init(destination: EmptyView(), label: { EmptyView() })
   }
}

Then, in your List, you can do something like this for the row:

// ...
ForEach(section.items) { item in
    Button(action: {
        // your custom navigation / action goes here
    }) {
        HStack {
            Text(item.name)
            Spacer()
            NavigationLink.empty
        }
    }
 }
 // ...

The above produces the same result as if you had used a NavigationLink and also highlights / dehighlights the row as expected on interactions.

Maraud answered 31/3, 2021 at 15:54 Comment(5)
Thanks a lot, I prefer this solution to the chevron symbol. Only drawback is that this only works on iOS 14, it doesn't work on iOS 13. Also, need to set font color for Text otherwise it will be shown in blue.Shirr
(Spacer is not necessary inside HStack since Text is the only visible view anyway.)Muskrat
Adding the button modifier .buttonStyle(.plain) will remove the blue tint and correct the colour of the disclosure chevron.Jonathanjonathon
this is not correct because the arrow will be tappable and it will navigate to an empty view.Carinacarinate
This no longer works. Using a NavigationLink without a parent NavigationView will result in a greyed out NavigationLink.Catabolism
R
15

Hopefully, this is what you are looking for. You can add the item to a HStack and with a Spacer in between fake it that its a Link:

HStack {
                    Text("Item 1")
                    Spacer()
                    Button(action: {

                    }){
                        Image(systemName: "chevron.right")
                            .font(.body)
                    }
                }
Revisionism answered 6/6, 2020 at 23:1 Comment(4)
Thanks a lot for your answer! - I already did that and unfortunately it did not solve my problem 100% because the chevron.right does not match the appearance for the default disclosure indicator. I should have written that in my question, sorry for that -> edited my question now.Danais
You could try to heavily edit the font into the right style. I got pretty close by doing: .font(Font.system(size: 13, weight: .semibold, design: .default)) .foregroundColor(Color(red: 0.771, green: 0.771, blue: 0.779))Revisionism
Oh you are right, I totally forgot that I could modify the image in that way. So many things to remember in SwiftUI. Thanks, it looks really like the original. Thanks !Danais
If you use the color .foregroundColor(Color(UIColor.tertiaryLabel)) then it correctly supports dark mode as well.Overcapitalize
V
7

in iOS15 the following is a better match as the other solutions were little too big and not bold enough. it'll also resize better to different Display scales better than specifying font sizes.

HStack {
    Text("Label")
    Spacer()
    Image(systemName: "chevron.forward")
      .font(Font.system(.caption).weight(.bold))
      .foregroundColor(Color(UIColor.tertiaryLabel))
}

Would be good if there was an offical way of doing this. Updating every OS tweak is annoying.

Vmail answered 14/8, 2021 at 9:11 Comment(1)
The font seems to be closer to .font(.footnote.bold())Tutu
L
3

I created a custom NavigationLink that:

  1. Adds an action API (instead of having to push a View)
  2. Shows the disclosure indicator
  3. Ensures that List cell selection remains as-is

Usage

MYNavigationLink(action: {
  didSelectCell()
}) {
  MYCellView()
}

Code

import SwiftUI

struct MYNavigationLink<Label: View>: View {
  
  @Environment(\.colorScheme) var colorScheme
  
  private let action: () -> Void
  private let label: () -> Label
  
  init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) {
    self.action = action
    self.label = label
  }
  
  var body: some View {
    Button(action: action) {
      HStack(spacing: 0) {
        label()
        Spacer()
        NavigationLink.empty
          .layoutPriority(-1) // prioritize `label`
      }
    }
    // Fix the `tint` color that `Button` adds
    .tint(colorScheme == .dark ? .white : .black) // TODO: Change this for your app
  }
}

// Inspiration:
// - https://mcmap.net/q/487999/-swiftui-list-disclosure-indicator-without-navigationlink
private extension NavigationLink where Label == EmptyView, Destination == EmptyView {
   static var empty: NavigationLink {
       self.init(destination: EmptyView(), label: { EmptyView() })
   }
}
Langbehn answered 21/5, 2022 at 20:3 Comment(1)
This is the most elegant solutionGrayback
C
3

The easy solution is to add a view modifier which will add the arrow, than it will be easy to use on other screens:

struct DisclosureIndicatorModifier: ViewModifier {
    
    func body(content: Content) -> some View {
            
        HStack(spacing: 0) {
            content
            Spacer()
            Image(systemName: "chevron.right")
                                        .font(Font.system(size: 13, weight: .semibold, design: .default))
                                        .foregroundColor(Color(red: 0.771, green: 0.771, blue: 0.779))
        }
        .contentShape(Rectangle())
            
    }
}

extension View {
    func disclosureIndicator() -> some View {
        modifier(DisclosureIndicatorModifier())
    }
}

and to use just add the modifier like in the example bellow, where I have a List row with an icon and a title:

struct DashboardSiteRow: View {
    var title: String
    
    var body: some View {
        HStack {
            SquareIcon()
            
            Text(title)
           
        }
        .disclosureIndicator()
        
    }
}
Carinacarinate answered 10/8, 2023 at 14:11 Comment(0)
B
2

I found an original looking solution. Inserting the icon by hand does not bring the exact same look.

The trick is to use the initializer with the "isActive" parameter and pass a local binding which is always false. So the NavigationLink waits for a programmatically trigger event which will never occur.

// use this initializer
NavigationLink(isActive: <Binding<Bool>>, destination: <() -> _>, label: <() -> _>)

You can pass an empty closure to the destination parameter. It will never get called anyway. To do some action you put a button on top within a ZStack.

func navigationLinkStyle() -> some View {
    let never = Binding<Bool> { false } set: { _ in }
    return ZStack {
        NavigationLink(isActive: never, destination: { }) {
            Text("Item 1")  // your list cell view
        }
        Button {
           // do your action on tap gesture
        } label: {
            EmptyView()  // invisible placeholder
        }
    }
}
Burgonet answered 8/12, 2021 at 18:1 Comment(0)
C
1

For accessibility you might need to mimic UIKit version of disclosure indicator. You don't need to implement it this way per se but if you use e.g. Appium for testing you might want to have it like this to keep tests succeeding

Apparently UIKit's disclosure indicator is a disabled button with some accessibility values so here's the solution:

struct DisclosureIndicator: View {
    var body: some View {
        Button {

        } label: {
            Image(systemName: "chevron.right")
                .font(.body)
                .foregroundColor(Color(UIColor.tertiaryLabel))
        }
        .disabled(true)
        .accessibilityLabel(Text("chevron"))
        .accessibilityIdentifier("chevron")
        .accessibilityHidden(true)
    }
}
Chronopher answered 23/12, 2021 at 15:30 Comment(0)
S
0

Or maybe create a fake one and use it, even if you tap you can call your events.

 NavigationLink(destination: EmptyView()) {
            HStack {
                Circle()
                 Text("TITLE")  
               
            }
        }
        .contentShape(Rectangle())
        .onTapGesture {
            print("ALERT MAYBE")
        }
Suburbia answered 16/12, 2020 at 11:51 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.