Xamarin.Mac 中的菜单

本文介绍如何在 Xamarin.Mac 应用程序中使用菜单。 它介绍如何在 Xcode 和 Interface Builder 中创建和维护菜单和菜单项,还描述了如何以编程方式使用这些内容。

在 Xamarin.Mac 应用程序中使用 C# 和 .NET 时,你可以访问的 Cocoa 菜单与使用 Objective-C 和 Xcode 的开发人员访问的菜单相同。 由于 Xamarin.Mac 与 Xcode 直接集成,你可以使用 Xcode 的 Interface Builder 来创建和维护菜单栏、菜单和菜单项(或者选择直接使用 C# 代码创建它们)。

菜单是 Mac 应用程序的用户体验不可或缺的一部分,通常显示在用户界面的各个部分:

  • 应用程序的菜单栏 - 这是每个 Mac 应用程序的屏幕顶部显示的主菜单。
  • 上下文菜单 - 当用户右键单击或控件单击窗口中的项时,将显示这些菜单。
  • 状态栏 - 这是应用程序菜单栏最右侧的区域,显示在屏幕顶部(菜单栏时钟左侧),并随着项的添加而向左扩展。
  • 停靠菜单 - 当用户右键单击或控件单击应用程序的图标时,或者当用户左键单击图标并按住鼠标按钮时,出现的停靠区中的每个应用程序的菜单。
  • 弹出按钮和下拉列表 - 弹出按钮显示所选的项,并在用户单击时显示一个选项列表以供选择。 下拉列表是一种弹出按钮类型,通常用于选择特定于当前任务的上下文的命令。 两者都可以出现在窗口中的任意位置。

示例菜单

在本文中,我们将介绍在 Xamarin.Mac 应用程序中使用 Cocoa 菜单栏、菜单和菜单项的基础知识。 强烈建议先阅读Hello, Mac 一文,特别是 Xcode 和 Interface Builder 简介输出口和操作部分,因为其中介绍了我们将在本文中使用的关键概念和技术。

你可能还需要查看 Xamarin.Mac 内部机制文档的向 Objective-C 公开 C# 类/方法部分,其中介绍了用于将 C# 类连接到 Objective-C 对象和 UI 元素的 RegisterExport 属性。

应用程序的菜单栏

在 Windows OS 上运行的应用程序中,每个窗口都可以附加自己的菜单栏,而 macOS 上运行的每个应用程序都有一个菜单栏,该菜单栏沿屏幕顶部运行,用于该应用程序中的每个窗口:

菜单栏

此菜单栏上的项在任何给定时间都根据应用程序及其用户界面的当前上下文或状态激活或停用。 例如:如果用户选择文本字段,“编辑”菜单上的项将启用,例如“复制”和“剪切”。

根据 Apple 和在默认情况下,所有 macOS 应用程序都有一组标准菜单和菜单项,它们显示在应用程序的菜单栏中:

  • Apple 菜单 - 此菜单提供对系统范围的项的访问,这些项随时可供用户使用,而不考虑正在运行的应用程序。 开发人员无法修改这些项。
  • 应用菜单 - 此菜单以粗体显示应用程序的名称,并帮助用户确定当前正在运行的应用程序。 它包含应用于整个应用程序的项,而不是给定的文档或进程(例如退出应用程序)。
  • 文件菜单 - 这些项用于创建、打开或保存应用程序使用的文档。 如果应用程序并非基于文档,可以重命名或删除此菜单。
  • 编辑菜单 - 包含用于编辑或修改应用程序用户界面中的元素的命令,例如“剪切”、“复制”和“粘贴”。
  • 格式菜单 - 如果应用程序使用文本,此菜单包含用于调整该文本格式的命令。
  • 视图菜单 - 包含影响如何在应用程序用户界面中显示(查看)内容的命令。
  • 特定于应用程序的菜单 - 它们是特定于应用程序的任何菜单(例如 Web 浏览器的书签菜单)。 它们应显示在栏上的“视图”菜单和“窗口”菜单之间。
  • 窗口菜单 - 包含用于在应用程序中使用窗口的命令,以及当前打开的窗口的列表。
  • 帮助菜单 - 如果应用程序提供屏幕上的帮助,“帮助”菜单应该是栏上最右侧的菜单。

若要纤细了解应用程序菜单栏、标准菜单和菜单项,请参阅 Apple 的人机界面指南

默认应用程序菜单栏

每当创建新的 Xamarin.Mac 项目时,你都会自动获得一个标准的默认应用程序菜单栏,其中包含 macOS 应用程序通常具有的典型项(如上述部分所述)。 应用程序的默认菜单栏在 Solution Pad 中项目下的 Main.storyboard 文件中定义(以及应用的 UI 的其余部分):

选择主情节提要

双击 Main.storyboard 文件将其打开,以便在 Xcode 的 Interface Builder 中编辑,你会看到菜单编辑器界面:

在 Xcode 中编辑 UI,显示了 Main dot storyboard。

在这里,可以单击“文件”菜单中的“打开”菜单项等项,并在“特性检查器”中编辑或调整其属性:

编辑菜单的属性

本文稍后将介绍如何添加、编辑和删除菜单和菜单项。 目前,我们只想查看默认情况下可用的菜单和菜单项,以及它们如何通过一组预定义的输出口和操作自动向代码公开(有关详细信息,请参阅输出口和操作文档)。

