SwiftUI List disclosure indicator without NavigationLink - swift

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() })
}
}

Related

Some elements stop responding to tap gesture when tap gesture is added to superview

In theory SwiftUI should give the child’s view gesture higher priority compared to parent's view gesture. And it is really so for most situations. But I encountered some situations where child's gestures stop work as expected.
For example Picker of .pickerStyle(.segmented) or a Button inside a Form.
How can we make those elements handle taps again?
import SwiftUI
struct ContentView: View {
#State private var message = "Message"
#State private var kind = Kind.item
#State private var isToggleOn = false
let newGesture = TapGesture().onEnded {
print("Tap on VStack.")
}
var body: some View {
VStack(spacing:25) {
// Elements responding to tap
Image(systemName: "heart.fill")
.resizable()
.frame(width: 75, height: 75)
.padding()
.foregroundColor(.red)
.onTapGesture {
print("Tap on image.")
}
Rectangle()
.fill(Color.blue)
Button("Button 1") {
print("Button 1 tapped")
}
Toggle("Toggle", isOn: $isToggleOn)
Picker("Kind", selection: $kind) {
ForEach(Kind.allCases) { kind in
Text(kind.description).tag(kind)
}
}
// Elements below stops responding to tap
Picker("Kind", selection: $kind) {
ForEach(Kind.allCases) { kind in
Text(kind.description).tag(kind)
}
}
.pickerStyle(.segmented)
Form {
Section {
Button("Button 2") {
print("Button 2 tapped")
}
} header: {
Text("Header")
}
}
}
.simultaneousGesture(newGesture)
.border(Color.purple, width: 3)
}
}
enum Kind: String, CaseIterable, CustomStringConvertible, Codable, Identifiable {
case item, service
var description: String {
switch self {
case .item:
return NSLocalizedString("Item", comment: "Item Kind in ItemModel")
case .service:
return NSLocalizedString("Service", comment: "Item Kind in ItemModel")
}
}
var id: Self { self }
}
This happens because some components change they behaviour according the way you are using.
For example in the case of the button inside the form. As you see in your first Button, if this component is outside of a Form {} the action of it may and should be in the action: handler. However, if you want or need it to be inside the Form, you will have to add a .onTapGesture {}.
If in your code you ignore the action handler and add a tapGesture instead, it will work
Form {
Section {
Button("Button 2") {
}.onTapGesture {
print("Button 2 tapped")
}
} header: {
Text("Header")
}
}
For the picker I can't tell a 100% but I think is just incompatible and the simultaneous create an interference. That might be as the Picker changing is not a tapGesture itself, but an intrinsec functionality of the component.
There is a way to force Button execute its action in this case.
Just add a .buttonStyle(.plain) or .buttonStyle(.bordered) to it. Maybe other styles will do. This way Button gets priority to handle taps.

SwiftUI - How to make a class have a menu?

I have a button (chevron) that shows at the side of a search bar like this:
This button has a lot of customizations, so I decided to give it its own class, to reduce code pollution and make the code neat.
This is the class's body...
var body: some View {
Button(action: {
onTap()
}) {
Image(systemName: "chevron.right.square").renderingMode(.original)
.renderingMode(.template)
.foregroundColor(color)
}
.font(fontSymbol)
Spacer().frame(width: 10)
}
The Image inside a Button is just me trying other options. I have created this as just the image without the button too.
The problem is this:
I will create an instance of this class on the main view.
I want that to display a menu when it is tapped.
This is how I am using it on the main class
MenuButton()
.contextMenu {
Button(action: {
}) {
Text("option 1")
}
Button(action: {
}) {
Text("option 2")
}
Button(action: {
}) {
Text("option 3")
}
}
This is the result...
The box is shown already expanded in height.
I was expecting a popover to appear only after tapping on the chevron and that popover to be pointed to the chevron or something like that.
Remove the spacer to locate button where it should be
.font(fontSymbol)
//Spacer().frame(width: 10) // << this one
and context menu works by long press as expected

Disable Scrolling in SwiftUI List/Form

Lately, I have been working on creating a complex view that allows me to use a Picker below a Form. In every case, the Form will only have two options, thus not enough data to scroll downwards for more data. Being able to scroll this form but not Picker below makes the view feel bad. I can't place the picker inside of the form or else SwiftUI changes the styling on the Picker. And I can't find anywhere whether it is possible to disable scrolling on a List/Form without using:
.disable(condition)
Is there any way to disable scrolling on a List or Form without using the above statement?
Here is my code for reference
VStack{
Form {
Section{
Toggle(isOn: $uNotifs.notificationsEnabled) {
Text("Notifications")
}
}
if(uNotifs.notificationsEnabled){
Section {
Toggle(isOn: $uNotifs.smartNotifications) {
Text("Enable Smart Notifications")
}
}.animation(.easeInOut)
}
} // End Form
.listStyle(GroupedListStyle())
.environment(\.horizontalSizeClass, .regular)
if(!uNotifs.smartNotifications){
GeometryReader{geometry in
HStack{
Picker("",selection: self.$hours){
ForEach(0..<24){
Text("\($0)").tag($0)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width:geometry.size.width / CGFloat(5))
.clipped()
Text("hours")
Picker("",selection: self.$min){
ForEach(0..<61){
Text("\($0)").tag($0)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width:geometry.size.width / CGFloat(5))
.clipped()
Text("min")
}
Here it is
Using approach from my post SwiftUI: How to scroll List programmatically [solution]?, it is possible to add the following extension
extension ListScrollingProxy {
func disableScrolling(_ flag: Bool) {
scrollView?.isScrollEnabled = !flag
}
}
and the use it as in example for above demo
struct DemoDisablingScrolling: View {
private let scrollingProxy = ListScrollingProxy()
#State var scrollingDisabled = false
var body: some View {
VStack {
Button("Scrolling \(scrollingDisabled ? "Off" : "On")") {
self.scrollingDisabled.toggle()
self.scrollingProxy.disableScrolling(self.scrollingDisabled)
}
Divider()
List(0..<50, id: \.self) { i in
Text("Item \(i)")
.background(ListScrollingHelper(proxy: self.scrollingProxy))
}
}
}
}
You can use the .scrollDisabled(true) modifier on the component (Form or List) to accomplish this behavior.

SwiftUI: NavigationLink is always activated when in a List

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

SwiftUI: How to remove caret right in NavigationLink which is inside a List

I have a issue about NavigationLink in SwiftUI. I have a List restaurant and have NavigationLink in it. I tried to remove the caret right in right of NavigationLink section but not success
I tried to remove caret using buttonStyle but is's not work.
List(vm.restaurants) { (restaurant: Restaurant) in
NavigationLink(destination: ResDetailView(restaurant: restaurant)) {
RestaurantRow(life: life)
}.buttonStyle(PlainButtonStyle())
}
Chris' answer works, but EmptyView has a height which adds empty space to the bottom of the cell. Instead, you can use ZStack to make the navigation link on top of the cell.
List {
ForEach(0..<items.count) { i in
ZStack {
ContactRow(item: self.items[i])
NavigationLink(destination: ChatView()) {
EmptyView()
}
}
}
}
you can do it like this:
var body: some View {
NavigationView() {
List(menu, id: \.self) { section in
VStack{
Text(section.name)
NavigationLink(destination: Dest()) {
EmptyView()
}
}
}
}
The easiest way to get rid of the disclosure indicator is to set the padding on the NaviagtionLink:
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Destination(item: item)) {
CustomCell(item: item)
} .padding([.trailing], -30.0)
}
}
}
I wouldn't recommend it though - it's a way of showing your users that there is more data available if the tap on one of the cells.