SwiftUI - How to make a class have a menu? - swift

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

Related

How to hide the navigation button when using NavigationSplitView?

Now that the NavigationView was deprecated, I try to use the NavigationSplitView. But I can't hide the navigation toggle button, and also can't custom the title bar (I want to keep title and add filter button).
My app screenshot
I just want to realize like the Mail.app
Mail.app screenshot
Some other app screenshot
code snippet as bellow:
// ...
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
DirectoryList(selection: $selectionDir)
} content: {
PaperList(selection: $selectionPaper)
.navigationTitle(Text("Papers"))
.toolbar {
HStack {
Button {
// something todo
} label: {
Label("Experience", systemImage: "wand.and.stars")
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
} detail: {
Editor(selectionPaper?.name ?? "")
}
}
// ...

Color in ZStack not behaving properly

so I'd like to lay a Color at the top of a ZStack over another view. The reason I don't want to use overlay is the Color is going to have a tap gesture attached to it. A minimal reproducible example is below. Essentially, I want the Color.secondary to be confined to the same area as the HStack (without explicitly setting frame size. Here's the code:
struct ContentView: View {
var body: some View {
ZStack {
HStack {
Spacer()
Button(action: {
print("tapped button")
}, label: {
Text("button")
})
}.background(Color.red)
Color.secondary
.onTapGesture {
print("clicked color")
}
}
}
}
So I'd like the view to just be a white screen, with an HStack that looks slightly darker red.
Below is a picture of my UI. The view is greyed out during onboarding, and the user will essentially just tap the grey area to go to the next step in the onboarding. If I attach a tap gesture to the Color view and then just hide the color view according to state changes, this isn't a problem. After the onboarding is completed, the greyed area won't be there and the buttons underneath need to be interactable. When using overlays, AFTER onboarding, I don't want tapping anywhere on the view to change the app state.
https://i.stack.imgur.com/lphHg.png
Given your further description, I believe you have the onTapGesture(perform:) in the wrong place, outside of the overlay rather than inside.
In the code below, the onTapGesture(perform:) can only be tapped on the gray overlay when it is showing. However when it is attached after the overlay, it can be tapped on when not tapping on something like the button.
Working code:
struct ContentView: View {
#AppStorage("is_onboarding") var isOnboarding = true
var body: some View {
HStack {
Spacer()
Button {
print("tapped button")
} label: {
Text("button")
}
}
.background(Color.red)
.overlay {
if isOnboarding {
Color.secondary
.onTapGesture {
print("clicked color")
isOnboarding = false
}
}
}
}
}
If on iOS 14+ not iOS 15+, use the following overlay instead:
.overlay(
isOnboarding ?
Color.secondary
.onTapGesture {
print("clicked color")
isOnboarding = false
}
: nil
)

SwiftUI Button cannot be clicked on its whole area

I built a custom Button.
My problem is, that it can be only clicked on the Text Elements not on the whole area of the Button.
What's going wrong, how can I solve this?
struct MyButton: View {
var body: some View
{ Button( action:{ print("pressed") })
{ HStack {
VStack(alignment: .leading){
Text("Tomorrow").font(.caption)
Text("23.5.22 KW 23")
}
Spacer()
}
}
.padding([.horizontal],4.0)
.padding([.vertical],2.0)
.background(RoundedRectangle(cornerRadius: 5).fill(Color.red))
.buttonStyle(PlainButtonStyle())
.padding([.horizontal],8.0)
}
}
By default, only the parts of the view that actually render something are tappable. You need to add a .contentShape(Rectangle()) modifier to your Button to make the entire area interactive.

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

How to apply a context menu to buttons in a SwiftUI list row?

When I'm long-pressing on a button in list row, all of context menus for all buttons are shown. It looks like the whole list row is selected. How can I make it so that the context menu is shown only for the pressed button?
I've seen this question which is somewhat related, and tried BorderlessButtonStyle(). It enables the indiviual buttons in the row to be clickable, but it doesn't solve the context menu problem. I've also tried using .onTapGesture() instead of Button(), but that didn't work either.
In the example below I'm expecting to see only Action for button 1 when long-pressing on Button 1 - but Action for button 2 is also shown.
struct ContentView: View {
var body: some View {
List {
ForEach((1..<3), content: { _ in
ListButton()
})
}
}
}
struct ListButton: View {
var body: some View {
HStack {
Spacer()
Button("Button 1") { }
.buttonStyle(BorderlessButtonStyle())
.contextMenu(menuItems: {
Text("Action for button 1")
})
Spacer()
Button("Button 2") { }
.buttonStyle(BorderlessButtonStyle())
.contextMenu(menuItems: {
Text("Action for button 2")
})
Spacer()
}
}
}
You could use Menu instead of ContextMenu and use the label of the menu to display your button; as alternative solution.
However, to give the buttons gesture more priority than the row itself, you can add .highPriorityGesture(TapGesture()) to the end of you buttons.
You can achieve what you want by using Menu instead of ContextMenu like this:
struct ListButton: View {
var body: some View {
HStack {
Spacer()
Button("Button 1") { }
.buttonStyle(BorderlessButtonStyle())
.contextMenu(menuItems: {
Text("Action for button 1")
})
Spacer()
// Here I used menu instead of the button 2
Menu {
Text("Action for button 2")
} label: {
Button("Button 2") { }
.buttonStyle(BorderlessButtonStyle())
}.highPriorityGesture(TapGesture())
Spacer()
}
}
}
Just switch to ScrollView that fixes your problem!