macOS SwiftUI TextEditor的复制、粘贴和剪切键盘快捷方式

4
我正在使用SwiftUI为macOS菜单/状态栏制作一个应用程序,当点击它时,会打开一个NSPopover。该应用程序以TextEditor(在Big Sur中新增)为中心,但是该TextEditor似乎不响应通常的Cmd+C/V/X键盘快捷键进行复制、粘贴和剪切。我知道TextEditor支持这些快捷键,因为如果我在XCode中启动一个新项目,并且不将其放入NSPopover中(例如,我只将其放入普通的Mac应用程序中),那么它就可以工作。复制/粘贴/剪切选项仍然出现在右键菜单中,但我不确定为什么不能使用键盘快捷键在NSPopover中访问它们。
我认为这与当您单击以打开popover时,macOS没有“聚焦”于应用程序有关。通常,当您打开应用程序时,您会在Mac菜单栏的左上角(靠近Apple徽标)看到应用程序名称和相关菜单选项。我的应用程序不会这样做(可能是因为它是一个popover)。
以下是相关代码:
ContentView.swift中的TextEditor:
TextEditor(text: $userData.note)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .padding(10)
                    .font(.body)
                    .background(Color(red: 30 / 255, green: 30 / 255, blue: 30 / 255))

在NotedApp.swift中的NSPopover逻辑:

@main
struct MenuBarPopoverApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        Settings{
            EmptyView()
        }
    }
}
class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBarItem: NSStatusItem?

    func applicationDidFinishLaunching(_ notification: Notification) {
        
        let contentView = ContentView()

        popover.behavior = .transient
        popover.animates = false
        popover.contentViewController = NSViewController()
        popover.contentViewController?.view = NSHostingView(rootView: contentView)
        statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusBarItem?.button?.title = "Noted"
        statusBarItem?.button?.action = #selector(AppDelegate.togglePopover(_:))
    }
    @objc func showPopover(_ sender: AnyObject?) {
        if let button = statusBarItem?.button {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
        }
    }
    @objc func closePopover(_ sender: AnyObject?) {
        popover.performClose(sender)
    }
    @objc func togglePopover(_ sender: AnyObject?) {
        if popover.isShown {
            closePopover(sender)
        } else {
            showPopover(sender)
        }
    }
}

您可以在此GitHub存储库中找到整个应用程序:https://github.com/R-Taneja/Noted


