SwiftUI List Button with Disclosure Indicator - ios

I have a SwiftUI view that consists of a list with some items. Some of these are links to other screens (so I use NavigationLink to do this) and others are actions I want to perform on the current screen (E.g. button to show action sheet).
I am looking for a way for a Button in a SwiftUI List to show with a disclosure indicator (the chevron at the right hand sign that is shown for NavigationLink).
Is this possible?
E.g.
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
Button("Action Sheet") {
self.showingActionSheet = true
}
.foregroundColor(.black)
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}
Current Behaviour:
Wanted Behaviour:

My answer uses the SwiftUI-Introspect framework, used to:
Introspect underlying UIKit components from SwiftUI
In this case, it is used to deselect the row after the NavigationLink is pressed.
I would think a button with the normal accent color and without the NavigationLink be more intuitive to a user, but if this is what you need, here it is. The following answer should work for you:
import Introspect
import SwiftUI
struct ExampleView: View {
#State private var showingActionSheet = false
#State private var tableView: UITableView?
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
NavigationLink(
destination: EmptyView(),
isActive: Binding<Bool>(
get: { false },
set: { _ in
showingActionSheet = true
DispatchQueue.main.async {
deselectRows()
}
}
)
) {
Text("Action Sheet")
}
}
.introspectTableView { tableView = $0 }
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
private func deselectRows() {
if let tableView = tableView, let selectedRow = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: selectedRow, animated: true)
}
}
}

The possible approach is to make row with custom chevron, like in demo below (tested with Xcode 12.1 / iOS 14.1)
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
HStack {
Text("Navigation Link")
// need to hide navigation link to use same chevrons
// because default one is different
NavigationLink(destination: Text("xx")) { EmptyView() }
Image(systemName: "chevron.right")
.foregroundColor(Color.gray)
}
HStack {
Button("Action Sheet") {
self.showingActionSheet = true
}
.foregroundColor(.black)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(Color.gray)
}
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}

I used a ZStack to put a NavigationLink with an empty label behind the actual label content. This way you get the correct chevron symbol.
For the isActive Binding, you can just use a .constant(false) Binding that will always return false and cannot be changed.
This will result in a row that looks exactly like a NavigationLink, but behaves like a Button. This unfortunately also includes the drawback that the user has to click on the label-content (the text) to activate the button and cannot just click on empty space of the cell.
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
Button {
self.showingActionSheet = true
} label: {
// Put a NavigationLink behind the actual label for the chevron
ZStack(alignment: .leading) {
// NavigationLink that can never be activated
NavigationLink(
isActive: .constant(false),
destination: { EmptyView() },
label: { EmptyView() }
)
// Actual label content
Text("Action Sheet")
}
}
// Prevent the blue button tint
.buttonStyle(.plain)
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}

struct NavigationButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
ZStack(alignment: .leading) {
NavigationLink(
isActive: .constant(false),
destination: { EmptyView() },
label: { EmptyView() }
)
configuration.label
}
}
}
Example:
NavigationView {
List {
Button(action: {
openURL(URL(string: "https://www.apple.com")!)
}) {
Text("Visit Apple")
}
.buttonStyle(NavigationButton())
}
}

Related

Hide chevron/arrow on NavigationLink when displaying a view, SwiftUI

