65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 应用程序中的集成帮助系统

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.78/5 (15投票s)

2012年12月9日

CPOL

6分钟阅读

viewsIcon

45102

downloadIcon

2109

快速指南,无需阅读冗长枯燥(!!)的文档即可理解屏幕的工作流程。

引言

我有一个 WPF 应用程序,我正在考虑为它创建帮助文档。如果用户在某个屏幕上需要帮助并按下 F1,我不想打开一个传统的 CHM 或 HTML 屏幕。所以我的计划是,屏幕将描述它自己的帮助信息,控件将给出它们的介绍,并且还会显示活动流程,使用户能够理解屏幕的基本流程。但如果用户在操作屏幕时还想了解每个控件的点击介绍呢?好的,那么在鼠标点击控件时,我也会在下方的状态栏中打印出基本的介绍(如果它有的话)。这只是一个快速指南,可以避免阅读冗长乏味的文档,并为您提供屏幕的基本信息。

您还不清楚吗?!没问题。让我们以一个简单的屏幕来理解我的想法。

一个简单的登录屏幕,包含以下控件:

  1. 用户名文本框
  2. 密码框
  3. 登录按钮
  4. 一个打开“心愿单”子屏幕的按钮

我们先来谈谈流程。前三个控件有一些流程需要展示。我的意思是:

  1. 用户需要先设置用户名
  2. 然后输入密码
  3. 然后点击登录按钮

概念

所以在这里您可以看到,我还添加了一个状态栏,用于在选择控件时显示控件描述(如下图,已选中登录按钮)。但如果用户通过按 F1 请求此屏幕的帮助,屏幕看起来应该像这样:

为了做到这一点,我准备了 XML 文档,其中包含控件的所有必要描述,如标题、帮助描述、在线指南的 URL、快捷键/热键以及用于排序活动流程顺序的流程索引(如果有)。如果工作流程或快捷键/热键有任何更改,您需要自己同步 XML,这与其他文档一样。它当然不能替代文档指南,只是一个快速的指导。这就是为什么我在这里保留了一个 URL,以便用户可以访问在线指南以获取更多详细信息。

在这里,我将元素名称设置为唯一的,因为我将把这个文档与 UI 中使用的控件进行映射。流程索引也已设置。如果一个控件不属于某个流程,我的意思是用户可以随时使用它,例如搜索控件、启动设置的子窗口或发送心愿单,则将流程索引留空。

结果看起来会是这样的:

Using the Code

为了加载和读取此 XML,我准备了一个加载器类,该类根据所选语言加载并生成动态帮助数据模型。我像资源文件一样维护多个相同的 XML。在此示例中,在应用程序初始化期间,我会在内存中加载 XML 并生成模型以进行缓存。之后,我将根据一些 UI 工作来使用这些帮助定义。

public class DynamicHelpStringLoader
{
    private const string HelpStringReferenceFolder = "DynamicHelpReference";
    private const string UsFileName = "DynamicHelp_EN_US.xml";
    private const string FrFileName = "DynamicHelp_FR.xml";
    private const string EsFileName = "DynamicHelp_ES.xml";
    private const string DefaultFileName = "DynamicHelp_EN_US.xml";

    /// <summary>
    /// This is the collection where all the JerichoMessage objects
    /// will be stored.
    /// </summary>
    private static readonly Dictionary<string, DynamicHelpModel> HelpMessages;

    private static Languages _languageType;

