Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Integrated Help System in a WPF Application

0.00/5 (No votes)
20 Jun 2013 1  
Quick guideline to understand a workflow of screen without reading long and boring(!!) documentation guide
This is a quick guideline to get rid of reading long and boring documentation and give you very basic information about a screen.

Introduction

I have a WPF application and I am thinking of creating a help documentation for it. If anyone needs help on some screen and presses F1, I don’t like to open a traditional CHM or HTML based screen for that. So my plan is that the screen will describe its own help description and controls will give their introduction and also show you the activity flow to make the user understand the basic flow of the screen. But what if the user also wants an introduction to each control click while working on a screen? OK, then I will also print the basic introduction into the below status bar on the mouse click of the control (if it has any). It is just a quick guideline to get rid of reading long and boring documentation and give you a very basic information of a screen.

Still not clear to you?!! No problem. Let us take a simple screen to understand my thinking here:

A simple Login screen which controls:

  1. Textbox for user name
  2. Password box
  3. Login button
  4. And a button to open a Wish-List child screen

Let us talk about the flow first. The first three controls have some flow to show. What I mean is that:

  1. User needs to set the username first
  2. Then enter the password
  3. And then click on the login button

Concept

Image 1

So here you can see that I have also added a status bar to show the control description while selecting a control (here in the picture, the login button has been selected). But if the user asks for help for this screen by clicking F1, the screen should look like this:

Image 2

Image 3

To do this, I have prepared XML documentation, where I have kept all the necessary descriptions of a control like Title, Help Description, URL for an online guide, shortcut key/hot key, and flow index (if any) for ordering the activity flow sequences. You do need to synchronize the XML yourself if you have changes to any workflow or short-cut/hot keys, which is true for other documentation also. It, of course, is not a replacement for a documentation guide, just a quick guideline for the user. That is why I have kept a URL here to go to the on-line guide for more details.

Image 4

Here, I have put the element name as unique as I am going to map this documentation with the control used in the UI. Flow Indexes have also been set. If a control is not a part of some flow, I mean the user can use it whenever he wants, e.g., search control, launching the child window for settings or sending a wish-list simply keeps the flow index empty.

Image 5

And the result will look something like this:

Image 6

Using the Code

To load and read this XML, I have prepared a loader class which loads and generates a Dynamic Help data model based on the chosen language. I am maintaining multiple XMLs same just like a resource file does. In this sample, during the initialization of the application, I do XML loading and model generation in memory to cache. Later on, I am going use these help definitions based on some UI work.

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();
    }
}

Now it is time to jump into the UI work. I have created an attach property which enables dynamic help for a screen or window. So it will be a simple boolean attach property. On setting it, I am creating a Help Group and adding into a list. This list is necessary while working with child windows. The help group keeping is the element which has been enabled as dynamic help. It is normally the root panel of a window. In the sample, I have used the first child panel of the window as the element to enable dynamic help to get the Adorner layer where I can set the Text – “Help Model (Press F1 again to Exit)”.

You could look into the XAML here where I have put the element name and later on, these unique strings are mapped for retrieving help description.

Image 7

I also have kept the window and hooked closing event and mouse click and bound Command of ApplicationCommands.Help. The mouse click event has been subscribed to find out the control it currently is in and checks for Help description in status bar. The Help command has been bound to get F1 pressed and toggle the help mode. In help mode, I am going to find out all the controls with the help description in the children of the element where you have setup the attached property. I need to hook the closing event here to clear the Help Group with all its event-subscriptions.

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);
            }
        }
    }
}

Let's come to the mouse click event and how I find the control with the help description. Here, it traverses to the top until I got the control with the help description. On finding the control, it will be able to show you the description in the below status bar.

Here in this method, ElementMouse, a hit test has been executed using InputHitTest to get the control the user clicked. After that, it checks for the help description, if not found, it goes to the parent and checks. So I am traversing here to the top until I find the nearest control with help description.

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});
        }
    }
}

On the help command execution, it toggles the help mode. If the help mode is true, I have traversed the children recursively to find out all the children with Help description and started a timer there to show a popup on those controls in a sequential way.

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});
        }
    }
}

Controls that don’t have any flow have been shown on the first tick of the timer. After that, the flow text has been shown with the flow index in a sequential way. For this, I have found out the minimum flow index and then found out the data according to that index and shown their pop up. I have also removed those from the list since those have already been shown.

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;
    }
}

For the child window, it will give you the same kind of result if you enable dynamic help for them.

Image 8

Points of Interest

To show the popup, I have tried to resolve some popup issue like updating location of popup while dragging the window and changing the WindowState. Also, a behaviour has been added to resolve the issue popup relocation of Window resizing. I wasn't able to test all cases here, so it may don't work in all cases. I am hoping you give me feedback if you have faced any unusual things. But I am trying to provide a concept here which doesn't meet all the expectations or features we are expecting from the documentation or help guide. But I think it gives users a quick guideline to understand the workflow of a screen without reading a boring(!!) documentation guide. So it is not a replacement for long documentation. You can keep both and allow the user to open your long documentation from this quick documentation.

References

History

  • 20th June, 2013: Initial version

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here