Does an NSFilePromiseProviderDelegate need to be also an NSView or NSViewController? - swift

I am going through the AppKit tutorial Supporting Drag and Drop Through File Promises. I downloaded the demo app.
I tried to extract the NSFilePromiseProviderDelegate functionality from the ImageCanvasController class (which is an NSViewController) into a separate class. (Please see before & after code snippets below.)
Before my changes, dragging an image from the app canvas out into Finder and Apple Notes worked fine. But after my changes, nothing happens when I drag into Notes, and I get this error when I drag into Finder:
2022-02-26 23:16:52.713742+0100 MemeGenerator[31536:1975798] *** CFMessagePort: dropping corrupt reply Mach message (0b000100)
Is there any undocumented protocol conformance that I need to add to the new class? Or is there some underlying logic for which NSFilePromiseProviderDelegate only works if it's also an NSView or an NSViewController? In all the guides I found online, it is always tied to a view, but I didn't find any warning that it has to be.
Note:
The reason I want to separate the promise-provider functionality from views is, this way I could provide multiple NSDraggingItem objects to beginDraggingSession. For example, when multiple items are selected, and there is a mouseDragged event on one of them, I could start a dragging session including all the selected items.
Code before
class ImageCanvasController: NSViewController, NSFilePromiseProviderDelegate, ImageCanvasDelegate, NSToolbarDelegate {
...
/// Queue used for reading and writing file promises.
private lazy var workQueue: OperationQueue = {
let providerQueue = OperationQueue()
providerQueue.qualityOfService = .userInitiated
return providerQueue
}()
...
func pasteboardWriter(forImageCanvas imageCanvas: ImageCanvas) -> NSPasteboardWriting {
let provider = NSFilePromiseProvider(fileType: kUTTypeJPEG as String, delegate: self)
provider.userInfo = imageCanvas.snapshotItem
return provider
}
// MARK: - NSFilePromiseProviderDelegate
/// - Tag: ProvideFileName
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String {
let droppedFileName = NSLocalizedString("DropFileTitle", comment: "")
return droppedFileName + ".jpg"
}
/// - Tag: ProvideOperationQueue
func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue {
return workQueue
}
/// - Tag: PerformFileWriting
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: #escaping (Error?) -> Void) {
do {
if let snapshot = filePromiseProvider.userInfo as? ImageCanvas.SnapshotItem {
try snapshot.jpegRepresentation?.write(to: url)
} else {
throw RuntimeError.unavailableSnapshot
}
completionHandler(nil)
} catch let error {
completionHandler(error)
}
}
}
Code after
class CustomFilePromiseProviderDelegate: NSObject, NSFilePromiseProviderDelegate {
/// Queue used for reading and writing file promises.
private lazy var workQueue: OperationQueue = {
let providerQueue = OperationQueue()
providerQueue.qualityOfService = .userInitiated
return providerQueue
}()
/// - Tag: ProvideFileName
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String {
let droppedFileName = NSLocalizedString("DropFileTitle", comment: "")
return droppedFileName + ".jpg"
}
/// - Tag: ProvideOperationQueue
func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue {
return workQueue
}
/// - Tag: PerformFileWriting
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: #escaping (Error?) -> Void) {
do {
if let snapshot = filePromiseProvider.userInfo as? ImageCanvas.SnapshotItem {
try snapshot.jpegRepresentation?.write(to: url)
} else {
throw RuntimeError.unavailableSnapshot
}
completionHandler(nil)
} catch let error {
completionHandler(error)
}
}
}
class ImageCanvasController: NSViewController, ImageCanvasDelegate, NSToolbarDelegate {
...
func pasteboardWriter(forImageCanvas imageCanvas: ImageCanvas) -> NSPasteboardWriting {
let delegate = CustomFilePromiseProviderDelegate()
let provider = NSFilePromiseProvider(fileType: kUTTypeJPEG as String, delegate: delegate)
provider.userInfo = imageCanvas.snapshotItem
return provider
}
}