那你在问什么呢?你是想在用户从编辑菜单中选择复制/剪切/粘贴时运行代码吗?考虑到编辑菜单并不属于你的应用程序,这样做没有任何意义。 - undefined
@LeoDabus 我希望用户能够在TextEditor中使用典型的键盘快捷键(Cmd + C/V/X)来复制/粘贴/剪切文本。 - undefined
我找到了一个非常相似的问题(https://stackoverflow.com/questions/49637675/cut-copy-paste-keyboard-shortcuts-not-working-in-nspopover),但是唯一的答案已经过时了,而且我不确定如何在Big Sur/最新版本的Swift中实现它。 - undefined
2个回答

6

我在构建一个SwiftUI macOS弹出窗口应用程序时,偶然发现了这个问题(和解决方案),而当前的解决方案虽然可用,但存在一些缺点。最大的问题是需要让我们的ContentView意识到并响应编辑操作,很可能这对于嵌套视图和复杂导航来说无法很好地扩展。

我的解决方案依赖于NSResponder链和使用NSApplication.sendAction(_:to:from:)发送nil目标。支持文本输入的NSTextViewNSTextField对象都利用NSText,当这些对象中的任何一个成为第一响应者时,消息会传递给它们。

我已经确认以下内容可以在复杂层次结构中工作,并提供NSText上可用的所有文本编辑方法。

菜单示例

@main
struct MyApp: App {
var body: some Scene {
    Settings {
        EmptyView()
    }
    .commands {
        CommandMenu("Edit") {
            Section {

                // MARK: - `Select All` -
                Button("Select All") {
                    NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.a)
                
                // MARK: - `Cut` -
                Button("Cut") {
                    NSApp.sendAction(#selector(NSText.cut(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.x)
                
                // MARK: - `Copy` -
                Button("Copy") {
                    NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.c)
                
                // MARK: - `Paste` -
                Button("Paste") {
                    NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil)
                }
                .keyboardShortcut(.v)
            }
        }
    }
}

键盘事件修饰符(可选)

这只是一个方便的修饰符,不需要使用它也可以实现工作解决方案。

// MARK: - `Modifiers` -

fileprivate struct KeyboardEventModifier: ViewModifier {
    enum Key: String {
        case a, c, v, x
    }
    
    let key: Key
    let modifiers: EventModifiers
    
    func body(content: Content) -> some View {
        content.keyboardShortcut(KeyEquivalent(Character(key.rawValue)), modifiers: modifiers)
    }
}

extension View {
    fileprivate func keyboardShortcut(_ key: KeyboardEventModifier.Key, modifiers: EventModifiers = .command) -> some View {
        modifier(KeyboardEventModifier(key: key, modifiers: modifiers))
    }
}

希望这能帮助其他人解决这个问题!
在 macOS Monterrey 12.1 上使用 SwiftUI 2.0 进行测试。

优雅的解决方案,巧妙地构建在NSResponder链上。非常有帮助!谢谢! - undefined
这对我来说效果很好,但是我不得不使用.keyboardShortcut("V")等,而不是.v,所以我不确定发生了什么。这个神秘的CommandMenu实际上没有出现在任何地方吗? - undefined

1
我在寻找一个类似的解决方案来处理TextField,并且找到了一个有点不太正规的方法。这里是一个相似的方式,使用TextEditor来解决你的问题。
我试图解决的第一个问题是让textField成为第一响应者(当弹出窗口打开时聚焦)。
可以使用SwiftUI-Introspect库(https://github.com/timbersoftware/SwiftUI-Introspect)来完成这个任务,就像在这个TextField的答案中所看到的一样(https://dev59.com/z1MI5IYBdhLWcg3wUZyd#59277051)。 对于TextEditor,你可以这样做:
TextEditor(text: $userData.note)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .padding(10)
    .font(.body)
    .background(Color(red: 30 / 255, green: 30 / 255, blue: 30 / 255))

    .introspect(
        selector: TargetViewSelector.siblingContaining,
        customize: { view in
            view.becomeFirstResponder()
    })

现在来谈论你的主要问题——剪切/复制/粘贴,你也可以使用Introspect。 首先要从TextEditor内部获取对NSTextField的引用:
    .introspect(
        selector: TargetViewSelector.siblingContaining,
        customize: { view in
            view.becomeFirstResponder()
    
            // Extract the NSText from the NSScrollView
            mainText = ((view as! NSScrollView).documentView as! NSText)
            //

    })

mainText变量必须在某处声明,但不能作为ContentView内的@State,因为我的TextField出现了选择问题。最终,我只是将其放在Swift文件的根级别:

import SwiftUI
import Introspect

// Stick this somewhere
var mainText: NSText!

struct ContentView: View {

...

下一步是设置一个带有命令的菜单,这也是我认为没有剪切/复制/粘贴的主要原因。在您的应用程序中添加一个命令菜单并添加所需的命令。
@main
struct MenuBarPopoverApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        Settings{
            EmptyView()
        }
        .commands {
            MenuBarPopoverCommands(appDelegate: appDelegate)
        }
    }
}

struct MenuBarPopoverCommands: Commands {
    
    let appDelegate: AppDelegate
    
    init(appDelegate: AppDelegate) {
        self.appDelegate = appDelegate
    }
    
    var body: some Commands {
        CommandMenu("Edit"){ // Doesn't need to be Edit
            Section {
                Button("Cut") {
                    appDelegate.contentView.editCut()
                }.keyboardShortcut(KeyEquivalent("x"), modifiers: .command)
                
                Button("Copy") {
                    appDelegate.contentView.editCopy()
                }.keyboardShortcut(KeyEquivalent("c"), modifiers: .command)
                
                Button("Paste") {
                    appDelegate.contentView.editPaste()
                }.keyboardShortcut(KeyEquivalent("v"), modifiers: .command)
                
                // Might also want this
                Button("Select All") {
                    appDelegate.contentView.editSelectAll()
                }.keyboardShortcut(KeyEquivalent("a"), modifiers: .command)
            }
        }
    }
}

还需要使contentView可访问:
class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBarItem: NSStatusItem?

    // making this a class variable
    var contentView: ContentView! 

    func applicationDidFinishLaunching(_ notification: Notification) {

        // assign here
        contentView = ContentView()
...

最后是实际的命令。

struct ContentView: View {

...

    func editCut() {
        mainText?.cut(self)
    }
    
    func editCopy() {
        mainText?.copy(self)
    }
    
    func editPaste() {
        mainText?.paste(self)
    }
    
    func editSelectAll() {
        mainText?.selectAll(self)
    }

    // Could also probably add undo/redo in a similar way but I haven't tried

...

}

这是我在StackOverflow上的第一个回答,希望我的回答能让人理解并且做得正确。但我希望有人能提供更好的解决方案,当我遇到这个问题时,自己也正在寻找答案。


1
太棒了,解决方案非常有效。谢谢!我没搞清楚如何使撤销/重做功能正常工作,所以如果你知道的话,请告诉我。 - undefined
我不得不使用.introspectTextField {…},不确定我最终是否得到了introspect库的不同版本。谢谢! - undefined
1
@lionello 请看下面我的解决方案! - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接