例如,如果单击“打开”菜单项的“连接检查器”,可以看到它会自动连接到 openDocument: 操作:

查看附加操作

如果选择“界面层次结构”中的“第一个响应方”,并在“连接检查器”中向下滚动,会看到“打开”菜单项附加到的 openDocument: 操作的定义(以及应用程序的其他几个默认操作,无论这些操作是否自动连接到控件):

查看所有附加操作

为什么这很重要? 在下一部分中,将了解这些自动定义的操作如何与其他 Cocoa 用户界面元素一起使用,以便自动启用和禁用菜单项,并为项提供内置功能。

稍后我们将使用这些内置操作来通过代码启用和禁用项,并在项被选中时提供自己的功能。

内置菜单功能

如果在添加任何 UI 项或代码之前运行了新创建的 Xamarin.Mac 应用程序,你会注意到某些项会自动连接并启用(具有自动内置的完整功能),例如“应用”菜单中的“退出”项:

已启用的菜单项

而其他菜单项(如“剪切”、“复制”和“粘贴”)则不会:

已禁用的菜单项

让我们停止应用程序,然后双击 Solution Pad 中的 Main.storyboard 文件将其打开,以便在 Xcode 的 Interface Builder 中编辑。 接下来,将文本视图从“库”拖到“界面编辑器”中的窗口视图控制器上:

从库中选择文本视图

在“约束编辑器”中,让我们将文本视图固定到窗口边缘,并通过单击编辑器顶部的所有四个红色 I 并单击“添加 4 个约束”按钮来设置文本视图随窗口的增长和缩小的位置:

编辑约束

保存对用户界面设计的更改,并切换回 Visual Studio for Mac,以便将更改与 Xamarin.Mac 项目同步。 现在启动应用程序,在文本视图中键入一些文本,将其选择,然后打开“编辑”菜单:

自动启用/禁用菜单项

请注意如何“剪切”、“复制”和“粘贴”项如何自动启用并完全正常运行,而无需编写单行代码。

这是怎么回事? 请记住内置预定义操作,这些操作连接到默认菜单项(如上所示),大多数属于 macOS 的 Cocoa 用户界面元素都内置了特定操作的挂钩(例如 copy:)。 因此,当它们添加到窗口、处于活动状态和被选中时,会自动启用相应的菜单项或附加到该操作的项。 如果用户选择该菜单项,则会调用并执行 UI 元素中内置的功能,而无需开发人员干预。

启用和禁用菜单和项

默认情况下,每次发生用户事件时,NSMenu 都会根据应用程序的上下文自动启用和禁用每个可见菜单和菜单项。 可通过 3 种方法来启用/禁用项:

  • 自动菜单启用 - 如果 NSMenu 可找到响应菜单项所连接到的操作的相应对象,则会启用该菜单项。 例如,上面具有 copy: 操作的内置挂钩的文本视图。
  • 自定义操作和 validateMenuItem: - 对于绑定到窗口或视图控制器自定义操作的任何菜单项,可以添加 validateMenuItem: 操作并手动启用/禁用菜单项。
  • 手动菜单启用 - 手动设置每个 NSMenuItemEnabled 属性来单独启用或禁用菜单中的每个项。

若要选择系统,请设置 NSMenuAutoEnablesItems 属性。 true 表示自动(默认行为),false 表示手动。

重要

如果选择使用手动菜单启用,不会自动更新任何菜单项(即使是由 NSTextView 等 AppKit 类控制的菜单项)。 你将负责在代码中手动启用和禁用所有项。

使用 validateMenuItem

如上所述,对于绑定到窗口或视图控制器自定义操作的任何菜单项,可以添加 validateMenuItem: 操作并手动启用/禁用菜单项。

在下面的示例中,Tag 属性将用于根据 NSTextView 中所选文本的状态,决定 validateMenuItem: 操作将启用/禁用的菜单项的类型。 Tag 属性已在 Interface Builder 中为每个菜单项设置:

设置 Tag 属性

以下代码添加到视图控制器中:

[Action("validateMenuItem:")]
public bool ValidateMenuItem (NSMenuItem item) {

    // Take action based on the menu item type
    // (As specified in its Tag)
    switch (item.Tag) {
    case 1:
        // Wrap menu items should only be available if
        // a range of text is selected
        return (TextEditor.SelectedRange.Length > 0);
    case 2:
        // Quote menu items should only be available if
        // a range is NOT selected.
        return (TextEditor.SelectedRange.Length == 0);
    }

    return true;
}

当运行此代码且未在 NSTextView 中选择任何文本时,将禁用两个换行菜单项(即使它们与视图控制器上的操作连接):

显示已禁用的项目

如果选择了文本部分并重新打开菜单,则两个换行菜单项将可用:

显示已启用的项目

在代码中启用和响应菜单项

如上所述,只要将特定的 Cocoa 用户界面元素添加到 UI 设计(如文本字段),几个默认菜单项就会自动启用并自动运行,而无需编写任何代码。 接下来,让我们看看如何将自己的 C# 代码添加到 Xamarin.Mac 项目,以便在用户选择菜单项时启用该菜单项并提供功能。

