Add haptic feedback to button in SwiftUI - swift

I am able to create my custom style of button with a ButtonStyle but I would like to add haptic feedback when the button is touched down and let go of. I know there is a configuration.isPressed variable available in ButtonStyle, but how do I give haptic feedback when it actually changes.
I know how to give haptic feedback but need help knowing when the user touches the button and let's go.
I have tried this extension as a button:
struct TouchGestureViewModifier: ViewModifier {
let touchBegan: () -> Void
let touchEnd: (Bool) -> Void
let abort: (Bool) -> Void
#State private var hasBegun = false
#State private var hasEnded = false
#State private var hasAborted = false
private func isTooFar(_ translation: CGSize) -> Bool {
let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
return distance >= 20.0
}
func body(content: Content) -> some View {
content.gesture(DragGesture(minimumDistance: 0)
.onChanged { event in
guard !self.hasEnded else { return }
if self.hasBegun == false {
self.hasBegun = true
self.touchBegan()
} else if self.isTooFar(event.translation) {
self.hasAborted = true
self.abort(true)
}
}
.onEnded { event in
print("ended")
if !self.hasEnded {
if self.isTooFar(event.translation) {
self.hasAborted = true
self.abort(true)
} else {
self.hasEnded = true
self.touchEnd(true)
}
}
self.hasBegun = false
self.hasEnded = false
})
}
}
// add above so we can use it
extension View {
func onTouchGesture(touchBegan: #escaping () -> Void = {},
touchEnd: #escaping (Bool) -> Void = { _ in },
abort: #escaping (Bool) -> Void = { _ in }) -> some View {
modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd, abort: abort))
}
}
Then I do this on a view:
.onTouchGesture(
// what to do when the touch began
touchBegan: {
// tell the view that the button is being pressed
self.isPressed = true
// play click in sound
playSound(sound: "clickin-1", type: "wav")
// check if haptic feedback is possible
if self.engine != nil {
// if it is - give some haptic feedback
let pattern = try? CHHapticPattern(events: [self.event], parameters: [])
let player = try? self.engine?.makePlayer(with: pattern!)
try? self.engine!.start()
try? player?.start(atTime: 0)
}
},
// what to do when the touch ends. sometimes this doesnt work if you hold it too long :(
touchEnd: { _ in
// tell the view that the user lifted their finger
self.isPressed = false
playSound(sound: "clickout-1", type: "wav")
// check if haptic feedback is possible
if self.engine != nil {
// if it is - give some haptic feedback
let pattern = try? CHHapticPattern(events: [self.event], parameters: [])
let player = try? self.engine?.makePlayer(with: pattern!)
try? self.engine!.start()
try? player?.start(atTime: 0)
}
},
// if the user drags their finger away, abort the action
abort: { _ in
self.isPressed = false
}
)
But it gets stuck halfway through sometimes which is not very useable for users. How do I do it on the more reliable Button with ButtonStyle?

Related

How to make a swifui button action repeat in a loop after one click of the button