Does an NSFilePromiseProviderDelegate need to be also an NSView or NSViewController?
No.
delegate is a local variable and is released at the end of pasteboardWriter(forImageCanvas:). Without a delegate the file promise provider doesn't work. Solution: keep a strong reference to the delegate.
class ImageCanvasController: NSViewController, ImageCanvasDelegate, NSToolbarDelegate {
let delegate = CustomFilePromiseProviderDelegate()
...
func pasteboardWriter(forImageCanvas imageCanvas: ImageCanvas) -> NSPasteboardWriting {
let provider = NSFilePromiseProvider(fileType: kUTTypeJPEG as String, delegate: delegate)
provider.userInfo = imageCanvas.snapshotItem
return provider
}
}

Related

NSItemProvider[URL] - how to COPY with drag&Drop instead of MOVE?

I have implemented function that returns NSItemProvider
func dragOutsideWnd(url: URL?) -> NSItemProvider {
if let url = url {
TheApp.appDelegate.hideMainWnd()
let provider = NSItemProvider(item: url as NSSecureCoding?, typeIdentifier: UTType.fileURL.identifier as String)
provider.suggestedName = url.lastPathComponent
//provider.copy()// This doesn't work :)
//DispatchQueue.main.async {
// TheApp.appDelegate.hideMainWnd()
//}
return provider
}
return NSItemProvider()
}
and I have use it this way:
.onDrag {
return dragOutsideWnd(url: itm.url)
}
This drag&drop action performs file MOVE action to any place of FINDER/HDD.
But how to perform COPY action?
Remember Drag&Drop is actually implemented with NSPasteboard.
I have written an example for you:
GitHub
Now the key to your questions:
To control dragging behavior(your window is the source):
Draggable objects conform to the NSDraggingSource protocol, so check the first method of the protocol:
#MainActor func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation
As the method docsuggests, return different NSDragOperation in this delegation method. That includes: "Copy","Move", "Link", etc.
To control dropping behavior(your window is the destination):
NSView that accepts drop conforms to the NSDraggingDestination protocol, so you need to override the draggingEntered(_:) method by adding this code inside the DestinationView class implementation:
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation
{
var allow = true
//.copy .move, see more options in NSDragOperation, up to you.
return allow ? .copy : NSDragOperation()
}
More info form Apple's Documentation
For swiftUI, a simple show case SwiftUI Showcase
Further Reading: RayWenderlich.com has a detailed tutorial Drag and Drop Tutorial for macOS tutorial for you(needs a little swift upgrade).
Thanks a lot to answer of kakaiikaka!
The following solution works in swiftUI:
import Foundation
import SwiftUI
extension View {
func asDragable(url: URL, tapAction: #escaping () -> () , dTapAction: #escaping () -> ()) -> some View {
self.background {
DragDropView(url: url, tapAction: tapAction, dTapAction: dTapAction)
}
}
}
struct DragDropView: NSViewRepresentable {
let url: URL
let tapAction: () -> ()
let dTapAction: () -> ()
func makeNSView(context: Context) -> NSView {
return DragDropNSView(url: url, tapAction: tapAction, dTapAction: dTapAction)
}
func updateNSView(_ nsView: NSView, context: Context) { }
}
class DragDropNSView: NSView, NSDraggingSource {
let url: URL
let tapAction: () -> ()
let dTapAction: () -> ()
let imgMove: NSImage = NSImage(named: "arrow.down.doc.fill_cust")!
init(url: URL, tapAction: #escaping () -> (), dTapAction: #escaping () -> ()) {
self.url = url
self.tapAction = tapAction
self.dTapAction = dTapAction
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
return mustBeMoveAction ? .move : .copy
}
}
extension DragDropNSView: NSPasteboardItemDataProvider {
func pasteboard(_ pasteboard: NSPasteboard?, item: NSPasteboardItem, provideDataForType type: NSPasteboard.PasteboardType) {
// If the desired data type is fileURL, you load an file inside the bundle.
if let pasteboard = pasteboard, type == NSPasteboard.PasteboardType.fileURL {
pasteboard.setData(url.dataRepresentation, forType:type)
}
}
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
tapAction()
if event.clickCount == 2 {
dTapAction()
}
}
override func mouseDragged(with event: NSEvent) {
//1. Creates an NSPasteboardItem and sets this class as its data provider. A NSPasteboardItem is the box that carries the info about the item being dragged. The NSPasteboardItemDataProvider provides data upon request. In this case a file url
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setDataProvider(self, forTypes: [NSPasteboard.PasteboardType.fileURL])
var rect = imgMove.alignmentRect
rect.size = NSSize(width: imgMove.size.width/2, height: imgMove.size.height/2)
//2. Creates a NSDraggingItem and assigns the pasteboard item to it
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
draggingItem.setDraggingFrame(rect, contents: imgMove) // `contents` is the preview image when dragging happens.
//3. Starts the dragging session. Here you trigger the dragging image to start following your mouse until you drop it.
beginDraggingSession(with: [draggingItem], event: event, source: self)
}
}
////////////////////////////////////////
///HELPERS
///////////////////////////////////////
extension DragDropNSView {
var dragGoingOutsideWindow: Bool {
guard let currEvent = NSApplication.shared.currentEvent else { return false }
if let rect = self.window?.contentView?.visibleRect,
rect.contains(currEvent.locationInWindow)
{
return false
}
return true
}
var mustBeMoveAction: Bool {
guard let currEvent = NSApplication.shared.currentEvent else { return false }
if currEvent.modifierFlags.check(equals: [.command]) {
return true
}
return false
}
}
extension NSEvent.ModifierFlags {
func check(equals: [NSEvent.ModifierFlags] ) -> Bool {
var notEquals: [NSEvent.ModifierFlags] = [.shift, .command, .control, .option]
equals.forEach{ val in notEquals.removeFirst(where: { $0 == val }) }
var result = true
equals.forEach{ val in
if result {
result = self.contains(val)
}
}
notEquals.forEach{ val in
if result {
result = !self.contains(val)
}
}
return result
}
}
usage:
FileIcon()
.asDragable( url: recent.url, tapAction: {}, dTapAction: {})
this element will be draggable and perform MOVE in case .command key pressed.
And will perform COPY in another case
Also it performs drag action only outside widndow. But it's easy to change.