例如,假设我们希望用户能够使用“文件”菜单中的“打开”项来选择文件夹。 由于我们希望这是一个应用程序范围的函数,而不限于给定的窗口或 UI 元素,我们将添加代码以将其处理给应用程序委托。

在 Solution Pad 中,双击 AppDelegate.CS 文件来打开它进行编辑:

选择应用委托

DidFinishLaunching 方法下面添加以下代码:

[Export ("openDocument:")]
void OpenDialog (NSObject sender)
{
    var dlg = NSOpenPanel.OpenPanel;
    dlg.CanChooseFiles = false;
    dlg.CanChooseDirectories = true;

    if (dlg.RunModal () == 1) {
        var alert = new NSAlert () {
            AlertStyle = NSAlertStyle.Informational,
            InformativeText = "At this point we should do something with the folder that the user just selected in the Open File Dialog box...",
            MessageText = "Folder Selected"
        };
        alert.RunModal ();
    }
}

现在运行应用程序并打开“文件”菜单:

“文件”菜单

请注意,“打开”菜单项现已启用。 如果选择它,将显示打开的对话框:

“打开”对话框

如果单击“打开”按钮,将显示警报消息:

示例对话框消息

这里的关键行是 [Export ("openDocument:")],它告诉 NSMenu AppDelegate 具有响应 openDocument: 操作的 void OpenDialog (NSObject sender) 方法。 如果你还记得上面的内容,在默认情况下,“打开”菜单项在 Interface Builder 中自动连接到此操作:

查看附加操作

接下来,让我们了解如何创建自己的菜单、菜单项和操作,并在代码中响应它们。

使用“打开最近使用的文件”菜单

默认情况下,“文件”菜单包含一个“打开最近使用的文件”项,用于跟踪用户已使用应用打开的最近几个文件。 如果要创建基于 NSDocument 的 Xamarin.Mac 的应用,将自动处理此菜单。 对于任何其他类型的 Xamarin.Mac 应用,你将负责手动管理和响应此菜单项。

若要手动处理“打开最近使用的文件”菜单,首先需要使用以下代码通知新文件已打开或已保存:

// Add document to the Open Recent menu
NSDocumentController.SharedDocumentController.NoteNewRecentDocumentURL(url);

即使应用未使用 NSDocuments,你仍可使用 NSDocumentController 来维护“打开最近使用的文件”菜单,方法是将具有文件位置的 NSUrl 发送到 SharedDocumentControllerNoteNewRecentDocumentURL 方法。

接下来,需要替代应用委托的 OpenFile 方法,打开用户从“打开最近使用的文件”菜单中选择的任何文件。 例如:

public override bool OpenFile (NSApplication sender, string filename)
{
    // Trap all errors
    try {
        filename = filename.Replace (" ", "%20");
        var url = new NSUrl ("file://"+filename);
        return OpenFile(url);
    } catch {
        return false;
    }
}

如果文件可以打开,则返回 true;否则返回 false,并向用户显示一个内置警告,显示无法打开该文件。

由于从“打开最近使用的文件”菜单返回的文件名和路径可能包含空格,因此我们需要在创建 NSUrl 之前正确转义此字符,否则我们将收到错误。 使用以下代码执行此操作:

filename = filename.Replace (" ", "%20");

最后,我们将创建一个 NSUrl 来指向该文件,然后使用应用委托中的帮助程序方法打开一个新窗口并将文件加载到其中:

var url = new NSUrl ("file://"+filename);
return OpenFile(url);

为了将所有内容结合起来,让我们看看 AppDelegate.cs 文件中的示例实现:

using AppKit;
using Foundation;
using System.IO;
using System;

namespace MacHyperlink
{
    [Register ("AppDelegate")]
    public class AppDelegate : NSApplicationDelegate
    {
        #region Computed Properties
        public int NewWindowNumber { get; set;} = -1;
        #endregion

        #region Constructors
        public AppDelegate ()
        {
        }
        #endregion

        #region Override Methods
        public override void DidFinishLaunching (NSNotification notification)
        {
            // Insert code here to initialize your application
        }

        public override void WillTerminate (NSNotification notification)
        {
            // Insert code here to tear down your application
        }

        public override bool OpenFile (NSApplication sender, string filename)
        {
            // Trap all errors
            try {
                filename = filename.Replace (" ", "%20");
                var url = new NSUrl ("file://"+filename);
                return OpenFile(url);
            } catch {
                return false;
            }
        }
        #endregion

        #region Private Methods
        private bool OpenFile(NSUrl url) {
            var good = false;

            // Trap all errors
            try {
                var path = url.Path;

                // Is the file already open?
                for(int n=0; n<NSApplication.SharedApplication.Windows.Length; ++n) {
                    var content = NSApplication.SharedApplication.Windows[n].ContentViewController as ViewController;
                    if (content != null && path == content.FilePath) {
                        // Bring window to front
                        NSApplication.SharedApplication.Windows[n].MakeKeyAndOrderFront(this);
                        return true;
                    }
                }

                // Get new window
                var storyboard = NSStoryboard.FromName ("Main", null);
                var controller = storyboard.InstantiateControllerWithIdentifier ("MainWindow") as NSWindowController;

                // Display
                controller.ShowWindow(this);

                // Load the text into the window
                var viewController = controller.Window.ContentViewController as ViewController;
                viewController.Text = File.ReadAllText(path);
                viewController.SetLanguageFromPath(path);
                viewController.View.Window.SetTitleWithRepresentedFilename (Path.GetFileName(path));
                viewController.View.Window.RepresentedUrl = url;

                // Add document to the Open Recent menu
                NSDocumentController.SharedDocumentController.NoteNewRecentDocumentURL(url);

                // Make as successful
                good = true;
            } catch {
                // Mark as bad file on error
                good = false;
            }

            // Return results
            return good;
        }
        #endregion

