WPF Таблица XAML

Игорь Ага

Ребят подскажите мне тугому как сделать подобную выгрузку элементов с бд с постраничной или динамической прокруткой данных? Задача: В базе хранятся фотографии и фио студентов. Требуется выводить фотографию и под ней ФИО студента.

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

На данный момент инф. берется таким способом.

1 ответ

Игорь Ага

Окей, ну давайте напишем как это нужно делать. Попробуем сразу сделать из этого MVVM.

Начнём с модели. Главное — модель должна стараться абстрагировать от нас то, что у неё под капотом. Имея это в виду, организуем её просто как список модельных объектов в памяти. Внешний код не должен от этого зависеть.

Итак:

public class StudentModel
{ public string Name { get; set; } public Uri ImageUri { get; set; }
}
public class StudentListModel
{ const string uri = "https://www.gravatar.com/avatar/********************************?" + "s=328&d=identicon&r=PG"; public IEnumerable<studentmodel> GetAllStudents() => Enumerable.Range(0, 200).Select(n => new StudentModel() { Name = $"Student #{n}", ImageUri = new Uri(uri) });
}
</studentmodel>

Функциональность «получить n-ую страницу» я оставляю на VM, держа в голове, что в реальности у вас вместо IEnumerable будет IQueryable. Если вам хочется сделать репозиторий поверх вашего обращения с базой (например потому, что вы не пользуетесь EF), почему бы и нет.

С моделью покамест всё, впиливать базу сюда уж придётся вам лично. Перейдём к VM.

Для начала, базовый класс для VM. Если вы пользуетесь каким-нибудь MVVM-фреймворком, он у вас уже есть. Если нет, напишем его. Он должен имплементировать INotifyPropertyChanged:

class VM : INotifyPropertyChanged
{ protected bool Set<t>(ref T field, T value, [CallerMemberName] string propertyName = null) { if (EqualityComparer<t>.Default.Equals(field, value)) return false; field = value; RaisePropertyChanged(propertyName); return true; } protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); public event PropertyChangedEventHandler PropertyChanged;
}
</t></t>

Теперь класс, отвечающий за одного студента. С этим всё просто:

class StudentVM : VM
{ string name; public string Name { get { return name; } set { Set(ref name, value); } } Uri imageUri; public Uri ImageUri { get { return imageUri; } set { Set(ref imageUri, value); } } public StudentVM(string name, Uri uri) { this.name = name; this.imageUri = uri; }
}

Если вы пишете в соответствии с трендами, и у вас иммутабельные классы, получится проще:

class StudentVM : VM
{ public string Name { get; } public Uri ImageUri { get; } public StudentVM(string name, Uri uri) { this.Name = name; this.ImageUri = uri; }
}

Дальше — класс, занимающийся списком студентов. Будем писать его по кускам. Начнём.

