Создаём на C# элемент управления "Секундомер" для Windows Forms

Разработка на C# Просмотров: 915

User Rating: 5 / 5

Изображение сгенерировано при помощи BlueWillow - генератора иллюстраций с ИИ

Всем привет, друзья.

Небольшое лирическое вступление: много лет назад, когда я только начинал увлекаться программированием, разработка своих собственных элементов управления казалась мне чем-то сложным, запредельным, загадочным, чем-то таким, что "далеко не для всех разработчиков". Отчасти это было связано с отсутствием на то время понятных и доступных материалов по этой теме, а отчасти даже с отсутствием желания влезать в эту тему, поскольку "там, кажется, всё как-то сложно и непонятно". Сегодня я попробую развеять этот миф о сложности разработки своих элементов управления и показать на примере, что это просто и вполне доступно каждому, кто знаком с языком C#. И ещё, согласитесь, мы ведь порой настолько привыкаем к нашим средам разработки, в которых мы постоянно пишем и пишем сотни строк кода, и к тем стандартным элементам управления, представленным на панели элементов, что совсем нечасто задумываемся о том, что ведь и мы тоже можем, как разработчики, создать свой собственный элемент управления! А это, в свою очередь, открывает для разработчика большие возможности... К счастью, на сегодняшний день уже довольно много официальных и неофициальных гайдов по разработке элементов управления на C#, но я также решил поделиться собственным опытом и видением, как это можно делать.

Начнём. В сегодняшней статье мы разберём с вами, как создать элемент управления для Windows Forms на языке C#. И в качестве примера мы разработаем "с нуля" элемент управления "Секундомер" (англ. Stopwatch), который потом можно будет использовать в любом приложении для Windows Forms, где требуются измерения времени с возможностью "отсечки" конкретных точек времени. Сразу скажу, что в рамках этой статьи мы ограничимся вполне конкретными функциональными возможностями нашего секундомера, поскольку, с одной стороны, это даст вам возможность доработать его впоследствии по своему вкусу (или даже исправить предлагаемый в статье код, если вы посчитаете это нужным), а с другой стороны, позволит уместить все основные, на мой взгляд, нюансы, связанные с разработкой элементов управления для Windows на C#, в рамки одной статьи.

В конце статьи вы сможете найти готовые архивы с тестовыми проектами: первый архив содержит проект с самим элементом управления, а во втором архиве будет демо-проект с приложением для Windows Forms, использующий секундомер для замеров времени.

Итак, разрабатываемый нами секундомер:

Для лучшего понимания того, что же получится в итоге после прочтения и применения шагов из статьи, ниже приведу несколько изображений разных состояний будущего секундомера.

Так будет выглядеть секундомер в "состоянии покоя". Это состояние при первой загрузке элемента управления, до выполнения каких-либо действий с ним:

 

Так будет выглядеть секундомер после его запуска, т.е. нажатия на кнопку "старт" и достижения точки времени "2 секунды 25 сотых секунды" (обратите внимание - появилась кнопка Δ "дельта", а кнопка старта сменилась на паузу, т.к. секундомер работает):

Так будет выглядеть секундомер при нажатии на кнопку "пауза", в момент, когда он отсчитает 40 секунд и 86 сотых секунды:

Наконец, если мы выставим в настройках нашего секундомера свойство LimitSeconds (для установки лимита по времени в секундах) равным значению 30, а также оставим LimitFontColor равным его значению по умолчанию (цвет Color.Red), то по достижению 30 секунд, секундомер будет выглядеть так:

Я также приведу скриншот той демо-программы, куда мы встроим секундомер после его разработки в первой части статьи:

Как видим, демо-приложение содержит отработавший секундомер с выставленным лимитом времени в 10 секунд. За время работы секундомера кнопка "дельта" была нажата 4 раза, и все 4 точки времени с соответствующими дельтами зафиксированы в левом списке "Дельты". В правой части окна демо-программы, в отдельном списке "Лог событий секундомера", будут фиксироваться все возникающие события, связанные с секундомером и нажатием его кнопок. Внизу, под каждым из списков, будет своя кнопка очистки списка.

Часть 1. Разработка элемента управления "Секундомер" (AINStopwatchControl)

1.1 Создание нового проекта

Создадим в среде разработки Microsoft Visual Studio новый проект с типом "Библиотека элементов управления Windows Forms (.NET Framework)", как показано на рисунках ниже:

Шаг 1. Создание проекта

 

Шаг 2. Выбор типа проекта "Библиотека элементов управления Windows Forms (.NET Framework)"

 

Шаг 3. В окне "Настроить новый проект" указываем в поле "Имя проекта" название будущего проекта: AINStopwatchControl:

На этом шаге можно поменять настройки расположения проекта, выбрав подходящую директорию, но я рекомендую оставить расположение по умолчанию. Также по умолчанию оставляем и значение в поле "Платформа" и нажимаем кнопку "Создать".

После этого будет создан новый проект с именем AINStopwatchControl, и на экране в среде разработки вы должны будете увидеть примерно следующее (на скриншоте ниже отображена лишь часть экрана - с областью конструктора элемента управления, окном "Обозреватель решений" и окном "Свойства"):

 

1.2. Редактирование визуального представления элемента управления

В левой части среды разработки находим "Панель элементов" и перетаскиваем из неё в область конструктора следующие стандартные элементы управления, из которых будет состоять новый элемент управления "секундомер":

Точное расположение текстовой метки и кнопок нам пока не важно, выстраиваем их приблизительно, как показано на рисунке ниже:

Добавленный элемент Timer не будет отображён в области конструктора элемента управления, но он появится внизу, под областью конструктора. Когда потребуется изменить его свойства, нужно будет просто его выделить одинарным кликом левой кнопки мыши:

 

Теперь устанавливаем для добавленных элементов следующие свойства:

Метка (для отображения времени секундомера):

Свойство Значение свойства
Font Impact; 27,75pt
Text 00:00.00
AutoSize True
Location 3; 0
Size  154; 45
Name LabelTimer

 

1-я кнопка (для кнопки "дельта" секундомера ):

Свойство Значение свойства
Font Arial; 14,25pt
Text
Location 163; 3
Size 35; 39
Name ButtonDelta

 

2-я кнопка (для кнопки "старт/пауза" секундомера ):

Свойство Значение свойства
Font Arial; 14,25pt
Text
Location 204; 3
Size 35; 39
Name ButtonStart

 

3-я кнопка (для кнопки "стоп" секундомера)

Свойство Значение свойства
Font Arial; 14,25pt
Text
Location 245; 3
Size 35; 39
Name ButtonStop

 

Элемент Timer

Свойство Значение свойства
Enabled False
Interval 10
Name TimerStopwatch

 

Далее делаем один клик левой кнопки мыши в области конструктора, чтобы выбрать сам элемент управления, который пока у нас называется UserControl1.

Устанавливаем свойство Size для элемента равным значению 286; 47:

Свойство Значение свойства
Size 286; 47

 

Нажимаем комбинацию клавиш Ctrl+S, чтобы сохранить все изменения в проекте, сделанные с элементом управления.

В результате всех перечисленных выше действий должна получиться следующая картина в области конструктора элемента управления:

 

Теперь нам необходимо поменять стандартное имя UserControl1 для элемента управления, которое было по умолчанию назначено при создании нового проекта. Для этого в правом верхнем окне "Обозреватель решений" кликаем один раз левой кнопкой мыши по узлу дерева UserControl1.cs, чтобы выделить этот узел:

 

Далее в окне "Свойства", которое расположено ниже, изменяем свойство "Имя файла" со значения UserControl1.cs на AINStopwatchControl.cs и нажимаем клавишу <Enter>.

В появившемся диалоговом окне с вопросом о том, следует ли переименовать также все ссылки в проекте, соглашаемся, нажав "Да":

 

Теперь можно заметить, что все ссылки и имя файла с исходным кодом элемента управления были переименованы с UserControl1 на AINStopwatchControl:

После этого повторно нажимаем Ctrl+S, чтобы сохранить все выполненные изменения.