        #region actions
        [Export ("openDocument:")]
        void OpenDialog (NSObject sender)
        {
            var dlg = NSOpenPanel.OpenPanel;
            dlg.CanChooseFiles = true;
            dlg.CanChooseDirectories = false;

            if (dlg.RunModal () == 1) {
                // Nab the first file
                var url = dlg.Urls [0];

                if (url != null) {
                    // Open the document in a new window
                    OpenFile (url);
                }
            }
        }
        #endregion
    }
}

根据应用的要求,你可能不希望用户同时在多个窗口中打开同一文件。 在我们的示例应用中,如果用户选择已打开的文件(从“打开最近使用的文件”或“打开...”菜单项),则包含该文件的窗口将放在前面。

为了实现这一点,我们在帮助程序方法中使用了以下代码:

var path = url.Path;

// Is the file already open?
for(int n=0; n<NSApplication.SharedApplication.Windows.Length; ++n) {
    var content = NSApplication.SharedApplication.Windows[n].ContentViewController as ViewController;
    if (content != null && path == content.FilePath) {
        // Bring window to front
        NSApplication.SharedApplication.Windows[n].MakeKeyAndOrderFront(this);
        return true;
    }
}

我们设计了 ViewController 类,用于在其 Path 属性中保存文件的路径。 接下来,我们将循环访问应用中所有当前打开的窗口。 如果文件已在其中一个窗口中打开,则会使用以下代码将其放到所有其他窗口的前面:

NSApplication.SharedApplication.Windows[n].MakeKeyAndOrderFront(this);

如果未找到匹配项,则会打开一个新窗口并加载文件,并在“打开最近使用的文件”菜单中记录该文件:

// Get new window
var storyboard = NSStoryboard.FromName ("Main", null);
var controller = storyboard.InstantiateControllerWithIdentifier ("MainWindow") as NSWindowController;

// Display
controller.ShowWindow(this);

// Load the text into the window
var viewController = controller.Window.ContentViewController as ViewController;
viewController.Text = File.ReadAllText(path);
viewController.SetLanguageFromPath(path);
viewController.View.Window.SetTitleWithRepresentedFilename (Path.GetFileName(path));
viewController.View.Window.RepresentedUrl = url;

// Add document to the Open Recent menu
NSDocumentController.SharedDocumentController.NoteNewRecentDocumentURL(url);

使用自定义窗口操作

与预连接到标准菜单项的内置“第一个响应方”操作一样,可以创建新的自定义操作并将其连接到 Interface Builder 中的菜单项。

首先,在其中一个应用窗口控制器上定义自定义操作。 例如:

[Action("defineKeyword:")]
public void defineKeyword (NSObject sender) {
    // Preform some action when the menu is selected
    Console.WriteLine ("Request to define keyword");
}

接下来,双击 Solution Pad 中的应用情节提要文件将其打开,以便在 Xcode 的 Interface Builder 中编辑。 选择“应用程序场景”下的“第一个响应方”,然后切换到“特性检查器”:

属性检查器

单击“特性检查器”底部的 + 按钮以添加新的自定义操作:

添加新操作

将其命名为与在窗口控制器上创建的自定义操作同名:

编辑操作名称

控件单击并从菜单项推动到“应用程序场景”下的“第一个响应方”。 从弹出列表中,选择刚刚创建的新操作(在本示例中为 defineKeyword:):

附加操作

保存对情节提要的更改,并返回到 Visual Studio for Mac 以同步更改。 如果运行应用,将自动启用/禁用自定义操作连接到的菜单项(根据带有正在打开的操作的窗口),选择菜单项将触发该操作:

测试新操作

添加、编辑和删除菜单

如前面几个部分所示,Xamarin.Mac 应用程序附带了特定 UI 控件将自动激活和响应的预设数量的默认菜单和菜单项。 我们还了解了如何将代码添加到应用程序,该代码也将启用和响应这些默认项。

在本部分中,我们将介绍如何删除不需要的菜单项、重新组织菜单以及如何添加新菜单、菜单项和操作。

在 Solution Pad 中双击 Main.storyboard 文件,将其打开进行编辑:

双击情节提要文件以在 Xcode 中编辑 UI。

对于特定的 Xamarin.Mac 应用程序,我们不会使用默认的“视图”菜单,因此我们会将它删除。 在“界面层次结构”中,选择作为主菜单栏一部分的“视图”菜单项:

选择“视图”菜单项

按 Delete 或 Backspace 可删除菜单。 接下来,我们不打算使用“格式”菜单中的所有项,我们希望从子菜单下移出我们将要使用的项。 在“界面层次结构”中,选择以下菜单项:

突出显示多个项目

将父菜单下的项从这些项当前所在的子菜单中拖出:

将菜单项拖动到父菜单

菜单现在应如下所示:

新位置中的项目

接下来,让我们将“文本”子菜单从“格式”菜单下拖出,并将其放在“格式”菜单和“窗口”菜单之间的主菜单栏上:

“文本”菜单

让我们返回到“格式”菜单下,删除“字体”子菜单项。 接下来,选择“格式”菜单并将其重命名为“字体”:

“字体”菜单

接下来,让我们创建一个预定义短语的自定义菜单,这些短语将在被选定时自动追加到文本视图中的文本。 在“库检查器”底部的搜索框中,键入“菜单”。这样可以更轻松地查找和使用所有菜单 UI 元素:

库检查器

现在,让我们执行以下操作来创建菜单:

  1. 将“库检查器”中的菜单项拖到“文本”菜单和“窗口”菜单之间的菜单栏上:

    在库中选择新菜单项

  2. 将项重命名为“短语”:

    设置菜单名称

  3. 接下来,从“库检查器”中拖出菜单:

    从库中选择菜单

  4. 然后,将菜单放置到刚刚创建的新菜单项,并将其名称更改为“短语”:

    编辑菜单名称

  5. 现在,让我们重命名三个默认菜单项:“地址”、“容器”和“问候语”:

    “短语”菜单

  6. 让我们通过从“库检查器”拖出菜单项并将其命名为“签名”来添加第 4 个菜单项:

    编辑菜单项名称

  7. 保存对菜单栏的更改。

现在,让我们创建一组自定义操作,以便新菜单项向 C# 代码公开。 在 Xcode 中,让我们切换到“助手”视图:

创建所需的操作

请执行以下操作:

  1. 通过控件从“地址”菜单项拖动到 AppDelegate.h 文件。

  2. 将“连接”类型切换为“操作”:

    选择操作类型

  3. 输入“phraseAddress”作为名称,然后按“连接”按钮来创建新操作:

    输入名称来配置操作。

  4. 对“日期”、“问候语”和“签名”菜单项重复上述步骤:

    已完成的操作

  5. 保存对菜单栏的更改。

接下来,我们需要为文本视图创建一个输出口,以便我们可以通过代码调整其内容。 在“助手编辑器”中选择 ViewController.h 文件,并创建一个名为 documentText 的新输出口:

创建出口

返回到 Visual Studio for Mac 以从 Xcode 同步更改。 接下来编辑 ViewController.cs 文件,使其如下所示:

using System;

using AppKit;
using Foundation;

namespace MacMenus
{
    public partial class ViewController : NSViewController
    {
        #region Application Access
        public static AppDelegate App {
            get { return (AppDelegate)NSApplication.SharedApplication.Delegate; }
        }
        #endregion

        #region Computed Properties
        public override NSObject RepresentedObject {
            get {
                return base.RepresentedObject;
            }
            set {
                base.RepresentedObject = value;
                // Update the view, if already loaded.
            }
        }

        public string Text {
            get { return documentText.Value; }
            set { documentText.Value = value; }
        }
        #endregion

        #region Constructors
        public ViewController (IntPtr handle) : base (handle)
        {
        }
        #endregion

        #region Override Methods
        public override void ViewDidLoad ()
        {
            base.ViewDidLoad ();

            // Do any additional setup after loading the view.
        }

        public override void ViewWillAppear ()
        {
            base.ViewWillAppear ();

            App.textEditor = this;
        }

        public override void ViewWillDisappear ()
        {
            base.ViewDidDisappear ();

            App.textEditor = null;
        }
        #endregion
    }
}

这会公开 ViewController 类外部的文本视图的文本,并在窗口获得或失去焦点时通知应用委托。 现在编辑 AppDelegate.cs 文件,使其如下所示:

using AppKit;
using Foundation;
using System;

namespace MacMenus
{
    [Register ("AppDelegate")]
    public partial class AppDelegate : NSApplicationDelegate
    {
        #region Computed Properties
        public ViewController textEditor { get; set;} = null;
        #endregion

        #region Constructors
        public AppDelegate ()
        {
        }
        #endregion

        #region Override Methods
        public override void DidFinishLaunching (NSNotification notification)
        {
            // Insert code here to initialize your application
        }

        public override void WillTerminate (NSNotification notification)
        {
            // Insert code here to tear down your application
        }
        #endregion

        #region Custom actions
        [Export ("openDocument:")]
        void OpenDialog (NSObject sender)
        {
            var dlg = NSOpenPanel.OpenPanel;
            dlg.CanChooseFiles = false;
            dlg.CanChooseDirectories = true;

            if (dlg.RunModal () == 1) {
                var alert = new NSAlert () {
                    AlertStyle = NSAlertStyle.Informational,
                    InformativeText = "At this point we should do something with the folder that the user just selected in the Open File Dialog box...",
                    MessageText = "Folder Selected"
                };
                alert.RunModal ();
            }
        }

        partial void phrasesAddress (Foundation.NSObject sender) {

            textEditor.Text += "Xamarin HQ\n394 Pacific Ave, 4th Floor\nSan Francisco CA 94111\n\n";
        }

        partial void phrasesDate (Foundation.NSObject sender) {

            textEditor.Text += DateTime.Now.ToString("D");
        }

        partial void phrasesGreeting (Foundation.NSObject sender) {

            textEditor.Text += "Dear Sirs,\n\n";
        }

