#region Apache License 2.0
/*
Nuclex .NET Framework
Copyright (C) 2002-2024 Markus Ewald / Nuclex Development Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#endregion // Apache License 2.0
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
namespace Nuclex.Windows.Forms.Controls {
/// Displays a progress spinner to entertain the user while waiting
#if NET6_0_OR_GREATER
[SupportedOSPlatform("windows")]
#endif
public partial class ProgressSpinner : UserControl {
/// Number of dots the progress spinner will display
private const int DotCount = 8;
/// Size of a normal dot (only ever assumed by the trailing dot)
private const int DotRadius = 4;
///
/// The leading dot will be DotCount times this larger than a normal dot
///
private const int ScaleFactor = 1;
/// Initializes a new progress spinner
public ProgressSpinner() {
SetStyle(
(
ControlStyles.AllPaintingInWmPaint |
ControlStyles.OptimizedDoubleBuffer |
ControlStyles.ResizeRedraw | ControlStyles.UserPaint |
ControlStyles.SupportsTransparentBackColor
),
true
);
InitializeComponent();
Disposed += new EventHandler(OnDisposed);
if(!DesignMode) {
StartSpinner();
}
}
/// Releases all resources owned by the control when it is destroyed
/// Control that is being destroyed
/// Not used
private void OnDisposed(object sender, EventArgs arguments) {
if(this.dotOutlinePen != null) {
this.dotOutlinePen.Dispose();
this.dotOutlinePen = null;
}
if(this.dotFillBrush != null) {
this.dotFillBrush.Dispose();
this.dotFillBrush = null;
}
}
/// Starts the spinner's animation
public void StartSpinner() {
this.spinnerRunning = true;
this.animationUpdateTimer.Enabled = true;
}
/// Stops the spinner's animation
public void StopSpinner() {
this.animationUpdateTimer.Enabled = false;
this.spinnerRunning = false;
}
/// Color used to fill the dots
public Color DotFillColor {
get { return this.dotFillColor; }
set {
if(value != this.dotFillColor) {
this.dotFillColor = value;
if(this.dotFillBrush != null) {
this.dotFillBrush.Dispose();
this.dotFillBrush = null;
}
}
}
}
/// Color used for the dots' outline
public Color DotOutlineColor {
get { return this.dotOutlineColor; }
set {
if(value != this.dotOutlineColor) {
this.dotOutlineColor = value;
if(this.dotOutlinePen != null) {
this.dotOutlinePen.Dispose();
this.dotOutlinePen = null;
}
}
}
}
/// Calculates the optimal size for the spinner control
/// The optimal size for the spinner control to have
///
/// Thanks to WinForms limited control transparency, the progress spinner needs to
/// redraw every control behind it each time it updates. Thus it's wise to keep it
/// as small as possible, but wide enough to fit the status text, if any.
///
public Size GetOptimalSize() {
SizeF textRectangle;
using(var dummyImage = new Bitmap(1, 1)) {
using(Graphics graphics = Graphics.FromImage(dummyImage)) {
textRectangle = graphics.MeasureString(
this.statusText, this.statusFont
);
}
}
return new Size(
Math.Max(128, (int)(textRectangle.Width + 2.0f)),
this.statusFont.Height + 128
);
}
/// Font that is used to display the status text
public Font StatusFont {
get { return this.statusFont; }
set { this.statusFont = value; }
}
/// Text that will be displayed as the control's status
public string StatusText {
get { return this.statusText; }
set { this.statusText = value; }
}
/// Called when the control is hidden or shown
/// Not used
protected override void OnVisibleChanged(EventArgs arguments) {
base.OnVisibleChanged(arguments);
this.animationUpdateTimer.Enabled = this.spinnerRunning && Visible;
}
/// Called when the control should redraw itself
/// Provides access to the drawing surface and tools
protected override void OnPaint(PaintEventArgs arguments) {
paintControlsBehindMe(arguments);
paintAnimatedDots(arguments);
paintStatusMessage(arguments);
}
/// Forcefully redraws the controls below this one
/// Provides access to the drawing surface and tools
///
///
/// WinForms has very poor transparency support. A transparent control will only
/// be transparent to its immediate parent (so the parent needs to be a container
/// control and hold the transparent control as its preferrably only child).
///
///
/// Worse yet, if you manually establish this relationship in your .Designer.cs
/// file, the Visual Studio WinForms designer will dismantle it next time you
/// edit something. This method fixes those issues by repainting all controls
/// that are behind this control and whose bounding box intersect this control.
///
///
private void paintControlsBehindMe(PaintEventArgs arguments) {
if(Parent != null && this.BackColor == Color.Transparent) {
using(var bmp = new Bitmap(Parent.Width, Parent.Height)) {
Parent.Controls.Cast()
.Where(c => Parent.Controls.GetChildIndex(c) > Parent.Controls.GetChildIndex(this))
.Where(c => c.Bounds.IntersectsWith(this.Bounds))
.OrderByDescending(c => Parent.Controls.GetChildIndex(c))
.ToList()
.ForEach(c => c.DrawToBitmap(bmp, c.Bounds));
arguments.Graphics.DrawImage(bmp, -Left, -Top);
}
}
}
/// Draws a simple animated dots animation
/// Provides access to the drawing surface and tools
private void paintAnimatedDots(PaintEventArgs arguments) {
if(this.dotOutlinePen == null) {
this.dotOutlinePen = new Pen(this.dotOutlineColor);
}
if(this.dotFillBrush == null) {
this.dotFillBrush = new SolidBrush(this.dotFillColor);
}
SmoothingMode prevousSmoothingMode = arguments.Graphics.SmoothingMode;
arguments.Graphics.SmoothingMode = SmoothingMode.HighQuality;
try {
PointF center = new PointF(Width / 2.0f, (Height - this.statusFont.Height - 2) / 2.0f);
int diameter = Math.Min(Width, Height - this.statusFont.Height - 2);
int bigRadius = diameter / 2 - DotRadius - (DotCount - 1) * ScaleFactor;
// Draw the dots
float unitAngle = 360.0f / DotCount;
for(int index = 0; index < DotCount; ++index) {
int dotIndex = (index + leadingDotIndex) % DotCount;
var dotPosition = new PointF(
center.X + (float)(bigRadius * Math.Cos(unitAngle * dotIndex * Math.PI / 180.0f)),
center.Y + (float)(bigRadius * Math.Sin(unitAngle * dotIndex * Math.PI / 180.0f))
);
int currentDotRadius = DotRadius + index * ScaleFactor;
var corner = new PointF(
dotPosition.X - currentDotRadius, dotPosition.Y - currentDotRadius
);
arguments.Graphics.FillEllipse(
this.dotFillBrush, corner.X, corner.Y, 2 * currentDotRadius, 2 * currentDotRadius
);
arguments.Graphics.DrawEllipse(
this.dotOutlinePen, corner.X, corner.Y, 2 * currentDotRadius, 2 * currentDotRadius
);
}
}
finally {
arguments.Graphics.SmoothingMode = prevousSmoothingMode;
}
}
/// Draws the status message under the animated dots
/// Provides access to the drawing surface and tools
private void paintStatusMessage(PaintEventArgs arguments) {
if(!string.IsNullOrEmpty(this.statusText)) {
SizeF textRectangle = arguments.Graphics.MeasureString(
this.statusText, this.statusFont
);
var messageArea = new RectangleF(
(Width - textRectangle.Width) / 2.0f,
Height - this.statusFont.Height - 1.0f,
textRectangle.Width,
this.statusFont.Height
);
// Draw text with a white halo. This is a little bit ugly...
{
messageArea.Offset(-1.0f, 0.0f);
arguments.Graphics.DrawString(
this.statusText, this.statusFont, Brushes.White, messageArea
);
messageArea.Offset(2.0f, 0.0f);
arguments.Graphics.DrawString(
this.statusText, this.statusFont, Brushes.White, messageArea
);
messageArea.Offset(-1.0f, -1.0f);
arguments.Graphics.DrawString(
this.statusText, this.statusFont, Brushes.White, messageArea
);
messageArea.Offset(0.0f, 2.0f);
arguments.Graphics.DrawString(
this.statusText, this.statusFont, Brushes.White, messageArea
);
messageArea.Offset(0.0f, -1.0f);
arguments.Graphics.DrawString(
this.statusText, this.statusFont, this.dotFillBrush, messageArea
);
}
}
}
/// Called when the animation timer ticks to update the animation state
/// Animation timer that has ticked
/// Not used
private void animationTimerTicked(object sender, EventArgs arguments) {
this.leadingDotIndex = (this.leadingDotIndex + 1) % DotCount; // Advance the animation
Invalidate(); // Request a redraw at the earliest opportune time
}
/// Whether the spinner has been started
private bool spinnerRunning;
/// Index of the currently leading dot
private int leadingDotIndex = 0;
/// Text that will be displayed under the control as the current status
private string statusText;
/// Color in which the dots will be filled
private Color dotFillColor = Color.RoyalBlue;
/// Color that will be used for the dots' outline
private Color dotOutlineColor = Color.White;
/// Brush used to fill the dots
private Brush dotFillBrush;
/// Brush used for the dots' outline
private Pen dotOutlinePen;
/// Font that is used to display the status text
private Font statusFont = SystemFonts.SmallCaptionFont;
}
} // namespace Nuclex.Windows.Forms.Controls