Теперь, когда все все основные элементы расположены в области конструктора, а также задано имя будущего элемента управления, переходим к программированию секундомера.

1.3. Программирование элемента управления

Настало время запрограммировать действия будущего секундомера. Для этого в окне "Обозреватель решений" кликаем правой кнопкой мыши по узлу дерева AINStopwatchControl.cs и выбираем опцию "Перейти к коду" (или можно также нажать клавишу F7): 

 

В результате откроется редактор кода для элемента управления со следующим содержимым:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace AINStopwatchControl
{
    public partial class AINStopwatchControl: UserControl
    {
        public AINStopwatchControl()
        {
            InitializeComponent();
        }
    }
}

Здесь можем сразу заметить, что класс для элемента управления является наследником от стандартного класса UserControl. Также для нас по умолчанию был сгенерирован конструктор для нашего класса AINStopwatchControl, в котором вызывается метод InitializeComponent(). Данный метод инициализирует должным образом все ранее размещённые нами элементы, устанавливая им все необходимые значения свойств.

Добавим сразу в тело нашего класса AINStopwatchControl, выше метода конструктора, следующие приватные поля и публичное свойство IsRunning:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace AINStopwatchControl
{
    public partial class AINStopwatchControl: UserControl
    {
        /// <summary>
        /// Флаг, определяющий запущен или нет секундомер
        /// </summary>
        private bool _isRunning = false;

        /// <summary>
        /// Открытое свойство секундомера, возвращает true, если секундомер в данный момент запущен
        /// </summary>
        public bool IsRunning { get { return _isRunning; } }

        private long _currentSeconds = 0;
        private long _currentMinutes = 0;
        private long _currentMillis = 0;
        private long _currentHundredthsOfASecond = 0;

        private long _previousSeconds = 0;
        private long _previousMinutes = 0;
        private long _previousMillis = 0;
        private long _previousHundredthsOfASecond = 0;

        public AINStopwatchControl()
        {
            InitializeComponent();
        }
    }
}

Приватное поле _isRunning будет определять, запущен ли в данный момент секундомер или остановлен/на паузе. Значение true - секундомер запущен, false - секундомер поставлен на паузу или вовсе остановлен.

Ему соответствует публичное свойство IsRunning, которое имеет единственный метод доступа get, возвращающий значение поля _isRunning. Смысл публичного свойства: мы хотим сделать возможным для всех программ-клиентов, использующих наш секундомер, считывать признак того, запущен таймер или нет, а вот позволять его менять извне самого элемента управления будет запрещено (это достигается за счёт того, что поле _isRunning помечено модификатором доступа private, следовательно, оно может быть изменено только из кода самого секундомера, но никак не извне).

Также мы определили в классе 8 приватных полей с префиксами _current и _previous. Поля с префиксом _current будут хранить текущее количество минут, секунд, миллисекунд и сотых долей секунды (сантисекунд), а поля с префиксом _previous - их предыдущие значения (для чего нам вообще нужны предыдущие значения мы узнаем далее по тексту статьи). Обратите внимание на тип данных long для этих полей. Это целочисленный тип данных, занимающий в памяти 64 бита (или 8 байт). Можно было бы выбрать и тип int, но в данном случае это дело вкуса. Тип long хоть и занимает чуть больше памяти, чем 32-битный тип int, но я решил выбрать тип long. Кроме этого, как мы потом увидим, свойство ElapsedMilliseconds, которое будет содержать количество набежавших в счётчике секундомера секунд, имеет тип long, что позволит избежать лишних приведений типов из long в int.

Теперь после всех этих полей и свойства, но всё ещё выше метода конструктора добавим поле _deltaNumber с типом long, поле _enableDeltaButton с типом bool, четыре публичных свойства (все с типом long) с именами CurrentSeconds, CurrentMinutes, CurrentMillis и CurrentHundredthsOfASecond, приватную константу timeFormat, приватное поле IsResetToInitialValues и приватное поле stopWatch, имеющее системный тип Stopwatch:

        private long _deltaNumber = 0;
        private bool _enableDeltaButton = true;

        public long CurrentSeconds { get { return _currentSeconds; } }
        public long CurrentMinutes { get { return _currentMinutes; } }
        public long CurrentMillis { get { return _currentMillis; } }
        public long CurrentHundredthsOfASecond { get { return _currentHundredthsOfASecond; } }

        /// <summary>
        /// Задаёт формат времени для его отображения в секундомере
        /// </summary>
        private readonly string timeFormat = "{0}:{1}.{2}";

        /// <summary>
        /// Закрытое свойство секундомера. Определяет, следует ли сбросить значения времени в исходные
        /// при очередном старте секундомера.
        /// </summary>
        private bool IsResetToInitialValues { get; set; } = false;

        /// <summary>
        /// Объект класса Stopwatch для измерения времени
        /// </summary>
        private Stopwatch stopWatch = new Stopwatch();

Поле _deltaNumber будет отвечать за порядковый номер очередного нажатия на кнопку "дельта", т.е. очередной "отсечки времени". Этот номер будет увеличиваться на единицу при каждом следующем нажатии кнопки "дельта" для секундомера.

Поле _enableDeltaButton по умолчанию устанавливается в true и регулирует, включена ли или отключена для секундомера кнопка "дельта" в принципе. Т.е. мы просто хотим предоставить программе-клиенту, в которую потом будет встраиваться элемент управления, возможность регулировки доступности кнопки "дельта". Значение по умолчанию true говорит о том, что по умолчанию мы хотим, чтобы кнопка "дельта" была доступна.

Свойства CurrentSeconds, CurrentMinutes, CurrentMillis и CurrentHundredthsOfASecond возвращают текущее количество секунд, минут, миллисекунд и сотых долей секунды. Можно заметить, что методы доступа get возвращают значения из соответствующих приватных полей. 

Для чего нужны приватные поля, да и ещё и свойства вдобавок? Дело в том, что мы хотим ограничить "доступ извне" к модификации всех счётчиков секунд, минут, миллисекунд и сотых секунды нашего секундомера в любых программах-клиентах, которые потом будут пользоваться секундомером. Т.е. эти программы смогут считать значения всех счётчиков, а вот модифицировать эти счётчики и "вторгнуться во внутреннюю работу секундомера" у них не получится. Сразу обратите внимание, что все поля с префиксом _previous, что мы определили выше, а также поле _deltaNumber вообще являются полностью приватными и не будут в явном виде никак доступны для программ-клиентов - как на чтение, так и на изменение. Это сугубо внутренние поля, нужные нам для обеспечения работы нашего секундомера.

Приватное поле timeFormat задаёт формат времени, отображаемый секундомером. В нашем случае это строка "{0}:{1}.{2}", т.е. секундомер при отображении времени в позиции {0} будет подставлять минуты, в позиции {1} - секунды, в позиции {2} - сотые доли секунды.

Приватное свойство IsResetToInitialValues будет использоваться для проверки при снятии секундомера с паузы или при старте секундомера после его останова кнопкой "стоп". Если запуск производится после кнопки "стоп", то значение поля будет равно true, и мы будем сбрасывать все показатели времени.

И, пожалуй, самое важное поле для нашего секундомера - это поле stopWatch. Оно имеет системный тип Stopwatch, который полезен для различных измерений времени, например, для замеров скорости работы кода в программе на C#. В нашем случае мы его будем использовать как внутренний счётчик, который и будет являться "сердцем" нашего секундомера и будет накапливать пройденное время.