I'm relatively new to swift and am I'm making a swiftui calling application with a deepfaked chatbot that requires me to transcribe the users speech to text and then play an appropriate response.
I currently have a working flow that that starts a speech recognition session when the user clicks a button, and stops the recording/recognition when the user clicks the button again. They need to keep clicking start/stop in order for this to work.
To make this hands free like a real voice chat app, I would like to get rid of requiring the user to click buttons. I would like them to click a "call" button once to get the recording and the speech recognition going, and then automatically detect when they stop talking with a 2 second timer. Then I can send the text to the backend and I would like to automatically restart the mic and the speech recognition so I can keep doing this in a loop to partition user input, until the user clicks the button again to hang up.
I have implemented a timer to detect when the user stops speaking, but when I try to restart the microphone and the speech recognition session using a repeat while loop, my program doesn't work as I expect and speech recognition doesn't work.
This is what I tried to do to make the "addItem" logic run in a loop once the user clicks the call button initially. The logic to end speech recognition after 2 seconds of silence works fine, but as soon as I add the repeat while loop, the program goes haywire after the first click of the call button. I can't figure out the proper way to make the logic loop after speech recognition ends and I get the text.
Main View code:
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Todo.created, ascending: true)], animation: .default) private var todos: FetchedResults<Todo>
#State private var recording = false
#ObservedObject private var mic = MicMonitor(numberOfSamples: 30)
private var speechManager = SpeechManager()
var body: some View {
NavigationView {
ZStack(alignment: .bottomTrailing) {
List {
Text(todos.last?.text ?? "----")
}
.navigationTitle("Speech To Text")
VStack{
recordButton()
}
}.onAppear {
speechManager.checkPermissions()
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func recordButton() -> some View {
Button(action: addItem) {
Image(systemName: "phone.fill")
.font(.system(size: 40))
.padding()
.cornerRadius(10)
}.foregroundColor(recording ? .red : .green)
}
private func addItem() { //THIS IS THE FUNCTION THAT I WANT TO RUN IN A LOOP WITHOUT NEEDING TO CLICK THE BUTTON EVERYTIME
if speechManager.isRecording {
self.recording = false
mic.stopMonitoring()
speechManager.stopRecording()
} else {
repeat {
self.recording = true
mic.startMonitoring()
speechManager.start { (speechText) in
guard let text = speechText, !text.isEmpty else {
self.recording = false
return
}
print("FINAL TEXT AFTER TIMER ENDS: ", text)
DispatchQueue.main.async {
withAnimation {
let newItem = Todo(context: viewContext)
newItem.id = UUID()
newItem.text = text
newItem.created = Date()
do {
try viewContext.save()
} catch {
print(error)
}
mic.stopMonitoring() //At this point, I want to restart the recording and the speech recognition session and keep doing the else statement in a loop automatically }
}
}
} while self.recording == true
}
speechManager.isRecording.toggle()
print("Toggeled isRecording!!")
}
}
Speech Recognition code:
import Foundation
import Speech
class SpeechManager {
public var isRecording = false
private var audioEngine: AVAudioEngine!
private var inputNode: AVAudioInputNode!
private var audioSession: AVAudioSession!
var timer : Timer?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
func checkPermissions() {
SFSpeechRecognizer.requestAuthorization{ (authStatus) in
DispatchQueue.main.async {
switch authStatus {
case .authorized: break
default:
print("Speech recognition is not available")
}
}
}
}
func start(completion: #escaping (String?) -> Void) {
if isRecording {
//stopRecording()
} else {
startRecording(completion: completion)
}
}
func startRecording(completion: #escaping (String?) -> Void) {
//createTimer(4)
guard let recognizer = SFSpeechRecognizer(), recognizer.isAvailable else {
print("Speech recognition is not available")
return
}
recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
recognitionRequest!.shouldReportPartialResults = true
recognizer.recognitionTask(with: recognitionRequest!) { (result, error) in
//let defaultText = self.text
guard error == nil else {
print("got error \(error!.localizedDescription)")
return
}
guard let result = result else { return }
////////////////
self.timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { (timer) in
self.timer?.invalidate()
print("invalidated timer")
self.stopRecording()
return
////////////////
})
if result.isFinal {
completion(result.bestTranscription.formattedString)
print("FINAL")
print(result.bestTranscription.formattedString)
}
}
audioEngine = AVAudioEngine()
inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer, _) in
self.recognitionRequest?.append(buffer)
}
audioEngine.prepare()
do {
audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.record, mode: .spokenAudio, options: .duckOthers)
try audioSession.setActive(true, options:.notifyOthersOnDeactivation)
try audioEngine.start()
} catch {
print(error)
}
}
func stopRecording() {
audioEngine.stop()
recognitionRequest?.endAudio()
recognitionRequest = nil
inputNode.removeTap(onBus: 0)
audioSession = nil
}
}

Create a list for CarPlay

