My custom ToolStripSlider Windows Forms control

In the last post I shared the full source code of my jack-of-all-trades application.

Since you can get the code there, I can now share classes and the code for controls, even if they refer to image resources in the project, because you can get it all from the zip file of the full source code.

When I created those controls, I was particularly happy with the ToolStripSlider control. It’s basically a Trackbar-alternative control in a ToolStrip, except instead of using a standard trackbar, it inherits from ToolStripControlHost, and hosts my Slider custom control, because that’s how you render other controls in a ToolStrip.

I originally wrote the Slider control because I just don’t like the way a standard Trackbar behaves. It has a couple of baffling SmallChange and LargeChange properties, and really doesn’t seem intuitive at all to use. All I wanted was a slider to use for seeking videos and setting the volume. It’s behaviour is totally the way I wanted it… That is, when you click it anywhere, it sets the value to one that corresponds with the click, and moves the tracker to the location where you clicked it. Also when you click it, it immediately sets its state to pressed, and allows you to drag the slider to a new location, then sets the state back to normal when you release the mouse or it loses focus.

In the app’s video player form shown below, I used the Slider control twice… The controls at the bottom are on a panel, with a Slider control docked to the top to act as a seeker control, and a ToolStripSlider control shown on the right of the ToolStrip, which is docked to the bottom of the panel.

It looks like this when using my normal theme…

And this when using my dark theme…

Of course the archive linked to has the code for all the controls, but today I’m just focusing on the slider.

The slider hosted on the ToolStrip is this:

using System;
using System.ComponentModel;
using System.Drawing;
using System.Windows.Forms;
using System.Windows.Forms.Design;

namespace Romy.Controls
{
    /// <summary>A transparent Slider control for any of the containers in the ToolStrip family of controls.</summary>
    /// <remarks>This hosts my <see cref="Slider"/> control, not a standard <see cref="TrackBar"/>.</remarks>
    [ToolStripItemDesignerAvailability(ToolStripItemDesignerAvailability.None | ToolStripItemDesignerAvailability.ToolStrip | ToolStripItemDesignerAvailability.StatusStrip)]
    public class ToolStripSlider : ToolStripControlHost
    {
        #region Public Constructors

        public ToolStripSlider()
            : base(CreateControlInstance())
        { }

        #endregion Public Constructors

        #region Public Events

        public event EventHandler ValueChanged;

        #endregion Public Events

        #region Public Properties

        [Description("This property is not applicable for this type.")]
        public override Color BackColor
        {
            get => base.BackColor;
            set { }
        }

        [Description("This property is not applicable for this type.")]
        public override Image BackgroundImage
        {
            get => null;
            set => base.BackgroundImage = null;
        }

        [Description("This property is not applicable for this type.")]
        public override ImageLayout BackgroundImageLayout
        {
            get => base.BackgroundImageLayout;
            set { }
        }

        [Browsable(true), Category("Appearance"), Description("Changes the Silder's color scheme."), DefaultValue(SliderColorScheme.Grey)]
        public SliderColorScheme ColorScheme
        {
            get => SliderControl.ColorScheme;
            set => SliderControl.ColorScheme = value;
        }

        [Browsable(true), Category("Behavior"), DefaultValue(100),
        Description("The maximum value for the position of the slider on the track.")]
        public int Maximum
        {
            get => this.SliderControl.Maximum;
            set => this.SliderControl.Maximum = value;
        }

        [Browsable(true), Category("Behavior"), DefaultValue(0),
        Description("The minimum value for the position of the slider on the track.")]
        public int Minimum
        {
            get => this.SliderControl.Minimum;
            set => this.SliderControl.Minimum = value;
        }

        [Category("Behavior"), Description("Allows resetting the slider by pressing the space bar."), DefaultValue(true)]
        public bool ResetEnabled
        {
            get => this.SliderControl.ResetEnabled;
            set => this.SliderControl.ResetEnabled = value;
        }

        [Browsable(true), Category("Behavior"),
        Description("When the space bar is pressed while the slider has input focus, the slider will reset to this value.")]
        public int ResetValue
        {
            get => this.SliderControl.ResetValue;
            set => this.SliderControl.ResetValue = value;
        }