        partial void phrasesSignature (Foundation.NSObject sender) {

            textEditor.Text += "Sincerely,\n\nKevin Mullins\nXamarin,Inc.\n";
        }
        #endregion
    }
}

在这里,我们将 AppDelegate 设置为了分部类,以便可使用我们在 Interface Builder 中定义的操作和输出口。 我们还公开一个 textEditor 来跟踪当前处于焦点的窗口。

以下方法用于处理自定义菜单和菜单项:

partial void phrasesAddress (Foundation.NSObject sender) {

    if (textEditor == null) return;
    textEditor.Text += "Xamarin HQ\n394 Pacific Ave, 4th Floor\nSan Francisco CA 94111\n\n";
}

partial void phrasesDate (Foundation.NSObject sender) {

    if (textEditor == null) return;
    textEditor.Text += DateTime.Now.ToString("D");
}

partial void phrasesGreeting (Foundation.NSObject sender) {

    if (textEditor == null) return;
    textEditor.Text += "Dear Sirs,\n\n";
}

partial void phrasesSignature (Foundation.NSObject sender) {

    if (textEditor == null) return;
    textEditor.Text += "Sincerely,\n\nKevin Mullins\nXamarin,Inc.\n";
}

现在,如果运行应用程序,“短语”菜单中的所有项都将处于活动状态,并在选中时将给定的短语添加到文本视图中:

正在运行的应用示例

现在,我们已经具备了使用应用程序菜单栏的基础知识,接下来让我们看看如何创建自定义上下文菜单。

通过代码创建菜单

除了使用 Xcode 的 Interface Builder 创建菜单和菜单项之外,有时 Xamarin.Mac 应用需要通过代码创建、修改或删除菜单、子菜单或菜单项。

在以下示例中,将创建一个类来保存动态创建的菜单项和子菜单的相关信息:

using System;
using System.Collections.Generic;
using Foundation;
using AppKit;

namespace AppKit.TextKit.Formatter
{
    public class LanguageFormatCommand : NSObject
    {
        #region Computed Properties
        public string Title { get; set; } = "";
        public string Prefix { get; set; } = "";
        public string Postfix { get; set; } = "";
        public List<LanguageFormatCommand> SubCommands { get; set; } = new List<LanguageFormatCommand>();
        #endregion

        #region Constructors
        public LanguageFormatCommand () {

        }

        public LanguageFormatCommand (string title)
        {
            // Initialize
            this.Title = title;
        }

        public LanguageFormatCommand (string title, string prefix)
        {
            // Initialize
            this.Title = title;
            this.Prefix = prefix;
        }

        public LanguageFormatCommand (string title, string prefix, string postfix)
        {
            // Initialize
            this.Title = title;
            this.Prefix = prefix;
            this.Postfix = postfix;
        }
        #endregion
    }
}

添加菜单和项

定义此类后,以下例程将分析 LanguageFormatCommand 对象的集合,并以递归方式生成新的菜单和菜单项,方法是将它们追加到已传入的现有菜单(在 Interface Builder 中创建)的底部:

private void AssembleMenu(NSMenu menu, List<LanguageFormatCommand> commands) {
    NSMenuItem menuItem;

    // Add any formatting commands to the Formatting menu
    foreach (LanguageFormatCommand command in commands) {
        // Add separator or item?
        if (command.Title == "") {
            menuItem = NSMenuItem.SeparatorItem;
        } else {
            menuItem = new NSMenuItem (command.Title);

            // Submenu?
            if (command.SubCommands.Count > 0) {
                // Yes, populate submenu
                menuItem.Submenu = new NSMenu (command.Title);
                AssembleMenu (menuItem.Submenu, command.SubCommands);
            } else {
                // No, add normal menu item
                menuItem.Activated += (sender, e) => {
                    // Apply the command on the selected text
                    TextEditor.PerformFormattingCommand (command);
                };
            }
        }
        menu.AddItem (menuItem);
    }
}

对于具有空白 Title 属性的任何 LanguageFormatCommand 对象,此例程都会在菜单节之间创建一个分隔符菜单项(一条浅灰色线):

menuItem = NSMenuItem.SeparatorItem;

如果提供了标题,则会创建具有该标题的新菜单项:

menuItem = new NSMenuItem (command.Title);

如果 LanguageFormatCommand 对象包含子级 LanguageFormatCommand 对象,则会创建子菜单,并递归调用 AssembleMenu 方法来生成该菜单:

menuItem.Submenu = new NSMenu (command.Title);
AssembleMenu (menuItem.Submenu, command.SubCommands);

对于没有子菜单的任何新菜单项,将添加代码来处理用户选择的菜单项:

menuItem.Activated += (sender, e) => {
    // Do something when the menu item is selected
    ...
};

测试菜单创建

有了上面所有的代码,如果创建了以下 LanguageFormatCommand 对象集合:

// Define formatting commands
FormattingCommands.Add(new LanguageFormatCommand("Strong","**","**"));
FormattingCommands.Add(new LanguageFormatCommand("Emphasize","_","_"));
FormattingCommands.Add(new LanguageFormatCommand("Inline Code","`","`"));
FormattingCommands.Add(new LanguageFormatCommand("Code Block","```\n","\n```"));
FormattingCommands.Add(new LanguageFormatCommand("Comment","<!--","-->"));
FormattingCommands.Add (new LanguageFormatCommand ());
FormattingCommands.Add(new LanguageFormatCommand("Unordered List","* "));
FormattingCommands.Add(new LanguageFormatCommand("Ordered List","1. "));
FormattingCommands.Add(new LanguageFormatCommand("Block Quote","> "));
FormattingCommands.Add (new LanguageFormatCommand ());