    /// <summary>
    /// The static constructor.
    /// </summary>
    static DynamicHelpStringLoader()
    {
        HelpMessages = new Dictionary<string,DynamicHelpModel>();
        _languageType = Languages.None;
    }
    /// <summary>
    /// Generates the collection of JerichoMessage objects as if the provided language.
    /// </summary>
    /// <param name="languages">The Languages enum. 
    /// Represents the user's choice of language.</param>
    public static void GenerateCollection(Languages languages)
    {
        if (_languageType == languages)
        {
            return;
        }
        _languageType = languages;
        string startUpPath = Path.GetDirectoryName(
          System.Reflection.Assembly.GetExecutingAssembly().
                                GetModules()[0].FullyQualifiedName);
        string fileName;
        switch (languages)
        {
            case Languages.English:
                fileName = UsFileName;
                break;
            case Languages.French:
                fileName = FrFileName;
                break;
            case Languages.Spanish:
                fileName = EsFileName;
                break;
            default:
                fileName = DefaultFileName;
                break;
        }

        Task.Factory.StartNew(() =>
                      {
                          LoadXmlFile(Path.Combine(startUpPath,
                                                   string.Format(@"{0}\{1}", 
                                                   HelpStringReferenceFolder,
                                                   fileName)));
                      });
    }
    /// <summary>
    /// Load the provided xml file and populate the dictionary.
    /// </summary>
    /// <param name="fileName"></param>
    private static void LoadXmlFile(string fileName)
    {
        XDocument doc = null;
        try
        {
            //Load the XML Document                
            doc = XDocument.Load(fileName);
            //clear the dictionary
            HelpMessages.Clear();

            var helpCodeTypes = doc.Descendants("item");
            //now, populate the collection with JerichoMessage objects
            foreach (XElement message in helpCodeTypes)
            {
                var key = message.Attribute("element_name").Value;
                if(!string.IsNullOrWhiteSpace(key))
                {
                    var index = 0;
                    //get all Message elements under the help type
                    //create a JerichoMessage object and insert appropriate values
                    var dynamicHelp = new DynamicHelpModel
                                          {
                                              Title = message.Element("title").Value,
                                              HelpText = message.Element("helptext").Value,
                                              URL = message.Element("moreURL").Value,
                                              ShortCut = message.Element("shortcut").Value,
                                              FlowIndex = (int.TryParse(message.Element(
                                                "flowindex").Value, out index)) ? index : 0
                                          };
                    //add the JerichoMessage into the collection
                    HelpMessages.Add(key.TrimStart().TrimEnd(), dynamicHelp);
                }
            }
        }
        catch (FileNotFoundException)
        {
            throw new Exception(LanguageLoader.GetText("HelpCodeFileNotFound"));
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
 
    /// <summary>
    /// Returns mathced string from the xml.
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    public static DynamicHelpModel GetDynamicHelp(string name)
    {
        
        if(!string.IsNullOrWhiteSpace(name))
        {
            var key = name.TrimStart().TrimEnd();
            if(HelpMessages.ContainsKey(key))
                return HelpMessages[key];
        }
        return new DynamicHelpModel();
    }
}

现在是时候进入 UI 工作了。我创建了一个附加属性,它为屏幕或窗口启用动态帮助。所以它将是一个简单的布尔附加属性。设置它时,我将创建一个帮助组并将其添加到列表中。列表在处理子窗口时是必需的。帮助组是已启用动态帮助的元素。它通常是窗口的根面板。在示例中,我使用窗口的第一个子面板作为启用动态帮助的元素,以获取 Adorner 层,我可以在其中设置文本——“帮助模型(再次按 F1 退出)”。

您可以在 XAML 中看到我设置了元素名称,稍后,这些唯一的 string 将被映射以检索帮助描述。

我还保留了窗口并挂钩了关闭事件和鼠标点击事件,并绑定了 ApplicationCommands.Help 的 Command。鼠标点击事件已订阅,用于查找当前所在的控件,并在状态栏中检查 Help 描述。Help 命令已绑定以捕获 F1 按键并切换帮助模式。在帮助模式下,我将查找在您设置了附加属性的元素的所有子元素中具有帮助描述的所有控件。我需要在这里挂钩关闭事件,以清除帮助组及其所有事件订阅。

private static bool HelpActive { get; set; }
 
public static void SetDynamicHelp(UIElement element, bool value)
{
    element.SetValue(DynamicHelpProperty, value);
}
public static bool GetDynamicHelp(UIElement element)
{
    return (Boolean)element.GetValue(DynamicHelpProperty);
}

public static readonly DependencyProperty DynamicHelpProperty =
  DependencyProperty.RegisterAttached("DynamicHelp", typeof(bool), typeof(UIElement),
                                      new PropertyMetadata(false, DynamicHelpChanged));

private static readonly List<HelpGroup> HelpGroups = new List<HelpGroup>();

public static HelpGroup Current
{
    get
    {
        return HelpGroups.LastOrDefault();
    }
}

private static void DynamicHelpChanged
        (DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var element = d as UIElement;

    if (null != element)
    {
        if (null != HelpGroups && !HelpGroups.Any
           (g => null != g.Element && g.Element.Equals(element)))
        {
            UIElement window = null;
            if (element is Window)
                window = (Window)element;
            else
                window = Window.GetWindow(element);

            //Note: Use below code if you have used any custom window class other
            //than child of Window (for example WindowBase is base of your custom window)
            //if (window == null)
            //{
            //    if (element is WindowBase)
            //        window = (WindowBase)element;
            //    else
            //        window = element.TryFindParent<WindowBase>();
            //}

            if (null != window)
            {
                var currentGroup = new HelpGroup { Screen = window, 
                    Element = element, ScreenAdorner = new HelpTextAdorner(element) };
                var newVal = (bool)e.NewValue;
                var oldVal = (bool)e.OldValue;
                                              
                // Register Events
                if (newVal && !oldVal)
                {
                    if (currentGroup.Screen != null)
                    {
                        if (!currentGroup.Screen.CommandBindings.OfType<CommandBinding>().Any(
                             c => c.Command.Equals(ApplicationCommands.Help)))
                        {
                            if (currentGroup._helpCommandBind == null)
                            {
                                currentGroup._helpCommandBind = 
                                new CommandBinding
                                    (ApplicationCommands.Help, HelpCommandExecute);
                            }
                            currentGroup.Screen.CommandBindings.Add
                                            (currentGroup._helpCommandBind);
                        }

                        if (currentGroup._helpHandler == null)
                        {
                            currentGroup._helpHandler = 
                                      new MouseButtonEventHandler(ElementMouse);
                        }
                        currentGroup.Screen.PreviewMouseLeftButtonDown += 
                                                     currentGroup._helpHandler;
                        if (window is Window)
                            ((Window)currentGroup.Screen).Closing += WindowClosing;
                        //else
                        //    ((WindowBase)currentGroup.Screen).Closed += 
                        //        new EventHandler<WindowClosedEventArgs>(RadWindowClosed);
                    }
                }
                HelpGroups.Add(currentGroup);
            }
        }
    }
}

让我们来看看鼠标点击事件,以及我如何找到具有帮助描述的控件。在这里,它会一直向上遍历,直到找到具有帮助描述的控件。找到控件后,它将能够显示状态栏中的描述。

在此方法 ElementMouse 中,使用 InputHitTest 执行了命中测试,以获取用户点击的控件。之后,它会检查帮助描述,如果找不到,它会去父级查找。所以,我在这里向上遍历,直到找到最近的具有帮助描述的控件。

static void ElementMouse(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
    if(e.ButtonState != MouseButtonState.Pressed
        || e.ClickCount != 1)
        return;

    var element = sender as DependencyObject;
    if (null != element)
    {
        UIElement window = null;
        if (element is Window)
            window = (Window)element;
        else
            window = Window.GetWindow(element);

        //Note:  Use bellow code if you have used any custom window class other
        //than child of Window (for example WindowBase is base of your custom window)
        //if (window == null)
        //{
        //    if (element is WindowBase)
        //        window = (WindowBase) element;
        //    else
        //        window = element.TryFindParent<WindowBase>();
        //}

        if (null != window)
        {
            // Walk up the tree in case a parent element has help defined
            var hitElement = (DependencyObject)window.InputHitTest(e.GetPosition(window));

            var checkHelpDo = hitElement;                    
            string helpText = Current.FetchHelpText(checkHelpDo);
            while ( string.IsNullOrWhiteSpace(helpText) && checkHelpDo != null &&
                    !Equals(checkHelpDo, Current.Element) &&
                    !Equals(checkHelpDo, window))
            {
                checkHelpDo = (checkHelpDo is Visual)?  
                              VisualTreeHelper.GetParent(checkHelpDo) : null;
                helpText = Current.FetchHelpText(checkHelpDo);
            }
            if (string.IsNullOrWhiteSpace(helpText))
            {
                Current.HelpDO = null;
            }
            else if (!string.IsNullOrWhiteSpace(helpText) && Current.HelpDO != checkHelpDo)
            {
                Current.HelpDO = checkHelpDo;
            }

            if (null != OnHelpMessagePublished)
                 OnHelpMessagePublished(checkHelpDo, 
                   new HelperPublishEventArgs() 
                       { HelpMessage = helpText, Sender = hitElement});
        }
    }
}

在帮助命令执行时,它会切换帮助模式。如果帮助模式为 true,我将递归遍历子元素,查找所有具有 Help 描述的子元素,并在此处启动一个计时器,以按顺序在这些控件上显示弹出窗口。

private static void DoGenerateHelpControl(DependencyObject dependObj, HelperModeEventArgs e)
{
    // Continue recursive toggle. Using the VisualTreeHelper works nicely.
    for (int x = 0; x < VisualTreeHelper.GetChildrenCount(dependObj); x++)
    {
        DependencyObject child = VisualTreeHelper.GetChild(dependObj, x);
        DoGenerateHelpControl(child, e);
    }

    // BitmapEffect is defined on UIElement so our DependencyObject 
    // must be a UIElement also
    if (dependObj is UIElement)
    {
        var element = (UIElement)dependObj;
        if (e.IsHelpActive)
        {
            var helpText = e.Current.FetchHelpText(element);
            if (!string.IsNullOrWhiteSpace(helpText) && element.IsVisible
                && !IsWindowAdornerItem(element))
            {
                // Any effect can be used, I chose a simple yellow highlight
                _helpElements.Add(new HelpElementArgs() { Element = element, 
                    HelpData = DynamicHelperViewer.GetPopUpTemplate
                               (element, helpText, e.Current), 
                    Group = e.Current });
            }
        }
        else if (element.Effect == HelpGlow)
        {
            if(null != OnHelpTextCollaped)
                OnHelpTextCollaped(null, new HelpElementArgs()
                                  { Element =element, Group = e.Current});
        }
    }
}

没有流程的控件将在计时器的第一个滴答声时显示。之后,流程文本将按顺序显示,并附带流程索引。为此,我找到了最小的流程索引,然后根据该索引找到数据并显示它们的弹出窗口。由于这些已经显示,我也已将其从列表中移除。

public static void HelpTimerTick(object sender, ElapsedEventArgs args)
{
    if(null != _helpElements && _helpElements.Count > 0)
    {
        int idx = _helpElements.Min(e => e.HelpData.Data.FlowIndex);
        var data = _helpElements.Where(e => e.HelpData.Data.FlowIndex.Equals(idx));
        foreach (var helpElementArgse in data.ToList())
        {
            _helpElements.Remove(helpElementArgse);
            if (null != OnHelpTextShown)
            {
                OnHelpTextShown(sender, helpElementArgse);
            }   
        }
    }
    else
    {
        _helpTimer.Enabled = false;
    }
}

对于子窗口,如果您为其启用动态帮助,它将为您提供类似的结果。

关注点

为了显示弹出窗口,我尝试解决了一些弹出窗口问题,例如在拖动窗口时更新弹出窗口的位置以及更改 WindowState。另外,还添加了一个行为来解决窗口重调整时的弹出窗口重定位问题。我无法在此测试所有情况,所以它可能不适用于所有情况。如果您遇到任何不寻常的情况,我希望您能给我反馈。但我试图在这里提供一个概念,它并不满足我们对文档或帮助指南的所有期望或功能。但我认为它为用户提供了一个快速指南,可以在不阅读乏味(!)的文档指南的情况下理解屏幕的工作流程。所以它不能替代冗长的文档。您可以同时保留两者,并允许用户从此快速文档中打开您的长篇文档。

参考文献

历史

  • 2013年6月20日:初始版本
WPF 应用程序中的集成帮助系统 - CodeProject - 代码之家
© . All rights reserved.