Как работать с массивами структурированных данных, часть II

Добавляем меню

Надо признать, что кнопки занимают сильно много места. Так что давайте удалим все кнопки, а вместо них добавим стандартное меню.

теперь добавим пункты меню как на картинке:

теперь кликнем дважды на пункт меню Файл/Загрузить…, должен добавится обработчик клика на этот пункт меню, вставим в него код из button3_Click

private void загрузитьToolStripMenuItem_Click(object sender, EventArgs e)
{
    var openFileDialog = new OpenFileDialog();
    if (openFileDialog.ShowDialog() != DialogResult.OK)
        return;
    
    var serializer = new XmlSerializer(typeof(SolarSystemObject[]));
    using (var fs = new FileStream(openFileDialog.FileName, FileMode.Open))
    {
        var arrayOfSolarObjects = (SolarSystemObject[])serializer.Deserialize(fs);
        this.solarSystemObjects.Clear();
        foreach (var solarObject in arrayOfSolarObjects)
        {
            this.solarSystemObjects.Add(solarObject);
        }
    }
}

вернемся на форму, кликнем дважды на пункт меню Файл/Сохранить…, вставим в обработчик код из button2_Click

private void сохранитьToolStripMenuItem_Click(object sender, EventArgs e)
{
    var saveFileDialog = new SaveFileDialog();
    if (saveFileDialog.ShowDialog() != DialogResult.OK)
        return;

    var serializer = new XmlSerializer(typeof(SolarSystemObject[]));
    using (var fs = new FileStream(saveFileDialog.FileName, FileMode.OpenOrCreate))
    {
        serializer.Serialize(fs, this.solarSystemObjects.ToArray());
    }
}

и снова вернемся на форму и кликнем дважды на пункт меню Данные/Добавить, вставим в обработчик код из button1_Click

private void добавитьToolStripMenuItem_Click(object sender, EventArgs e)
{
    var solarObjectForm = new SolarObjectForm();

    var solarObject = new SolarSystemObject();
    solarObjectForm.SolarObjectField = solarObject;

    if (solarObjectForm.ShowDialog() == DialogResult.OK)
    {
        this.solarSystemObjects.Add(solarObject);
    }
}

ура теперь можно запустить и проверить, что менюшка работает корректно.

Добавляем возможность редактировать записи

Добавляем контекстное меню

Я хочу, чтобы была возможность кликнуть на запись в таблице правой кнопкой мыши чтобы открылось контекстное меню и предложило отредактировать ее.

Для этого нам надо добавить элемент контекстного меню, добавляем:

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

Если вы случайно закрыли меню, то чтобы его открыть, посмотрите внимательнее: на экране внизу под формой есть невидимые компоненты, просто кликнете на contextMenuStrip1

и меню снова станет доступным для редактирования.

Однако, добавить контекстное меню недостаточно, его еще надо привязать к компоненте. Выделяем dataGridView находим у него свойство ContextMenuStrip и в выпадаюшем списке выбираем contextMenuStrip1:

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

Отлично!

Добавляем обработчик действия Редактировать

Выделим под формой contextMenuStrip1, и в появившемся меню дважды кликнем на Редактировать

private void редактироватьToolStripMenuItem_Click(object sender, EventArgs e)
{
    // если в таблице нет активных строк, просто выходим
    if (this.dataGridView.CurrentRow == null)
        return;
    
    // по номеру активной строки выбираем в списке solarSystemObjects (где содержатся данные таблицы) активный объект
    var solarSystemObject = this.solarSystemObjects[this.dataGridView.CurrentRow.Index];
    
    // создаем форму для редактирования
    var solarObjectForm = new SolarObjectForm();
    
    // привязываем к ней активный объект
    solarObjectForm.SolarObjectField = solarSystemObject;
    
    // открываем новую форму в виде модального окна
    solarObjectForm.ShowDialog();
}

Проверяем:

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

Для этого переключимся на новую форму SolarObjectForm.cs дважды щелкнув ее в обозревателе решений, и переключимся на ее код (нажав F7).

Теперь найдем строчку, в которой объявлено поле для хранения объекта

public SolarSystemObject SolarObjectField;

и превратим его в свойство, но не так как мы сделали ли бы это в классе SolarSystemObject:

public SolarSystemObject SolarObjectField {get; set;}

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

И так, я хочу, чтобы когда в главной форме, в методе редактировать пишут:

var solarObjectForm = new SolarObjectForm();
var solarObject = new SolarSystemObject();
solarObjectForm.SolarObjectField = solarObject; // <<< ВОТ В ЭТОТ МОМЕНТ ПРИСВОЕНИЯ ЗНАЧЕНИЯ СВОЙСТВУ

то значение не просто присваивалось полю, но и вызывался метод который заполнит поля новой формы соответствующими значениями.

Возвращаемся с чего начали. В коде SolarObjectForm.cs найдем строчку, в которой объявлено поле для хранения объекта:

public SolarSystemObject SolarObjectField;

и заменим ее следующей конструкцией:

private SolarSystemObject _solarObjectField; // это скрытое поле, в котором будет хранится непосредственно объект

// а это определенно свойство, которое представляет собой своего рода посредника между формой и скрытым полем
// так как этот посредник ничего хранить сам не может он использует для этого скрытое поле 
public SolarSystemObject SolarObjectField { 
    get // когда мы обращаемся к solarObjectForm.SolarObjectField вызывается этот метод
    {
        return this._solarObjectField; // который просто возвращает скрытое поле
    }
    set // а этот метод вызывается, когда мы присваиваем значения, например, solarObjectForm.SolarObjectField = solarObject
    {
        // value -- это ключевое слово, которое хранит переданное свойству значение
        // мы присваиваем скрытому полю значение value
        this._solarObjectField = value; 
        
        // а дальше заполняем поля на форме
        txtDayLengthInDays.Text = this._solarObjectField.DayLengthInDays.ToString();
        txtYearLengthInDays.Text = this._solarObjectField.YearLengthInDays.ToString();
        txtRadius.Text = this._solarObjectField.Radius.ToString();
        txtDistanceToTheSun.Text = this._solarObjectField.DistanceToTheSun.ToString();
        txtName.Text = this._solarObjectField.Name;
    }
}

Я понимаю, что это выглядит как страшное колдунство. И если вам не понятно, не постесняйтесь и спросите меня на паре, я попробую объяснить, могу даже картинку нарисовать… спрашивать можно неограниченное количество раз.

И так, тестируем:

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

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

Переключимся на старую форму и поправим метод редактироватьToolStripMenuItem_Click:

private void редактироватьToolStripMenuItem_Click(object sender, EventArgs e)
{
    // ...
    
    solarObjectForm.ShowDialog();
    
    dataGridView.InvalidateRow(this.dataGridView.CurrentRow.Index); // даем команду перерисовать строчку
}

теперь все должно быть ок.

Добавляем возможность удалять записи

Ломать – не строить, но даже чтобы удалить запись придется написать немного кода. Кликаем на наше контекстное меню

добавляем пункт Удалить запись

кликаем на новый пункт меню дважды и пишем:

private void удалитьЗаписьToolStripMenuItem_Click(object sender, EventArgs e)
{
    // мы люди вежливые, поэтому сначала спросим, а не ошибся ли пользователь
    // тут нам на помощь придет наш старый знакомый MessageBox, с функций Show, 
    // которой можно передать какой набор кнопок мы хотим видеть на форме, я хочу кнопки Yes и No
    var confirmResult = MessageBox.Show("Вы уверены, что хотите удалить запись", "Подтверждение", MessageBoxButtons.YesNo);
    if (confirmResult != DialogResult.Yes) // проверяем нажали ли Yes
        return;
    
    // удаляем запись
    this.solarSystemObjects.RemoveAt(this.dataGridView.CurrentRow.Index);
}

проверяем:

Прекрасно!

Добавляем отчеты

Я буду писать несколько вариантов отчетов. Создадим элемент главного меню “Отчеты” и добавим туда следующие пункты меню

  • Планета с самыми длинными сутками
  • Суммарный радиус планет
  • Поиск планет по названию
  • Планеты расстояние до солнца у которых попадает в промежуток
  • Поиск ближайшей планеты к указанной

Планета с самыми длинными сутками

Это совсем простой отчет тут просто надо найти взять все данные по планетам упорядочить по удалённости от солнца в порядке убывания и взять первую планету. Кликаем два раза на пункт меню Планета с самыми длинными сутками и пишем:

Способ 1

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

private void планетаССамымДлиннымДнемToolStripMenuItem_Click(object sender, EventArgs e)
{
    if (this.solarSystemObjects.Count == 0) // чтобы не падала программа если табличка пуста
        return;
    
    // предполагаем, что планет с наибольшей продолжительностью суток, это первая планета в списке
    var planet = this.solarSystemObjects[0]; 
    
    // в цикле по всем объектам
    foreach(var p  in this.solarSystemObjects)
    {
        // если у очередной планеты в списке продолжительность больше чем у той которую считаем за максимальную
        if (planet.DayLengthInDays > p.DayLengthInDays)
        {
            // то меняем максимальную планету на другую
            planet = p;
        }
    }

    MessageBox.Show("Планета с наибольшей продолжительностью суток это " + planet.Name);
}

Способ 2 (функциональный подход)

private void планетаССамымДлиннымДнемToolStripMenuItem_Click(object sender, EventArgs e)
{
    if (this.solarSystemObjects.Count == 0)
        return;
    
    var planet = this.solarSystemObjects
        .OrderBy(x => -x.DayLengthInDays) // упорядочиваем по продолжительности дня в порядке убывания 
        .First(); // берем первый элемент в упорядоченном списке
        
    MessageBox.Show("Планета с наибольшей продолжительностью суток это " + planet.Name);
}

Способ 3 (LINQ подход)

private void планетаССамымДлиннымДнемToolStripMenuItem_Click(object sender, EventArgs e)
{
    if (this.solarSystemObjects.Count == 0)
        return;

    var planet = ( // типа SQL запроса
        from p in this.solarSystemObjects // откуда берем
        orderby p.DayLengthInDays descending // как упорядочиваема
        select p // в конце надо обязательно чтобы select был
    ).First(); // берем только первый элемент

    MessageBox.Show("Планета с наибольшей продолжительностью суток это " + planet.Name);
}

Суммарный радиус планет

Способ 1

простой расчёт суммы

private void суммарныйРадиусПланетToolStripMenuItem_Click(object sender, EventArgs e)
{
    float sumRadius = 0;
    foreach (var planet in this.solarSystemObjects)
    {
        sumRadius += planet.Radius;
    }
    MessageBox.Show("Суммарный радиус планет " + sumRadius);
}

Способ 2 (функциональный)

private void суммарныйРадиусПланетToolStripMenuItem_Click(object sender, EventArgs e)
{
    var sumRadius = this.solarSystemObjects
        .Select(x => x.Radius) // этой строчкой список планет превращается в список радиусов
        .Sum(); // суммируем список радиусов
    MessageBox.Show("Суммарный радиус планет " + sumRadius);
}

Способ 3 (LINQ подоход)

private void суммарныйРадиусПланетToolStripMenuItem_Click(object sender, EventArgs e)
{
    var sumRadius = (
        from p in this.solarSystemObjects // запрос, откуда берем
        select p.Radius // и чего берем
    ).Sum(); // суммируем
    MessageBox.Show("Суммарный радиус планет " + sumRadius);
}

Поиск планет по названию

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

называем ее SearchByTestForm.cs

оформляем как на картинке:

  • у TextBox, свойство (Name): txtSearch
  • у кнопки Найти свойство DialogResult: Ok
  • у кнопки Отмена свойство DialogResult: Cancel

переключимся на код этой формы (нажмем F7), и добавил прокси-свойство, через которое можно будет узнать введенное пользователем значение в txtSearch. Сделать это необходимо так как доступ к компонентам формы ограничен из вне.

Пишем:

namespace WindowsFormsApp8
{
    public partial class SearchByTextForm : Form
    {
        public string SearchText // добавили свойство
        {
            get // при обращении к этому свойству получим содержимое текста в txtSearch
            {
                return txtSearch.Text;
            }
        }

        public SearchByTextForm()
        {
            InitializeComponent();
        }
    }
}

Вернемся к нашей старой форме и кликаем дважды на пункт меню Поиск планеты по названию. И теперь уже напишем отчет

Способ 1