So I am currently having to manually add new stations to our CarPlay app. However we have a JSON which our app uses for iPhone and iPad. So I am wondering how do I create a list that uses this information instead of me manually creating it.
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) {
let DRN1 = CPListItem(text: "DRN1", detailText: "Perth's No.1 Online Station.")
let Hits = CPListItem(text: "DRN1 Hits", detailText: "Top 40 songs and yesturdays hits.")
let United = CPListItem(text: "DRN1 United", detailText: "Perth's Dedicated LGBTIQA+ Station.")
let Dance = CPListItem(text: "DRN1 Dance", detailText: "Playing the hottest dance tracks.")
if #available(iOS 14.0, *) {
let nowplay = CPNowPlayingTemplate.shared
DRN1.setImage(UIImage(imageLiteralResourceName:"DRN1Logo"))
DRN1.handler = { item, completion in
print("selected DRN1")
AdStichrApi.station = "DRN1"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
Hits.setImage(UIImage(imageLiteralResourceName:"DRN1Hits"))
Hits.handler = { item, completion in
print("selected Hits")
MusicPlayer.shared.player?.pause()
AdStichrApi.station = "DRN1Hits"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1Hits", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
//interfaceController.presentTemplate(nowplay, animated: false)
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
United.setImage(UIImage(imageLiteralResourceName:"DRN1United"))
United.handler = { item, completion in
print("selected United")
AdStichrApi.station = "DRN1United"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/DRN1United", type: "radio")
// do work in the UI thread here
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
completion()
}
}
Dance.setImage(UIImage(imageLiteralResourceName:"DRN1Dance"))
Dance.handler = { item, completion in
print("selected Dance")
AdStichrApi.station = "dance"
MusicPlayer.shared.startBackgroundMusic(url:"https://api.example.com.au:9000/station/dance", type: "radio")
Nowplayinginfo().getsong()
DispatchQueue.main.asyncAfter(deadline: .now() + 5.00) {
completion()
interfaceController.pushTemplate(nowplay, animated: true,completion:nil)
}
}
} else {
// Fallback on earlier versions
}
let listTemplate = CPListTemplate(title: "Select a Station", sections: [CPListSection(items:[DRN1,United,Hits,Dance])])
However in my iOS app I just use
Api().getStations { (stations) in
self.stations = stations
}
Which fetches the JSON from the backend and provides me with every station available.
I am wondering How can I create a CPList using this information instead.
Example:
I found this example but it is fetching local data and I don't need the tab bar at this time.
https://github.com/T0yBoy/CarPlayTutorial/tree/master/CarPlayTutorial
From my understanding all I need to do is create a list
for station in (radios) {
let item = CPListItem(text: station.name, detailText: station.name)
item.accessoryType = .disclosureIndicator
item.setImage(UIImage(named: station.imageurl))
item.handler = { [weak self] item, completion in
guard let strongSelf = self else { return }
// strongSelf.favoriteAlert(radio: radio, completion: completion)
}
radioItems.append(item)
}
I know I need to do something along the lines of
Api().getStations { (stations) in
self.radios = stations
INSERT THE STATIONS INTO A LIST for
let listTemplate = CPListTemplate(title: "Select a Station", sections: [CPListSection(items:stations)])
}
This is how it should still look after creating a list from the API
Give this a try:
// In some function in the CarPlaySceneDelegate
Api().getStations { (stations) in
var stationItems: [CPListItem] = []
self.radios = stations
for station in stations {
let item = CPListItem(text: station.name,
detailText: station.description
image: station.image)
item.handler = { [weak self] item, completion in
// manage what should happen on tap
// like navigate to NowPlayingView
}
stationItems.append(item)
}
loadList(withStations: stationItems)
}
// Load the list template
private func loadList(withStations stations: [CPListItem]) {
let section = CPListSection(items: stations)
let listTemplate = CPListTemplate(title: "Select a station",
sections: [section])
// Set the root template of the CarPlay interface
// to the list template with a completion handler
interfaceController?.setRootTemplate(listTemplate,
animated: true) { success, error in
// add anything you want after setting the root template
}
}
The for loop to add the stations in a CPListItem array can be replaced by a map function but I did it this way for clarity.
Update
In your class CarPlaySceneDelegate, fix the spelling from
var interfactController: CPInterfaceController?
to
var interfaceController: CPInterfaceController?
In your templateApplicationScene didConnect comment out the interfactController?.setRootTemplate(listTemplate, animated: false) for now
and add this line to it self.interfaceController = interfaceController
So the updated function looks like this:
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
// Add this
self.interfaceController = interfaceController
Api().getStations { (stations) in
var stationItems: [CPListItem] = []
self.stations = stations
for station in stations {
let item = CPListItem(text: station.name,
detailText: station.name
// image: imageLiteralResourceName:"DRN1Logo")
)
item.handler = { [weak self] item, completion in
// manage what should happen on tap
// like navigate to NowPlayingView
print(item)
}
stationItems.append(item)
}
self.loadList(withStations: stationItems)
}
}
Do you see any better results ?

