Title: Make an application with skins in C#, Part 2 (the code)
My previous post explained the controls that this example uses to let you give a form a skin. Basically an arrangement of Panel and PictureBox controls with the Dock properties set appropriately draw the form's edge and corner images. If you resize the form, the controls along the form's edges tile their images to cover their new areas.
The controls alone produce the form's appearance, but the form still needs to do the following.
- Remove the normal window borders
- Load different skins
- Let the user move, resize, and close the form
When the program starts, it uses the following code to get the form ready to display its skin.
// Prepare the form.
private void Form1_Load(object sender, EventArgs e)
{
this.Visible = false;
this.ControlBox = false;
this.FormBorderStyle = FormBorderStyle.None;
this.TransparencyKey = Color.Cyan;
this.BackColor = Color.Cyan;
// Start with the Safety skin.
cboSkin.SelectedIndex = 0;
this.Visible = true;
}
This code first hides the form so the user doesn't see it pop into existence before the code has had a chance to set the form's appearance.
The event handler then hides the form's control box. That removes the system menu in the upper left corner that contains the Restore, Move, Size, Minimize, Maximize, and Close commands. It also prevents the user from right-clicking on the form's icon in the Taskbar and displaying a similar menu. Finally this step removes the minimize, maximize/restore, and close buttons from the form's upper right corner.
Next code then sets the form's border style to None. That removes the form's borders and title bar. At this point, all that's left is a rectangular content area.
The program then sets the form's TransparencyKey and BackColor properties to Cyan. Parts of the form that are cyan, including parts of the images, are clipped off of the form. This can give the form a non-rectangular shape.
This code finishes by selecting the first skin (the code that handles this is shown shortly) and making the form visible again.
The trickiest part of the program is the code that lets the user move and resize the form. Normally Windows tells a form that it should do one of these things by sending the form a message. For example, it uses the WM_NCLBUTTONDOWN message to tell the form that the user pressed the left button down on a non-client area such as a border or the title bar.
To let the user perform the same tasks, the example program uses a similar strategy. When the user presses the mouse down over a control that represents a skinned non-client area such as the title area, it sends the form an appropriate WM_NCLBUTTONDOWN message. The following code shows the ProcessMouseDownMessage method that sends that messaage.
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HT_CAPTION = 2;
private const int HT_BOTTOM = 15;
private const int HT_BOTTOMLEFT = 16;
private const int HT_BOTTOMRIGHT = 17;
private const int HT_LEFT = 10;
private const int HT_RIGHT = 11;
private const int HT_TOP = 12;
private const int HT_TOPLEFT = 13;
private const int HT_TOPRIGHT = 14;
// Let the user move the form.
private void ProcessMouseDownMessage(Control ctl, int wParam)
{
ctl.Capture = false;
Message msg = Message.Create(this.Handle,
WM_NCLBUTTONDOWN, (IntPtr)wParam, IntPtr.Zero);
WndProc(ref msg);
}
This method takes a parameter that indicates the control that was pressed. The wParam parameter is sent to the form to tell it which operation to perform.
The code starts by setting the control's Capture property to false. Normally when you press the mouse down on a control, that control captures any future mouse events until you release the mouse. To make this technique work, the code needs to remove that capture so the form can receive future mouse events.
The code then builds the necessary Message structure and calls the form's WndProc method to process it.
The following code shows how the form's various controls call the ProcessMouseDownMessage method to make the form take various actions.
// Resize with various controls.
private void picLowerRight_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_BOTTOMRIGHT);
}
private void picLowerLeft_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_BOTTOMLEFT);
}
private void picUpperLeft_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_TOPLEFT);
}
private void picUpperRight_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_TOPRIGHT);
}
private void picRight_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_RIGHT);
}
private void picBottom_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_BOTTOM);
}
private void picLeft_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(sender as Control, HT_LEFT);
}
For example, if you press the mouse down over control in the form's upper left corner, the picUpperLeft_MouseDown event handler calls ProcessMouseDownMessage passing it the parameter HT_TOPLEFT. That method sends a message to the form telling it that the user pressed the mouse down over the form's top left corner. The form responds by starting a resize on that corner.
The following code shows how the program lets the user move the form.
// On mouse down, start moving the form.
private void panTop_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(panTop, HT_CAPTION);
}
// On mouse down, start moving the form.
private void lblTitle_MouseDown(object sender, MouseEventArgs e)
{
ProcessMouseDownMessage(lblTitle, HT_CAPTION);
}
When the user presses the mouse down on the title bar Panel or the title Label, the program uses the ProcessMouseDownMessage method to tell the form to start a form move.
When you select a value from the ComboBox, the following event handler changes the form's skin.
// Switch skins.
private void cboSkin_SelectedIndexChanged(object sender, EventArgs e)
{
string dir = Application.StartupPath;
int pos = dir.LastIndexOf(@"\");
pos = dir.LastIndexOf(@"\", pos - 1);
dir = dir.Substring(0, pos + 1);
switch (cboSkin.SelectedIndex)
{
case 0:
LoadSkinFromDirectory(dir + "Safety");
lblTitle.Top =
panTopContainer.Height - lblTitle.Height - 14;
lblTitle.ForeColor = Color.Yellow;
break;
case 1:
LoadSkinFromDirectory(dir + "Rivets");
lblTitle.Top =
panTopContainer.Height - lblTitle.Height - 4;
lblTitle.ForeColor = Color.White;
break;
case 2:
LoadSkinFromDirectory(dir + "Pipes");
lblTitle.Top =
panTopContainer.Height - lblTitle.Height - 8;
lblTitle.ForeColor = Color.White;
break;
}
}
For this example, I stored the skin images in subdirectories below the example's main source code directory. The cboSkin_SelectedIndexChanged event handler composes the name of the appropriate subdirectory and calls the following LoadSkinFromDirectory method to load the desired skin.
// Load skin images from the files in a directory.
private void LoadSkinFromDirectory(string dir)
{
this.Visible = false;
if (!dir.EndsWith("/")) dir += "/";
picLeft.BackgroundImage = LoadImage(dir + "Left.png");
picRight.BackgroundImage = LoadImage(dir + "Right.png");
picBottom.BackgroundImage = LoadImage(dir + "Bottom.png");
picLowerRight.Image = LoadImage(dir + "LowerRight.png");
picLowerLeft.Image = LoadImage(dir + "LowerLeft.png");
picUpperLeft.Image = LoadImage(dir + "UpperLeft.png");
picUpperRight.Image = LoadImage(dir + "UpperRight.png");
panTop.BackgroundImage = LoadImage(dir + "Top.png");
panMiddle.BackgroundImage = LoadImage(dir + "Middle.png");
if (picLeft.BackgroundImage != null)
picLeft.Width = picLeft.BackgroundImage.Width;
if (picRight.BackgroundImage != null)
picRight.Width = picRight.BackgroundImage.Width;
CloseButtonUp = LoadImage(dir + "CloseUp.png");
CloseButtonDown = LoadImage(dir + "CloseDown.png");
if (picBottom.BackgroundImage != null)
panBottomContainer.Height = picBottom.BackgroundImage.Height;
if (picUpperLeft.Image != null)
panTopContainer.Height = picUpperLeft.Image.Height;
picClose.Image = CloseButtonUp;
picClose.Left = (picUpperLeft.Width - picClose.Width) / 2;
picClose.Top = (picUpperLeft.Height - picClose.Height) / 2;
lblTitle.Top = panTopContainer.Height - lblTitle.Height - 2;
this.Visible = true;
}
This code mostly calls the LoadImage method (which is described next) to set the various controls' images. Notice that each control's image has a particular name. For example, the image file that should be loaded into the bottom PictureBox is named Bottom.png.
[Aside: There are lots of other ways you could load the skin images. For example, you could store them in application properties. Or you could store file names in an application configuration file so the program could load different skins when it starts depending on how it is configured. Or you could put all of the images in a single big image file with the different pieces at particular locations. For example, the bottom image might be in the area 100 <= x <= 200 and 175 <= y <= 225. The method used by this example, with the images stored in separate files within the same directory, seems simplest.]
In addition to loading the image files, the LoadSkinFromDirectory method also sets the sizes of the edge PictureBox controls. For example, the left side PictureBox control's height is determined by the height of the form. This code sets that control's width so it fits its image.
The following code shows the LoadImage method.
// Load a skin image with error handling.
private Bitmap LoadImage(string file)
{
try { return new Bitmap(file); }
catch { return null; }
}
This method simply loads an image file and catches an error if one occurs.
There are only two other interesting pieces of code. The first makes the Close button display its up and down images when the user clicks on it. The program uses the technique described in the post Make a PictureBox that acts like a button in C#.
The last piece lets the user close the form. Even though the program removed the form's system menu, so the user cannot use that menu's Close command, the user can still close the form by pressing Alt-F4. I wanted to stop the user from doing that so you could only close the form by pressing the Close button.
The following code shows how the program prevents Alt-F4 from closing the form.
// Only close if the user clicked the Close button.
private bool CloseOk = false;
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
e.Cancel = !CloseOk;
}
The variable CloseOk keeps track of whether it is okay to close the form. If it's not okay to close the form (normally it isn't), the FormClosing event handler sets e.Cancel to true so the form isn't closed.
The following code shows how the program closes the form when the user clicks the Close button.
// Close the form.
private void picClose_Click(object sender, EventArgs e)
{
CloseOk = true;
Close();
}
This code just sets CloseOk to true and then closes the form.
I know this seems complicated, but it's not too bad. Adding new skins is relatively easy. Drawing the skin images is by far the hardest part.
Of course all of this work only sets the appearance of the form itself. It doesn't change the appearance of labels, text boxes, buttons, lists, scroll bars, or anything else inside the form. To customize all of those controls in a Windows Forms application, you would need to make your own versions of them.
If you want to fully customize an application, you should probably use WPF/XAML. That lets you customize the application much more consistently, although it can be a lot of work.
Download the example to experiment with it and to see additional details.
|