Thursday, August 24, 2006

Helping Disabled Controls Lead A Full Life (And Letting Your Users Keep Their Hair)

Ok, so there's two main points I want to cover in this post. The first is my thoughts on when you should disable a control vs. making it invisible vs. allowing the user to activate the control and receive a message. The second part is about creating a Windows Forms component that allows you to place a second tooltip on controls, this tooltip is displayed only when the control is disabled and the mouse hovers over it. This provides feedback to the user about why the control is disabled, which puts to bed my major complaint about disabled controls.

Firstly, disabled vs. invisible vs. messagebox. My colleagues and I often disagree over how we should prevent a user from accessing a certain function through the UI. One of my senior colleagues loves to make controls invisible, it's almost always his preferred option. Another of my colleagues isn't so big on making controls invisible, but prefers it to disabling controls because she feels a disabled control 'teases' her - it advertises a function and then won't let her use it. I share her sentiments. As for making controls invisible, it's not always the wrong answer, but there are a few down-sides to this;

1. I have situations where users have bought other systems or resorted to spreadsheets etc. to accomplish a job simply because they didn't know their existing software would do the job - and they didn't know because they couldn't see the option. This can even occur with entire modules (i.e they didn't know their accounting system could also do payroll, because all the payroll functions are unlicensed and therefore invisible).

2. You end up with weird blank spaces on your windows. If the control you're making invisible isn't a menu item, or isn't contained in a TableLayout/FlowLayout control of some kind, then making it invisible can destroy the look of your form. Sometimes this is true even if it is in a layout container. It can also lead users into thinking there's plenty more room on the window for the extra fields they want (and no one else does), simply because they can't see the hidden controls.

3. Whether a control is invisible or disabled, the user still can't do what they want to, and therefore they're still frustrated. Neither method tells the user why they can't do something or what they can do about it.

4. If you're lucky enough to have users who can read documentation, and you're twice as lucky as to have a user that actually does, they'll probably be frustrated when they check the manual or on-line help to find out if the function is available. Why ? Because the documentation will likely say, go here, click this, do that, now click button X - and button X won't be on the screen. At this point, they're wondering why not - did they get something wrong in one of the previous steps ? Are they in the right place, on the right window ? Do they have the wrong version of the software ? At least if the button is disabled they know they're in the right place - which is marginally better than not knowing anything.

Of course, with point 4, better documentation would help.