class StudentListVM : VM
{

Нам нужно свойство, отвечающее за количество страниц. Без него никак. Пишем:

int totalPages; public int TotalPages { get { return totalPages; } private set { if (value < 0) throw new ArgumentException(nameof(TotalPages)); Set(ref totalPages, value); } }

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

bool havePages; public bool HavePages { get { return havePages; } private set { Set(ref havePages, value); } }

и не забудем установить его в сеттере TotalPages:

private set { if (value < 0) throw new ArgumentException(nameof(TotalPages)); Set(ref totalPages, value); HavePages = totalPages > 0; // <--- добавили }

Следующий пункт — номер текущей страницы. С ним всё то же самое, только проверок в сеттере побольше.

int currentPageNo; public int CurrentPageNo { get { return currentPageNo; } set { if (value < 0) throw new ArgumentException(nameof(CurrentPageNo)); if (value >= TotalPages && HavePages) throw new ArgumentException(nameof(CurrentPageNo)); if (value != 0 && !HavePages) throw new ArgumentException(nameof(CurrentPageNo)); Set(ref currentPageNo, value) } }

Теперь, когда текущая страница поменялась, мы должны вычитать новый список студентов. Это нужно сделать в фоне, не блокируя UI-поток. Добавляем в CurrentPageNo

set { if (value < 0) throw new ArgumentException(nameof(CurrentPageNo)); if (value >= TotalPages && HavePages) throw new ArgumentException(nameof(CurrentPageNo)); if (value != 0 && !HavePages) throw new ArgumentException(nameof(CurrentPageNo)); if (Set(ref currentPageNo, value)) // если изменения были... PopulateCurrentPage().IgnoreResult(); // IgnoreResult напишем позже }

Ещё нужно свойство, хранящее список студентов:

IEnumerable<studentvm> currentPage; public IEnumerable<studentvm> CurrentPage { get { return currentPage; } private set { Set(ref currentPage, value); } }
</studentvm></studentvm>

и код, занимающийся обновлением. Он сложный.

// токен для остановки бегущего обновления CancellationTokenSource populationTaskCts = null; async Task PopulateCurrentPage() { // если старое обновление ещё бежит, отменяем его populationTaskCts?.Cancel(); // начиная с этой точки, старое обновление нам не помешает CurrentPage = null; if (!HavePages) return; using (var cts = new CancellationTokenSource()) { // теперь мы - текущее обновление populationTaskCts = cts; var workPageNo = CurrentPageNo; // может поменяться в процессе try { // асинхронно получаем модельный список var modelPage = await Task.Run(() => GetStudentsFromModel(workPageNo, cts.Token), cts.Token); if (cts.IsCancellationRequested) return; // создаём VM-объекты в UI-потоке var vmPage = modelPage.Select(p => new StudentVM(p.Name, p.ImageUri)) .ToList(); if (cts.IsCancellationRequested) return; // если мы оказались тут, то мы всё ещё текущее обновление Debug.Assert(workPageNo == CurrentPageNo); CurrentPage = vmPage; // и можем присвоить результат } catch (OperationCanceledException) when (cts.IsCancellationRequested) { // ничего не делаем, нас отменили } finally { // если мы текущее обновление, убираем за собой токен if (cts == populationTaskCts) populationTaskCts = null; // в противном случае там чужой токен, его не трогаем } } }

Нам ещё нужен экземпляр модели и процедура GetStudentsFromModel, которая будет их вычитывать:

StudentListModel model = new StudentListModel(); const int pageSize = 10; IEnumerable<studentmodel> GetStudentsFromModel(int pageNo, CancellationToken ct) { // make sure it's materialized on background thread return model.GetAllStudents().Skip(pageNo * pageSize).Take(pageSize).ToList(); }
</studentmodel>

К ней ещё понадобится (позже) подсчёт количества страниц:

int GetPageCount(CancellationToken ct) { var studentCount = model.GetAllStudents().Count(); return (int)Math.Ceiling((******)studentCount / pageSize); }

Сейчас эти процедуры синхронные, но если ваша модель будет выставлять async-интерфейс (как это делает новый EF), их нужно будет сделать асинхронными.

Далее. Нам нужна команда изменения текущей страницы.

public ICommand RequestPageChange { get; }

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

void OnPageChangeRequest(int newPage) { if (!HavePages) return; if (newPage < 0 || newPage >= TotalPages) return; // log an error? CurrentPageNo = newPage; }

Привязывать команду к имплементации придётся в конструкторе. Заодно и заведём его:

public StudentListVM() { RequestPageChange = new RelayCommand<int>(OnPageChangeRequest); }
</int>

(класс RelayCommand есть практически везде, я приведу его позже).

Да, нам ещё в конструкторе нужно запустить подсчёт количества страниц и первоначальное заполнение:

public StudentListVM() { RequestPageChange = new RelayCommand<int>(OnPageChangeRequest); StartInitialPopulate().IgnoreResult(); } async Task StartInitialPopulate() { TotalPages = await Task.Run(() => GetPageCount(CancellationToken.None)); currentPageNo = 0; await PopulateCurrentPage(); }
}
</int>

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

Ещё у нас тут упоминался RelayCommand:

public class RelayCommand<t> : ICommand
{ public RelayCommand(Action<t> onExecute) { OnExecute = onExecute; } public event EventHandler CanExecuteChanged; public bool CanExecute(object parameter) => true; public void Execute(object parameter) => OnExecute((T)parameter); readonly Action<t> OnExecute;
}
</t></t></t>

и функция IgnoreResult:

public static class TaskExtgensions
{ public static void IgnoreResult(this Task t) { }
}

На этом с вью-моделью, кажется, всё, перейдём к представлению.

Вью-модель прикрепляем к представлению, как описано здесь:

App.xaml:

App.xaml.cs:

using System.Windows;
using Pages.View;
using Pages.VM;
namespace Pages
{ public partial class App : Application { StudentListVM mainVM = new StudentListVM(); protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); new MainWindow() { DataContext = mainVM }.Show(); } }
}

Теперь идём в MainWindow.xaml. Начинаем:

На нашем мок-апе есть явно две области: нижняя маленькая со списком страниц, и верхняя большая с содержимым текущей страницы. Это напрашивается на Grid:

<grid> <grid.rowdefinitions> <rowdefinition height="*"> <rowdefinition height="Auto"> </rowdefinition></rowdefinition></grid.rowdefinitions>
</grid>

Теперь, верхний элемент. Он показывает список элементов, без текущего элемента и всего такого. Так что это ItemsControl:

Теперь, как расположены элементы в нём? Они идут не в столбик, а «перетекают» по горизонтали в следующую строку. Отлично, значит, нам нужно установить в ItemsPanel подходящий контейнер:

<itemscontrol.itemspanel> <itemspaneltemplate> <wrappanel isitemshost="True"> </wrappanel></itemspaneltemplate> </itemscontrol.itemspanel>

Как выглядит отдельный элемент? Это не стандартный ToString(), это текст с картинкой. Напишем для этого ItemTemplate:

<itemscontrol.itemtemplate> <datatemplate datatype="{x:Type vm:StudentVM}"> <!-- фиксированный размер элемента и рамка --> <border width="150" height="200" borderthickness="1" borderbrush="Gray" cornerradius="5"> <dockpanel lastchildfill="True" background="LightGray"> <!-- текст прижмём вниз и центрируем --> <textblock dockpanel.dock="Bottom" text="{Binding Name}" textalignment="Center"> <!-- остальное место занимает картинка --> </textblock></dockpanel> </border> </datatemplate> </itemscontrol.itemtemplate>

Вроде бы ничего не забыли.

Окей, с таблицей справились, теперь нам нужен список страниц. Откуда его взять? У нас по идее есть только количество страниц, нужно его превратить в список при помощи конвертера. Затем, мы ведь не хотим показывать все страницы? Их может быть очень много. Будем показывать первые страницы, последние страницы и страницы вокруг текущей.

Засучим рукава и вперёд!

Для начала, тип для описания страницы или заполнителя:

enum PageEntryType { Normal, Current, Ellipsis }
struct PageEntry
{ public int PageNumber { get; } public PageEntryType Type { get; } public PageEntry(int num, PageEntryType type) { PageNumber = num; Type = type; }
}

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

Нам нужен IMultiValueConverter, потому что у нас два входных значения.

class PageListConverter : IMultiValueConverter
{ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { int currentPage = (int)values[0]; int numberOfPages = (int)values[1]; return RecalcList(currentPage, numberOfPages); } IEnumerable<pageentry> RecalcList(int currentPage, int numberOfPages) { const int pagesAroundCurrent = 3; // сколько страниц вокруг текущей const int pagesAroundEnd = 2; // сколько страниц по краям var min = Math.Max(0, currentPage - pagesAroundCurrent); var max = Math.Min(numberOfPages - 1, currentPage + pagesAroundCurrent); // перекрывается список вокруг текущей страницы со списком у левого края? bool separateLeftEnd = pagesAroundEnd + 1 < min; if (!separateLeftEnd) min = 0; // перекрывается список вокруг текущей страницы со списком у правого края? bool separateRightEnd = numberOfPages - 1 - pagesAroundEnd - 1 > max; if (!separateRightEnd) max = numberOfPages - 1; if (separateLeftEnd) { for (int n = 0; n < pagesAroundEnd; n++) yield return new PageEntry(n, PageEntryType.Normal); // между списками разрыв - многоточие yield return new PageEntry(-1, PageEntryType.Ellipsis); } for (int n = min; n <= max; n++) yield return new PageEntry(n, (n == currentPage) ? PageEntryType.Current : PageEntryType.Normal); if (separateRightEnd) { // между списками разрыв - многоточие yield return new PageEntry(-1, PageEntryType.Ellipsis); for (int n = numberOfPages - pagesAroundEnd; n < numberOfPages; n++) yield return new PageEntry(n, PageEntryType.Normal); } } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) { throw new NotSupportedException(); }
}
</pageentry>

Отлично, список у нас есть, теперь его нужно показать. Как показывать список? Возвращаемся к нашему недописанному XAML'у. Нам нужен ItemsControl, как обычно. Кладём его во вторую строку и добавим небольшой маргин.

В ресурсы положим конвертер

<itemscontrol.resources> <view:pagelistconverter x:key="PageListConverter"> </view:pagelistconverter></itemscontrol.resources>

а элементы берём из количества страниц и номера текущей, с использованием этого конвертера:

<itemscontrol.itemssource> <multibinding converter="{StaticResource PageListConverter}"> <binding path="CurrentPageNo"> <binding path="TotalPages"> </binding></binding></multibinding> </itemscontrol.itemssource>

Окей, дальше нам нужно эти элементы расположить по горизонтали. Для этого сгодится StackPanel. Саму панель тоже центрируем:

<itemscontrol.itemspanel> <itemspaneltemplate> <stackpanel orientation="Horizontal" horizontalalignment="Center" isitemshost="True"> </stackpanel></itemspaneltemplate> </itemscontrol.itemspanel>

Теперь отображение каждого элемента списка. У нас есть три варианта отображения: обыкновенная страница показывается в виде ссылки, текущая — без ссылки, но жирным шрифтом, а на месте для многоточия должно появиться многоточие. Выбор из этих трёх вариантов делаем при помощи привязки Visibility блока к значению нашего элемента через конвертер. Конвертер мы напишем позже, он будет делать видимым только один из трёх нужных элементов. Сам конвертер мы добавим в ресурсы ItemsControl'а:

<itemscontrol.resources> <view:pagelistconverter x:key="PageListConverter"> <view:pageentrytypetovisibilityconverter x:key="PageEntryTypeToVisibilityConverter"> </view:pageentrytypetovisibilityconverter></view:pagelistconverter></itemscontrol.resources>

Продолжаем:

<itemscontrol.itemtemplate> </itemscontrol.itemtemplate>

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

Первый вариант: нормальное отображение. Нам нужна гиперссылка. По нажатию на неё вызовем обработчик из code-behind.

<textblock visibility="{Binding Converter={StaticResource PageEntryTypeToVisibilityConverter}, ConverterParameter={x:Static view:PageEntryType.Normal}}"> </textblock>

Второй вариант: текущая страница. Вместо ссылки — TextBlock с жирным шрифтом.

Ну и третий вариант — просто многоточие.

Вот и всё с отображением элемента.

Больше в XAML'е делать нечего.

Заимплементируем обработчик клика по ссылке. Он лежит в классе MainWindow

public partial class MainWindow : Window
{ public MainWindow() { InitializeComponent(); } void OnPageChangeRequest(object sender, RoutedEventArgs e) {

Нам нужно где-то взять команду. Чтобы не лазить по DataContext'у, сделаем простой трюк: привяжем эту команду в свободное свойство. Например, в Tag. (Это мы сделаем потом, снова-таки в XAML'е.)

var command = (ICommand)Tag; // прочитали оттуда команду if (command == null) return; var hyperlink = (Hyperlink)sender;

Номер страницы можно вытащить из DataContext'а гиперссылки.

var pageNo = ((PageEntry)hyperlink.DataContext).PageNumber; command.Execute(pageNo); }
}

Возвращаемся в XAML и дописываем Tag:

Последняя недописанная вещь — PageEntryTypeToVisibilityConverter. Он очень простой:

public class PageEntryTypeToVisibilityConverter : IValueConverter
{ public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { PageEntryType expectedType = (PageEntryType)parameter; PageEntryType actualType = ((PageEntry)value).Type; return expectedType == actualType ? Visibility.Visible : Visibility.Collapsed; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotSupportedException(); }
}

Запускаем и получаем:

Да, а давайте ещё сделаем так, чтобы при пустом списке нам показывалось какое-то разумное сообщение. Это просто. Возвращаемся в XAML и добавляем ещё один элемент во внешний Grid:

Но так этот элемент будет виден всегда, а нам нужно только когда страниц нету. Не проблема, привяжемся к свойству HavePages. Нам нужно сконвертировать его в Visibility. Но стандартный BooleanToVisibilityConverter конвертирует не в ту сторону, в которую нам надо, поэтому украдём конвертер отсюда:

public class BooleanConverter<t> : IValueConverter
{ public BooleanConverter(T trueValue, T falseValue) { OnTrue = trueValue; OnFalse = falseValue; } public T OnTrue { get; set; } public T OnFalse { get; set; } public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture) { if (!(value is bool)) return DependencyProperty.UnsetValue; return ((bool)value) ? OnTrue : OnFalse; } public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotSupportedException(); }
}
public class BooleanToVisibilityConverter : BooleanConverter<visibility>
{ public BooleanToVisibilityConverter() : base(Visibility.Visible, Visibility.Collapsed) {}
}
</visibility></t>

Теперь можно написать так:

Нужно только положить его в ресурсы окна:

<window x:class="Pages.View.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:view="clr-namespace:Pages.View" xmlns:vm="clr-namespace:Pages.VM" xmlns:sys="clr-namespace:System;assembly=mscorlib" title="Pages" height="600" width="800" tag="{Binding RequestPageChange}"> <window.resources> <view:booleantovisibilityconverter x:key="BooleanToVisibilityConverter" ontrue="Collapsed" onfalse="Visible"> </view:booleantovisibilityconverter></window.resources>
</window>

Дальнейшее наведение красоты — на вас.

licensed under cc by-sa 3.0 with attribution.