private void поискПланетыПоНазваниюToolStripMenuItem_Click(object sender, EventArgs e)
{
    // так как мы не просто закрываем форму, а на самом деле просто прячем ее, когда нажимаем OK
    // чтобы она в памяти не висела, используем конструкцию using, 
    // которая гарантирует удаление формы из памяти по завершению функции
    using(var form = new SearchByTextForm()) 
    {
        if (form.ShowDialog() != DialogResult.OK) // проверяем что нажали OK
            return;

        var planets = new List<string>();
        foreach(var planet in this.solarSystemObjects)
        {
            if (planet.Name.Contains(form.SearchText)) // если название содержит введенный текст
            {
                planets.Add(planet.Name); // добавляем название в список
            }
        }

        // выводим список названий планет
        // тут я использую функцию String.Join, у которой два параметра разделитель
        // я указал "\n" -- это перенос на новую строку
        // а второй параметр — это список значений, я передаю
        // список планет удовлетворяющих поисковой строке
        // ну а дальше просто вывожу все на экран
        MessageBox.Show(String.Join("\n", planets));
    }
}

Способ 2 (функциональный подход)

private void поискПланетыПоНазваниюToolStripMenuItem_Click(object sender, EventArgs e)
{
    using(var form = new SearchByTextForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;

        var planets = this.solarSystemObjects
            .Select(x => x.Name)
            .Where(x => x.Contains(form.SearchText));

        MessageBox.Show(String.Join("\n", planets));
    }
}

Способ 3 (LINQ подоход)

private void поискПланетыПоНазваниюToolStripMenuItem_Click(object sender, EventArgs e)
{
    using(var form = new SearchByTextForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;


        var planets = from p in this.solarSystemObjects
                      where p.Name.Contains(form.SearchText)
                      select p.Name;

        MessageBox.Show(String.Join("\n", planets));
    }
}

можно запустить и проверить (например, поиск планет содержащих букву “а”)

Планеты расстояние до солнца у которых попадает в промежуток

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

назовем ее RangeForm.cs

добавляем элементы на форму:

  • у первого TextBox, свойство (Name): txtFrom
  • у второго TextBox, свойство (Name): txtTo
  • у кнопки Ok свойство DialogResult: Ok
  • у кнопки Отмена свойство DialogResult: Cancel

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

//...

namespace WindowsFormsApp8
{
    public partial class RangeForm : Form
    {
        public float FromValue // свойство преобразует значение в txtForm в число и возвращает результат
        {
            get
            {
                float result;
                if (float.TryParse(txtFrom.Text, out result))
                    return result;
                return 0; // если преобразовать не удалось, возвращаем 0
            }
        }

        public float ToValue // свойство преобразует значение в txtTo в число и возвращает результат
        {
            get
            {
                float result;
                if (float.TryParse(txtTo.Text, out result))
                    return result;
                return 0; // если преобразовать не удалось, возвращаем 0
            }
        }

        public RangeForm()
        {
            InitializeComponent();
        }
    }
}

теперь снова вернемся на главную форму Form.cs, найдем в меню пункт Планеты расстояние до солнца у которых попадает в промежуток и дважды кликнем на него дважды чтобы добавить обработчик. И напишем наш отчет.

Способ 1

Тут по сути тот же код что и в запросе выше, только тут я уже использую два прокси-свойства формы (form.FromValue и form.ToValue), ну и условие немного посложнее:

private void планетыРасстояниеДоСолнцаУКоторыхПопадаетВПромежутокToolStripMenuItem1_Click(object sender, EventArgs e)
{
    using (var form = new RangeForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;

        var planets = new List<string>();
        foreach(var p in this.solarSystemObjects)
        {
            if (form.FromValue < p.DistanceToTheSun && p.DistanceToTheSun <= form.ToValue)
            {
                planets.Add(p.Name);
            }
        }

        MessageBox.Show(String.Join("\n", planets));
    }
}

Способ 2 (функциональный подход)

private void планетыРасстояниеДоСолнцаУКоторыхПопадаетВПромежутокToolStripMenuItem1_Click(object sender, EventArgs e)
{
    using (var form = new RangeForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;

        var planets = this.solarSystemObjects
            .Where(p => form.FromValue < p.DistanceToTheSun && p.DistanceToTheSun <= form.ToValue)
            .Select(p => p.Name);

        MessageBox.Show(String.Join("\n", planets));
    }
}

Способ 3 (LINQ подход)

