swiftswiftui

How to intercept value from NSResponder and use that string?


My goal: I have a macOS app built, and I want to...

  1. Have a button to open the emoji picker (managed this)
  2. When user select an emoji, intercept that value with NSResponder
  3. Then use it on my Button label

The code below is a simplification. I need help: How can I intercept the value from the NSResponder and print it?

import SwiftUI
import AppKit

struct ContentView: View {
    private let emojiResponder = EmojiResponder()
    
    var body: some View {
        Button {
            // open the emoji picker
            if let window = NSApplication.shared.keyWindow {
                window.makeFirstResponder(emojiResponder)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    NSApp.orderFrontCharacterPalette(nil)
                }
            }
        } label: {
            Text("Emoji?")
        }
        .onAppear {
            emojiResponder.onEmojiSelected = { selectedEmoji in
                print(selectedEmoji)
            }
        }
    }
}

// Custom Emoji NSResponder
class EmojiResponder: NSResponder {
    var onEmojiSelected: ((String) -> Void)?
    
    // use insertTest method from NSResponder
    // I assume this allows me to get the input 
    override func insertText(_ insertString: Any) {
        guard let selectedEmoji = insertString as? String else { return }
        onEmojiSelected?(selectedEmoji)
        print(selectedEmoji)
    }
}

Can anyone give me a pointer on how to solve this? What happens now: The emoji picker is opened, but nothing happens after I select an emoji

Note: I'm using the solution from this discussion - https://stackoverflow.com/a/77797594/5133585. But I'm hoping a solution without a hidden TextField.


The reason I don't want to use a TextField here:

I have a list that loops through an array. On each row, there are 3 TextFields: [ emoji ] —— [ name ] —— [ date ]

When I select a row and press enter on macOS, it focuses on the emoji textfield. What I want is to focus on the name TextField. Hence, I'm hoping to remove this emoji TextField and solve that problem.


Solution

  • The emoji picker must focus on something, and it has to be something you have control over, in order to get what is selected.

    If you don't like to focus a hidden TextField, you can focus on the button that opens up the emoji picker. Create an NSButton subclass that conforms to NSTextInputClient, like in this answer, and wrap it in a NSViewRepresentable.

    struct EmojiButton: NSViewRepresentable {
        // I chose to use a callback closure here
        // but you can easily make this into a @Binding if you prefer that
        let onSelectEmoji: (String) -> Void
        
        func makeNSView(context: Context) -> TextReceiverButton {
            context.coordinator.button
        }
        
        func updateNSView(_ nsView: TextReceiverButton, context: Context) {
            nsView.receiveText = onSelectEmoji
        }
        
        func makeCoordinator() -> Coordinator {
            Coordinator()
        }
        
        @MainActor
        class Coordinator: NSObject {
            let button = TextReceiverButton(title: "Emoji", target: nil, action: nil)
            
            override init() {
                super.init()
                button.target = self
                button.action = #selector(displayEmojiInButton(_:))
            }
            
            @objc func displayEmojiInButton(_ sender: Any) {
                NSApp.orderFrontCharacterPalette(nil)
                Task {
                    // this has to be done asynchronously, as in your attempt
                    button.window?.makeFirstResponder(button)
                }
            }
        }
    }
    
    class TextReceiverButton: NSButton, NSTextInputClient {
        var receiveText: ((String) -> Void)?
        
        nonisolated func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
            
        }
        
        nonisolated func unmarkText() {
            
        }
        
        nonisolated func selectedRange() -> NSRange {
            .init()
        }
        
        nonisolated func markedRange() -> NSRange {
            .init()
        }
        
        nonisolated func hasMarkedText() -> Bool {
            false
        }
        
        nonisolated func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
            nil
        }
        
        nonisolated func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
            .zero
        }
        
        nonisolated func characterIndex(for point: NSPoint) -> Int {
            0
        }
    
        nonisolated func insertText(_ string: Any, replacementRange: NSRange) {
            if let receivedText = string as? String {
                Task { @MainActor in
                    receiveText?(receivedText)
                }
            }
        }
        
    
        nonisolated func validAttributesForMarkedText() -> [NSAttributedString.Key] {
            return [.font, .paragraphStyle, .writingDirection]
        }
    }
    

    Since you mentioned a selectable List with rows containing text fields, here is an example usage in a similar situation. This simply prints out the selected emoji.

    struct ContentView: View {
        @State var text = ""
        @State var selection: Int?
        
        var body: some View {
            List(selection: $selection) {
                ForEach(0..<10) { i in Row().tag(i) }
            }
        }
    }
    
    struct Row: View {
        @State var name = ""
        @State var date = ""
        var body: some View {
            HStack {
                EmojiButton { 
                    print("Selected", $0)
                }
                TextField("Name", text: $name)
                TextField("Date", text: $date)
            }
        }
    }
    

    To have the emoji picker show up as a pop up, the previously-focused view before doing orderFrontCharacterPalette must be a text field. So if you want the popup to show up on the button, you still have to put an invisible NSTextView at the centre of the button.

    Perhaps since this text field is on the AppKit side, SwiftUI doesn't know about it, and will not have the undesirable focus behaviour when pressing the return key.

    When the button is pressed, set the first responder to the invisible text view, then asynchronously call orderFrontCharacterPalette.

    Here is an example implementation.

    struct EmojiButton: NSViewRepresentable {
        let onSelectEmoji: (String) -> Void
        
        func makeNSView(context: Context) -> NSView {
            context.coordinator.view
        }
        
        func updateNSView(_ nsView: NSView, context: Context) {
            context.coordinator.textView.receiveText = onSelectEmoji
        }
        
        func makeCoordinator() -> Coordinator {
            Coordinator()
        }
        
        @MainActor
        class Coordinator: NSObject {
            let button = NSButton(title: "Emoji", target: nil, action: nil)
            let textView = TextReceiverView(frame: .init(x: 0, y: 0, width: 1, height: 1))
            let view = NSView()
            
            override init() {
                super.init()
                
                button.target = self
                button.action = #selector(displayEmojiInButton(_:))
                
                // remove the cursor
                textView.insertionPointColor = .clear
                
                button.translatesAutoresizingMaskIntoConstraints = false
                textView.translatesAutoresizingMaskIntoConstraints = false
                // add the textView first so the button goes above it
                view.addSubview(textView)
                view.addSubview(button)
                NSLayoutConstraint.activate([
                    button.leftAnchor.constraint(equalTo: view.leftAnchor),
                    button.rightAnchor.constraint(equalTo: view.rightAnchor),
                    button.topAnchor.constraint(equalTo: view.topAnchor),
                    button.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                    textView.widthAnchor.constraint(equalToConstant: 1),
                    textView.heightAnchor.constraint(equalToConstant: 1),
                    textView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
                    textView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
                ])
            }
            
            @objc func displayEmojiInButton(_ sender: Any) {
                button.window?.makeFirstResponder(textView)
                Task {
                    try await Task.sleep(for: .milliseconds(100))
                    NSApp.orderFrontCharacterPalette(nil)
                }
            }
        }
    }
    
    class TextReceiverView: NSTextView {
        
        var receiveText: ((String) -> Void)?
    
        override func insertText(_ string: Any, replacementRange: NSRange) {
            if let receivedText = string as? String {
                receiveText?(receivedText)
            }
        }
    }