Can anyone think how to call an action when double clicking a NavigationLink in a List in MacOS? I've tried adding onTapGesture(count:2) but it does not have the desired effect and overrides the ability of the link to be selected reliably.
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item)) {
ItemRow(itemRow: item) //<-my row view
}.buttonStyle(PlainButtonStyle())
.simultaneousGesture(TapGesture(count:2)
.onEnded {
print("double tap")
})
}
}
}
}
EDIT:
I've set up a tag/selection in the NavigationLink and can now double or single click the content of the row. The only trouble is, although the itemDetail view is shown, the "active" state with the accent does not appear on the link. Is there a way to either set the active state (highlighted state) or extend the NavigationLink functionality to accept double tap as well as a single?
#State var selection:String?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink(destination: Item(itemDetail: item), tag: item.id, selection: self.$selection) {
ItemRow(itemRow: item) //<-my row view
}.onTapGesture(count:2) { //<- Needed to be first!
print("doubletap")
}.onTapGesture(count:1) {
self.selection = item.id
}
}
}
}
}
Here's another solution that seems to work the best for me. It's a modifier that adds an NSView which does the actual handling. Works in List even with selection:
extension View {
/// Adds a double click handler this view (macOS only)
///
/// Example
/// ```
/// Text("Hello")
/// .onDoubleClick { print("Double click detected") }
/// ```
/// - Parameters:
/// - handler: Block invoked when a double click is detected
func onDoubleClick(handler: #escaping () -> Void) -> some View {
modifier(DoubleClickHandler(handler: handler))
}
}
struct DoubleClickHandler: ViewModifier {
let handler: () -> Void
func body(content: Content) -> some View {
content.background {
DoubleClickListeningViewRepresentable(handler: handler)
}
}
}
struct DoubleClickListeningViewRepresentable: NSViewRepresentable {
let handler: () -> Void
func makeNSView(context: Context) -> DoubleClickListeningView {
DoubleClickListeningView(handler: handler)
}
func updateNSView(_ nsView: DoubleClickListeningView, context: Context) {}
}
class DoubleClickListeningView: NSView {
let handler: () -> Void
init(handler: #escaping () -> Void) {
self.handler = handler
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if event.clickCount == 2 {
handler()
}
}
}
https://gist.github.com/joelekstrom/91dad79ebdba409556dce663d28e8297
I've tried all these solutions but the main issue is using gesture or simultaneousGesture overrides the default single tap gesture on the List view which selects the item in the list. As such, here's a simple method I thought of to retain the default single tap gesture (select row) and handle a double tap separately.
struct ContentView: View {
#State private var monitor: Any? = nil
#State private var hovering = false
#State private var selection = Set<String>()
let fruits = ["apple", "banana", "plum", "grape"]
var body: some View {
List(fruits, id: \.self, selection: $selection) { fruit in
VStack {
Text(fruit)
.frame(maxWidth: .infinity, alignment: .leading)
.clipShape(Rectangle()) // Allows the hitbox to be the entire word not the if you perfectly press the text
}
.onHover {
hovering = $0
}
}
.onAppear {
monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) {
if $0.clickCount == 2 && hovering { // Checks if mouse is actually hovering over the button or element
print("Double Tap!") // Run action
}
return $0
}
}
.onDisappear {
if let monitor = monitor {
NSEvent.removeMonitor(monitor)
}
}
}
}
This works if you just need to single tap to select and item, but only do something if the user double taps. If you want to handle a single tap and a double tap, there still remains the problem of single tap running when its a double tap. A potential work around would be to capture and delay the single tap action by a few hundred ms and cancel it if it was a double tap action
Use simultaneous gesture, like below (tested with Xcode 11.4 / macOS 10.15.5)
NavigationLink(destination: Text("View One")) {
Text("ONE")
}
.buttonStyle(PlainButtonStyle()) // << required !!
.simultaneousGesture(TapGesture(count: 2)
.onEnded { print(">> double tap")})
or .highPriorityGesture(... if you need double-tap has higher priority
Looking for a similar solution I tried #asperi answer, but had the same issue with tappable areas as the original poster. After trying many variations the following is working for me:
#State var selection: String?
...
NavigationLink(destination: HistoryListView(branch: string), tag: string, selection: self.$selection) {
Text(string)
.gesture(
TapGesture(count:1)
.onEnded({
print("Tap Single")
selection = string
})
)
.highPriorityGesture(
TapGesture(count:2)
.onEnded({
print("Tap Double")
})
)
}
Related
I used the .onTapGesture in TabView but it is not working. It also affecting the tab bar functionality.
TabView{
}
.onTapGesture {
<#code#>
}
Instead of using onTapGesture on tabView we can write an extension to Binding and it will detect the new tab selection value even if we tap the tab bar within the same tab it will detect the changes. Here I am provided the binding extension.
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}}
I used this in my tabView. I attached my code below.
TabView(selection: $tabSelection.onUpdate {
setNewValue(value: tabSelection)
}) {
ContentView()
.tabItem {
Label {
Text("Home")
} icon: {
Image("HomePage_icon")
.renderingMode(.template)
}
}
.tag(TabViews.homepage)
}
SetNewValue function, this function acts like onTapGesture
func setNewValue(value: TabViews){
self.tabSelection = value
*/ inside this function we can write the code, we like to write it in onTapGesture */
}
List {
ItemView(item: item)
.myCustomTapHandler {
print("ItemView was tapped, triggered from List!")
}
}
}
struct ItemView: View {
var body: some View {
VStack {
Button(action: {
// this should fire myCustomTapHandler
}) {
Text("Hello world")
}
}
}
I have a custom ItemView with a simple button. I want to re create the same trailing closure syntax as .onTapGesture, with only triggering when you tap the Button. This will be named .myCustomTapHandler How to do this in SwiftUI?
What you are describing (The dot) is a ViewModifier
struct MyItemListView: View {
var body: some View {
List {
//Using a ViewModifier you make the whole View tappable not just the button
MyItemView(item: 2)
.myCustomTapHandler{
print("ItemView has custom modifier that makes the whole ItemView tappable")
}
}
}
}
struct MyCustomTapHandler: ViewModifier {
var myCustomTapHandler: () -> Void
func body(content: Content) -> some View {
content
//Add the onTap to the whole View
.onTapGesture {
myCustomTapHandler()
}
}
}
extension View {
func myCustomTapHandler(myCustomTapHandler: #escaping () -> Void) -> some View {
modifier(MyCustomTapHandler(myCustomTapHandler: myCustomTapHandler))
}
}
The ViewModifier affects the entire View not just the Button.
But, unless you are doing something else it is just an onTapGesture.
This is likely not the best solution because with the Button in the ItemView you will have inconsistent results.
Sometimes the Button will get the tap and sometimes the ViewModifier will get the tap and given that the View is in a List it will likely make the whole tapping confusing because the List has properties that make the whole row tappable anyway vs just the Text of the `Button
If you want the Button to perform an action that is defined in the ListView you can pass it as a parameter.
This will likely give you the best results.
struct MyItemListView: View {
var body: some View {
VStack {
//This passes the custom action to the button
MyItemView(item: 1){
print("Button needs to be tapped to trigger this")
}
}
}
}
struct MyItemView: View {
let item: Int
var myCustomTapHandler: () -> Void
var body: some View {
VStack {
Button(action: {
myCustomTapHandler()
}) {
Text("Hello world")
}
}
}
}
You can add like this.
struct ItemView: View {
private var action: (() -> Void)? = nil
var body: some View {
VStack {
Button(action: {
action?()
}) {
Text("Hello world")
}
}
}
func myCustomTapHandler(onAction: #escaping () -> Void) -> Self {
var view = self
view.action = onAction
return view
}
}
Usage
struct ContentView: View {
var body: some View {
ItemView()
.myCustomTapHandler {
print("Hello word")
}
}
}
Another way is...
I don't think with dot property you will get action. You need closure inside the ItemView.
Like this
struct ItemView: View {
var action: () -> Void
var body: some View {
VStack {
Button(action: action) {
Text("Hello world")
}
}
}
}
Usage
List {
ItemView {
// Here you will get action
print("ItemView was tapped, triggered from List!")
}
}
I'm developing a simple MIDI keyboard. Each piano key is a button. As soon you press it, it sends a "MIDI note ON" signal to a virtual device:
Button(action: {
MidiDevice.playNote("C")
}) {
Image(systemName: "piano-white-key")
}
It works fine. The latency is good and the user can play the key for just a fraction of a second or hold the button for longer notes. Now, how do I intercept the "user has lifted her finger" action in order to immediately send the MidiDevice.stopNote("C") event?
Here is possible solution (as far as I understood your goal) - to use ButtonStyle to detect isPressed state. Standard Button sends actions of tap UP, so we just add action handler for tap DOWN.
Tested with Xcode 12.4 / iOS 14.4
struct ButtonPressHandler: ButtonStyle {
var action: () -> ()
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.foregroundColor(configuration.isPressed ?
Color.blue.opacity(0.7) : Color.blue) // just to look like system
.onChange(of: configuration.isPressed) {
if $0 {
action()
}
}
}
}
struct TestButtonPress: View {
var body: some View {
Button(action: {
print(">> tap up")
}) {
Image(systemName: "piano-white-key")
}
.buttonStyle(ButtonPressHandler {
print("<< tap down")
})
}
}
In addition to Asperi's answer, you can create an extension which will make it more SwiftUI-style:
extension Button {
func onTapEnded(_ action: #escaping () -> Void) -> some View {
buttonStyle(ButtonPressHandler(action: action))
}
}
struct ContentView: View {
var body: some View {
Button(action: {
print(">> tap up")
}) {
Image(systemName: "piano-white-key")
}
.onTapEnded {
print("<< tap down")
}
}
}
if I set a custom Back Button (which everyone wants, hiding the ugly text ;-) ) and using .navigationBarBackButtonHidden, the standard Swipe Back gesture on the navigation controller does not work. Is there a way to get this back and having a custom back button?
For Example:
NavigationView {
NavigationLink(destination: DummyViewer())
{
Text("Go to next view"
}
}
struct DummyViewer: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
Text("Hello, World!").navigationBarBackButtonHidden(true)
.navigationBarItems(leading:
Button(action: { self.presentationMode.wrappedValue.dismiss()}) {
Text("Custom go back")
}
)
}
}
If I do so, I cannot swipe back to the previous view, seems the gesture is then disabled... How to get it back?
BR
Steffen
Nothing I found about creating a custom NavigationView worked but I found that by extending UINavigationController I was able to have a custom back button and the swipe back gesture.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
I would like to integrate the answer given by Nick Bellucci to make the code also works in other circumstances, e.g. when the child view of the NavigationView is a ScrollView, or a View that is listening for Drag gestures.
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
// To make it works also with ScrollView
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}
I've just created a hack which will not animate view but it works
extension View {
func onBackSwipe(perform action: #escaping () -> Void) -> some View {
gesture(
DragGesture()
.onEnded({ value in
if value.startLocation.x < 50 && value.translation.width > 80 {
action()
}
})
)
}
}
Usage
struct TestView: View {
#Environment (\.presentationMode) var mode
var body: some View {
VStack {
Color.red
}
.onBackSwipe {
mode.wrappedValue.dismiss()
}
}
}
You can set the title to an empty string. So back bar button title will be empty:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: Text("Here you are")) {
Text("Next").navigationBarTitle("")
}
}
}
}
You can set the title onAppear or onDisappear if you need to.
If it's still actual, here I answered, how to set custom back button and save swipe back gesture.
When creating a List view onAppear triggers for elements in that list the way you would expect: As soon as you scroll to that element the onAppear triggers. However, I'm trying to implement a horizontal list like this
ScrollView(.horizontal) {
HStack(spacing: mySpacing) {
ForEach(items) { item in
MyView(item: item)
.onAppear { \\do something }
}
}
}
Using this method the onAppear triggers for all items at once, that is to say: immediately, but I want the same behavior as for a List view. How would I go about doing this? Is there a manual way to trigger onAppear, or control when views load?
Why I want to achieve this: I have made a custom Image view that loads an image from an URL only when it appears (and substitutes a placeholder in the mean time), this works fine for a List view, but I'd like it to also work for my horizontal 'list'.
As per SwiftUI 2.0 (XCode 12 beta 1) this is finally natively solved:
In a LazyHStack (or any other grid or stack with the Lazy prefix) elements will only initialise (and therefore trigger onAppear) when they appear on screen.
Here is possible approach how to do this (tested/worked with Xcode 11.2 / iOS 13.2)
Demo: (just show dynamically first & last visible cell in scrollview)
A couple of important View extensions
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
extension View {
func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: #escaping (CGRect) -> Void) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let frame = geometry.frame(in: space)
if frame.intersects(rect) {
execute(frame)
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
And a demo view of how to use them with cell views being in scroll view
struct TestScrollViewOnVisible: View {
#State private var firstVisible: Int = 0
#State private var lastVisible: Int = 0
#State private var visibleRect: CGRect = .zero
var body: some View {
VStack {
HStack {
Text("<< \(firstVisible)")
Spacer()
Text("\(lastVisible) >> ")
}
Divider()
band()
}
}
func band() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<50) { i in
self.cell(for: i)
.ifVisible(in: self.visibleRect, in: .named("my")) { rect in
print(">> become visible [\(i)]")
// do anything needed with visible rects, below is simple example
// (w/o taking into account spacing)
if rect.minX <= self.visibleRect.minX && self.firstVisible != i {
DispatchQueue.main.async {
self.firstVisible = i
}
} else
if rect.maxX >= self.visibleRect.maxX && self.lastVisible != i {
DispatchQueue.main.async {
self.lastVisible = i
}
}
}
}
}
}
.coordinateSpace(name: "my")
.rectReader(self.$visibleRect, in: .named("my"))
}
func cell(for idx: Int) -> some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.yellow)
.frame(width: 80, height: 60)
.overlay(Text("\(idx)"))
}
}
I believe what you want to achieve can be done with LazyHStack.
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { value in
Text("Row \(value)")
.onAppear {
// Write your code for onAppear here.
}
}
}
}