delegate method does not get called second time

I am building a simple currency converter app. When ViewController gets opened it calls a function from CoinManager.swift:
class ViewController: UIViewController {
var coinManager = CoinManager()
override func viewDidLoad() {
super.viewDidLoad()
coinManager.delegate = self
coinManager.getCoinPrice(for: "AUD", "AZN", firstCall: true)
}
...
}
CoinManager.swift:
protocol CoinManagerDelegate {
func didUpdatePrice(price1: Double, currency1: String, price2: Double, currency2: String)
func tellTableView(descriptions: [String], symbols: [String])
func didFailWithError(error: Error)
}
struct CoinManager {
var delegate: CoinManagerDelegate?
let baseURL = "https://www.cbr-xml-daily.ru/daily_json.js"
func getCoinPrice (for currency1: String,_ currency2: String, firstCall: Bool) {
if let url = URL(string: baseURL) {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error != nil {
self.delegate?.didFailWithError(error: error!)
return
}
if let safeData = data {
if let coinData = self.parseJSON(safeData) {
if firstCall {
var descriptions = [""]
let listOfCoins = Array(coinData.keys)
for key in listOfCoins {
descriptions.append(coinData[key]!.Name)
}
descriptions.removeFirst()
self.delegate?.tellTableView(descriptions: descriptions, symbols: listOfCoins)
}
if let coinInfo1 = coinData[currency1] {
let value1 = coinInfo1.Value
if let coinInfo2 = coinData[currency2] {
let value2 = coinInfo2.Value
//this line does not do anything the second time I call getCoinPrice:
self.delegate?.didUpdatePrice(price1: value1, currency1: currency1, price2: value2, currency2: currency2)
//And this one does work
print("delegate:\(currency1)")
} else {
print("no name matches currency2")
}
} else {
print("no name matches currency1")
}
}
}
}
task.resume()
}
}
func ParseJSON....
}
The method it calls (ViewController.swift):
extension ViewController: CoinManagerDelegate {
func didUpdatePrice(price1: Double, currency1: String, price2: Double, currency2: String) {
print("didUpdatePrice called")
DispatchQueue.main.async {
let price1AsString = String(price1)
let price2AsString = String(price2)
self.leftTextField.text = price1AsString
self.rightTextField.text = price2AsString
self.leftLabel.text = currency1
self.rightLabel.text = currency2
}
}
...
}
and finally, CurrencyViewController.swift:
var coinManager = CoinManager()
#IBAction func backButtonPressed(_ sender: UIBarButtonItem) {
dismiss(animated: true, completion: nil)
coinManager.getCoinPrice(for: "USD", "AZN", firstCall: false)
}
So when I launch the app i get following in my debug console:
didUpdatePrice called
delegate:AUD
And when I call getCoinPrice() from CurrencyViewController the delegate method does not get called. I know that my code goes through the delegate function line as I get this in debug console:
delegate:USD
I just can't wrap my head around it. The delegate method does not work when gets called second time. Even though it is called by the same algorithm
It's because you're creating a new object of CoinManager in CurrencyViewController where the delegate is not set. So you've to set the delegate every time you create a new instance of CoinManager.
#IBAction func backButtonPressed(_ sender: UIBarButtonItem) {
dismiss(animated: true, completion: nil)
coinManager.delegate = self
coinManager.getCoinPrice(for: "USD", "AZN", firstCall: false)
}
Update: So, the above solution would require for you to make the delegate conformance in CurrencyViewController. If you're looking for an alternate solution you should probably pass the instance of coinManager in ViewController to CurrencyViewController. For that here are the things you need to update.
In CurrencyViewController:
class CurrencyViewController: UIViewController {
var coinManager: CoinManager! // you can optional unwrap if you intent to use CurrencyViewController without coinManager
//...
And in ViewController:
currencyViewController.coinManager = coinManager // passing the instance of coinManager
Can you share the full code of CoinManager? I see this part
if firstCall {
...
}
Maybe some block logic here or unhandled cases? And can you share the full code of protocol?
Also try to print something before this code:
if error != nil {
self.delegate?.didFailWithError(error: error!)
return
}

Passing Data from Delegate Swift Class to EnvironmentObject in SwiftUI Struct

How do I pass incoming data from a method triggered by a delegate in a Swift class to an EnvironmentObject?
I am aware that for this to work my Swift class needs to be called/initialized from the SwiftUI struct (be a child of the parent SwiftUI struct). However, I initialise my Swift class in the ExtensionDelegate of the Apple Watch app. I would like to see the UI Text element change when the name is updated.
The following code runs on the Apple Watch:
class User: ObservableObject {
#Published var id: UUID?
#Published var name: String?
}
//SwiftUI struct
struct UI: View {
#EnvironmentObject var userEnv: User
var body: some View {
Text(userEnv.name)
}
}
// Swift class
class WatchConnectivityProvider: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityProvider()
private let session: WCSession
init(session: WCSession = .default) {
self.session = session
super.init()
}
func activateSession() {
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
//This func gets triggered when data is sent from the iPhone
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: #escaping ([String : Any]) -> Void) {
let list = message["list"]
let jsonDecoder = JSONDecoder()
if let data = try? jsonDecoder.decode(User.self, from: list as! Data) {
// !!! This is where I would like to update the EnvironmentObject userEnv !!!
// What is the best way to do this? Remember, this class has already been initialised within the ExtensionDelegate.
}
}
}
//ExtensionDelegate of Apple Watch app, initialising the WatchConnectivityProvider
class ExtensionDelegate: NSObject, WKExtensionDelegate {
func applicationDidFinishLaunching() {
// Perform any final initialization of your application.
WatchConnectivityProvider.shared.activateSession()
}
}
Dependency Injection
One of the solutions could be to store the reference to your #EnvironmentObject globally, eg. in some dependency container.
enum Dependencies {
struct Name: Equatable {
let rawValue: String
static let `default` = Name(rawValue: "__default__")
static func == (lhs: Name, rhs: Name) -> Bool { lhs.rawValue == rhs.rawValue }
}
final class Container {
private var dependencies: [(key: Dependencies.Name, value: Any)] = []
static let `default` = Container()
func register(_ dependency: Any, for key: Dependencies.Name = .default) {
dependencies.append((key: key, value: dependency))
}
func resolve<T>(_ key: Dependencies.Name = .default) -> T {
return (dependencies
.filter { (dependencyTuple) -> Bool in
return dependencyTuple.key == key
&& dependencyTuple.value is T
}
.first)?.value as! T
}
}
}
Then you create your object like this:
Dependencies.Container.default.register(User())
And you can access it from anywhere in your code:
let user: User = Dependencies.Container.default.resolve()
user.modify()
A more detailed explanation of Dependency Injection for Swift can be found here.
Singleton
Alternatively you can use standard Singleton pattern to make your User data available globally. A more detailed explanation can be found here.
Final thoughts
Clean Architecture for SwiftUI is a good example (in my opinion at least) of how to write an iOS app in the clean way. It's a little bit complicated, but you can pick up some parts.
Here bad Jumbo code:
class ExtensionDelegate: ObservableObject, NSObject, WCSessionDelegate, WKExtensionDelegate {
var session: WCSession?
#Published var model = Model
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: #escaping ([String : Any]) -> Void) {
print(#function)
var replyValues = Dictionary<String, Any>()
replyValues["status"] = "failed"
// 2442 Bytes
if let data = message["data"] as? Data {
// push that work back to the main thread
DispatchQueue.main.async {
self.model = try? JSONDecoder().decode(Model.self, from: data)
}
if let vm = vm {
replyValues["status"] = "ok"
replyValues["id"] = vm.id.uuidString
}
}
replyHandler(replyValues)
}
...

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.

Swift UITextField target-actions as closure, problem with not removing target-actions

I have such code a little modified from code of Eric Armstrong
Adding a closure as target to a UIButton
But there is the problem with both codes. Those from Eric does remove all target-actions on
func removeTarget(for controlEvent: UIControl.Event = .touchUpInside)
And modified code on the other hand do not remove target-actions at all. Of course it is caused by if condition, but it also means that there are no targets stored properly in Storable property.
extension UIControl: ExtensionPropertyStorable {
class Property: PropertyProvider {
static var property = NSMutableDictionary()
static func makeProperty() -> NSMutableDictionary? {
return NSMutableDictionary()
}
}
func addTarget(for controlEvent: UIControl.Event = .touchUpInside, target: #escaping (_ sender: Any) ->()) {
let key = String(describing: controlEvent)
let target = Target(target: target)
addTarget(target, action: target.action, for: controlEvent)
property[key] = target
}
func removeTarget(for controlEvent: UIControl.Event = .touchUpInside) {
let key = String(describing: controlEvent)
if let target = property[key] as? Target {
removeTarget(target, action: target.action, for: controlEvent)
property[key] = nil
}
}
}
// Wrapper class for the selector
class Target {
private let t: (_ sender: Any) -> ()
init(target t: #escaping (_ sender: Any) -> ()) { self.t = t }
#objc private func s(_ sender: Any) { t(sender) }
public var action: Selector {
return #selector(s(_:))
}
}
// Protocols with associatedtypes so we can hide the objc_ code
protocol PropertyProvider {
associatedtype PropertyType: Any
static var property: PropertyType { get set }
static func makeProperty() -> PropertyType?
}
extension PropertyProvider {
static func makeProperty() -> PropertyType? {
return nil
}
}
protocol ExtensionPropertyStorable: class {
associatedtype Property: PropertyProvider
}
// Extension to make the property default and available
extension ExtensionPropertyStorable {
typealias Storable = Property.PropertyType
var property: Storable {
get {
let key = String(describing: type(of: Storable.self))
guard let obj = objc_getAssociatedObject(self, key) as? Storable else {
if let property = Property.makeProperty() {
objc_setAssociatedObject(self, key, property, .OBJC_ASSOCIATION_RETAIN)
}
return objc_getAssociatedObject(self, key) as? Storable ?? Property.property
}
return obj
}
set {
let key = String(describing: type(of: Storable.self))
return objc_setAssociatedObject(self, key, newValue, .OBJC_ASSOCIATION_RETAIN) }
}
}
My aim is to precisely register target-actions with closures and remove them without removing all other target-actions added to given UITextField via #selector. Now I can have removed ALL or NONE of target-actions while using this approach for closure-style target actions.
UPDATE
Based on Eric Armstrong answer i have implemented my version.
But what I have experienced in version proposed by Eric was that when adding target actions to TextField on TableView list while cells appear and then removing this target actions from Text Fields while cells diseappear the previous code seems to remove all target actions on removeTarget(for:) exection. So when in other place in code like UITableViewCell I have added additional target action on totaly different target (UITableViewCell object, not this custom Target() objects) while cells was disappearing and then again appearing on screen and removeTarget(for) was executed then this other (external as I call them target actions) also was removed and never called again.
I consider that some problem was usage of [String: Target] dictionary which is value type and it was used in case of property getter in objc_getAssociatedObject where there was
objc_getAssociatedObject(self, key) as? Storable ?? Property.property
So as I understand it then there wasn't objc object for given key and Storable was nil and nil-coalescing operator was called and static value type Property.property return aka [String : Dictionary]
So it was returned by copy and Target object was stored in this copied object which wasn't permanently stored and accessed in removeTarget(for:) always as nil. So nil was passed to UIControl.removetTarget() and all target actions was always cleared!.
I have tried simple replacing [String: Target] Swift dictionary with NSMutableDictionary which is a reference type so I assume it can be stored. But this simple replacement for static variable and just returning it via nil-coalesing operator caused as I assume that there as only one such storage for Target objects and then while scrolling Table View each removeForTarget() has somehow remove all target actions from all UITextFields not only from current.
I also consider usage of String(describing: type(of: Storable.self)) as being wrong as it will be always the same for given Storable type.
Ok, I think I finally solved this issue
The main problem was usage of AssociatedKey! it needs to be done like below
https://stackoverflow.com/a/48731142/4415642
So I ended up with such code:
import UIKit
/**
* Swift 4.2 for UIControl and UIGestureRecognizer,
* and and remove targets through swift extension
* stored property paradigm.
* https://stackoverflow.com/a/52796515/4415642
**/
extension UIControl: ExtensionPropertyStorable {
class Property: PropertyProvider {
static var property = NSMutableDictionary()
static func makeProperty() -> NSMutableDictionary? {
return NSMutableDictionary()
}
}
func addTarget(for controlEvent: UIControl.Event = .touchUpInside, target: #escaping (_ sender: Any) ->()) {
let key = String(describing: controlEvent)
let target = Target(target: target)
addTarget(target, action: target.action, for: controlEvent)
property[key] = target
print("ADDED \(ObjectIdentifier(target)), \(target.action)")
}
func removeTarget(for controlEvent: UIControl.Event = .touchUpInside) {
let key = String(describing: controlEvent)
if let target = property[key] as? Target {
print("REMOVE \(ObjectIdentifier(target)), \(target.action)")
removeTarget(target, action: target.action, for: controlEvent)
property[key] = nil
}
}
}
extension UIGestureRecognizer: ExtensionPropertyStorable {
class Property: PropertyProvider {
static var property: Target?
}
func addTarget(target: #escaping (Any) -> ()) {
let target = Target(target: target)
addTarget(target, action: target.action)
property = target
}
func removeTarget() {
let target = property
removeTarget(target, action: target?.action)
property = nil
}
}
// Wrapper class for the selector
class Target {
private let t: (_ sender: Any) -> ()
init(target t: #escaping (_ sender: Any) -> ()) { self.t = t }
#objc private func s(_ sender: Any) { t(sender) }
public var action: Selector {
return #selector(s(_:))
}
deinit {
print("Deinit target: \(ObjectIdentifier(self))")
}
}
// Protocols with associatedtypes so we can hide the objc_ code
protocol PropertyProvider {
associatedtype PropertyType: Any
static var property: PropertyType { get set }
static func makeProperty() -> PropertyType?
}
extension PropertyProvider {
static func makeProperty() -> PropertyType? {
return nil
}
}
protocol ExtensionPropertyStorable: class {
associatedtype Property: PropertyProvider
}
// Extension to make the property default and available
extension ExtensionPropertyStorable {
typealias Storable = Property.PropertyType
var property: Storable {
get {
guard let obj = objc_getAssociatedObject(self, &AssociatedKeys.property) as? Storable else {
if let property = Property.makeProperty() {
objc_setAssociatedObject(self, &AssociatedKeys.property, property, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
return objc_getAssociatedObject(self, &AssociatedKeys.property) as? Storable ?? Property.property
}
return obj
}
set {
return objc_setAssociatedObject(self, &AssociatedKeys.property, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
private struct AssociatedKeys {
static var property = "AssociatedKeys.property"
}