How to catch a fatalError caused by during a UITest?

The UITest in question launches the app, taps a cell which pushes the Screen to be tested and then fails with a fatalError() when i make a change that i expect will call a fatalError().
How can i catch the fatalError on the UITest and use it to report that the UITest has failed?
Here is the UITest:
class ConcreteFoodScreenUITests: XCTestCase
{
let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
app.launch()
}
func testPathToConcreteFoodScreen() throws {
//Tap Concrete Cell in FoodDashboard to go to the ConcreteFoodScreen
XCTAssertTrue(app.otherElements["FoodDashboard"].exists)
app.scrollViews.otherElements.tables.staticTexts["100 g, 100cal, P: 90g, F: 80g, C: 70g"].tap()
//ConcreteFoodScreen
XCTAssertTrue(app.otherElements["ConcreteFoodScreen"].exists)
app.tables.cells.containing(.staticText, identifier:"Scale").children(matching: .textField).element.tap()
app.keys["5"].tap() //FIXME: Crashes with a Fatal Error
}
}
Here is the code that is being triggered that i want to know about:
class ScaleCellTextField: SWDecimalTextField {
//there is more code here but not relevant
func updateFoodEntry() {
fatalError()
// if let scale = Double(self.text!) {
// concreteFood.scale = scale
// }
}
}
You can see that i commented out some code here to get it working.
I'm afraid you can't. FatalError() ends your app's process, so I'm not sure you can catch this kind of event.
EDIT:
But...
You can use our dear good old Darwin Notifications... In order to add create a communication channel between your apps : the tested app and the tester app.
You'll need to add a file to both your targets:
typealias NotificationHandler = () -> Void
enum DarwinNotification : String {
case fatalError
}
class DarwinNotificationCenter {
let center: CFNotificationCenter
let prefix: String
var handlers = [String:NotificationHandler]()
init(prefix: String = "com.stackoverflow.answer.") {
center = CFNotificationCenterGetDarwinNotifyCenter()
self.prefix = prefix
}
var unsafeSelf: UnsafeMutableRawPointer {
return Unmanaged.passUnretained(self).toOpaque()
}
deinit {
CFNotificationCenterRemoveObserver(center, unsafeSelf, nil, nil)
}
func notificationName(for identifier: String) -> CFNotificationName {
let name = prefix + identifier
return CFNotificationName(name as CFString)
}
func identifierFrom(name: String) -> String {
if let index = name.range(of: prefix)?.upperBound {
return String(name[index...])
}
else {
return name
}
}
func handleNotification(name: String) {
let identifier = identifierFrom(name: name)
if let handler = handlers[identifier] {
handler()
}
}
func postNotification(for identifier: String) {
let name = notificationName(for: identifier)
CFNotificationCenterPostNotification(center, name, nil, nil, true)
}
func registerHandler(for identifier: String, handler: #escaping NotificationHandler) {
handlers[identifier] = handler
let name = notificationName(for: identifier)
CFNotificationCenterAddObserver(center,
unsafeSelf,
{ (_, observer, name, _, _) in
if let observer = observer, let name = name {
let mySelf = Unmanaged<DarwinNotificationCenter>.fromOpaque(observer).takeUnretainedValue()
mySelf.handleNotification(name: name.rawValue as String)
}
},
name.rawValue,
nil,
.deliverImmediately)
}
func unregisterHandler(for identifier: String) {
handlers[identifier] = nil
CFNotificationCenterRemoveObserver(center, unsafeSelf, notificationName(for: identifier), nil)
}
}
extension DarwinNotificationCenter {
func postNotification(for identifier: DarwinNotification) {
postNotification(for: identifier.rawValue)
}
func registerHandler(for identifier: DarwinNotification, handler: #escaping NotificationHandler) {
registerHandler(for: identifier.rawValue, handler: handler)
}
func unregisterHandler(for identifier: DarwinNotification) {
unregisterHandler(for: identifier.rawValue)
}
}
Then, simply, in your tested application:
#IBAction func onTap(_ sender: Any) {
// ... Do what you need to do, and instead of calling fatalError()
DarwinNotificationCenter().postNotification(for: .fatalError)
}
To catch it in your test, just do the following:
// This is the variable you'll update when notified
var fatalErrorOccurred = false
// We need a dispatch group to wait for the notification to be caught
let waitingFatalGroup = DispatchGroup()
waitingFatalGroup.enter()
let darwinCenter = DarwinNotificationCenter()
// Register the notification
darwinCenter.registerHandler(for: .fatalError) {
// Update the local variable
fatalErrorOccurred = true
// Let the dispatch group you're done
waitingFatalGroup.leave()
}
// Don't forget to unregister
defer {
darwinCenter.unregisterHandler(for: .fatalError)
}
// Perform you tests, here just a tap
app.buttons["BUTTON"].tap()
// Wait for the group the be left or to time out in 3 seconds
let _ = waitingFatalGroup.wait(timeout: .now() + 3)
// Check on the variable to know whether the notification has been received
XCTAssert(fatalErrorOccurred)
And that's it...
Disclaimer: You should not use testing code within your production code, so the use of DarwinNotification should not appear in production. Usually I use compilation directive to only have it in my code in debug or tests, not in release mode.

