Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Button inside a NavigationLink

Tags:

swiftui

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.

like image 846
Dylan Rowe Avatar asked Dec 18 '19 23:12

Dylan Rowe


3 Answers

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() }
like image 186
Alex Motor Avatar answered Nov 02 '22 23:11

Alex Motor


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()
                }
            }
        }
    }
like image 2
Asperi Avatar answered Nov 02 '22 21:11

Asperi


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 😉

like image 11
Jim Dovey Avatar answered Nov 02 '22 23:11

Jim Dovey