I am trying to remove the chevron that appears on the right of the screen with a navigationLink that contains a view. This is my code below:
NavigationView {
List {
NavigationLink(destination: DynamicList()) {
ResultCard()
}
...
}
Other answers on Stack Overflow have recommended using something like the below:
NavigationLink(...)
.opacity(0)
However, this doesn't work in my case since bringing the opacity down to 0 also removes the view that I am trying to display. This is also the case with '.hidden'. I've searched everywhere and the only somewhat working solution I could find was to alter the padding in order to 'push' the chevron off of the side, but this is a poor solution since the 'ResultCard' view will appear wonky/off-centre on different display sizes.
Perhaps it isn't possible to remove the chevron - and if this is the case, is there any other way I can allow the user to tap the 'ResultCard' view and be taken to a new page, that isn't through a navigation link?
I'm banging my head on the wall so any ideas are very much appreciated.
You can use an .overlay on your label view with a NavigationLink with an EmptyView() set as its label:
struct ContentView : View {
var body: some View {
NavigationView {
List {
NavigationLink("Link 1", destination: Text("Hi"))
Text("Test")
.overlay(NavigationLink(destination: Text("Test"), label: {
EmptyView()
}))
}
}
}
}
Update:
Another solution, which seems to work with other types of Views besides Text:
struct ContentView : View {
#State private var linkActive = false
var body: some View {
NavigationView {
List {
NavigationLink("Link 1", destination: Text("Hi"))
Button(action: { linkActive = true }) {
Image(systemName: "pencil")
}.overlay(VStack {
if linkActive {
NavigationLink(destination: Text("Test"), isActive: $linkActive) {
EmptyView()
}.opacity(0)
}
})
}
}
}
}
The Update solution from jnpdx almost worked for me, but it messed up the animation to the next view. Here's what worked for me (is actually simpler than jnpdx's answer):
struct ContentView : View {
#State private var linkActive = false
var body: some View {
NavigationView {
List {
Button(action: { linkActive = true }) {
Image(systemName: "pencil")
}.overlay(
NavigationLink(
isActive: $linkActive,
destination: { Text("Test") },
label: { EmptyView() }
).opacity(0)
)
}
}
}
}
Here are two alternative variations using .background():
// Replicate the iPhone Favorites tab with the info button
// - Compose a button to link from a NavigationView to a next view
// - Use this when you want to hide the navigation chevron decoration
// - and/or to have a button trigger the link
struct NavigationLinkButton<Destination: View, Label: View>: View {
#Binding var selectedID: String?
#State var rowID: String
#ViewBuilder var destination: () -> Destination
#ViewBuilder var label: () -> Label
var body: some View {
HStack {
Spacer()
Button {
selectedID = rowID
} label: {
label()
}
.buttonStyle(.plain) // prevent List from processing *all* buttons in the row
.background {
NavigationLink("", tag: rowID, selection: $selectedID) {
destination()
}
.hidden()
}
}
}
}
// Replicate the iOS Spotlight search for contacts with action buttons
// - Compose a list row to link from a NavigationView to a next view
// - Use this when you want to hide the navigation chevron decoration
// - and add action buttons
struct NavigationLinkRowHidingChevron<Destination: View, Label: View>: View {
#Binding var selectedID: String?
#State var rowID: String
#ViewBuilder var destination: () -> Destination
#ViewBuilder var label: () -> Label
var body: some View {
ZStack {
// Transparent button to capture taps across the entire row
Button("") {
selectedID = rowID
}
label()
}
.background {
NavigationLink("", tag: rowID, selection: $selectedID) {
destination()
}
.hidden()
}
}
}
// Example Usages
//
// #State private var selectedID: String?
// #State private var editMode: EditMode = .inactive
//
// ... and then in the body:
// List {
// ForEach(items) { item in
// row(for: item)
// .swipeActions(edge: .leading, allowsFullSwipe: true) {
// ...
// }
// .contextMenu {
// ...
// }
// .onDrag({
// ...
// })
// // Overlay so that a tap on the entire row will work except for these buttons
// .overlay {
// // Hide info button just as with Phone Favorites edit button
// if editMode == .inactive {
// NavigationLinkHidingChevron(selectedID: $selectedID, rowID: item.id) {
// // Destination view such as Detail(for: item)
// } label: {
// // Button to activate nav link such as an 􀅴 button
// }
// }
// }
// }
// .onDelete(perform: deleteItems)
// .onMove(perform: moveItems)
// }
//
// ... or this in the body:
// NavigationLinkHidingChevron(selectedID: $selectedID, rowID: contact.id) {
// // Destination view such as Detail(for: item)
// } label: {
// // Content for the list row
// }
// .contextMenu {
// ...
// }
// .overlay {
// HStack(spacing: 15) {
// // Right-justify the buttons
// Spacer()
// // Buttons that replace the chevron and take precedence over the row link
// }
// }
This worked for me, building on #wristbands's solution and targeting iOS 16 using Xcode 14.1:
struct ContentView : View {
var body: some View {
NavigationStack {
List {
Text("View") // your view here
.overlay {
NavigationLink(destination: { Text("Test") },
label: { EmptyView() })
.opacity(0)
}
}
}
}
}

SwiftUI toolbar not showing on a NavigationLink view

