SwiftUI: NavigationLink is always activated when in a List - swift

I can't prevent SwiftUI's NavigationLink from being activated when in a List, I have this simple piece of code in which I need to do some kind of business check before deciding to show the details page or not (in a real world app, there might be some business logic happens inside the button's action):
struct ContentView: View {
#State var showDetail = false
var body: some View {
NavigationView {
List {
Text("Text 1")
Text("Text 2")
Text("Text 3")
NavigationLink(destination: DetailView(), isActive: $showDetail) {
LinkView(showDetails: $showDetail)
}
}
}
}
}
struct LinkView: View {
#Binding var showDetails: Bool
var body: some View {
Button(action: {
self.showDetails = false
}) {
Text("Open Details")
}
}
}
struct DetailView: View {
var body: some View {
Text("Detail View")
}
}
how can I prevent navigation link from opening the details page in this case ? and is this a bug in the SDK ?
p.s. XCode version: 13.3.1 and iOS version (real device): 13.3.1
Edit
I can't replace List with ScrollView because I have a ForEach list of items in my real app, so don't post an answer considering using ScrollView.

in a real world app, there might be some business logic happens inside
the button's action
seems to be a little bit alogical.You can simply conditionally disable the link (and inform the user, that the link is unavailable by visual appearance)
NavigationLink(...).disabled(onCondition)
where
func disabled(_ disabled: Bool) -> some View
Parameters
disabled
A Boolean value that determines whether users can interact with this view.
Return Value
A view that controls whether users can interact with this view.
Discussion
The higher views in a view hierarchy can override the value you set on this view. In the following example, the button isn’t interactive because the outer disabled(_:) modifier overrides the inner one:
HStack {
Button(Text("Press")) {}
.disabled(false)
}
.disabled(true)

If I correctly understood your goal, it can be as follows
List {
Text("Text 1")
Text("Text 2")
Text("Text 3")
LinkView(showDetails: $showDetail)
.background(
NavigationLink(destination: DetailView(), isActive: $showDetail) { EmptyView() })
}
and
struct LinkView: View {
#Binding var showDetails: Bool
var body: some View {
Button(action: {
self.showDetails = true // < activate by some logic
}) {
Text("Open Details")
}
}
}

If you use .disable(true) it will reduce your list item opacity like a disabled button, to prevent this. use below code style. Use Navigation Link in backGround and check your navigation condition on Tap Gesture of your view.
VStack{
List(0..<yourListArray.count, id: \.self) { index in
{
Text("\(yourListArr[index].firstName)")
}().onTapGesture{
let jobType = getFlags(jobsArr: yourListArray, index:index)
if jobType.isCancelledFlag == true{
self.shouldNavigate = false
}else{
self.shouldNavigate = true
}
}//Tap Gesture End
.background(NavigationLink(destination: YourDestinationView(),isActive: self.$shouldNavigate) {
}.hidden())}}//vStack

Related

Interacting with a confirmationDialog or alert is causing the parent view to pop

When you navigate and open the confirmation dialog. When you select Yes, No or Cancel the page that the app was on is dismissed and it takes you back to the form on the previous page.
We also found this happens with alerts too.
It's a simple enough app structure, top level tabs then a menu which links to sub pages.
Here is a quick demo of the bug:
We put together an example app that demonstrates this.
How can we prevent this from happening while also maintaining the app structure?
import SwiftUI
#main
struct testApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
TabView() {
Form {
NavigationLink(destination: SubPage()) {
Image(systemName: "clock")
Text("Sub Page")
}
// This is where more menu options would be
}
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
struct SubPage: View {
#State private var confirmDialogVisible = false
var body: some View {
VStack {
Button{
confirmDialogVisible = true
} label: {
Text("popup")
}
}
.confirmationDialog("Confirm?", isPresented: $confirmDialogVisible) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
}
We are using XCode 14.1
And running on iOS 16.1
I usually use ViewModifer to keep consistency between tabs. One modifier for the root of the tab and one for the children.
///Modifier that uses `ToolbarViewModifier` and includes a `NavigationView`
struct NavigationViewModifier: ViewModifier{
func body(content: Content) -> some View {
NavigationView{
content
.modifier(ToolbarViewModifier())
}
}
}
///`toolbar` that can be used by the root view of the navigation
///and the children of the navigation
struct ToolbarViewModifier: ViewModifier{
let title: String = "Company Name"
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .principal) {
VStack{
Image(systemName: "sparkles")
Text(title)
}
}
}
}
}
Then the Views use it something like this.
import SwiftUI
struct CustomTabView: View {
var body: some View {
TabView{
ForEach(0..<4){ n in
CustomChildView(title: n.description)
//Each tab gets a `NavigationView` and the shared toolbar
.modifier(NavigationViewModifier())
.tabItem {
Text(n.description)
}
}
}
}
}
struct CustomChildView: View {
let title: String
#State private var showConfirmation: Bool = false
var body: some View {
VStack{
Text(title)
NavigationLink {
CustomChildView(title: "\(title) :: \(UUID().uuidString)")
} label: {
Text("open child")
}
Button("show confirmation") {
showConfirmation.toggle()
}
.confirmationDialog("Confirm?", isPresented: $showConfirmation) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
//Each child uses the shared toolbar
.modifier(ToolbarViewModifier())
}
}
I stuck with NavigationView since that is what you have in your code but
if we take into consideration the new NavigationStack the possibilities of these two modifiers become exponentially better.
You can include custom back buttons that only appear if the path is not empty, return to the root from anywhere, etc.
Apple says that
Tab bars use bar items to navigate between mutually exclusive panes of content in the same view
Make sure the tab bar is visible when people navigate to different areas in your app
https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars/
Having the NavigationView or NavigationStack on top goes against these guidelines and therefore are the source of endless bugs, Especially when you take into consideration iPadOS.
Simple solution would be to use navigationDestination.
struct testApp: App {
#State var goToSubPage = false
var body: some Scene {
WindowGroup {
NavigationStack {
TabView() {
Form {
VStack {
Image(systemName: "clock")
Text("Sub Page")
}
.onTapGesture {
goToSubPage = true
}
// This is where more menu options would be
}
.navigationDestination(isPresented: $goToSubPage, destination: {
SubPage()
})
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
I tested it and it won't popped off itself anymore.

How do I prevent the user from switching to certain tabs in a SwiftUI TabView

I am creating sort of a game in swift that gives the user a new clue every time they guess incorrectly. I would like to have a TabView for the clues, but I don’t want the player to have access to every clue at the start. I was thinking either locking the tabs in the TabView until a function is called or making an array of different views in the TabView that can be edited.
One note before starting my reply: in this community you need to be clear about the issue and show examples of your code, error messages, points where you get stuck. Your answer is quite generic, but I will give you some hints.
Each Tab is a view (almost) like any other one, it can be hidden conditionally.
You can just hide the tabs until the user reaches the point in the game where the clues must be shown. In addition, or in alternative, you can switch to different views to see each clue.
See an example below (there's room for improvement, this is the idea):
struct Tabs: View {
#State private var showTab2 = false // This will trigger the tab to be shown
// It can be stored in your view model
var body: some View {
TabView {
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 1", systemImage: "checkmark")}
if showTab2 { // Tab 2 is hidden until you change the state variable
Tab2()
.tabItem {Label("Tab 2", systemImage: "flag")}
}
Example(showNextClue: $showTab2)
.tabItem {Label("Tab 3", systemImage: "trash")}
}
}
}
struct Tab2: View {
#State private var clueIndex = 0 // What clue will you show now?
var body: some View {
VStack {
switch clueIndex { // Show the applicable clue
// You can iterate through an array of views as you proposed, chose the best method
case 0:
Text("Now you can see this clue number 1")
case 1:
Text("Clue number 2")
default:
Text("Clue \(String(clueIndex))")
}
Button {
clueIndex += 1
} label: {
Text("Show next clue")
}
.padding()
}
}
}
struct Example: View {
#Binding var showNextClue: Bool
var body: some View {
VStack {
Text("This is open")
.padding()
Button {
showNextClue.toggle()
} label: {
Text(showNextClue ? "Now you can see the next clue" : "Click here to see your next clue")
}
}
}
}
Unfortunately you cannot disable the standard TabBar selectors. But you can do your own custom ones, e.g. like this:
struct ContentView: View {
#State private var currentTab = "Home"
#State private var activeTabs: Set<String> = ["Home"] // saves the activated tabs
var body: some View {
VStack {
TabView(selection: $currentTab) {
// Tab Home
VStack(spacing: 20) {
Text("Home Tab")
Button("Unlock Hint 1") { activeTabs.insert("Hint 1")}
Button("Unlock Hint 2") { activeTabs.insert("Hint 2")}
}
.tag("Home")
// Tab 1. Hint
VStack {
Text("Your first Hint")
}
.tag("Hint 1")
// Tab 2. Hint
VStack {
Text("Your Second Hint")
}
.tag("Hint 2")
}
// custom Tabbar buttons
Divider()
HStack {
OwnTabBarButton("Home", imageName: "house.fill")
OwnTabBarButton("Hint 1", imageName: "1.circle")
OwnTabBarButton("Hint 2", imageName: "2.circle")
}
}
}
func OwnTabBarButton(_ label: String, imageName: String) -> some View {
Button {
currentTab = label
} label: {
VStack {
Image(systemName: imageName)
Text(label)
}
}
.disabled(!activeTabs.contains(label))
.padding([.horizontal,.top])
}
}

SwiftUI List disclosure indicator without NavigationLink

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.
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.
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)
}
}
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.
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.
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
}
}
}
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)
}
}
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")
}
I created a custom NavigationLink that:
Adds an action API (instead of having to push a View)
Shows the disclosure indicator
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://stackoverflow.com/a/66891173/826435
private extension NavigationLink where Label == EmptyView, Destination == EmptyView {
static var empty: NavigationLink {
self.init(destination: EmptyView(), label: { EmptyView() })
}
}