private void планетыРасстояниеДоСолнцаУКоторыхПопадаетВПромежутокToolStripMenuItem1_Click(object sender, EventArgs e)
{
    using (var form = new RangeForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;

        var planets = from p in this.solarSystemObjects
                      where form.FromValue < p.DistanceToTheSun && p.DistanceToTheSun <= form.ToValue
                      select p.Name;

        MessageBox.Show(String.Join("\n", planets));
    }
}

проверяем:

Поиск ближайшей планеты к указанной

Тут снова понадобится новая форма. Я хочу, чтобы на новой форме присутствовал выпадающий список с названиями планет. Чтобы можно было выбрать планету, нажать OK и получить в ответ название ближайшей планеты.

Добавляем форму:

назовем ее ComboBoxForm.cs

добавляем элементы на форму:

  • элемент типа ComboBox, свойство (Name): cmbPlanets
  • у кнопки Ok свойство DialogResult: Ok
  • у кнопки Отмена свойство DialogResult: Cancel

Давайте добавим прокси-свойство, которое будет возвращать выбранное в cmbPlanets значение:

namespace WindowsFormsApp8
{
    public partial class ComboBoxForm : Form
    {
        public string SelectedPlanet
        {
            get
            {
                return cmbPlanets.SelectedValue.ToString();
            }
        }

        public ComboBoxForm()
        {
            InitializeComponent();
        }
    }
}

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

private void поискБлижайщейПланетыКУказанойToolStripMenuItem_Click(object sender, EventArgs e)
{
    using(var form = new ComboBoxForm())
    {
        if (form.ShowDialog() != DialogResult.OK)
            return;

        // найдем планету в списке у которой название совпадает с выбранным
        var planet = this.solarSystemObjects
            .Where(p => p.Name == form.SelectedPlanet)
            .FirstOrDefault(); // этот метод в отличии от First() не будет выдавать ошибки если планеты не найдется, а просто вернет null

        // если такая нашлась
        if (planet != null)
        {
            var nearestPlanet = this.solarSystemObjects
                .Where(p => p.Name != planet.Name) // чтоб планету с самой собой не сравнивать, а то так получается что ближайшая планета к Земле это Земля
                .OrderBy(p => Math.Abs(planet.DistanceToTheSun - p.DistanceToTheSun)) // упорядочить в порядке удаления
                .First(); // выбрать первое значение

            MessageBox.Show("Ближайшая планет: " + nearestPlanet.Name);
        }
    }
}

пробуем запустить:

кто бы мог подумать, у нас пустой список…

В общем чтобы список заполнить надо форме как-нибудь передать названия планет. Для этого воспользуемся конструктором.

Переключимся на форму ComboBoxForm, перейдем в ее код и заменим

namespace WindowsFormsApp8
{
    public partial class ComboBoxForm : Form
    {
        // ...
    
        public ComboBoxForm() // <<< ВОТ ЭТОТ КОНСТРУКТОР
        {
            InitializeComponent();
        }
    }
}

на

namespace WindowsFormsApp8
{
    public partial class ComboBoxForm : Form
    {
        // ...
        
        public ComboBoxForm(List<string> planetNames) // <<< добавили список названий в качестве аргумента функции
        {
            InitializeComponent();

            this.cmbPlanets.DataSource = planetNames; // привязываем переданный список к выпадающему списку
        }
    }
}

снова возвращаемся на главную форму в обработчик поискБлижайщейПланетыКУказанойToolStripMenuItem_Click. Правим его следующим образом:

private void поискБлижайщейПланетыКУказанойToolStripMenuItem_Click(object sender, EventArgs e)
{
    // формирую список названий планет
    var planetNames = this.solarSystemObjects
        .Select(p => p.Name)
        .OrderBy(p => p) // по алфавиту упорядочиваем
        .Distinct() // только уникальные значения, ну вдруг у вас будет две планеты с одинаковым названием
        .ToList();
        
    using(var form = new ComboBoxForm(planetNames)) // передаю список планет в конструктор new ComboBoxForm(planetNames)
    {
        // остальное не трогаем
        // ...
    }
}

запускаем и проверяем

Тут уж не буду все три варианта расписывать, думаю и все так понятно.

Собственно, и все. Этого должно хватить чтобы написать код по курсовой.