Доступ к данным

Повышение удобства использования WPF-элемента DataGrid с помощью шаблонов столбцов

Джули Лерман

Загрузка примера кода

Недавно я делала кое-какую работу для одного клиента с использованием Windows Presentation Foundation (WPF). Хотя я всячески приветствую применение сторонних инструментов, иногда я избегаю их, чтобы понять, какие проблемы поджидают разработчиков, которые по тем или иным причинам используют только те инструменты, которые являются частью Visual Studio.

Поэтому я скрестила пальцы и с головой окунулась в WPF-элемент управления DataGrid (далее для краткости WPF DataGrid). Я обнаружила некоторые проблемы с удобством использования, на решение которых у меня ушли дни, даже с помощью поиска в Интернете и предложений в онлайновых форумах. Как оказалось, разбиение столбцов DataGrid на пары дополняющих шаблонов играет важную роль в решении этих проблем. Поскольку решения были не очевидными, я поделюсь ими в этой статье.

Основное внимание в данной статье будет уделено работе с WPF-элементами управления ComboBox и DatePicker, находящимися внутри WPF DataGrid.

DatePicker и новые строки DataGrid

Одной из проблем было взаимодействие пользователя с полями даты в моем DataGrid. Я создала DataGrid перетаскиванием объекта Data Source в окно WPF. По умолчанию дизайнер создает DatePicker для каждого значения DateTime в объекте. Например, вот столбец, созданный для поля DateScheduled:

<DataGridTemplateColumn x:Name=" dateScheduledColumn"  
  Header="DateScheduled" Width="100">
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <DatePicker
        SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
          ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

Это поведение по умолчанию мешает редактированию. Существующие строки не обновлялись при изменении. DatePicker не инициировал режим редактирования в DataGrid, а это значит, что связывание с данными не приводило к передаче изменений в нижележащий объект. Добавление атрибута UpdateSourceTrigger к элементу Binding и присваивание ему значения в PropertyChanged решили эту конкретную проблему:

<DatePicker
   SelectedDate="{Binding Path= DateScheduled, Mode=TwoWay,
     ValidatesOnExceptions=true, NotifyOnValidationError=true,
     UpdateSourceTrigger=PropertyChanged}" />

Однако в случае новых строк последствия неспособности DatePicker переключить DataGrid в режим редактирования были хуже. В DataGrid новая строка представляется NewRowPlaceHolder. Когда вы впервые что-то набираете в какой-нибудь ячейке в новой строке, в режиме редактирования инициируется вставка в источник данных (и вновь не в базу данных, а в нижележащий источник в памяти). Но этого не происходит, так как DatePicker не вызывает переключения в режим редактирования.

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

На рис. 1 показана новая строка, где в первый редактируемый столбец введена дата. 

image: Entering a Date Value into a New Row Placeholder

Рис. 1. Ввод даты в поле новой строки

Но после редактирования значения в следующем столбце предыдущее значение теряется, как видно на рис. 2.

image: Date Value Is Lost After the Value of the Task Column in the New Row Is Modified

Рис. 2. Введенная дата теряется после изменения значения в столбце Task новой строки

Значение ключа в первом поле становится нулевым, и только что введенная дата сбрасывается в 1/1/0001. Редактирование поля Task в конечном счете заставляет DataGrid добавить новую сущность в источник. Значение идентификатора становится целым (по умолчанию 0), и дата принимает минимальное значение по умолчанию в .NET — 1/1/0001. Если бы я указала дату по умолчанию для этого класса, дата, введенная пользователем, сменилась бы на значение по умолчанию для класса, а не для .NET. Заметьте, что дата в поле Date Performed не меняется на значение по умолчанию. Это вызвано тем, что DatePerformed является свойством, допускающим пустые значения (nullable property).

Поэтому теперь пользователь должен вернуться и снова исправить Scheduled Date. Уверена, что это вряд ли кому понравится. Я довольно долго провозилась с этой проблемой. Даже пробовала заменять этот столбец на DataTextBoxColumn, но в этом случае приходилось решать проблему проверок, от которой меня избавлял DatePicker.

В конце концов, Варша Махадеван (Varsha Mahadevan) из группы WPF подсказал мне правильный путь.

Благодаря композиционной природе WPF можно использовать два элемента для одного столбца. В DataGridTemplateColumn есть не только элемент CellTemplate, но и CellEditingTemplate. Вместо того чтобы добиваться от элемента управления DatePicker инициации режима редактирования, я использую DatePicker, только когда нахожусь в этом режиме. Для отображения даты в CellTemplate я перешла на TextBlock. Вот новый XAML для dateScheduledCoumn:

<DataGridTemplateColumn x:Name="dateScheduledColumn" 
  Header="Date Scheduled" Width="125">
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Path= DateScheduled, StringFormat=\{0:d\}}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>
  <DataGridTemplateColumn.CellEditingTemplate>
    <DataTemplate>
      <DatePicker SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
                  ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>