SwiftUI: Dismiss View Within macOS NavigationView

As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Here’s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Here’s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.

NavigationLink isActive does not work inside navigationBarItems(trailing:) modifier

I am using the newest versions of Xcode (11 Beta 16) and macOS (10.15 Beta 6)
I am trying to create two views. From the first one view you should be able to navigate to the second one view via the trailing navigation bar item and to navigate back you should be able to use the system generated back button (which works) and additionally a trailing navigation bar button (which has some additional functionality like saving the data but this is not important for my problem).
Option 1 does work but if you comment out option 1 and uncomment option 2 (my wanted layout) the done button just does not navigate back.
struct ContentView1: View {
#State var show = false
var body: some View {
NavigationView {
Form {
Text("View 1")
// Option 1 that does work
NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
Text("Move")
}
}
.navigationBarTitle(Text("Title"))
// Option 2 that does NOT work
// .navigationBarItems(trailing: NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
// Text("Move")
// })
}
}
}
struct ContentView2: View {
#Binding var show: Bool
var body: some View {
Form {
Text("View 2")
Text(show.description)
}
.navigationBarItems(trailing: Button(action: {
self.show = false
}, label: {
Text("Done")
}))
}
}
Any suggestions how to fix that?
Option 2 plays nicely with presentationMode:
struct ContentView2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form {
Text("View 2")
}
.navigationBarItems(trailing: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Done")
}))
}
}