Теперь мы добавляем следующие публичные, доступные извне, свойства секундомера, смысл которых был описан ещё в начале статьи:

        [Category("Appearance")]
        [Description("Лимит по количеству секунд")]
        [DisplayName("LimitSeconds")]
        [Browsable(true)]
        [DefaultValue(0)]
        public long LimitSeconds { get; set; } = 0;

        [Category("Appearance")]
        [Description("Лимит по количеству минут")]
        [DisplayName("LimitMinutes")]
        [Browsable(true)]
        [DefaultValue(10)]
        public long LimitMinutes { get; set; } = 10;

        [Category("Appearance")]
        [Description("Цвет шрифта таймера в момент достижения лимита по минутам и/или секундам")]
        [DisplayName("LimitFontColor")]
        [Browsable(true)]
        [DefaultValue(typeof(Color), "#FFFF0000")]
        public Color LimitFontColor { get; set; } = Color.Red;

        [Category("Appearance")]
        [Description("Цвет шрифта таймера в момент достижения лимита по минутам и/или секундам")]
        [DisplayName("ButtonsBackgroundColor")]
        [Browsable(true)]
        public Color ButtonsBackgroundColor {
            get {
                return ButtonDelta.BackColor;
            }
            set { 
                ButtonStart.BackColor = value;
                ButtonStop.BackColor = value;
                ButtonDelta.BackColor = value;
            }
        }

        [Category("Appearance")]
        [Description("Разрешить кнопку дельты")]
        [DisplayName("EnableDeltaButton")]
        [Browsable(true)]
        [DefaultValue(true)]
        public bool EnableDeltaButton { 
            get {
                return _enableDeltaButton;
            }
            set {
                if (value) {
                    if (IsRunning) {
                        ButtonDelta.Visible = true;
                        ButtonDelta.Enabled = true;
                    } else {
                        ButtonDelta.Visible = true;
                    }
                    ButtonStart.Left = ButtonDelta.Right + 5;
                    ButtonStop.Left = ButtonStart.Right + 5;
                    Width = ButtonStop.Right + 5;
                }
                else {
                    ButtonDelta.Visible = false;
                    ButtonStart.Left = ButtonDelta.Left;
                    ButtonStop.Left = ButtonStart.Right + 5;
                    Width = ButtonStop.Right + 5;
                }
                _enableDeltaButton = value;
            } 
        }

Видно, что по умолчанию мы "зашиваем" в нашем элементе управления определённые значения каждого из свойств:

Кратко поясню смысл атрибутов (конструкции в квадратных скобках [ и ] перед объявлением каждого свойства:

Теперь добавим несколько событий с названиями StartStopwatch, PauseStopwatch, StopStopwatch, LimitReached для секундомера, а также событие DeltaStopwatch и отдельный делегат для этого события, который будет во втором параметре принимать экземпляр специального нового класса AINStopwatchDeltaEventArgs, который мы создадим чуть позже. Поэтому делегат и событие DeltaStopwatch пусть пока у нас будут закомментированы:

        [Category("Action")]
        [Description("Возникает при нажатии кнопки \"Старт\" для таймера")]
        [DisplayName("StartStopwatch")]
        public event EventHandler StartStopwatch;

        [Category("Action")]
        [Description("Возникает при нажатии кнопки \"Пауза\" для таймера")]
        [DisplayName("PauseStopwatch")]
        public event EventHandler PauseStopwatch;

        [Category("Action")]
        [Description("Возникает при нажатии кнопки \"Стоп\" для таймера")]
        [DisplayName("StopStopwatch")]
        public event EventHandler StopStopwatch;


        //public delegate void AINStopwatchDeltaStopwatch(object sender, AINStopwatchDeltaEventArgs e);

        //[Category("Action")]
        //[Description("Возникает при нажатии кнопки \"Дельта\" для таймера")]
        //[DisplayName("DeltaStopwatch")]
        //public event AINStopwatchDeltaStopwatch DeltaStopwatch;

        [Category("Action")]
        [Description("Возникает при достижении установленного лимита времени для минут и/или секунд, задаваыемых свойствами LimitMinutes и LimitSeconds")]
        [DisplayName("LimitReached")]
        public event EventHandler LimitReached;

На этом этапе мы задали все приватные поля, свойства и события нашего секундомера. Впереди нас ждёт создание класса AINStopwatchDeltaEventArgs, который будет содержать специфичные параметры для события DeltaStopwatch, а также, конечно же, код самого элемента управления, т.е. его методы.

1.4 Создание класса AINStopwatchDeltaEventArgs

Давайте добавим в проект новый класс, который будет описывать параметры для события секундомера DeltaStopwatch. Возникает вопрос: а чем это событие секундомера так уж примечательно? Почему делегат нужен лишь для него, а все остальные события задаются у нас системным делегатом EventHandler?

*Пара слов про делегат: делегат - это специальный тип, который позволяет задать ссылку на метод с определенным списком параметров и типом возвращаемого значения. К примеру делегат вида public delegate void F(object obj, int value) - это ссылка на любой метод, который принимает два параметра: obj с типом object и value с типом int, а сам ничего не возвращает (возвращаемый тип метода - void).

Дело в том, что все остальные события - StartStopwatch, PauseStopwatch, StopStopwatch и LimitReached - не нуждаются в каких-то специальных передаваемых параметрах в момент их возникновения. Программе-клиенту должно быть достаточно уже того, что они произошли, чтобы на них как-то отреагировать и написать необходимую логику. К примеру, программе-клиенту, использующей наш секундомер, будет достаточно "отловить" событие StartStopwatch и записать в лог факт старта секундомера, или изменить другие элементы управления формы клиентского приложения.

Событие же DeltaStopwatch примечательно тем, что оно возникает в момент нажатия на кнопку "дельта". И его недостаточно просто "отловить", ведь клиентской программе нужно знать точно все параметры секундомера на момент очередной "отсечки времени". Также в клиентской программе будет важно понимать, какова дельта времени относительно предыдущей "отсечки времени". Поэтому нам недостаточно реализовать передачу параметров события DeltaStopwatch через экземпляр стандартного класса EventArgs. Вместо этого мы создадим наследника класса EventArgs, и это будет как раз наш класс AINStopwatchDeltaEventArgs.

Итак, добавляем в наш проект новый класс. Это делается через окно "Обозреватель решений". Вызываем кликом правой кнопки мыши контекстное меню на проекте (узел AINStopwatchControl), далее выбираем пункты меню Добавить → Класс..., как показано на рисунке:

 

В открывшемся окне по центру убеждаемся, что выбран тип нового элемента Класс, а в текстовом поле снизу указываем имя файла для нового класса вместе с расширением .csAINStopwatchDeltaEventArgs.cs:

Нажимаем кнопку "Добавить" и попадаем в редактор кода для класса:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace AINStopwatchControl {
    internal class AINStopwatchDeltaEventArgs {
    }
}

Первое, что мы делаем, - это изменяем модификатор доступа для нового класса с internal на public. Это необходимо для того, чтобы новый класс был доступен за пределами сборки для нашего элемента управления "секундомер". А мы как раз хотим сделать класс публичным, чтобы программы-клиенты, работающие с секундомером, могли обрабатывать событие DeltaStopwatch и считывать параметры события, задаваемые классом. Также после имени класса, через оператор двоеточия ( : ), мы должны указать, что класс является наследником базового класса EventArgs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace AINStopwatchControl {
    public class AINStopwatchDeltaEventArgs : EventArgs {
    }
}

Далее мы заполняем класс полями, которые мы разберём чуть ниже:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace AINStopwatchControl {
    public class AINStopwatchDeltaEventArgs {
        private long _deltaNumber = 0;
        private long _millis = 0;
        private long _seconds = 0;
        private long _minutes = 0;
        private long _hundredthsOfASecond = 0;
        private long _deltaMillisTotal = 0;

        private long _deltaMinutes = 0;
        private long _deltaSeconds = 0;
        private long _deltaHundredsOfASecond = 0;

        /// <summary>
        /// Номер дельты (отсечки времени секундомера)
        /// </summary>
        public long DeltaNumber { get { return _deltaNumber; } }

        /// <summary>
        /// Количество миллисекунд на момент отсечки времени секундомера
        /// </summary>
        public long Millis { get { return _millis; } }

        /// <summary>
        /// Количество секунд на момент отсечки времени секундомера
        /// </summary>
        public long Seconds { get { return _seconds; } }

        /// <summary>
        /// Количество минут на момент отсечки времени секундомера
        /// </summary>
        public long Minutes { get { return _minutes; } }

        /// <summary>
        /// Количество сотых секунды (сантисекунд) на момент отсечки времени секундомера
        /// </summary>
        public long HundredthsOfASecond { get { return _hundredthsOfASecond; } }

        /// <summary>
        /// Дельта в миллисекундах от предыдущей отсечки времени секундомера
        /// </summary>
        public long DeltaMillisTotal { get { return _deltaMillisTotal; } }

        public long DeltaMinutes { get { return _deltaMinutes; } }
        public long DeltaSeconds { get { return _deltaSeconds; } }
        public long DeltaHundredsOfASecond { get { return _deltaHundredsOfASecond; } }

        public string TimeString {
            get {
                return string.Format("{0}:{1}.{2}",
                    Minutes.ToString().PadLeft(2, '0'),
                    Seconds.ToString().PadLeft(2, '0'),
                    HundredthsOfASecond.ToString().PadLeft(2, '0')
                    );
            }
        }

        public string DeltaTimeString {
            get {
                return string.Format("{0}:{1}.{2}",
                    DeltaMinutes.ToString().PadLeft(2, '0'),
                    DeltaSeconds.ToString().PadLeft(2, '0'),
                    DeltaHundredsOfASecond.ToString().PadLeft(2, '0')
                    );
            }
        }
    }
}

Итак, мы добавили в наш новый класс множество полей и некоторые публичные свойства:

Поскольку все эти поля приватные, они не будут доступны при обработке события секундомера в программе-клиенте.

Но мы также задали и публичные свойства, доступные для клиентского кода, именно к ним и будет доступ у того разработчика, который захочет обработать событие DeltaStopwatch:

Теперь добавляем в наш класс, сразу после всех полей и свойств, следующий конструктор для класса:

        public AINStopwatchDeltaEventArgs(long deltaNumber, long millis, long seconds, long minutes, long hundredthsOfASecond, long deltaMillisTotal) {            
            _deltaNumber = deltaNumber;
            _millis = millis;
            _seconds = seconds;
            _minutes = minutes;
            _hundredthsOfASecond = hundredthsOfASecond;

            _deltaMillisTotal = deltaMillisTotal;

            _deltaMinutes = _deltaMillisTotal / 60000;

            _deltaSeconds = _deltaMillisTotal / 1000;
            if (_deltaMinutes > 0) {
                _deltaSeconds -= 60 * _deltaMinutes;
            }

            _deltaHundredsOfASecond = _deltaMillisTotal / 10;

            if (_deltaMinutes > 0) {
                _deltaHundredsOfASecond -= 60 * _deltaMinutes;
            }
            if (_deltaSeconds > 0) {
                _deltaHundredsOfASecond -= 100 * _deltaSeconds;
            }
        }

Здесь остановимся и разберём, что будет делать конструктор: во-первых, он примет через свои параметры значение порядкового номера дельты (deltaNumber), количество миллисекунд (millis), секунд (seconds), минут (minutes), сотых секунды (hundredthsOfASecond) и общее количество дельты, в миллисекундах (deltaMillisTotal), и инициализирует соответствующие внутренние приватные поля класса.

Далее идут арифметические вычисления. Чтобы определить общее количество целых минут в "дельте", нужно общее количество миллисекунд в дельте поделить на 60000 (поскольку 60 - количество секунд в минуте, а в самой секунде 1000 миллисекунд):

_deltaMinutes = _deltaMillisTotal / 60000;

Для определения количества секунд мы общее количество миллисекунд в дельте делим на 1000, а также проверяем - если есть какие-то минуты в дельте, то их следует вычесть из получившегося результата:

_deltaSeconds = _deltaMillisTotal / 1000;
if (_deltaMinutes > 0) {
    _deltaSeconds -= 60 * _deltaMinutes;
}

Похожим образом определяем количество сотых секунды. Но в этот раз общее количество миллисекунд в дельте будем делить на значение 10, поскольку, как мы помним, на этапе расположения элементов управления мы указали свойство Interval для элемента Timer равным 10. Это значит, что мы будем для обновления времени секундомера запускать внутренний таймер с интервалом в 10 миллисекунд. Это та самая частота, с которой метка времени секундомера будет регулярно обновлять время на часах секундомера. А дальше мы также из результата деления последовательно вычтем значение целых минут и секунд. Поскольку в одной секунде 100 "сотых секунды", то множитель во втором вычитании у нас 100:

_deltaHundredsOfASecond = _deltaMillisTotal / 10;

if (_deltaMinutes > 0) {
    _deltaHundredsOfASecond -= 60 * _deltaMinutes;
}
if (_deltaSeconds > 0) {
    _deltaHundredsOfASecond -= 100 * _deltaSeconds;
}

Теперь наш класс почти полностью готов. Последнее, что нам осталось сделать - это сгенерировать для него переопределения для методов Equals и GetHashCode. Для этого сразу после закрывающей фигурной скобки, завершающей тело конструктора, кликаем правой кнопкой мыши в редакторе кода, вызывая контекстное меню и выбираем "Быстрые действия и рефакторинг":

 

Далее выбираем пункт меню "Создать Equals и GetHashCode...":

 

В появившемся диалоговом окне оставляем всё как есть, без изменений, но убеждаясь, что все поля и свойства отмечены флажками, после чего нажимаем "OK":

 

В результате в код класса будет добавлен автоматически сгенерированный код для методов Equals и GetHashCode, который учитывает все добавленные нами приватные поля и свойства нашего класса:

        public override bool Equals(object obj) {
            return obj is AINStopwatchDeltaEventArgs args &&
                   _deltaNumber == args._deltaNumber &&
                   _millis == args._millis &&
                   _seconds == args._seconds &&
                   _minutes == args._minutes &&
                   _hundredthsOfASecond == args._hundredthsOfASecond &&
                   _deltaMillisTotal == args._deltaMillisTotal &&
                   _deltaMinutes == args._deltaMinutes &&
                   _deltaSeconds == args._deltaSeconds &&
                   _deltaHundredsOfASecond == args._deltaHundredsOfASecond &&
                   DeltaNumber == args.DeltaNumber &&
                   Millis == args.Millis &&
                   Seconds == args.Seconds &&
                   Minutes == args.Minutes &&
                   HundredthsOfASecond == args.HundredthsOfASecond &&
                   DeltaMillisTotal == args.DeltaMillisTotal &&
                   DeltaMinutes == args.DeltaMinutes &&
                   DeltaSeconds == args.DeltaSeconds &&
                   DeltaHundredsOfASecond == args.DeltaHundredsOfASecond &&
                   TimeString == args.TimeString &&
                   DeltaTimeString == args.DeltaTimeString;
        }

        public override int GetHashCode() {
            int hashCode = 587367537;
            hashCode = hashCode * -1521134295 + _deltaNumber.GetHashCode();
            hashCode = hashCode * -1521134295 + _millis.GetHashCode();
            hashCode = hashCode * -1521134295 + _seconds.GetHashCode();
            hashCode = hashCode * -1521134295 + _minutes.GetHashCode();
            hashCode = hashCode * -1521134295 + _hundredthsOfASecond.GetHashCode();
            hashCode = hashCode * -1521134295 + _deltaMillisTotal.GetHashCode();
            hashCode = hashCode * -1521134295 + _deltaMinutes.GetHashCode();
            hashCode = hashCode * -1521134295 + _deltaSeconds.GetHashCode();
            hashCode = hashCode * -1521134295 + _deltaHundredsOfASecond.GetHashCode();
            hashCode = hashCode * -1521134295 + DeltaNumber.GetHashCode();
            hashCode = hashCode * -1521134295 + Millis.GetHashCode();
            hashCode = hashCode * -1521134295 + Seconds.GetHashCode();
            hashCode = hashCode * -1521134295 + Minutes.GetHashCode();
            hashCode = hashCode * -1521134295 + HundredthsOfASecond.GetHashCode();
            hashCode = hashCode * -1521134295 + DeltaMillisTotal.GetHashCode();
            hashCode = hashCode * -1521134295 + DeltaMinutes.GetHashCode();
            hashCode = hashCode * -1521134295 + DeltaSeconds.GetHashCode();
            hashCode = hashCode * -1521134295 + DeltaHundredsOfASecond.GetHashCode();
            hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(TimeString);
            hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(DeltaTimeString);
            return hashCode;
        }

Последний штрих - подчистим неиспользуемые импорты в классе. Для этого поднимаемся в самый верх в редакторе кода и оставляем только указанные импорты, удалив все остальные:

using System;
using System.Collections.Generic;

Теперь класс полностью готов. Сохраняем все изменения, нажатием комбинации Ctrl+S и возвращаемся к коду нашего секундомера. Теперь мы можем раскомментировать объявление делегата для события DeltaStopwatch и само событие. Для этого необходимо выбрать закомментированную область кода с ними и последовательно нажать комбинации клавиш Ctrl+K, Ctrl+U:

        public delegate void AINStopwatchDeltaStopwatch(object sender, AINStopwatchDeltaEventArgs e);

        [Category("Action")]
        [Description("Возникает при нажатии кнопки \"Дельта\" для таймера")]
        [DisplayName("DeltaStopwatch")]
        public event AINStopwatchDeltaStopwatch DeltaStopwatch;

На этом мы завершили с подготовкой всех необходимых полей нашего секундомера и можем приступать к программированию его методов.

Часть 1.5 Добавляем методы для реализации логики работы секундомера

После тела метода конструктора AINStopwatchControl напишем следующий метод ResetCurrentValues:

        /// <summary>
        /// Сброс всех значений секундомера в начальные
        /// </summary>
        private void ResetCurrentValues() {
            _currentMillis = 0;
            _currentSeconds = 0;
            _currentMinutes = 0;
            _currentHundredthsOfASecond = 0;

            _deltaNumber = 0;
        }

Он очень простой и, думаю, не требует пояснений - просто сбрасываем все основные счётчики секундомера в 0, включая порядковый номер дельт.

Дальше создадим метод ResetPreviousValues, он такой же простой, как и предыдущий, но сбрасывает в 0 все приватные поля с префиксом _previous:

        /// <summary>
        /// Сброс предыдущих значений секундомера в начальные
        /// </summary>
        private void ResetPreviousValues() {
            _previousMillis = 0;
            _previousMinutes = 0;
            _previousSeconds = 0;
            _previousHundredthsOfASecond = 0;            
        }

Теперь напишем метод с названием StopwatchStopAndSetIsRunningToFalse:

        private void StopwatchStopAndSetIsRunningToFalse() {
            _isRunning = false;
            stopWatch.Stop();
            TimerStopwatch.Stop();
        }

Этот метод сбрасывает флаг запущенности секундомера _isRunning, останавливает (без сброса внутренних значений!) внутренний экземпляр счётчика stopWatch с помощью вызова stopWatch.Stop(), а также останавливает наш таймер, обновляющий метку времени с помощью вызова TimerStopwatch.Stop()

Добавим в код для класса секундомера ещё несколько методов:

        private void SetStartTextForButtonStart() {
            ButtonStart.Text = "►";
        }
        private void SetPauseTextForButtonStart() {
            ButtonStart.Text = "||";
        }

        /// <summary>
        /// Ставит секундомер на паузу
        /// </summary>
        private void Pause() {
            StopwatchStopAndSetIsRunningToFalse();

            if (EnableDeltaButton) {
                ButtonDelta.Enabled = false;
            }
            SetStartTextForButtonStart();
        }

        private void Stop() {
            StopwatchStopAndSetIsRunningToFalse();
                        
            stopWatch.Reset();
            ResetCurrentValues();
            ButtonStop.Enabled = false;

            if (EnableDeltaButton) {
                ButtonDelta.Visible = false;
                ButtonDelta.Enabled = false;
            }

            SetStartTextForButtonStart();
        }

Методы SetStartTextForButtonStart и SetPauseTextForButtonStart просто обновляют текст на кнопках "старт" и "пауза".

Метод Pause() реализует логику для постановки секундомера на паузу. Как видим, в нём мы вызываем ранее добавленный метод StopwatchStopAndSetIsRunningToFalse, регулируем доступность кнопки "дельта" и обновляем текст на кнопке "старт".

Метод Stop() реализует логику для полной остановки секундомера (со сбросом всех значений). Он похож на метод Pause(), но т.к. требуется полный сброс всех значений, дополнительно вызываем stopWatch.Reset() и ResetCurrentValues(). При помощи ButtonStop.Enabled = false мы также делаем сразу недоступной саму кнопку "стоп", чтобы пользователь не смог нажать её ещё раз. Наконец, мы регулируем видимость и доступность кнопки "дельта" и обновляем текст на кнопке "старт".

Теперь добавим ещё несколько методов в класс:

        /// <summary>
        /// Отображает текущее время секундомера в метке LabelTimer
        /// </summary>
        private void ShowTime() {
            LabelTimer.Text = string.Format(timeFormat,
                    CurrentMinutes.ToString().PadLeft(2, '0'),
                    CurrentSeconds.ToString().PadLeft(2, '0'),
                    CurrentHundredthsOfASecond.ToString().PadLeft(2, '0')
                );
        }

        private void StopwatchStopWithReset() {
            stopWatch.Stop();
            stopWatch.Reset();
        }

        private void SetStateLimitExceeded() {
            StopwatchStopWithReset();

            TimerStopwatch.Stop();
            LabelTimer.ForeColor = LimitFontColor;
            ButtonStart.Enabled = true;
            
            SetStartTextForButtonStart();

            if (EnableDeltaButton) {
                ButtonDelta.Enabled = false;
            }

            ButtonStop.Enabled = false;
            IsResetToInitialValues = true;
            _isRunning = false;
            ShowTime();
            LimitReached?.Invoke(this, new EventArgs());
        }

Метод ShowTime() будет обновлять текст в метке LabelTimer, устанавливая в ней текущее время секундомера.

Метод StopwatchStopWithReset() останавливает со сбросом значения счётчик stopWatch.

Метод SetStateLimitExceeded() необходим для того, чтобы перевести секундомер в режим "время истекло". Он будет вызываться в тот момент, когда секундомер "поймет", что достиг установленного лимита, задаваемого свойствами LimitSeconds и/или LimitMinutes. Как именно это делается мы увидим чуть далее по тексту. Как видно, этот метод также останавливает со сбросом счётчик stopWatch, останавливает таймер TimerStopwatch, устанавливает цвет для метки LabelTimer в заданный через свойство LimitFontColor, а также делает снова доступной кнопку "старт", обновляя на ней текст.

Мы также регулируем доступность кнопки "дельта" и делаем недоступной кнопку "стоп" (поскольку время вышло, нажать на неё теперь нельзя). Важным моментом является установка флага IsResetToInitialValues в значение true. Это означает, что при ближайшем следующем перезапуске секундомера потребуется сбросить все значения в 0. Наконец, мы сбрасываем флаг запущенности секундомера с помощью _isRunning = false, отображаем текущее время вызовом ShowTime() и вызываем следующим образом событие LimitReached:

LimitReached?.Invoke(this, new EventArgs());

Здесь я поясню эту запись. Вопросительный знак перед точкой с последующим вызовом метода Invoke означает буквально следующее: если на событие LimitReached кто-то подписан (т.е. какой-то клиентский код его обрабатывает), то мы вызываем событие LimitReached, в противном же случае не делаем ничего. Если же кто-то подписан на событие мы передаем в параметрах object sender и EventArgs e для метода Invoke значения this и new EventArgs().

this означает, что "передаём себя", т.е. текущий экземпляр элемента управления "секундомер", который и вызывает событие, а new EventArgs() - это просто создание нового экземпляра класса EventArgs, в котором мы ничего особенного не передаём.

На этом этапе мы добавили все необходимые для секундомера методы для обеспечения логики его внутренней работы.

Последнее, что осталось сделать - это обработать события от наших кнопок "дельта", "старт", "стоп", которые имеются у секундомера, а также обработать событие Tick для элемента таймера TimerStopwatch, ведь именно он будет регулярно обновлять метку со временем. 

Для этого мы должны вернуться из редактора кода вновь в представление конструктора и последовательно, двойным кликом, нажать на:

После каждого двойного клика для нас в коде будет автоматически генерироваться метод-обработчик для нажатия на конкретную кнопку, поэтому, оставляя этот обработчик пустым, мы снова и снова возвращаемся из редактора кода в конструктор и дважды "прокликиваем" все перечисленные выше кнопки и таймер.

В результате этих действий должны добавиться в код следующие пустые методы:

        private void ButtonDelta_Click(object sender, EventArgs e) {

        }

        private void ButtonStart_Click(object sender, EventArgs e) {

        }

        private void ButtonStop_Click(object sender, EventArgs e) {

        }

        private void TimerStopwatch_Tick(object sender, EventArgs e) {

        }

Теперь осталось написать в них следующий код (его разберём ниже):

        private void ButtonDelta_Click(object sender, EventArgs e) {
            long currentMillis = CurrentMillis;
            long deltaMillis = currentMillis - _previousMillis;

            _previousHundredthsOfASecond = CurrentHundredthsOfASecond;
            _previousMillis = CurrentMillis;
            _previousSeconds = CurrentSeconds;
            _previousMinutes = CurrentMinutes;

            _deltaNumber++;

            DeltaStopwatch?.Invoke(this, new AINStopwatchDeltaEventArgs(_deltaNumber,
                _previousMillis, _previousSeconds, _previousMinutes, _previousHundredthsOfASecond,
                deltaMillis));
        }

        private void ButtonStart_Click(object sender, EventArgs e) {
            if (IsRunning) {
                // если секундомер уже запущен, поставим его в режим паузы
                Pause();
                PauseStopwatch?.Invoke(this, new EventArgs());
            } else {
                if (IsResetToInitialValues) {
                    ResetCurrentValues();
                    LabelTimer.ForeColor = this.ForeColor;
                    IsResetToInitialValues = false;
                }

                stopWatch.Start();
                TimerStopwatch.Start();
                _isRunning = true;

                SetPauseTextForButtonStart();
                ButtonStop.Enabled = true;

                if (EnableDeltaButton) {
                    ButtonDelta.Visible = true;
                    ButtonDelta.Enabled = true;
                }

                StartStopwatch?.Invoke(this, new EventArgs());
            }
        }

        private void ButtonStop_Click(object sender, EventArgs e) {
            Stop();
            ShowTime();
            StopStopwatch?.Invoke(this, new EventArgs());
        }

        private void TimerStopwatch_Tick(object sender, EventArgs e) {
            _currentMillis = stopWatch.ElapsedMilliseconds;
            _currentSeconds = CurrentMillis / 1000;

            long millisDiv10 = (long)CurrentMillis / 10;
            _currentHundredthsOfASecond = millisDiv10 % 100;

            if (LimitSeconds > 0 && CurrentSeconds >= LimitSeconds && (LimitMinutes == 0 || (LimitMinutes > 0 && LimitMinutes == CurrentMinutes))) {
                SetStateLimitExceeded();
                return;
            }

            if (CurrentSeconds >= 60) {
                _currentSeconds = 0;
                _currentMinutes++;

                if (LimitMinutes > 0 && CurrentMinutes >= LimitMinutes && (LimitSeconds == 0 || (LimitSeconds > 0 && LimitSeconds == CurrentSeconds))) {
                    SetStateLimitExceeded();
                    return;
                }

                StopwatchStopWithReset();
                stopWatch.Start();
            }

            ShowTime();
        }

Разбор всех этих методов привожу ниже.

Обработчик ButtonDelta_Click (нажатие на кнопку "дельта")

При обработке нажатия на кнопку "дельта" мы первостепенно фиксируем текущее показание количества миллисекунд, которое "протикало" в нашем бегущем секундомере и сохраняем его в локальную переменную метода currentMillis:

long currentMillis = CurrentMillis;

Следующим же шагом мы вычисляем "дельту" времени, в миллисекундах, которое прошло от времени предыдущей засечки времени (_previousMillis) до текущего момента:

long deltaMillis = currentMillis - _previousMillis;

Дальше мы перезаписываем во все поля с префиксом _previous все текущие показатели секундомера - таким образом после этих инструкций все текущие показатели станут предыдущими (важно учитывать, что секундомер постоянно "бежит"!):

_previousHundredthsOfASecond = CurrentHundredthsOfASecond;
_previousMillis = CurrentMillis;
_previousSeconds = CurrentSeconds;
_previousMinutes = CurrentMinutes;

Это нужно для того, чтобы суметь посчитать в следующий раз "дельту", когда пользователь нажмёт на кнопку "дельта" на секундомере.

Дальше мы инкрементируем (увеличиваем на единицу) порядковый номер дельты и вызываем событие DeltaStopwatch в том случае, если на него кто-либо подписан:

_deltaNumber++;

DeltaStopwatch?.Invoke(this, new AINStopwatchDeltaEventArgs(_deltaNumber,
                _previousMillis, _previousSeconds, _previousMinutes, _previousHundredthsOfASecond,
                deltaMillis));

Обратите внимание, что именно в этот момент вызова события DeltaStopwatch мы создаём экземпляр нами созданного класса AINStopwatchDeltaEventArgs, передавая в его конструктор все необходимые для события параметры.

Обработчик ButtonStart_Click (нажатие на кнопку "старт/пауза")

При нажатии на кнопку "старт/пауза" мы проверяем: а запущен ли сейчас вообще секундомер?

if (IsRunning) {
   // секундомер запущен ...
} else {
   // секундомер не запущен ...
}

Если он запущен, то нажатие на кнопку "старт/пауза" означает его постановку на паузу, что мы и делаем, вызывая метод Pause(), после чего вызывая событие PauseStopwatch, если на него кто-либо подписан:

// если секундомер уже запущен, поставим его режим паузы
Pause();
PauseStopwatch?.Invoke(this, new EventArgs());

Если же секундомер не запущен (блок кода для else), то мы проверяем флаг IsResetToInitialValues, говорящий о необходимости сброса в начальные значения и делаем это, если флаг установлен. При этом мы сбрасываем шрифт метки со временем в цвет самого элемента управления "секундомер", а также сбрасываем сам флаг IsResetToInitialValues:

if (IsResetToInitialValues) {
    ResetCurrentValues();
    LabelTimer.ForeColor = this.ForeColor;
    IsResetToInitialValues = false;
}

Дальше мы стартуем внутренний счётчик stopWatch и стартуем таймер TimerStopwatch для обновления метки со временем, а также поднимаем флаг того, что секундомер запустился:

stopWatch.Start();
TimerStopwatch.Start();
_isRunning = true;

Наконец мы устанавливаем текст на кнопке "старт" в значок паузы "||" , включаем кнопку "стоп", регулируем доступность кнопки "дельта" и последним шагом вызываем событие StartStopwatch, если на него кто-либо подписан:

SetPauseTextForButtonStart();
ButtonStop.Enabled = true;

if (EnableDeltaButton) {
	ButtonDelta.Visible = true;
	ButtonDelta.Enabled = true;
}

StartStopwatch?.Invoke(this, new EventArgs());
Обработчик ButtonStop_Click (нажатие на кнопку "стоп")

Тут всё просто, мы вызываем наш метод Stop(), что написали и разобрали ранее. Дальше мы показываем последнее успевшее "натикать" в секундомере время через вызов ShowTime(), ну и вызываем наше событие StopStopwatch, если на него кто-то подписан:

Stop();
ShowTime();
StopStopwatch?.Invoke(this, new EventArgs());
Обработчик TimerStopwatch_Tick (вызов таймера через каждые 10 миллисекунд для обновления времени в метке LabelTimer)

Первые две строки метода устанавливают общее число миллисекунд, которое прошло с момента запуска секундомера в переменную _currentMillis и вычисляет количество "протикавших" секунд делением на 1000, сохраняя результат в _currentSeconds:

_currentMillis = stopWatch.ElapsedMilliseconds;
_currentSeconds = CurrentMillis / 1000;

Дальше немного "магии": мы делим количество "натикавших" миллисекунд на 10, поскольку интервал запуска нашего таймера TimerStopwatch - выставлен в 10 миллисекунд, он же обозначает частоту обновления текста в метке LabelTimer. И когда "натикает" 1000 миллисекунд (т.е. 1 секунда), то деление на 10 даст нам значение 100, т.е. это будет 100 сантисекунд (сотых секунды), что равноценно 1 секунде. Этот результат мы сохраняем в переменной millisDiv10, а затем вычисляем остаток от деления на 100 от этого значения и сохраняем в переменную _currentHundredthsOfASecond. Дело в том, что остаток от деления на 100 даст нам точное число сантисекунд (к примеру, 55 % 100 = 55, а 100 % 100 = 0, т.е. сантисекунды сбросятся в 0, а вот число секунд будет увеличено на единицу):

long millisDiv10 = (long)CurrentMillis / 10;
_currentHundredthsOfASecond = millisDiv10 % 100;

Дальше идёт условие, в котором мы проверяем комбинацию условий:

Ниже этот код:

if (LimitSeconds > 0 && CurrentSeconds >= LimitSeconds && (LimitMinutes == 0 || (LimitMinutes > 0 && LimitMinutes == CurrentMinutes))) {
    SetStateLimitExceeded();
    return;
}

Дальше следует код, который проверит, что секунды "перевалили за 60", и в этом случае их нужно обнулить, а вот число минут увеличить на единицу. И ровно аналогичным образом, как описано было выше, мы проверяем условия вхождения в состояние "лимит по секундам/минутам" превышен. Последними двумя строками внутри условия if - если предел по минутам не был достигнут - будут остановка и сброс значений счётчика stopWatch и его повторный запуск, т.е. по достижении каждой очередной минуты мы перезапускаем счётчик stopWatch. Это требуется, чтобы сантисекунды и секунды секундомера снова начали "бежать", начиная с нулевых значений:

if (CurrentSeconds >= 60) {
	_currentSeconds = 0;
	_currentMinutes++;

	if (LimitMinutes > 0 && CurrentMinutes >= LimitMinutes && (LimitSeconds == 0 || (LimitSeconds > 0 && LimitSeconds == CurrentSeconds))) {
		SetStateLimitExceeded();
		return;
	}

	StopwatchStopWithReset();
	stopWatch.Start();
}

Ну и последней строкой в методе идёт отображение текущего времени нашего секундомера:

ShowTime();
Завершающие шаги для элемента управления

Теперь нам ещё остаётся добавить в код несколько действий, которые будут выполняться при загрузке элемента управления. Для этого необходимо перейти в режим конструктора, кликнуть на любой свободной области секундомера (главное - не на кнопках и не на метке со временем), так, что чтобы в окне "Свойства" был выбран AINStopwatchControl. Теперь кликом по иконке "молнии" переходим к событиям секундомера и дважды кликаем напротив события Load. В результате будет сгенерирован метод-обработчик события Load, в который мы пропишем следующий код:

        private void AINStopwatchControl_Load(object sender, EventArgs e) {
            ResetPreviousValues();
            ButtonStop.Enabled = false;
            ButtonDelta.Visible = false;
        }

Нетрудно догадаться, что делают эти три строки кода: сперва мы сбрасываем все предыдущие значения секундомера, а затем делаем недоступной кнопку "стоп" и делаем невидимой кнопку "дельта". Это будет некоторым исходным состоянием кнопок секундомера при его первичной загрузке.

Самое последнее, что мы сделаем - это "подчистим" неиспользуемые импорты в классе элемента управления. Для этого в редакторе кода переходим в самый верх и оставляем только следующие импорты, удалив все неиспользуемые:

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

Друзья, весь код секундомера теперь полностью написан! И мы готовы к тестированию элемента управления.

Часть 1.6 Тестируем работу элемента управления "секундомер"

Теперь мы можем проверить, как работает наш секундомер. К слову, при разработке элемента управления можно и нужно делать это часто, например на этапе добавления новых полей, свойств, методов в элемент управления, но я намеренно не стал писать в предыдущих разделах про проверку секундомера, чтобы выделить её в отдельный раздел и не отвлекаться от разработки кода секундомера и класса для параметров события DeltaStopwatch.

Итак, давайте проверим работу разработанного секундомера. Для этого в среде Microsoft Visual Studio необходимо убедиться, что мы находимся в режиме отладки (Debug) и нажать на кнопку "Пуск":

 

После этого произойдет сборка проекта и запустится окно контейнера для проверки пользовательских элементов управления, где будет отрисован наш секундомер:

Зелёными областями на рисунке выше подсвечены добавленные нами в рамках статьи публичные свойства секундомера, которые можно изменять и тестировать работу секундомера. К примеру, как видим, LimitMinutes выставлен в значение по умолчанию 10 минут, что означает, что по умолчанию секундомер достигнет максимум 10 секунд, а затем будет остановлен, при этом цвет шрифта для метки со временем будет установлен в свойство LimitFontColor, которое по умолчанию равно красному цвету.

Также можем обратить множество других стандартных свойств, которые унаследованы от родительского класса UserControl (вспомним, что после первичного создания проекта для нас был автоматически создан класс, который сразу наследуется от класс UserControl).

Мы можем поменять, какое-то из стандартных свойств при тестировании секундомера. К примеру, давайте сделаем, чтобы цвет метки со временем секундомера и все его кнопки имели цвет шрифта, отличный от чёрного.

Для этого изменяем стандартное свойство ForeColor, выбирая в выпадающем окне вкладку "Интернет", а ней цвет Brown. Результат должен получиться такой:

 

Теперь запускаем секундомер, нажимая на кнопку "старт" и видим, что он начинает отсчитывать время:

 

Попробуйте "поиграть" со значениями свойств секундомера LimitFontColor, LimitMinutes, LimitSeconds, EnableDeltaButton, ButtonsBackgroundColor и посмотреть на результаты после изменений этих свойств.

Чтобы быстрее получить состояние лимита по времени, когда секундомер автоматически остановится сам, я рекомендую попробовать установить значение свойства LimitMinutes в 0, а значение свойства LimitSeconds, к примеру, в значение 15, и тогда через 15 секунд работы секундомера вы увидите его остановку.

Часть 2. Разработка демонстрационного проекта для Windows Forms для встраивания элемента управления "секундомер"

Выше мы проверили работу секундомера в самом проекте секундомера посредством специального контейнера для тестирования элементов управления. Однако самое интересное - это использование разработанного элемента управления в других проектах для Windows Forms, ведь там мы уже можем полноценно подписаться на все доступные события нашего секундомера, а также сделать что-то полезное, например, написать простенькую программу, которая умеет засекать "дельты" времени - ровно так, как это делает обычный настоящий секундомер для измерения времени забега разных бегунов на дистанции.

И в этой части мы разберём создание небольшого демо-проекта, куда добавим разработанный выше секундомер.

Поскольку в предыдущих статьях мы уже рассматривали не раз создание приложений на C# для Windows Forms, то я постараюсь минимизировать описательную часть и опишу лишь самые важные шаги и детали.

Итак, прежде, чем мы создадим отдельный демо-проект, нужно выпустить релизную версию секундомера: для этого изменяем конфигурацию решения с режима Debug на Release и в меню среды разработки выбираем Сборка → Собрать решение.

Это действие приведёт к созданию в директории AINStopwatchControl\bin\Release\ файла AINStopwatchControl.dll, который и содержит наш элемент управления.

После этого сохраняем все изменения в проекте секундомера и закрываем текущее решение в среде разработки через меню Файл → Закрыть решение.

Теперь создаём новый проект с типом "Приложение Windows Forms (.NET Framework)" и называем его AINStopwatchControlDemo.

В новом проекте, слева на панели элементов, нам нужно добавить созданный ранее секундомер. Для этого кликаем правой кнопкой мыши по панели элементов и выбираем пункт меню "Выбрать элементы...":

 

В открывшемся окне с выбором элементов нажимаем кнопку "Обзор..." и далее в файловой системе находим каталог, в котором была собрана релизная версия элемента управления - AINStopwatchControl\bin\Release\ , там располагается DLL-библиотека AINStopwatchControl.dll, содержащая разработанный секундомер. Выбираем эту библиотеку и подтверждаем выбор.

В результате мы увидим, что сборка была добавлена, и флажком выбран элемент управления AINStopwatchControl в таблице:

Нажимаем "OK" в диалоговом окне, после чего элемент управления должен появиться на панели элементов:

 

Далее, имя исходного файла для главной формы приложения переименовываем в FrmAINStopwatchControlMain.cs. Используйте для этого "Обозреватель решений".

На главную форму перетаскиваем из панели элементов элемент управления AINStopwatchControl и устанавливаем ему свойство Name в значение AINStopwatchCtrl.

Также перетаскиваем на форму две метки (Label), два списка (ListBox) и две кнопки (Button).

Меткам задаём свойства Name равные LabelDeltas и LabelEventLog.

Спискам задаём свойства Name равные ListBoxDeltas и ListBoxEventLog.

Кнопкам задаём свойства Name равные ButtonClearDeltas и ButtonClearLog.

Дополнительно проставляем следующие свойства:

Главная форма FrmAINStopwatchControlMain:

Элемент секундомера AINStopwatchCtrl:

Метка LabelDeltas:

Метка LabelEventLog:

Список ListBoxDeltas:

Список ListBoxEventLog:

Кнопка ButtonClearDeltas:

Кнопка ButtonClearLog:

Обратите внимание, что мы сбросили для секундомера лимит по минутам, установив LimitMinutes в значение 0, а лимит по секундам LimitSeconds выставили в значение 10, т.е. теперь секундомер сам остановится спустя 10 секунд работы.

Добавляем к коду метод AddEvengLog, который будет добавлять событие от секундомера в журнал событий (т.е. в список ListBoxEventLog):

        /// <summary>
        /// Добавляет запись в журнал событий секундомера
        /// </summary>
        /// <param name="eventName">название события секундомера</param>
        /// <param name="eventDescription">описание события секундомера</param>
        private void AddEventLog(string eventName, string eventDescription) {
            ListBoxEventLog.Items.Add(string.Format("Событие: {0}. Описание: {1}", eventName, eventDescription));
        }

Далее переходим в конструктор главной формы и дважды кликаем по кнопке "Очистить дельты"ButtonClearDeltas ). В сгенерированном методе-обработчике пишем код:

        /// <summary>
        /// Нажатие на кнопку "&Очистить дельты"
        /// </summary>
        /// <param name="sender">объект кнопки, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void ButtonClearDeltas_Click(object sender, EventArgs e) {
            ListBoxDeltas.Items.Clear();
        }

