Detect "On Tap End" on Button - swiftui

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

Related

onTapGesture is not working in TabView, how to make it work?

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 */
}

How to add conditional ToolbarItems to NavigationView Toolbar with SwiftUI?

I am trying to add an icon button to the leading edge of a NavigationView's toolbar - but I want that button to only be visible when the device is in landscape mode. (Kinda like how the default Notes app works)
I have the following code:
var body: some View {
VStack {
// ... content display
}
.toolbar {
if UIDevice.current.orientation.isLandscape {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: toggleFullscreen) {
if isFullscreen {
Image(systemName: "arrow.down.right.and.arrow.up.left")
} else {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: createNewNote) {
Image(systemName: "square.and.pencil")
}
}
})
}
If I remove the conditional if clause within the toolbar content, it works fine, but it seems to not recognize the if statement at all?
However, the if statement in the VStack works fine.
The specific error I get is:
Closure containing control flow statement cannot be used with result builder 'ToolbarContentBuilder'
Any idea how to fix this?
P.S. Consider me a complete SwiftUI noob. Although, I am proficient in React - so any React analogies to help me understand would be much appreciated.
You're detecting the device orientation in a wrong way SwiftUI One way you can achieve that is by using a custom modifier to detect device rotation:
Cfr: https://www.hackingwithswift.com/quick-start/swiftui/how-to-detect-device-rotation
The code would look like this:
1. Create custom modifier: Detecting Device Rotation
// Our custom view modifier to track rotation and call our action
struct DeviceRotationViewModifier: ViewModifier {
let action: (UIDeviceOrientation) -> Void
func body(content: Content) -> some View {
content
.onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
action(UIDevice.current.orientation)
}
}
}
// A View wrapper to make the modifier easier to use
extension View {
func onRotate(perform action: #escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action))
}
}
2. Use the created modifier:
struct ContentView: View {
#State private var isFullscreen = false
#State private var orientation = UIDeviceOrientation.unknown
var body: some View {
NavigationView {
VStack {
// ... content display
}
.onRotate { orientation = $0 }
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if orientation.isLandscape {
Button(action: {}) {
if isFullscreen {
Image(systemName: "arrow.down.right.and.arrow.up.left")
} else {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {}) {
Image(systemName: "square.and.pencil")
}
}
}
}
}
}

SwiftUI NavigationLink double click on List MacOS

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

How do I present a SwiftUI context menu conditionally?

Consider the following view code:
Text("Something")
.contextMenu {
// Some menu options
}
This works fine. What I would like to do: present the contextMenu through a view modifier indirection. Something like this:
Text("Something")
.modifier(myContextMenu) {
// Some menu options
}
Why: I need to do some logic inside the modifier to conditionally present or not present the menu. I can’t work out the correct view modifier signature for it.
There is another contextMenu modifier available which claims that I can conditionally present the context menu for it. Upon trying this out, this does not help me, because as soon as I add contextMenu modifier to a NavigationLink on iOS, the tap gesture on it stops working. There is discussion in a response below.
How do I present a context menu using a view modifier?
Here is a demo for optional context menu usage (tested with Xcode 11.2 / iOS 13.2)
struct TestConditionalContextMenu: View {
#State private var hasContextMenu = false
var body: some View {
VStack {
Button(hasContextMenu ? "Disable Menu" : "Enable Menu")
{ self.hasContextMenu.toggle() }
Divider()
Text("Hello, World!")
.background(Color.yellow)
.contextMenu(self.hasContextMenu ?
ContextMenu {
Button("Do something1") {}
Button("Do something2") {}
} : nil)
}
}
}
Something like this?
Text("Options")
.contextMenu {
if (1 == 0) { // some if statements here
Button(action: {
//
}) {
Text("Choose Country")
Image(systemName: "globe")
}
}
}
Here’s what I came up with. Not entirely satisfied, it could be more compact, but it works as expected.
struct ListView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: ItemView(item: "Something")) {
Text("Something").modifier(withiOSContextMenu())
}.modifier(withOSXContextMenu())
}
}
}
}
struct withOSXContextMenu: ViewModifier {
func body(content: Content) -> some View {
#if os(OSX)
return content.contextMenu(ContextMenu {
ContextMenuContent()
})
#else
return content
#endif
}
}
struct withiOSContextMenu: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
return content.contextMenu(ContextMenu {
ContextMenuContent()
})
#else
return content
#endif
}
}
func ContextMenuContent() -> some View {
Group {
Button("Click me") {
print("Button clicked")
}
Button("Another button") {
print("Another button clicked")
}
}
}

SwiftUI - navigationBarBackButtonHidden - swipe back gesture?

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.