        public Slider SliderControl => Control as Slider;

        [Category("Behavior"), DefaultValue(false),
                        Description("If enabled, allows moving the slider with the left and right arrow keyboard keys.")]
        public bool UsesArrowKeys
        {
            get => this.SliderControl.UsesArrowKeys;
            set => this.SliderControl.UsesArrowKeys = value;
        }

        [Browsable(true), Category("Behavior"),
        Description("The position of the slider.")]
        public double Value
        {
            get => SliderControl.Value;
            set => SliderControl.Value = value;
        }

        #endregion Public Properties

        #region Protected Properties

        protected override Padding DefaultMargin => new Padding(0);

        protected override Padding DefaultPadding => new Padding(0);

        protected override Size DefaultSize => new Size(100, 20);

        #endregion Protected Properties

        #region Protected Methods

        protected override void OnSubscribeControlEvents(Control control)
        {
            base.OnSubscribeControlEvents(control);
            this.SliderControl.ValueChanged += new EventHandler(ToolStripSlider_ValueChanged);
        }

        protected override void OnUnsubscribeControlEvents(Control control)
        {
            base.OnUnsubscribeControlEvents(control);
            this.SliderControl.ValueChanged -= new EventHandler(ToolStripSlider_ValueChanged);
        }

        #endregion Protected Methods

        #region Private Methods

        private static Control CreateControlInstance() => new Slider(100, new Size(80, 20));

        private void ToolStripSlider_ValueChanged(object sender, EventArgs e) => ValueChanged?.Invoke(this, EventArgs.Empty);

        #endregion Private Methods
    }
}

And the control it hosts is this:

using System;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Security.Permissions;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Romy.Controls
{
    public enum SliderColorScheme
    {
        Grey,
        Sepia
    }

    /// <summary>Specifies the visual state of a Slider thumb.</summary>
    /// <remarks>This is a copy of <see cref="System.Windows.Forms.VisualStyles.TrackBarThumbState"/>.</remarks>
    [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1008:EnumsShouldHaveZeroValue",
    Justification = "This is a copy of System.Windows.Forms.VisualStyles.TrackBarThumbState, which has the same issue.")]
    public enum SliderThumbState
    {
        /// <summary>The slider appears normal.</summary>
        Normal = 1,

        /// <summary>The thumb appears hot.</summary>
        Hot = 2,

        /// <summary>The thumb appears pressed.</summary>
        Pressed = 3,

        /// <summary>The thumb appears disabled.</summary>
        Disabled = 5,
    }

    /// <summary>Custom TrackBar-like control that supports a transparent BackColor.
    /// (This control currently only supports a horizontal track orientation.)</summary>
    /// <remarks><para>
    /// This was originally designed to be used only by this application's media player,
    /// in terms of its beheviour when clicked (described below) and its appearance (similar
    /// to the other controls on the media player). However, when a similar control was
    /// needed elsewhere, the <b>ColorScheme</b> property was added.</para>
    /// <para>
    /// Currently two schemes are supported: Grey is intended to have colours similar to
    /// a standard TrackBar (so that it doesn't look out of place when used with standard
    /// controls, e.g. on a ToolStrip), and Sepia is the theme used on the media player.
    /// </para><para>
    /// Note that the afore-mentioned ColorSchemes property has nothing to do with the
    /// Themes used throughout the application. I wrote this control long before thinking
    /// of implementing color-themes on the application level.</para>
    /// <para>
    /// See <see cref="ToolStripSlider"/> for a <b>Slider</b> that can be
    /// used on any of the containers in the ToolStrip family of controls. (Hosted by a
    /// <see cref="ToolStripControlHost"/>.)</para>
    /// <para>
    /// This is designed to behave the way I would have preferred the TrackBar control to
    /// behave, at least when the track is clicked. It doesn't have the SmallChange and
    /// LargeChange properties of a standard TrackBar. Clicking the track anywhere (rather
    /// than within a limited range of the slider's thumb button) sets the Value property,
    /// moves the thumb to the point clicked, and sets its state to pressed. Then with the
    /// mouse button held down, you can drag to slide the slider and seek to a new position.
    /// (and optionally make fine adjustments using the keyboard's arrow keys.)</para>
    /// <para>
    /// This was originally based on the TrackBarRenderer example in the Visual Studio 2010
    /// help, but has changed drastically since its initial implementation. Most notably, all
    /// references to the TrackBarRenderer were removed, and all drawing is hand-coded.</para>
    /// </remarks>
    public class Slider : Control
    {
        private double tickSpace;

        private double value;

        private const int TrackHeight = 4;

        private const int WM_KEYDOWN = 0x0100;

        private static readonly Lazy<Image> thumb = new Lazy<Image>(() => Properties.Resources.thumb);

        private static readonly Lazy<Image> thumbGrey = new Lazy<Image>(() => Properties.Resources.thumbGrey);

        private static readonly Lazy<Image> thumbGreyHot = new Lazy<Image>(() => Properties.Resources.thumbGreyHot);

        private static readonly Lazy<Image> thumbGreyPressed = new Lazy<Image>(() => Properties.Resources.thumbGreyPressed);

        private static readonly Lazy<Image> thumbHot = new Lazy<Image>(() => Properties.Resources.thumbHot);

        private static readonly Lazy<Image> thumbPressed = new Lazy<Image>(() => Properties.Resources.thumbPressed);

        private static readonly Lazy<Image> trackEdge = new Lazy<Image>(() => Properties.Resources.trackEdge);

        private static readonly Lazy<Image> trackGreyEdge = new Lazy<Image>(() => Properties.Resources.trackGreyEdge);

        private static readonly Lazy<Image> trackGreyMiddle = new Lazy<Image>(() => Properties.Resources.trackGreyMiddle);

        private static readonly Lazy<Image> trackMiddle = new Lazy<Image>(() => Properties.Resources.trackMiddle);

        private Rectangle thumbRectangle = new Rectangle();

        private Rectangle trackRectangle = new Rectangle();

        private SliderThumbState thumbState = SliderThumbState.Normal;

        public Slider(int maximum, Size sliderSize)
        {
            SetRange(0, maximum);
            this.SetBounds(this.Bounds.Left, this.Bounds.Top, sliderSize.Width, sliderSize.Height);

            Initialize();
        }

        public Slider() : this(100, new Size(100, 20))
        { }

        [Category("Behavior"), Description("Allows resetting the slider by pressing the space bar."), DefaultValue(true)]
        public bool ResetEnabled { get; set; } = true;

        [Browsable(false)]
        public bool ThumbClicked { get; private set; }

        /// <summary>Set true to allow the left and right arrow keyboard keys to fine-adjust the slider value.</summary>
        /// <remarks>Depending on where the control is situated, this may not be feasible. It is generally
        /// better to do this from a parent form's OnKeyDown handler, hence this defaults to false.</remarks>
        [Category("Behavior"), DefaultValue(false),
        Description("If enabled, allows moving the slider with the left and right arrow keyboard keys.")]
        public bool UsesArrowKeys { get; set; }

        [Description("This property is not applicable for this type.")]
        public override Color BackColor
        {
            get => base.BackColor;
            set { }
        }

        [Category("Behavior"), Description("The position of the slider.")]
        public double Value
        {
            get => value;
            set
            {
                this.value = value < this.Minimum ? this.Minimum : value > this.Maximum ? this.Maximum : value;
                UpdateThumbLocation();

                ValueChanged?.Invoke(this, EventArgs.Empty);
            }
        }

        [Description("This property is not applicable for this type.")]
        public override Image BackgroundImage
        {
            get => null;
            set => base.BackgroundImage = null;
        }

        public static Image Thumb => thumb.Value;

        public static Image ThumbGrey => thumbGrey.Value;

        public static Image ThumbGreyHot => thumbGreyHot.Value;

        public static Image ThumbGreyPressed => thumbGreyPressed.Value;

        public static Image ThumbHot => thumbHot.Value;

        public static Image ThumbPressed => thumbPressed.Value;

        public static Image TrackEdge => trackEdge.Value;

        public static Image TrackGreyEdge => trackGreyEdge.Value;

        public static Image TrackGreyMiddle => trackGreyMiddle.Value;

        public static Image TrackMiddle => trackMiddle.Value;

        [Description("This property is not applicable for this type.")]
        public override ImageLayout BackgroundImageLayout
        {
            get => base.BackgroundImageLayout;
            set { }
        }

        [Category("Behavior"), DefaultValue(100),
        Description("The maximum value for the position of the slider on the track.")]
        public int Maximum { get; set; }

        [Category("Behavior"),
        Description("The minimum value for the position of the slider on the track.")]
        public int Minimum { get; set; }

        [Category("Behavior"),
        Description("Pressing the space bar when ResetEnabled is set and the slider has input focus \"resets\" the slider to this value.")]
        public int ResetValue { get; set; }

        [Category("Appearance"), Description("Changes the Silder's color scheme."), DefaultValue(SliderColorScheme.Grey)]
        public SliderColorScheme ColorScheme { get; set; }

        protected SliderThumbState ThumbState
        {
            get => thumbState;
            set
            {
                thumbState = value;
                this.Invalidate();

                if (thumbState == SliderThumbState.Hot)
                    CheckThumbstate();
            }
        }

        public event EventHandler ValueChanged;

        /// <summary>Allows using the keyboard to modify the slider value.</summary>
        public override bool PreProcessMessage(ref Message msg)
        {
            new SecurityPermission(SecurityPermissionFlag.UnmanagedCode).Demand();

            bool processedMessage = false;

            if (msg.Msg == WM_KEYDOWN)
            {
                Keys keys = (Keys)msg.WParam.ToInt32();

                switch (keys)
                {
                    case Keys.Left:
                        if (UsesArrowKeys)
                        {
                            if (this.Value > this.Minimum)
                                this.Value--;

                            processedMessage = true;
                        }
                        break;
                    case Keys.Right:
                        if (UsesArrowKeys)
                        {
                            if (this.Value < this.Maximum)
                                this.Value++;

                            processedMessage = true;
                        }
                        break;
                    case Keys.Space:
                        if (ResetEnabled)
                        {
                            this.Value = this.ResetValue;
                            processedMessage = true;
                        }
                        break;
                }
            }
            return processedMessage;
        }

        /// <summary>Sets the minimum and maximum values for a <see cref="Slider"/>.</summary>
        /// <remarks>Like the <see cref="TrackBar.SetRange(int, int)"/> method, you can
        /// use this method to set the entire range for the <see cref="Slider"/> at the same time.
        /// To set the minimum or maximum values individually, use the <see cref="Minimum"/>
        /// and <see cref="Maximum"/> properties. If the <i>minValue</i> parameter is greater
        /// than the <i>maxValue</i> parameter, <i>maxValue</i> is set equal to <i>minValue</i>.</remarks>
        /// <param name="minValue">The lower limit of the range of the slider. </param>
        /// <param name="maxValue">The upper limit of the range of the slider.</param>
        public void SetRange(int minValue, int maxValue)
        {
            if (minValue > maxValue)
                maxValue = minValue;

            this.Minimum = minValue;
            this.Maximum = maxValue;
        }

        protected override void OnLeave(EventArgs e)
        {
            StopSliding();
            base.OnLeave(e);
        }

        protected override void OnLostFocus(EventArgs e)
        {
            StopSliding();
            base.OnLostFocus(e);
        }

        protected override void OnMouseDown(MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left)
            {
                if (tickSpace > 0) // Set the value to the position clicked.
                {
                    int newValue = ValueFromXCoordinate(e);

                    if (newValue < this.Minimum)
                        newValue = this.Minimum;

                    if (newValue > this.Maximum)
                        newValue = this.Maximum;

                    Value = newValue;
                    ThumbClicked = true;
                    ThumbState = SliderThumbState.Pressed;
                }
            }
            base.OnMouseDown(e);
        }

        /// <summary>Grab focus to enable receiving key messages.</summary>
        protected override void OnMouseHover(EventArgs e)
        {
            Form form = this.FindForm();

            if (form == Form.ActiveForm)
                this.SelectAndFocus();

            base.OnMouseHover(e);
        }

        /// <summary>Track cursor movements.</summary>
        protected override void OnMouseMove(MouseEventArgs e)
        {
            // The user is moving the thumb.
            if (ThumbClicked)
            {
                int newValue = ValueFromXCoordinate(e);

                if (newValue < this.Minimum)
                    newValue = this.Minimum;

                if (newValue > this.Maximum)
                    newValue = this.Maximum;

                Value = newValue;

                if (!thumbRectangle.Contains(e.Location))
                    this.OnLostFocus(EventArgs.Empty);
                else
                    ThumbState = SliderThumbState.Pressed;
            }
            else // The cursor is passing over the track.
                ThumbState = thumbRectangle.Contains(e.Location) ? SliderThumbState.Hot : SliderThumbState.Normal;

            base.OnMouseMove(e);
        }

        /// <summary>Redraw the slider thumb if the user has moved it.</summary>
        protected override void OnMouseUp(MouseEventArgs e)
        {
            if (ThumbClicked)
            {
                if (e.Location.X > trackRectangle.X && e.Location.X < (trackRectangle.X + trackRectangle.Width - thumbRectangle.Width))
                {
                    ThumbClicked = false;
                    ThumbState = SliderThumbState.Hot;
                }

                ThumbClicked = false;
            }

            base.OnMouseUp(e);
        }

        /// <summary>Draw the slider.</summary>
        protected override void OnPaint(PaintEventArgs e)
        {
            DrawHorizontalTrack(e);
            DrawVerticalThumb(e);

            base.OnPaint(e);
        }

        protected override void OnSizeChanged(EventArgs e)
        {
            if (Width > 0)
                this.SetBounds(this.Bounds.Left, this.Bounds.Top, Width, Height);

            InitializeSlider();

            base.OnSizeChanged(e);
        }

        private int ValueFromXCoordinate(MouseEventArgs e)
        {
            // Note that this and XCoordinateFromValue are the same equation, just solving for a different value.
            return this.trackRectangle.Width == 0 ? 0 : (int)(((double)e.Location.X / this.trackRectangle.Width) * (this.Maximum - this.Minimum) + this.Minimum);
        }

        private int XCoordinateFromValue()
        {
            return this.Maximum == 0 || this.trackRectangle.Width == 0 ? 0 : (int)((double)(value - this.Minimum) / (this.Maximum - this.Minimum) * this.trackRectangle.Width);
        }

        /// <summary>Draws a horizontal slider track.</summary>
        private void DrawHorizontalTrack(PaintEventArgs e)
        {
            // Draw the left edge of the track. (Using my 1x4 pixel "edge" graphic.)
            e.Graphics.DrawImage(this.ColorScheme == SliderColorScheme.Sepia ? TrackEdge : TrackGreyEdge, trackRectangle.Left, trackRectangle.Top, 1, trackRectangle.Height);

            // Draw the middle of the track. (It simply tiles the 1x4 pixel middle graphic.)
            using (TextureBrush trackMiddleBrush = new TextureBrush(this.ColorScheme == SliderColorScheme.Sepia ? TrackMiddle : TrackGreyMiddle, WrapMode.Tile))
            {
                Rectangle rect = new Rectangle(trackRectangle.Left + 1, trackRectangle.Top, trackRectangle.Width - 2, trackRectangle.Height);
                e.Graphics.FillRectangle(trackMiddleBrush, rect);
            }

            // Draw the right edge of the track. (Draws the 1x4 pixel edge graphic again.)
            e.Graphics.DrawImage(this.ColorScheme == SliderColorScheme.Sepia ? TrackEdge : TrackGreyEdge, trackRectangle.Left + trackRectangle.Width - 1, trackRectangle.Top, 1, trackRectangle.Height);
        }

        /// <summary>Draws a vertical thumb button (the thumb button is vertical; the track is horizontal).
        /// This button is symmetrical, so the concept of bottom-pointing or top-pointing is irrelevant.</summary>
        private void DrawVerticalThumb(PaintEventArgs e)
        {
            if (this.ColorScheme == SliderColorScheme.Sepia)
                e.Graphics.DrawImage(ThumbState == SliderThumbState.Normal ? Thumb : ThumbState == SliderThumbState.Hot ? ThumbHot : ThumbPressed, new PointF(thumbRectangle.Left, thumbRectangle.Top - 1));
            else
                e.Graphics.DrawImage(ThumbState == SliderThumbState.Normal ? ThumbGrey : ThumbState == SliderThumbState.Hot ? ThumbGreyHot : ThumbGreyPressed, new PointF(thumbRectangle.Left, thumbRectangle.Top - 1));
        }

        private void Initialize()
        {
            this.SetStyle(ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.SupportsTransparentBackColor, true);
            this.BackColor = Color.Transparent;
            this.DoubleBuffered = true;
            this.ColorScheme = SliderColorScheme.Grey;
        }

        private void InitializeSlider()
        {
            this.Value = this.Minimum;

            using (Graphics g = this.CreateGraphics())
            {
                // Calculate the size of the track bar.
                trackRectangle = new Rectangle(
                    ClientRectangle.X + 4,
                    (ClientRectangle.Height - TrackHeight) / 2,
                    ClientRectangle.Width - 8,
                    TrackHeight);

                // Note: Leftmost position indicates value Minimum, not necessarily 0.
                tickSpace = (double)(trackRectangle.Width) / (Maximum - Minimum);

                thumbRectangle.Size = Thumb.Size;

                thumbRectangle.X = XCoordinateFromValue();
                thumbRectangle.Y = 2 + trackRectangle.Y - thumbRectangle.Size.Height / 2;
            }
        }

        private void StopSliding()
        {
            /* In case of a mouse down event with no corresponding mouse up; ensure the thumb isn't left in the pressed state.
             * Otherwise it could be very confusing to the user if they happen to move their mouse over the control. */
            if (ThumbClicked)
            {
                ThumbClicked = false;
                ThumbState = SliderThumbState.Normal;
            }
        }

        private void UpdateThumbLocation()
        {
            int x = XCoordinateFromValue();

            /* These adjustments allow for the width of the thumb itself, such that when you drag the slider to the min
             * and max positions, there is no "bouncing" affect as its position gets corrected, and there is no visible
             * edge of the track sticking out from behind the thumb when it is orientated in either min or max position. */
            int minValueAllowed = this.trackRectangle.Left - this.thumbRectangle.Width / 2;
            int maxAllowedValue = this.trackRectangle.Left + this.trackRectangle.Width - this.thumbRectangle.Width / 2;

            if (x < minValueAllowed)
                x = minValueAllowed;

            if (x > maxAllowedValue)
                x = maxAllowedValue;

            thumbRectangle.X = x;
            this.Invalidate();
        }

        /// <summary>Prevent the thumb button getting left in the hot state. (This can happen if the
        /// control loses focus without any relevant handlers being called, e.g. Alt+Tab.)</summary>
        private async void CheckThumbstate()
        {
            try
            {
                while (ThumbState == SliderThumbState.Hot && thumbRectangle.Contains(this.PointToClient(MousePosition)))
                {
                    await Task.Delay(TimeSpan.FromSeconds(1));
                }

                if (ThumbState == SliderThumbState.Hot)
                    ThumbState = SliderThumbState.Normal;
            }
            catch (ObjectDisposedException) { } // The owning form may be closed before our PointToClient call. (which will then cause an attempt to recreate the Handle of the already disposed form)
        }
    }
}
Advertisements

About Jerome

I am a senior C# developer in Johannesburg, South Africa. I am also a recovering addict, who spent nearly eight years using methamphetamine. I write on my recovery blog about my lessons learned and sometimes give advice to others who have made similar mistakes, often from my viewpoint as an atheist, and I also write some C# programming articles on my programming blog.
This entry was posted in Programming and tagged , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s