Заметьте, что теперь мне не нужно указывать UpdateSourceTrigger. Я внесла те же изменения в столбец DatePerformed.

Теперь столбцы дат начинают работать как чисто текстовые, пока вы не выбираете ячейку; после этого она переключается на DatePicker, как показано на рис. 3.

image: DateScheduled Column Using Both a TextBlock and a DatePicker

Рис. 3. Столбец DateScheduled, в котором используются и TextBlock, и DatePicker

В строках выше новой значок календаря DatePicker больше не отображается.

Но еще не все корректно. Мы по-прежнему получаем .NET-значение по умолчанию, начиная редактировать строку. И вот теперь можно воспользоваться преимуществом определения значения по умолчанию в нижележащем классе. Я модифицировала конструктор класса ScheduleItem так, чтобы он инициализировал новые объекты текущей датой. Если данные извлекаются из базы данных, они замещают значения по умолчанию. В своем проекте я использую Entity Framework, поэтому мои классы генерируются автоматически. Однако эти классы частичные, что позволяет мне добавить конструктор в дополнительный частичный класс:

public partial class ScheduleItem
    {
      public ScheduleItem()
      {
        DateScheduled = DateTime.Today;
      }
    }

Теперь, когда я начинаю вводить данные в новую строку, изменяя поле DateScheduled, элемент DataGrid создаст за меня новый ScheduleItem, и в элементе управления DatePicker будет показано значение по умолчанию (текущая дата). Когда пользователь продолжит редактировать строку, введенное значение останется на месте.

Уменьшаем количество щелчков, необходимых для перехода к редактированию

Один недостаток шаблона из двух частей заключается в том, что вам приходится дважды щелкать ячейку, чтобы DatePicker начал работать. Это, конечно, очень неудобно для любого, кто вводит данные, особенно если он привык пользоваться исключительно клавиатурой, не касаясь мыши. Поскольку DatePicker находится в шаблоне редактирования, он не получит фокус ввода, пока вы не активизируете режим редактирования (по умолчанию). Такой дизайн предназначался для элементов управления TextBox, и в них все это прекрасно работает. Но для DatePicker этот дизайн не годится. Вы можете использовать комбинацию XAML и кода, чтобы сразу же делать DatePicker готовым к приему ввода, как только пользователь переходит табулятором в очередную ячейку.

Сначала мне нужно добавить Grid в CellEditingTemplate, чтобы он стал контейнером для DatePicker. Затем, используя WPF FocusManager, вы можете принудительно делать этот Grid фокальной точкой ячейки, когда пользователь переходит в эту ячейку. Вот новый элемент Grid вокруг DatePicker:

<Grid FocusManager.FocusedElement="{Binding ElementName= dateScheduledPicker}">
  <DatePicker x:Name=" dateScheduledPicker" 
    SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
    ValidatesOnExceptions=true, NotifyOnValidationError=true}"  />
</Grid>

Заметьте, что я присваиваю имя элементу управления DatePicker и указываю на это имя, используя FocusedElement Binding ElementName.

Теперь обратите внимание на DataGrid, который содержит этот DatePicker; я добавила три новых свойства (RowDetailsVisibilityMode, SelectionMode и SelectionUnit), а также новый обработчик события (SelectedCellsChanged):

<DataGrid AutoGenerateColumns="False" EnableRowVirtualization="True" 
          ItemsSource="{Binding}" Margin="12,12,22,31" 
          Name="scheduleItemsDataGrid" 
          RowDetailsVisibilityMode="VisibleWhenSelected" 
          SelectionMode="Extended" SelectionUnit="Cell"
          SelectedCellsChanged="scheduleItemsDataGrid_SelectedCellsChanged">

Эти изменения в DataGrid обеспечат уведомление о том, что пользователь выбрал новую ячейку в DataGrid. Наконец, когда это произойдет, вам нужно будет добиться, чтобы DataGrid действительно перешел в режим редактирования; в итоге в DatePicker появится соответствующий указатель вставки. Эту последнюю часть логики содержит метод scheduleItemsDataGrid_SelectedCellsChanged:

private void scheduleItemsDataGrid_SelectedCellsChanged
  (object sender, 
   System.Windows.Controls.SelectedCellsChangedEventArgs e)
{
  if (e.AddedCells.Count == 0) return;
  var currentCell = e.AddedCells[0];
  string header = (string)currentCell.Column.Header;

  var currentCell = e.AddedCells[0];
  
  if (currentCell.Column == 
    scheduleItemsDataGrid.Columns[DateScheduledColumnIndex])
  {
    scheduleItemsDataGrid.BeginEdit();
  }
}

В объявлениях класса я определила константу DateScheduledColumnIndex, равную 1, — это позиция столбца в сетке.