I'm trying to show a toolbar on a view that is inside to navigation links. When I navigate to the third view I get the following message:
2020-09-15 23:09:31.097289-0500 CountTime[35018:3542166] [Assert]
displayModeButtonItem is internally managed and not exposed for
DoubleColumn style. Returning an empty, disconnected UIBarButtonItem
to fulfill the non-null contract.
And the toolbar is not shown. This happens only on iPhone, not iPad. I'm using Xcode 12 GM.
Here is the code:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: SecondView(),
label: {
Text("Navigate")
})
}
}
}
struct SecondView: View {
var body: some View {
ZStack {
NavigationLink(
destination: Text("Destination")
.toolbar {
ToolbarItem(placement: ToolbarItemPlacement.bottomBar) {
Button(action: {
print("sharing")
}) {
Image(systemName: "square.and.arrow.up")
}
}
},
label: {
Text("Navigate")
})
}
}
}
displayModeButtonItem is internally managed and not exposed for
DoubleColumn style
In your case SwiftUI for some reason tries to present a NavigationView in a DoubleColumn style.
A possible solution is to specify the style explicitly:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: SecondView()) {
Text("Navigate")
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
I got the some issue.
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: {
showEditView = true
}, label: {
Text("Edit")
})
}
}
does not work but the deprecated navigationBarItems works.
.navigationBarItems(trailing:
Button(action: {
showEditView = true
}, label: {
Text("Edit")
})
)

Maintain navigation item button alignment in SwiftUI as second button is added & removed

I have a scenario where there will be one or two trailing buttons based upon some criteria. I would like to have it such that the buttons always align to trailing for visual consistency, but so far, they appear to center align, regardless of what I do.
Below is a minimum example showing this:
import SwiftUI
struct ContentView: View {
#State private var isButtonShown = true
var body: some View {
NavigationView {
Button(action: {
self.isButtonShown.toggle()
}, label: {
Text(self.isButtonShown ? "Hide Button" : "Show Button")
})
.navigationBarItems(trailing:
HStack {
if self.isButtonShown {
Button(action: {
print("A tapped")
}, label: {
Text("A")
})
Spacer(minLength: 30)
}
Button(action: {
print("B tapped")
}, label: {
Text("B")
})
}
.frame(alignment: .trailing)
)
}
}
}
And a video that shows what happens when I select the button.
My goal is to have B remain in the same position, regardless of whether or not A is shown.
Finally, I tried a few other items:
Moved .frame(alignment: .trailing) to the NavigationView level
Added an else after self.isButtonShown that added a Spacer()
Applied .frame(alignment: .trailing) to the B Button
It is know issue in SwiftUI 1.0
SwiftUI 2.0
The solution based on new .toolbar does not have it. Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State private var isButtonShown = true
var body: some View {
NavigationView {
Button(action: {
self.isButtonShown.toggle()
}, label: {
Text(self.isButtonShown ? "Hide Button" : "Show Button")
})
.toolbar {
ToolbarItem(placement: .primaryAction) {
if self.isButtonShown {
Button(action: {
print("A tapped")
}, label: {
Text("A")
})
}
}
ToolbarItem(placement: .primaryAction) {
Button(action: {
print("B tapped")
}, label: {
Text("B")
})
}
}
}
}
}

SwiftUI navigation bar title and items does not disappear when swiping back fails

The problem is that the title and the item of the navigation bar does not disappear which is an unexpected behaviour.
struct DestinationView: View {
#State private var showingActionSheet = false
var body: some View {
Text("DestinationView")
.padding(.top, 100)
.navigationBarTitle(Text("Destination"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("tapped")
}, label: {
Text("second")
}))
.actionSheet(isPresented: self.$showingActionSheet) { () -> ActionSheet in
ActionSheet(title: Text("Settings"), message: nil, buttons: [
.default(Text("Delete"), action: {
}),
.cancel()
])
}
}
}
The problem is that the .navigationBarTitle(), .navigationBarItems() modifiers and the .actionSheet() modifier are under each other in code. (But it can be the .alert() or the .overlay() modifiers as well instead of .actionSheet())
The solution in this case:
struct DestinationView: View {
#State private var showingActionSheet = false
var body: some View {
List {
Text("DestinationView")
.padding(.top, 100)
.navigationBarTitle(Text("Destination"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("tapped")
}, label: {
Text("second")
}))
}
.actionSheet(isPresented: self.$showingActionSheet) { () -> ActionSheet in
ActionSheet(title: Text("Settings"), message: nil, buttons: [
.default(Text("Delete"), action: {
}),
.cancel()
])
}
}
}
Add
.navigationViewStyle(StackNavigationViewStyle())
to the NavigationView seems to fix it for me.

Show leading navigationBarItems button only if shown as a modal