var Headings = new LanguageFormatCommand ("Headings");
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 1","# "));
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 2","## "));
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 3","### "));
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 4","#### "));
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 5","##### "));
Headings.SubCommands.Add(new LanguageFormatCommand("Heading 6","###### "));
FormattingCommands.Add (Headings);

FormattingCommands.Add(new LanguageFormatCommand ());
FormattingCommands.Add(new LanguageFormatCommand("Link","[","]()"));
FormattingCommands.Add(new LanguageFormatCommand("Image","![](",")"));
FormattingCommands.Add(new LanguageFormatCommand("Image Link","[![](",")](LinkImageHere)"));

并且该集合传递给 AssembleMenu 函数(“格式”菜单设置为“基本”),则将创建以下动态菜单和菜单项:

正在运行的应用中的新菜单项

删除菜单和项

如果需要从应用的用户界面中删除任何菜单或菜单项,只需为 NSMenu 类的 RemoveItemAt 方法提供要删除的项的从零开始的索引即可使用该方法。

例如,若要删除上述例程创建的菜单和菜单项,可使用以下代码:

public void UnpopulateFormattingMenu(NSMenu menu) {

    // Remove any additional items
    for (int n = (int)menu.Count - 1; n > 4; --n) {
        menu.RemoveItemAt (n);
    }
}

在上面的代码中,前 4 个菜单项是在 Xcode 的 Interface Builder 中创建的,并始终在应用中可用,因此不会动态删除它们。

上下文菜单

当用户右键单击或控件单击窗口中的项时,将显示上下文菜单。 默认情况下,macOS 中内置的几个 UI 元素已经附加了上下文菜单(例如文本视图)。 但是,有时我们可能需要为已添加到窗口的 UI 元素创建自己的自定义上下文菜单。

让我们在 Xcode 中编辑 Main.storyboard 文件,并将“窗口”窗口添加到设计中,在“标识检查器”中将其类设置为“NSPanel”,在“窗口”菜单中添加新的助手项,并使用“显示 Segue”将其附加到新窗口:

在 Main dot storyboard 文件中设置跳转类型。

请执行以下操作:

  1. 将“标签”从“库检查器”拖到“面板”窗口中,并将其文本设置为“属性”:

    编辑标签的值

  2. 接下来,将“菜单”从“库检查器”拖到视图层次结构中的视图控制器上,并重命名三个默认菜单项(“文档”、“文本”和“字体”):

    所需的菜单项

  3. 现在,通过控件从“属性标签”拖动到“菜单”中:

    拖动以创建跳转

  4. 从弹出对话框中,选择“菜单”:

    通过从“标签”上下文菜单中的“出口”中选择菜单来设置跳转类型。

  5. 在“标识检查器”中,将视图控制器的类设置为“PanelViewController”:

    设置跳转类

  6. 切换回 Visual Studio for Mac 进行同步,然后返回到 Interface Builder。

  7. 切换到“助手编辑器”并选择 PanelViewController.h 文件。

  8. 为名为 propertyDocument 的“文档”菜单项创建一个操作:

    配置名为 propertyDocument 的操作。

  9. 对其余菜单项重复创建操作:

    对剩余菜单项重复上述操作。

  10. 最后,为名为 propertyLabel 的“属性标签”创建一个输出口:

    配置出口

  11. 保存更改并返回到 Visual Studio for Mac 以与 Xcode 同步。

编辑 PanelViewController.cs 文件并添加以下代码:

partial void propertyDocument (Foundation.NSObject sender) {
    propertyLabel.StringValue = "Document";
}

partial void propertyFont (Foundation.NSObject sender) {
    propertyLabel.StringValue = "Font";
}

partial void propertyText (Foundation.NSObject sender) {
    propertyLabel.StringValue = "Text";
}

现在,如果运行应用程序并右键单击面板中的属性标签,我们将看到自定义上下文菜单。 如果从菜单中选择和项,标签的值将更改:

正在运行的上下文菜单

接下来,让我们看看如何创建状态栏菜单。

状态栏菜单

状态栏菜单显示状态菜单项的集合,这些菜单项向用户提供交互或反馈,例如菜单或反映应用程序状态的图像。 即使应用程序在后台运行,应用程序的状态栏菜单也已启用且处于活动状态。 系统范围的状态栏位于应用程序菜单栏右侧,是 macOS 中当前唯一可用的状态栏。

让我们编辑 AppDelegate.cs 文件,使 DidFinishLaunching 方法如下所示:

public override void DidFinishLaunching (NSNotification notification)
{
    // Create a status bar menu
    NSStatusBar statusBar = NSStatusBar.SystemStatusBar;

    var item = statusBar.CreateStatusItem (NSStatusItemLength.Variable);
    item.Title = "Text";
    item.HighlightMode = true;
    item.Menu = new NSMenu ("Text");

    var address = new NSMenuItem ("Address");
    address.Activated += (sender, e) => {
        PhraseAddress(address);
    };
    item.Menu.AddItem (address);

    var date = new NSMenuItem ("Date");
    date.Activated += (sender, e) => {
        PhraseDate(date);
    };
    item.Menu.AddItem (date);

    var greeting = new NSMenuItem ("Greeting");
    greeting.Activated += (sender, e) => {
        PhraseGreeting(greeting);
    };
    item.Menu.AddItem (greeting);

    var signature = new NSMenuItem ("Signature");
    signature.Activated += (sender, e) => {
        PhraseSignature(signature);
    };
    item.Menu.AddItem (signature);
}

通过 NSStatusBar statusBar = NSStatusBar.SystemStatusBar; 可以访问系统范围的状态栏。 var item = statusBar.CreateStatusItem (NSStatusItemLength.Variable); 创建新的状态栏项。 在此处,我们将创建一个菜单和多个菜单项,并将菜单附加到刚刚创建的状态栏项。

如果运行应用程序,将显示新的状态栏项。 从菜单中选择项将更改文本视图中的文本:

正在运行的状态栏菜单

接下来,我们来看看如何创建自定义停靠菜单项。

自定义停靠菜单

当用户右键单击或控件单击停靠菜单中的应用程序图标时,Mac 应用程序会显示停靠菜单:

自定义停靠菜单

让我们执行以下操作来为应用程序创建自定义停靠菜单:

  1. 在 Visual Studio for Mac 中,右键单击应用程序的项目,然后选择“添加”>“新建文件...”在“新建文件”对话框中,选择“Xamarin.Mac”>“空界面定义”,对“名称”使用“DockMenu”,然后单击“新建”按钮创建新的 dockMenu.xib 文件:

    添加空接口定义

  2. 在 Solution Pad 中双击 DockMenu.xib 文件,将其打开以在 Xcode 中进行编辑。 使用以下项创建新菜单:地址、日期、问候语和签名

    设置 UI 布局

  3. 接下来,让我们将新菜单项连接到我们在上面的添加、编辑和删除菜单部分中为自定义菜单创建的现有操作。 切换到“连接检查器”,然后选择“界面层次结构”中的“第一响应方”。 向下滚动并查找 phraseAddress: 操作。 将该操作上的圆圈中的线条拖动到“地址”菜单项:

    将一行拖动到“地址”菜单项。

  4. 对附加到其相应操作的所有其他菜单项重复此操作:

    对其他菜单项重复上述操作,将其附加到相应操作。

  5. 接下来,选择“界面层次结构”中的“应用程序”。 在“连接检查器”中,将 dockMenu 输出口上圆圈中的线条拖动到刚刚创建的菜单上。

    拖动以连接出口

  6. 保存更改并切换回 Visual Studio for Mac,以便与 Xcode 同步。

  7. 双击“Info.plist”文件,将其打开进行编辑:

    编辑 info.plist 文件

  8. 单击屏幕底部的“源”按钮:

    选择“源代码”视图

  9. 单击“添加新条目”,单击绿色加号按钮,将属性名称设置为“AppleDockMenu”,并将值设置为“DockMenu”(新 .xib 文件的名称,不带扩展名):

    添加 DockMenu 项

现在,如果运行应用程序并在停靠菜单中右键单击其图标,将显示新的菜单项:

正在运行的停靠菜单的示例

如果我们从菜单中选择其中一个自定义项,将修改文本视图中的文本。

弹出按钮和下拉列表

弹出按钮显示所选的项,并在用户单击时显示一个选项列表以供选择。 下拉列表是一种弹出按钮类型,通常用于选择特定于当前任务的上下文的命令。 两者都可以出现在窗口中的任意位置。

让我们执行以下操作来为应用程序创建自定义下拉按钮:

  1. 在 Xcode 中编辑 Main.storyboard 文件,并将“库检查器”中的弹出按钮拖到我们在上下文菜单部分创建的“面板”窗口中:

    添加弹出窗口按钮

  2. 添加新菜单项,并将弹出窗口中项的标题设置为:“地址”、“日期”、“问候语”和“签名”

    配置菜单项

  3. 接下来,让我们将新菜单项连接到我们在上面的添加、编辑和删除菜单部分中为自定义菜单创建的现有操作。 切换到“连接检查器”,然后选择“界面层次结构”中的“第一响应方”。 向下滚动并查找 phraseAddress: 操作。 将该操作上的圆圈中的线条拖动到“地址”菜单项:

    拖动以连接操作

  4. 对附加到其相应操作的所有其他菜单项重复此操作:

    所需的所有操作

  5. 保存更改并切换回 Visual Studio for Mac,以便与 Xcode 同步。

现在,如果我们运行应用程序并从弹出窗口中选择一项,文本视图中的文本将更改:

正在运行的弹出窗口的示例

可以像弹出按钮一样创建和处理下拉列表。 与上下文菜单部分中的上下文菜单一样,可以创建自己的自定义操作,而不是附加到现有操作。

总结

本文详细介绍了如何在 Xamarin.Mac 应用程序中使用菜单和菜单项。 首先,我们查看了应用程序的菜单栏,然后了解了如何创建上下文菜单,接着了解了状态栏菜单和自定义停靠菜单。 最后,我们介绍了弹出菜单和下拉列表。