I have a view for a list item that displays some basic information about a task embedded within a navigationLink.
I would like to use a button within the navigationLink to toggle task.isComplete
without any navigation taking place.
This is my code so far:
var body: some View {
NavigationLink(destination: TaskDetailView()) {
HStack {
RoundedRectangle(cornerRadius: 5)
.frame(width: 15)
.foregroundColor(getColor(task: task))
VStack {
Text(task.name!)
.font(.headline)
Spacer()
}
Spacer()
Button(action: {
self.task.isComplete.toggle()
}) {
if task.isComplete == true {
Image(systemName: "checkmark.circle.fill")
} else {
Image(systemName: "circle")
}
}
.foregroundColor(getColor(task: task))
.font(.system(size: 22))
}
}
}
Currently, the button action will not be performed as whenever the button is pressed, the navigationLink takes you to the destination view. I have tried putting the button outside the navigationLink - this allows the action to take place however the navigation still takes place.
Is there a way that makes this possible?
Thanks.
To reach result as you want, you have to use something else with .onTapGesture handler instead of button on NavigationLink. Example below works for me.
Try replace:
Button(action: {
self.task.isComplete.toggle()
}) {
if task.isComplete == true {
Image(systemName: "checkmark.circle.fill")
} else {
Image(systemName: "circle")
}
}
with:
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
.onTapGesture { self.task.isComplete.toggle() }
Try this approach:
var body: some View {
NavigationLink(destination: TaskDetailView()) {
HStack {
RoundedRectangle(cornerRadius: 5)
.frame(width: 15)
.foregroundColor(getColor(task: task))
VStack {
Text(task.name!)
.font(.headline)
Spacer()
}
Spacer()
Button(action: {}) {
if task.isComplete == true {
Image(systemName: "checkmark.circle.fill")
} else {
Image(systemName: "circle")
}
}
.foregroundColor(getColor(task: task))
.font(.system(size: 22))
.onTapGesture {
self.task.isComplete.toggle()
}
}
}
}
I was running into the same issue and this question was basically the number-one hit when I searched for a solution. Thus, having found a reasonably graceful solution, this seems like a good place to leave it.
The problem appears to be that the NavigationLink
's gesture is installed such that it ignores its subviews gestures: it effectively uses .gesture(_:including:)
with a GestureMask
of .gesture
, meaning it'll take precedence over any internal gestures of similar priority.
Ultimately what you need is a button with a .highPriorityGesture()
installed to trigger its action, while maintaining the button's usual API: pass in a single action method to run when it's triggered, rather than define a plain Image and fiddle with a gesture recognizer to change states, etc. To use the standard Button
behavior and API, you'd need to go a little deeper, and define some of the button's behaviors directly. Happily, SwiftUI provides suitable hooks for this.
There are two ways to customize a button's appearance: you can implement either a ButtonStyle
or a PrimitiveButtonStyle
, which you then pass into the .buttonStyle()
modifier. Both these protocol types allow you to define a method which applies styling to a button's Label
view, whatever that's defined to be. In a regular ButtonStyle
, you receive the label view and an isPressed
boolean, allowing you to change appearance depending on the touch-down state of the button. A PrimitiveButtonStyle
offers more control at the expense of having to track touch state yourself: you receive the label view and a trigger()
callback that you can invoke to fire off the button's action.
The latter is the one we want here: we'll own the button's gesture and be able to track the touch state and determine when to fire it. Importantly for our use case, however, we're responsible for attaching that gesture to the view—meaning we can use the .highPriorityGesture()
modifier to do so and give the button a higher priority than the link itself.
For a simple button, this is actually fairly straightforward to implement. This is a simple button style which uses an internal view to manage the touch-down/pressed state, which makes the button semitransparent while touched, and which uses a high-priority gesture to implement the pressed/triggered state changes:
struct HighPriorityButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
MyButton(configuration: configuration)
}
private struct MyButton: View {
@State var pressed = false
let configuration: PrimitiveButtonStyle.Configuration
var body: some View {
let gesture = DragGesture(minimumDistance: 0)
.onChanged { _ in self.pressed = true }
.onEnded { value in
self.pressed = false
if value.translation.width < 10 && value.translation.height < 10 {
self.configuration.trigger()
}
}
return configuration.label
.opacity(self.pressed ? 0.5 : 1.0)
.highPriorityGesture(gesture)
}
}
}
The work happens inside the inner MyButton
view type. It maintains a pressed
state, defines a drag gesture (used to track down/up/ended events) which toggles that state and which calls the trigger()
method from the style configuration when the gesture ends (provided it still looks like a 'tap'). It then returns the button's provided label (i.e. the content of the label: { }
parameter of the original Button
) with two new modifiers attached: an .opacity()
of either 1
or 0.5
depending on the press state, and the defined gesture as a .highPriorityGesture()
.
This can be made a little simpler if you don't want to offer a different appearance for the touch-down state. Without the requirement of an @State
property to hold that, you can do everything inside the makeBody()
implementation:
struct StaticHighPriorityButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
let gesture = TapGesture()
.onEnded { _ in configuration.trigger() }
return configuration.label
.opacity(pressed ? 0.5 : 1.0)
.highPriorityGesture(gesture)
}
}
Finally, here's the preview I used to test the first implementation above. Run in a live preview and you'll see the button text dimming during touch-down:
struct HighPriorityButtonStyle_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
NavigationLink(destination: Text("Hello")) {
Button(action: { print("hello") }) {
Text("Button!")
.foregroundColor(.accentColor)
}
.buttonStyle(HighPriorityButtonStyle())
}
}
}
}
}
Note that there's a little more work to do if you want to cancel the gesture if the user starts dragging, etc. That's a whole other ball game. It's relatively straightforward to change the pressed
state based on the gesture value's translation
property, though, and only trigger if it ends while pressed. I'll leave that as an exercise for the reader 😉
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With