I have a view that can be shown either as a modal, or simply pushed onto a navigation stack. When it's pushed, it has the back button in the top left, and when it's shown as a modal, I want to add a close button (many of my testers were not easily able to figure out that they could slide down the modal and really expected an explicit close button).
Now, I have multiple problems.
How do I figure out if a View is shown modally or not? Or alternatively, if it's not the first view on a navigation stack? In UIKit there are multiple ways to easily do this. Adding a presentationMode #Environment variable doesn't help, because its isPresented value is also true for pushed screens. I could of course pass in a isModal variable myself but it seems weird that's the only way?
How do I conditionally add a leading navigationBarItem? The problem is that if you give nil, even the default back button is hidden.
Code to copy and paste into Xcode and play around with:
import SwiftUI
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(leading: closeButton, trailing: deleteButton)
}
private var closeButton: some View {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}
}
private var deleteButton: some View {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If I change closeButton to return an optional AnyView? and then return nil when isModal is false, I don't get a back button at all. I also can't call navigationBarItems twice, once with a leading and once with a trailing button, because the latter call overrides the first call. I'm kinda stuck here.
Okay, I managed it. It's not pretty and I am very much open to different suggestions, but it works 😅
import SwiftUI
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
public func conditionalNavigationBarItems(_ condition: Bool, leading: AnyView, trailing: AnyView) -> some View {
Group {
if condition {
self.navigationBarItems(leading: leading, trailing: trailing)
} else {
self
}
}
}
}
struct ContentView: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.showModal = true
}
NavigationLink("Push", destination: DetailView(isModal: false))
}
.navigationBarTitle("Home")
}
.sheet(isPresented: $showModal) {
NavigationView {
DetailView(isModal: true)
}
}
}
}
struct DetailView: View {
#Environment(\.presentationMode) private var presentationMode
let isModal: Bool
var body: some View {
Text("Hello World")
.navigationBarTitle(Text("Detail"), displayMode: .inline)
.navigationBarItems(trailing: deleteButton)
.conditionalNavigationBarItems(isModal, leading: closeButton, trailing: deleteButton)
}
private var closeButton: AnyView {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "xmark")
.frame(height: 36)
}.eraseToAnyView()
}
private var deleteButton: AnyView {
Button(action: { print("DELETE") }) {
Image(systemName: "trash")
.frame(height: 36)
}.eraseToAnyView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I don't see any trouble, just add Dismiss button to your navigation bar. You only have to rearrange your View hierarchy and there is no need to pass any binding to your DetailView
import SwiftUI
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
struct ContentView: View {
#State var sheet = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button("Open modally") {
self.sheet = true
}
NavigationLink("Push", destination: DetailView())
}.navigationBarTitle("Home")
}
.sheet(isPresented: $sheet) {
NavigationView {
DetailView().navigationBarTitle("Title").navigationBarItems(leading: Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss")
}))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You still can dismiss it with swipe down, you can add some buttons (as part of DetailView declaration) ... etc.
When pushed, you have default back button, if shown modaly, you have dismiss
button indeed.
UPDATE (based od discussion)
.sheet(isPresented: $sheet) {
NavigationView {
GeometryReader { proxy in
DetailView().navigationBarTitle("Title")
.navigationBarItems(leading:
HStack {
Button(action: {
self.sheet.toggle()
}, label: {
Text("Dismiss").padding(.horizontal)
})
Color.clear
Button(action: {
}, label: {
Image(systemName: "trash")
.imageScale(.large)
.padding(.horizontal)
})
}.frame(width: proxy.size.width)
)
}
}
}
finally I suggest you to use
extension View {
#available(watchOS, unavailable)
public func navigationBarItems<L, T>(leading: L?, trailing: T) -> some View where L : View, T : View {
Group {
if leading != nil {
self.navigationBarItems(leading: leading!, trailing: trailing)
} else {
self.navigationBarItems(trailing: trailing)
}
}
}
}
Whenever we provide .navigationBarItems(leading: _anything_), ie anything, the standard back button has gone, so you have to provide your own back button conditionally.
The following approach works (tested with Xcode 11.2 / iOS 13.2)
.navigationBarItems(leading: Group {
if isModal {
closeButton
} else {
// custom back button here calling same dismiss
}
}, trailing: deleteButton)
Update: alternate approach might be as follows (tested in same)
var body: some View {
VStack {
if isModal {
Text("Hello")
.navigationBarItems(leading: closeButton, trailing: deleteButton)
} else {
Text("Hello")
.navigationBarItems(trailing: deleteButton)
}
}
.navigationBarTitle("Test", displayMode: .inline)
}

Resources