Button Click Twice

As we all know, to avoid clicking twice, we can set the code bellow on the tap method and add a HUD such as SVProgress.show().
isUserInteractionEnabled = false
After the network request, set it to true and SVProgress.dismiss().
I wonder if there is a method to extract the function for those button which needs to send a request. I have thought to use method swizzling. Add this to the button extension, the codes is bellow. It seems not good. Do you guys have some good ways to extract the function? Using inheritance, protocol or something else?
extension UIButton {
private struct AssociatedKeys {
static var cp_submitComplete = "cp_submitComplete"
static var cp_defaultMessage:String = NSLocalizedString("Loading", comment: "prompt")
static var cp_customMessage = "cp_customMessage"
}
var submitNotComplete: Bool {
get {
let objc_Get = objc_getAssociatedObject(self, &AssociatedKeys.cp_submitComplete)
if objc_Get != nil {
if let objc_Get = objc_Get as? Bool, objc_Get == true {
return true
}
return false
} else {
return false
}
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.cp_submitComplete, newValue as Bool, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
if !newValue {
isUserInteractionEnabled = true
SVProgressHUD.dismiss()
}
}
}
var customMessage: String {
get {
let cp_customMessage = objc_getAssociatedObject(self, &AssociatedKeys.cp_customMessage)
if let message = cp_customMessage {
return message as! String
} else {
return AssociatedKeys.cp_defaultMessage
}
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.cp_customMessage, newValue as String, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
override open class func initialize() {
if self == UIButton.self {
DispatchQueue.once(NSUUID().uuidString, block: {
let systemSel = #selector(UIButton.sendAction(_:to:for:))
let swizzSel = #selector(UIButton.cpSendAction(_:to:for:))
let systemMethod = class_getInstanceMethod(self, systemSel)
let swizzMethod = class_getInstanceMethod(self, swizzSel)
let isAdd = class_addMethod(self, systemSel, method_getImplementation(swizzMethod), method_getTypeEncoding(swizzMethod))
if isAdd {
class_replaceMethod(self, swizzSel, method_getImplementation(systemMethod), method_getTypeEncoding(systemMethod));
} else {
method_exchangeImplementations(systemMethod, swizzMethod);
}
})
}
}
private dynamic func cpSendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
cpSendAction(action, to: target, for: event)
if submitNotComplete {
//begin submit
isUserInteractionEnabled = false
SVProgressHUD.show(withStatus: customMessage)
}
}
}
I think it's a bad idea to handle this kind of logic in UIButton. I would rather make the view controller responsible for enabling/disabling the button.
func handleTap(_ sender: UIButton) {
sender.isEnabled = false
SVProgressHUD.show(withStatus: customMessage)
doSomeTaskAsync(withCompletion: {
sender.isEnabled = true
SVProgressHUD.dismiss()
})
}

Swift 3 CFRunLoopRun in Thread?

I just made a simple testing app to display keycode of keystrokes along with modifiers. It works fine for 3 keystrokes, then the app crashes. When it crashes, debug console just shows (LLDB) at the end. Any suggestion what might be causing this? Maybe something has to do with thread or pointer, but I'm not sure how I can fix this. I'm including the code below. I'd really appreciate any help! Thanks!
import Cocoa
import Foundation
class ViewController: NSViewController {
#IBOutlet weak var textField: NSTextFieldCell!
let speech:NSSpeechSynthesizer = NSSpeechSynthesizer()
func update(msg:String) {
textField.stringValue = msg
print(msg)
speech.startSpeaking(msg)
}
func bridgeRetained<T : AnyObject>(obj : T) -> UnsafeRawPointer {
return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque())
}
override func viewDidLoad() {
super.viewDidLoad()
DispatchQueue.global().async {
func myCGEventCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
let parent:ViewController = Unmanaged<ViewController>.fromOpaque(refcon!).takeRetainedValue()
if [.keyDown].contains(type) {
let flags:CGEventFlags = event.flags
let pressed = Modifiers(rawValue:flags.rawValue)
var msg = ""
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskAlphaShift.rawValue)) {
msg+="caps+"
}
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskShift.rawValue)) {
msg+="shift+"
}
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskControl.rawValue)) {
msg+="control+"
}
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskAlternate.rawValue)) {
msg+="option+"
}
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskCommand.rawValue)) {
msg += "command+"
}
if pressed.contains(Modifiers(rawValue:CGEventFlags.maskSecondaryFn.rawValue)) {
msg += "function+"
}
var keyCode = event.getIntegerValueField(.keyboardEventKeycode)
msg+="\(keyCode)"
DispatchQueue.main.async {
parent.update(msg:msg)
}
if keyCode == 0 {
keyCode = 6
} else if keyCode == 6 {
keyCode = 0
}
event.setIntegerValueField(.keyboardEventKeycode, value: keyCode)
}
return Unmanaged.passRetained(event)
}
let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
guard let eventTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(eventMask), callback: myCGEventCallback, userInfo: UnsafeMutableRawPointer(mutating: self.bridgeRetained(obj: self))) else {
print("failed to create event tap")
exit(1)
}
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: eventTap, enable: true)
CFRunLoopRun()
}
// Do any additional setup after loading the view.
}
override var representedObject: Any? {
didSet {
// Update the view, if already loaded.
}
}
}
The main problem is the reference counting: You create a retained
reference to the view controller when installing the event handler, this happens exactly once.
Then you consume a reference in the callback, this happens for every
tap event. Therefore the reference count drops to zero eventually and
the view controller is deallocated, causing a crash.
Better pass unretained references to the callback, and take care that
the event handler is uninstalled when the view controller is deallocated.
Also there is no need to create a separate runloop for an OS X application, or to asynchronously dispatch the handler creation.
Make the callback a global function, not a method. Use
takeUnretainedValue() to get the view controller reference:
func myCGEventCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
let viewController = Unmanaged<ViewController>.fromOpaque(refcon!).takeUnretainedValue()
if type == .keyDown {
var keyCode = event.getIntegerValueField(.keyboardEventKeycode)
let msg = "\(keyCode)"
DispatchQueue.main.async {
viewController.update(msg:msg)
}
if keyCode == 0 {
keyCode = 6
} else if keyCode == 6 {
keyCode = 0
}
event.setIntegerValueField(.keyboardEventKeycode, value: keyCode)
}
return Unmanaged.passRetained(event)
}
In the view controller, keep a reference to the run loop source
so that you can remove it in deinit, and use
passUnretained() to pass a pointer to the view controller to
the callback:
class ViewController: NSViewController {
var eventSource: CFRunLoopSource?
override func viewDidLoad() {
super.viewDidLoad()
let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
let userInfo = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
if let eventTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
options: .defaultTap, eventsOfInterest: CGEventMask(eventMask),
callback: myCGEventCallback, userInfo: userInfo) {
self.eventSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), self.eventSource, .commonModes)
} else {
print("Could not create event tap")
}
}
deinit {
if let eventSource = self.eventSource {
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), eventSource, .commonModes)
}
}
// ...
}
Another option would be to install/uninstall the event handler in
viewDidAppear and viewDidDisappear.