Покончив со всеми изменениями, мы осчастливим конечного пользователя. Какое-то время у меня ушло на то, чтобы найти правильное сочетание XAML и элементов кода для корректной работы DatePicker в DataGrid, зато вы сможете избежать этих затрат времени. Теперь UI работает так, как является естественным для пользователя.

Включение поддержки в ограниченном ComboBox отображения данных из устаревшего приложения

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

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

Сначала я попыталась использовать DataGridComboBoxColumn:

<DataGridComboBoxColumn x:Name="frequencyCombo"   
 MinWidth="100" Header="Frequency"
 ItemsSource="{Binding Source={StaticResource frequencyViewSource}}"
 SelectedValueBinding=
 "{Binding Path=Frequency, UpdateSourceTrigger=PropertyChanged}">
</DataGridComboBoxColumn>

Элементы-источники определены в отделенном коде:

private void PopulateTrueFrequencyList()
{
  _frequencyList =
                 new List<String>{"",
                   "Initial","2 Weeks",
                   "1 Month", "2 Months",
                   "3 Months", "4 Months",
                   "5 Months", "6 Months",
                   "7 Months", "8 Months",
                   "9 Months", "10 Months",
                   "11 Months", "12 Months"
                 };
    }

Этот frequencyList связывается с frequencyViewSource.Source в другом методе.

В море возможных конфигураций DataGridComboBoxColumn я могла бы и не найти способа отображать значения, которые, возможно, уже хранятся в поле Frequency таблицы базы данных. Я не стану перечислять все варианты решения, которые я пыталась применить, в том числе тот вариант, где в нижнюю часть _frequencyList динамически по мере необходимости добавлялись эти дополнительные значения, а затем удалялись. Это было решение, которое мне не нравилось, но с которым мне пришлось бы смириться.

Я знала, что в WPF при многоуровневой композиции UI должен существовать подходящий механизм, и решив проблему с DatePicker, я поняла, что аналогичный подход можно было бы применить и для ComboBox. Первая часть фокуса — избегать изящный элемент управления DataGridComboBoxColumn и использовать более традиционный способ со встраиванием ComboBox в DataGridTemplateColumn. Затем, используя композиционную природу WPF, можно задействовать для столбца два элемента — точно так же, как и для столбца DateScheduled. Значения отображаются в TextBlock, а редактируются в ComboBox.

На рис. 4 показано, как я использовала сочетание этих элементов.

Рис. 4. Отображение значений столбца в TextBlock, а редактирование в ComboBox

<DataGridTemplateColumn x:Name="taskColumnFaster" 
  Header="Task" Width="100" >
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Path=Task}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>

  <DataGridTemplateColumn.CellEditingTemplate>
    <DataTemplate>
      <Grid FocusManager.FocusedElement=
       "{Binding ElementName= taskCombo}" >
        <ComboBox x:Name="taskCombo"
          ItemsSource="{Binding Source={StaticResource taskViewSource}}" 
          SelectedItem ="{Binding Path=Task}" 
            IsSynchronizedWithCurrentItem="False"/>
      </Grid>
    </DataTemplate>
  </DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>

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

Поддержка редактирования в ComboBox, когда ячейка получает фокус ввода

И вновь, поскольку ComboBox не будет доступен, пока пользователь дважды не щелкнет ячейку, я обертываю ComboBox в Grid для использования FocusManager.

Я модифицировала метод SelectedCellsChanged на случай, если пользователь начнет ввод новой строки данных, щелкнув ячейку Task, а не просто перейдет в первый столбец. Единственное изменение заключается в том, что код проверяет, не находится ли текущая ячейка в столбце Task:

private void scheduleItemsDataGrid_SelectedCellsChanged(object sender,  
  System.Windows.Controls.SelectedCellsChangedEventArgs e)
{
  if (e.AddedCells.Count == 0) return;
  var currentCell = e.AddedCells[0];
  string header = (string)currentCell.Column.Header;

  if (currentCell.Column == 
    scheduleItemsDataGrid.Columns[DateScheduledColumnIndex] 
    || currentCell.Column == scheduleItemsDataGrid.Columns[TaskColumnIndex])
  {
    scheduleItemsDataGrid.BeginEdit();
  }
}

Не пренебрегайте удобством работы для пользователей

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

Хотя WPF-средства связывания с данными в Visual Studio 2010 фантастически экономят время в процессе разработке, тщательное продумывание удобства в работе со сложными сетками данных — особенно в сочетании с не менее сложными элементами управления DatePicker и ComboBox — будут высоко оценены вашими конечными пользователями. Скорей всего, они даже не заметят ваши дополнительные усилия, поскольку все работает именно так, как они и ожидают — но это забавная часть нашей работы.

Джули Лерман (Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором очень популярной книги «Programming Entity Framework» (O’Reilly Media, 2009). Вы также можете читать ее заметки в twitter.com/julielerman.

Выражаю благодарность за рецензирование статьи эксперту Варше Махадевану (Varsha Mahadevan)