So when should you make a control invisible as opposed to disabled ? Well, it's a judgment call, but let me give you an example. In the new POS system I'm working on (Ontempo Store), the Cash Out option on EFTPOS can be disabled. Now this function would typically be disabled for either the whole company (i.e company policy is no cash out on EFTPOS - we're not a bank) and would apply to all stores, or it might be set on a per store basis depending on whether the store managers are given the power to decide, or perhaps it only applies to stores that happen to take a lot of cash transactions.

In any case, the option isn't likely to change states frequently, it's likely to be set one way or the other either permanently, or for months at a time, in any given location. It's state isn't context sensitive. Furthermore, the users of the system themselves aren't going to be able to change the setting. Someone at head office can, or maybe the store managers can, but the permanent, temporary or casual sales staff just aren't going to have permission to do it.

The final factors in the decision is that Cash Out on EFTPOS should be made invisible are;

a. This is an extremely common function of POS software.
b. This would be unlikely to be a huge addition to the system if it's not already supported.
c. If a user thinks cash out should be allowed and can't find the function, they'll likely ring their head office and get told cash out is deliberately turned off, or head office will enable the setting, or head office will contact us to find out if the feature is supported. In any case, we won't get 50 support calls from 50 different stores. This is a symptom of the type of user base that runs POS software.


i.e Our customers aren't likely to go out and buy another POS system because they can't find a cash out field or option, and we're not going to be increasing our support calls because people can't find it.

Given all these factors, I believe it's an appropriate decision to make the text field and label for Cash Out invisible on our EFTPOS payment panel.

If we were talking about a function that isn't strictly POS related, such as timesheeting, accounting, payroll etc. that we also had modules for, then we'd (probably) be better off leaving the controls visible but disabled. This is because it's a constant reminder to people that we can do these (non-standard) things, and they'll almost be sure to talk to us about them if they require them at a later stage. It's almost like free advertising !

The other time disabling controls is a better idea, is when they're disabled because of the state or context of the application. For example, the save button may be disabled because there haven't been any changes made or because the data is invalid. In a graphics program the Set Transparency option may be disabled because the current image type doesn't support transparent colours and so on. Anytime a function is not available but could be at any moment, if only a few changes are made, then disabling the control is appropriate.

Of course, in these situations I generally prefer not to disable controls or make them invisible. Instead I usually leave the controls visible and enabled, but when they're clicked I display a message box to the user saying the feature is unavailable and explaining why. It's that last bit that's key.

Whether you disable a control or make it invisible, you're frustrating the user who wants to use that option. You've taken it away from them, and you haven't told them why. It's like the parent who tells their child, "Because I said so", or the bully that holds your lunch above your head just out of your reach. It's mean, and you shouldn't do it.

By leaving the controls visible and enabled you ensure the user can find and see your feature, either because it's in plain sight or because they found it through the help system. By popping up a message box and using a conditional statement, you can still prevent the user from accessing the function inappropriately but you can also tell them what to do to enable the function ! This makes for a vastly more satisfying user experience.

Of course, disabled controls are a relatively common standard and some developers would just prefer to disable controls than write message box code. Additionally, there is one problem with leaving everything enabled - the user can't tell at a glance which functions are allowed in their current state. This isn't a problem as often as you might think, but in some situations it can be annoying.

So what's the answer ? Well, I suggested to Microsoft they put a second tooltip on the controls in Windows or .NET, one that is displayed only when the control is disabled. This property can then be set either programmatically, or statically in design mode if the control is only ever disabled for one reason. With this second tooltip, developers can still disable controls andusers can still find out why the control is disabled, then and do something about it.

Unfortunately, while Microsoft agreed it was a good idea, they said they had no plans to implement it. So, with the power of the .NET framework and Visual Studio, I did it myself ! Now I post sample code for my solution for your viewing pleasure. I should note this code hasn't been perfected yet, but it does the basic job and should be a great base for you build your own solution on.

So, what's involved ? First, we're going to build a Component that can be placed on the Windows Forms designer. The component will be an Extender Provider which means it provides a new property to (new and) existing controls on the form. Finally, since disabled controls don't receive mouse events or pop-up tooltips on their own, the component will have to track the mouse and pop-up a tooltip itself when the mouse cursor hovers over a disabled control.

To make life easier for myself, I've wrapped the standard ToolTip component shipped with Visual Studio 2005 and used it for actually displaying the tooltip. That means all my component has to do is determine when and which tooltip to display, then call the ToolTip component to have the tooltip displayed.

To get started, create a new Windows Control Library project in Visual Studio and add a new Component to it. Rename the component DisabledTooltip.

Add the following attributes above the component definition, like this;


using System;
using System.ComponentModel.Design;
using System.ComponentModel;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.Text;
using System.Threading;
using System.Windows.Forms;

[
ProvideProperty("DisabledTooltip",typeof(Control)),
DefaultEvent("Draw")
]
public partial class DisabledTooltip : Component, System.ComponentModel.IExtenderProvider
{

Place the following field definitions at the top of the component, like so;

//This is a timer used to determine if the mouse has 'hovered' over a disabled control for long enough that we should show the tooltip.
System.Threading.Timer timer;
//This is a hash table that contains the text tooltips to be displayed for each control when it is disabled. The hash table is keyed by the control, and stores strings.
private Hashtable disabledToolTips;
//This is an object used to store the state of the mouse.
private MouseEventArgs mouseValues;
//This is a reference to our parent, either a Form or UserControl. Since both of these inherit from Control, I've used that as the type of variable to store the reference in.
private Control parentControl;

//This is a boolean flag that tells us not to redisplay the tooltip, because we've just done it.
private bool ignoreNext = false;


//This is a delegate used to call the ShowTooltip method of the internal ToolTip component. We need the delegate because we want to show the tooltip from the callback made by our timer object which is on another thread, so we must invoke the call to cast it back to the UI thread.
private delegate void ShowToolTipDelegate(string toolTipText, Control control);

Now, declare some events for our component - just in case anyone wants to do owner drawing etc.


///
/// Occurs when the ToolTip is drawn and the OwnerDraw property is true.
///

public event EventHandler<DrawToolTipEventArgs> Draw;
///
/// Occurs before a ToolTip is initially displayed. This is the default event.
///

public event EventHandler<PopupEventArgs> PopUp;

Next, replace the default constructors with these ones


///
/// Default Constructor.
///

public DisabledTooltip()
{
InitializeComponent();

//Make sure we initialise our hash table for storing the tooltips in.
disabledToolTips = new Hashtable();
}

///
/// Full contructor.
///

/// The container owning this component.
public DisabledTooltip(IContainer container)
{
//This line is important - it helps us to obtain a reference to our parent.
container.Add(this);
InitializeComponent();

//Make sure we initialise our hash table for storing the tooltips in.
disabledToolTips = new Hashtable();
}

Ok, now we get to the hard part. We need to setup our component so it has a reference to it's parent. This is easier said than done, luckily Rick Strahl has a solution. Basically, we create a property that allows our parent to be set externally. We then override the Site property of the component, which is set by Visual Studio at design time prior to it generating the code to instantiate our component in the InitializeComponent method of the parent. We use the value of the Site property to pre-set our parent property, and the result is that Visual Studio automatically generates code that sets our property to 'this'. That gives us our reference !

So, add the following override for the Site method of the Component class, and the ParentControl property like so;


///
/// Gets or sets the System.ComponentModel.ISite of the System.ComponentModel.Component.
///

public override ISite Site
{
get { return base.Site; }
set
{
base.Site = value;
if (value != null)
{
IDesignerHost host = (IDesignerHost)value.GetService(typeof(IDesignerHost));
if (host != null)
{
IComponent componentHost = host.RootComponent;
if (componentHost is Control)
ParentControl = (Control)componentHost;
}
}
}
}


///
/// Gets or sets the parent form or user control of this component.
///

[Browsable(false)]
public Control ParentControl
{
get { return parentControl; }
set
{
//Don't bother doing anything if we're given the same value we've already got.
if (parentControl != value)
{
//If we already have a parent, unsubscribe from it's events.
if (parentControl != null)
parentControl.MouseMove -= this.parentForm_MouseMove;

parentControl = value;


//If our new value is a valid parent not null), then connect to it's mouse move event so we
//can watch the mouse and decide when to popup a tooltip. Also, initialise and start our
//timer.
if (parentControl != null)
{
parentControl.MouseMove += new MouseEventHandler(parentForm_MouseMove);
SetupTimer();
}
}
}
}

Ok, that let's us get a reference to our parent ! Now we need to implement the IExenderProvider interface so we can add our DisabledToolTip property to the other controls on the form. Throw the following code into your component.

bool IExtenderProvider.CanExtend(object extendee)
{
bool retVal = false;
if (extendee is System.Windows.Forms.Control)
retVal = true;
return retVal;
}


///
/// Gets the value of the DisabledTooltip property for the specified control.
///

/// The control to retrieve the property value of.
/// A string.
[
DefaultValue("")
]
public string GetDisabledTooltip(Control control)
{
string text = (string)disabledToolTips[control];
if (text == null)
text = string.Empty;
return text;
}

///
/// Sets the value of the DisabledTooltip property for the specified control.
///

/// The control to set the property value of.
/// A string containing the disabled tooltip for the specified control.

public void SetDisabledTooltip(Control control, string value)
{
if (value == null)
value = String.Empty;
if (value.Length == 0)
{
disabledToolTips.Remove(control);
}
else
{
disabledToolTips[control] = value;
}
}

Great. Now, we need to create some routines we've referred to in the code we've already written. We need a routine to initialise our timer (and another to reset it), as well as an event handler for the parent controls mouse move event and a callback method for the timer.

First, flick to the designer for our component and add a standard .NET ToolTip component called toolTip to our own. Then add the following code;


private void SetupTimer()
{
if (parentControl != null && !this.DesignMode)
{
if (timer == null)
timer = new System.Threading.Timer(this.MouseStopped, timer, toolTip.InitialDelay, toolTip.InitialDelay);
else
timer.Change(toolTip.InitialDelay, toolTip.InitialDelay);
}
}

private void ResetTimer()
{
if (timer == null)
SetupTimer();
else
timer.Change(toolTip.InitialDelay, toolTip.InitialDelay);
}

//This procedure is called back from our timer, and is the key to showing the tooltip.
//The timer fires when the mouse has been still long enough that a tooltop should be shown.
//In this routine, we check to see if the mouse if over a child control, and if so, if that control
//is disabled and has a disabled tooltip. If all of those criteria are met, we show the tooltip.
private void MouseStopped(object state)
{
//If we've already shown the tooltip, ignore the next timer event. This is because when the
//tooltip disappears on it's own, a mouse move event seems to fire on the parent, which
// causes us to be called again, and causes the tooltip to flicker and become unreadable.
if (!ignoreNext)
{
//First, stop our timer to prevent it firing again while we're busy.
if (timer != null)
{
timer.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
}

//Now, if we know the last location of the mouse, check to see if it's over a child control.
if (mouseValues != null)
{
Control c = null;
c = GetControlAtPoint(parentControl.Controls, mouseValues.Location);

//If the mouse is over a control, and the control is disabled...
if (c != null && !c.Enabled)
{
//Get the disabled tooltip from our hash table
string toolTipText = (string)disabledToolTips[c];
if (c != null && !String.IsNullOrEmpty(toolTipText))
{
//If our parent control hasn't been disabled and if we do have a disabled tooltip for this
//control then invoke back to the main thead and show the tooltip.
if (!parentControl.IsDisposed)
parentControl.Invoke(new ShowToolTipDelegate(ShowTooltip), toolTipText, c);
}
}
}
}
else
ignoreNext = false;
}

//This routine is 'invoked' from MouseStopped, and is reponsible for actually showing the tooltip
//by calling the Show method on our internal ToolTip component.
private void ShowTooltip(string toolTipText, Control control)
{
//Set a flag to say we should ignore the next MouseStopped call.
ignoreNext = true;
//Ask the internal ToolTip component to show our tooltip for us.
toolTip.Show(toolTipText, parentControl, control.Left, control.Top + System.Windows.Forms.SystemInformation.CaptionHeight, toolTip.AutoPopDelay);
}

//This is a recursive routine that finds the child control located under the mouse cursor, or
//returns null if there isn't one. This is called from MouseStopeed.
private Control GetControlAtPoint(System.Windows.Forms.Control.ControlCollection controls, System.Drawing.Point location)
{
bool foundControl = false;
Control c = null;

//Loop through each of the controls on the parent passed...
for (int cnt = 0; cnt < c =" controls[cnt];" color="#33cc00">//See if this control contains our mouse cursor co-ordinate
if (location.X >= c.Left &&amp;amp;amp; location.X <= c.Left + c.Width && location.Y >= c.Top && location.Y <= c.Top + c.Height) { //If this control has children then we need to check them too...
if (c.Controls.Count > 0)
{
Control c2 = GetControlAtPoint(c.Controls, location);

//If there is no other child under our cursor, return this control, otherwise return it's child
if (c2 != null)
c = c2;

foundControl = true;
break;
}
else
{
//Stop searching, c now contains the control we're looking for.
foundControl = true;
break;
}
}
}

//If we didn't find any control, then return null
if (!foundControl)
c = null;

return c;
}

//Each time the mouse moves on our parent, reset our timer to stop it from firing and record
//the positon of the mouse cursor.
void parentForm_MouseMove(object sender, MouseEventArgs e)
{
ResetTimer();
mouseValues = e;
}

Now we're nearly done. All we need to do is connect to the events on on internal ToolTip component and raise our own events in response, and also expose the useful properties of ToolTip component.

Connect the following event handlers to the appropriate events on the ToolTip component.

private void toolTip_Popup(object sender, PopupEventArgs e)
{
OnPopUp(e);
}

private void toolTip_Draw(object sender, DrawToolTipEventArgs e)
{
OnDraw(e);
}


///
/// Raises the Draw event.
///

/// A DrawToolTipEventArgs event arguments object.
protected void OnDraw(DrawToolTipEventArgs e)
{
if (Draw != null && OwnerDraw)
Draw(this, e);
}

///
/// Raises the PopUp event.
///

/// A PopupEventArgs event arguments object.
protected void OnPopUp(PopupEventArgs e)
{
if (PopUp != null)
PopUp(this, e);
}

Now add the following property definitions, and you're done !

///
/// Gets or sets the automatic delay for the tooltip.
///

public int AutomaticDelay
{
get { return toolTip.AutomaticDelay; }
set { toolTip.AutomaticDelay = value; }
}

///
/// Gets or set the amount of time the ToolTip remains visible if the pointer is stationary on a control with the specified ToolTip.
///

public int AutoPopDelay
{
get { return toolTip.AutoPopDelay; }
set { toolTip.AutoPopDelay = value; }
}

///
/// Gets or sets background colour for the ToolTip.
///

public Color BackColor
{
get { return toolTip.BackColor; }
set { toolTip.BackColor = value; }
}

///
/// Gets or sets the foreground colour for the ToolTip.
///

public Color ForeColor
{
get { return toolTip.ForeColor; }
set { toolTip.ForeColor = value; }
}

///
/// Gets or sets the time that passes before the ToolTip appears.
///

public int InitialDelay
{
get { return toolTip.InitialDelay; }
set { toolTip.InitialDelay = value; }
}

///
/// Gets or sets a value indicating whether the ToolTip should use a balloon window.
///

public bool IsBalloon
{
get { return toolTip.IsBalloon; }
set { toolTip.IsBalloon = value; }
}

///
/// Gets or sets a value indicating whether the ToolTip is drawn by the operating system or by code that you provide.
///

public bool OwnerDraw
{
get { return toolTip.OwnerDraw; }
set { toolTip.OwnerDraw = value; }
}

///
/// Gets or sets a value that determines how ampersand (&) characters are treated.
///

public bool StripAmpersands
{
get { return toolTip.StripAmpersands; }
set { toolTip.StripAmpersands = value; }
}

///
/// Gets or sets a value that defines the type of icon to be displayed alongside the ToolTip text.
///

public ToolTipIcon ToolTipIcon
{
get { return toolTip.ToolTipIcon; }
set { toolTip.ToolTipIcon = value; }
}

///
/// Gets or sets a title for the ToolTip window.
///

public string ToolTipTitle
{
get { return toolTip.ToolTipTitle; }
set { toolTip.ToolTipTitle = value; }
}

///
/// Gets or sets a value determining whether an animation effect should be used when displaying the ToolTip.
///

public bool UseAnimation
{
get { return toolTip.UseAnimation; }
set { toolTip.UseAnimation = value; }
}

///
/// Gets or sets a value determining whether a fade effect should be used when displaying the ToolTip.
///

public bool UseFading
{
get { return toolTip.UseFading; }
set { toolTip.UseFading = value; }
}

With the component complete, you can now add it to any .NET Windows Form. Once the component is added, any other controls on that form will get a property called Disabled ToolTip on xxxx where xxxx is the name of the component. Just set this property to the text you want in your disabled tooltip, and a way you go !

Now you can have the best of both worlds, disabled controls that don't tease your users.

No comments:

Post a Comment