Таким же образом дважды кликаем по кнопке "Очистить лог событий" ( ButtonClearLog ) и пишем в обработчике код:

        /// <summary>
        /// Нажатие на кнопку "&Очистить лог событий"
        /// </summary>
        /// <param name="sender">объект кнопки, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void ButtonClearLog_Click(object sender, EventArgs e) {
            ListBoxEventLog.Items.Clear();
        }

Теперь для элемента секундомера AINStopwatchCtrl "прокликиваем" двойными кликами мыши окне "Свойства", напротив каждого из событий секундомера:

 

Должно получиться следующее:

А в коде формы сгенерируются пустые методы-обработчики для обработки событий от секундомера:

        private void AINStopwatchCtrl_DeltaStopwatch(object sender, AINStopwatchControl.AINStopwatchDeltaEventArgs e) {

        }

        private void AINStopwatchCtrl_LimitReached(object sender, EventArgs e) {

        }

        private void AINStopwatchCtrl_PauseStopwatch(object sender, EventArgs e) {

        }

        private void AINStopwatchCtrl_StartStopwatch(object sender, EventArgs e) {

        }

        private void AINStopwatchCtrl_StopStopwatch(object sender, EventArgs e) {

        }

Пишем в них следующий код (в комментариях к методам разъяснено, что делает каждый из них, но в целом код очень простой и вы сами всё поймете после запуска приложения):

        /// <summary>
        /// Событие срабатывает при нажатии кнопки ∆ ("дельта") для секундомера.
        /// Событие фиксирует очередную точку времени секундомера и предоставляет 
        /// параметры в аргументе <paramref name="e"/>, которые показывают характеристики зафиксированной точки времени
        /// и дельты относительно предыдущего замера времени секундомера.
        /// </summary>
        /// <param name="sender">объект секундомера, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void AINStopwatchCtrl_DeltaStopwatch(object sender, AINStopwatchControl.AINStopwatchDeltaEventArgs e) {
            AddEventLog("DeltaStopwatch", "Фиксация очередной точки времени с дельтой относительно предыдущей точки");
            string newDelta = "'" + e.DeltaNumber + ") ВРЕМЯ: " + e.TimeString + "; ДЕЛЬТА: " + e.DeltaTimeString;//delta;
            ListBoxDeltas.Items.Add(newDelta);
        }

        /// <summary>
        /// Событие срабатывает в момент достижения заданного лимита времени для секундомера.
        /// Лимит может регулироваться свойствами LimitMinutes и LimitSeconds.
        /// Правила работы свойств:
        /// 1) Если задано свойство LimitMinutes, и оно больше нуля, а свойство LimitSeconds равно 0, то секундомер остановится по достижению
        /// заданного количества минут.
        /// 2) Если задано свойство LimitSeconds, и оно больше нуля, а свойство LimitMinutes равно 0, то секундомер остановится по достижению
        /// заданного количества секунд.
        /// 3) Если заданы оба свойства LimitSeconds и LimitMinutes, и они больше нуля, то секундомер остановится по достижению
        /// заданного количества минут и секунд.
        /// 4) Если оба свойства LimitSeconds и LimitMinutes равны 0, то никаких лимитов для секундомера не установлено.
        /// </summary>
        /// <param name="sender">объект секундомера, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void AINStopwatchCtrl_LimitReached(object sender, EventArgs e) {
            AddEventLog("LimitReached", "Остановка секундомера по достижению лимита, заданного свойствами LimitMinutes/LimitSeconds");
        }

        /// <summary>
        /// Событие срабатывает при нажатии на кнопку || ("Пауза") секундомера.
        /// </summary>
        /// <param name="sender">объект секундомера, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void AINStopwatchCtrl_PauseStopwatch(object sender, EventArgs e) {
            AddEventLog("PauseStopwatch", "Нажата кнопка паузы секундомера");
        }

        /// <summary>
        /// Событие срабатывает при нажатии на кнопку ► ("Старт") секундомера.
        /// </summary>
        /// <param name="sender">объект секундомера, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void AINStopwatchCtrl_StartStopwatch(object sender, EventArgs e) {
            AddEventLog("StartStopwatch", "Запуск секундомера");
        }

        /// <summary>
        /// Событие срабатывает при нажатии на кнопку ■ ("Стоп") секундомера.
        /// </summary>
        /// <param name="sender">объект секундомера, отправивший событие</param>
        /// <param name="e">параметры события</param>
        private void AINStopwatchCtrl_StopStopwatch(object sender, EventArgs e) {
            AddEventLog("StopStopwatch", "Остановка секундомера со сбросом");
            ListBoxDeltas.Items.Clear();
        }

На этом наше демо-приложение для секундомера готово. Можно его запустить, нажав клавишу F5. Далее попробуйте запустить секундомер, понажимать кнопки "дельта", "пауза", "стоп" и понаблюдать за тем, как логируются отсечки времени таймера. Можете также очищать лог событий и лог дельты, нажимая на соответствующие кнопки.

У меня вышло примерно следующее:

 

Друзья, на этом мы подошли к концу, и ниже я прикладываю ссылки на готовые архивы с разработанными проектами - для секундомера и для демонстрационного приложения.

Спасибо за внимание, делитесь своими вопросами и комментариями под этой статьей.

Ссылки на готовые проекты

Ссылка на проект с разработанным элементом управления "Секундомер" (AINStopwatchControl)

Ссылка на демо-проект с использованием элемента управления "Секундомер"