My goal: I have a macOS app built, and I want to...
NSResponder
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.
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)
}
}
}