public class StudentModel
{
public string Name { get; set; }
public Uri ImageUri { get; set; }
}
public class StudentListModel
{
const string uri = "https://www.gravatar.com/avatar/f81585f940e68c3bab934cc6f59c901e?" +
"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) });
}
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;
}
现在一个班负责一个学生。这很简单:
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);
}
}
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)
}
}
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); }
}
以及执行更新的代码。他很复杂。
// токен для остановки бегущего обновления
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();
}
它还需要(稍后)计算页数:
int GetPageCount(CancellationToken ct)
{
var studentCount = model.GetAllStudents().Count();
return (int)Math.Ceiling((double)studentCount / pageSize);
}
现在这些过程是同步的,但如果您的模型将公开异步接口(如新 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);
}
На нашем мок-апе есть явно две области: нижняя маленькая со списком страниц, и верхняя большая с содержимым текущей страницы. Это напрашивается на Grid:
Теперь, как расположены элементы в нём? Они идут не в столбик, а «перетекают» по горизонтали в следующую строку. Отлично, значит, нам нужно установить в 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"/>
<!-- остальное место занимает картинка -->
<Image Source="{Binding ImageUri}"/>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
Вроде бы ничего не забыли.
</ItemsControl>
Окей, с таблицей справились, теперь нам нужен список страниц. Откуда его взять? У нас по идее есть только количество страниц, нужно его превратить в список при помощи конвертера. Затем, мы ведь не хотим показывать все страницы? Их может быть очень много. Будем показывать первые страницы, последние страницы и страницы вокруг текущей.
Засучим рукава и вперёд!
Для начала, тип для описания страницы или заполнителя:
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();
}
}
Отлично, список у нас есть, теперь его нужно показать. Как показывать список? Возвращаемся к нашему недописанному XAML'у. Нам нужен ItemsControl, как обычно. Кладём его во вторую строку и добавим небольшой маргин.
Теперь отображение каждого элемента списка. У нас есть три варианта отображения: обыкновенная страница показывается в виде ссылки, текущая — без ссылки, но жирным шрифтом, а на месте для многоточия должно появиться многоточие. Выбор из этих трёх вариантов делаем при помощи привязки Visibility блока к значению нашего элемента через конвертер. Конвертер мы напишем позже, он будет делать видимым только один из трёх нужных элементов. Сам конвертер мы добавим в ресурсы ItemsControl'а:
Заимплементируем обработчик клика по ссылке. Он лежит в классе 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);
}
}
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) {}
}
现在你可以这样写:
<TextBlock FontSize="24" Grid.Row="0" Text="No students found so far..."
TextAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding HavePages,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
好的,好吧,让我们写下如何去做。让我们立即尝试从中创建 MVVM。
让我们从模型开始。最主要的是,该模型应该尝试从我们这里抽象出它背后的东西。考虑到这一点,让我们将其简单地组织为内存中的模型对象列表。外部代码不应依赖于它。
所以:
我将“获取
n第页”功能留给了 VM,请记住,实际上您IEnumerable将拥有IQueryable. 如果您想在碱基调用之上创建一个存储库(例如,因为您不使用 EF),为什么不呢。对于现在的模型,您必须亲自将底座插入此处。让我们继续讨论虚拟机。
首先,VM 的基类。如果您正在使用任何 MVVM 框架,那么您已经拥有它。如果没有,我们会把它写下来。它必须实现
INotifyPropertyChanged:现在一个班负责一个学生。这很简单:
如果你顺势而为,而且你有不可变的类,那就更容易了:
接下来是处理学生名单的课程。我们会一段一段地写出来。开始吧。
我们需要一个负责页数的属性。没有他什么都没有。我们写:
虽然一切都很简单。是的,如果我们没有一个学生,我们可能想在 UI 中显示一些特别的东西,所以我们放了一个辅助属性:
并且不要忘记在设置器中设置它
TotalPages:下一项是当前页码。他的一切都一样,只是二传手的检查更多了。
现在当前页面已经改变,我们需要减去新的学生列表。这应该在后台完成而不阻塞 UI 线程。添加
CurrentPageNo我们还需要一个存储学生列表的属性:
以及执行更新的代码。他很复杂。
我们还需要一个模型实例和一个
GetStudentsFromModel读取它们的过程:它还需要(稍后)计算页数:
现在这些过程是同步的,但如果您的模型将公开异步接口(如新 EF 所做的那样),则需要将它们设为异步。
更远。我们需要一个命令来改变当前页面。
及其实施:一种可行的方法:
您必须将命令绑定到构造函数中的实现。同时,我们开始吧:
(类
RelayCommand几乎无处不在,我稍后再给)。是的,我们还需要在构造函数中开始统计页数和初始填充:
似乎什么都没有忘记。这个类原来太大了,也许将它重构成小块是有意义的。
我们还在这里提到
RelayCommand:和功能
IgnoreResult:到此有了视图模型,似乎一切都解决了,让我们继续看视图。
我们将视图模型附加到视图,如下所述:
应用程序.xaml:
应用程序.xaml.cs:
现在让我们转到 MainWindow.xaml。开始:
На нашем мок-апе есть явно две области: нижняя маленькая со списком страниц, и верхняя большая с содержимым текущей страницы. Это напрашивается на
Grid:Теперь, верхний элемент. Он показывает список элементов, без текущего элемента и всего такого. Так что это
ItemsControl:Теперь, как расположены элементы в нём? Они идут не в столбик, а «перетекают» по горизонтали в следующую строку. Отлично, значит, нам нужно установить в
ItemsPanelподходящий контейнер:Как выглядит отдельный элемент? Это не стандартный
ToString(), это текст с картинкой. Напишем для этогоItemTemplate:Вроде бы ничего не забыли.
Окей, с таблицей справились, теперь нам нужен список страниц. Откуда его взять? У нас по идее есть только количество страниц, нужно его превратить в список при помощи конвертера. Затем, мы ведь не хотим показывать все страницы? Их может быть очень много. Будем показывать первые страницы, последние страницы и страницы вокруг текущей.
Засучим рукава и вперёд!
Для начала, тип для описания страницы или заполнителя:
Ну и сам конвертер. Надеюсь, я не напутал с вычислениями.
Нам нужен
IMultiValueConverter, потому что у нас два входных значения.Отлично, список у нас есть, теперь его нужно показать. Как показывать список? Возвращаемся к нашему недописанному XAML'у. Нам нужен
ItemsControl, как обычно. Кладём его во вторую строку и добавим небольшой маргин.В ресурсы положим конвертер
а элементы берём из количества страниц и номера текущей, с использованием этого конвертера:
Окей, дальше нам нужно эти элементы расположить по горизонтали. Для этого сгодится
StackPanel. Саму панель тоже центрируем:Теперь отображение каждого элемента списка. У нас есть три варианта отображения: обыкновенная страница показывается в виде ссылки, текущая — без ссылки, но жирным шрифтом, а на месте для многоточия должно появиться многоточие. Выбор из этих трёх вариантов делаем при помощи привязки
Visibilityблока к значению нашего элемента через конвертер. Конвертер мы напишем позже, он будет делать видимым только один из трёх нужных элементов. Сам конвертер мы добавим в ресурсыItemsControl'а:Продолжаем:
Располагаем элементы один на другом, видимым будет только один.
Первый вариант: нормальное отображение. Нам нужна гиперссылка. По нажатию на неё вызовем обработчик из code-behind.
Второй вариант: текущая страница. Вместо ссылки —
TextBlockс жирным шрифтом.Ну и третий вариант — просто многоточие.
Вот и всё с отображением элемента.
Больше в XAML'е делать нечего.
Заимплементируем обработчик клика по ссылке. Он лежит в классе
MainWindowНам нужно где-то взять команду. Чтобы не лазить по
DataContext'у, сделаем простой трюк: привяжем эту команду в свободное свойство. Например, вTag. (Это мы сделаем потом, снова-таки в XAML'е.)Номер страницы можно вытащить из
DataContext'а гиперссылки.Возвращаемся в XAML и дописываем
Tag:Последняя недописанная вещь —
PageEntryTypeToVisibilityConverter. Он очень простой:Запускаем и получаем:
是的,我们也让它在列表为空时显示某种合理的消息。这很简单。让我们回到 XAML 并向外部添加另一个元素
Grid:但是这样这个元素就会一直可见,只有在没有页面的时候我们才需要它。没问题,让我们绑定到
HavePages. 我们需要将其转换为Visibility. 但是标准的BooleanToVisibilityConverter转换方向错误,所以让我们从这里窃取转换器:现在你可以这样写:
你只需要将它放在窗口资源中:
美丽的进一步指导就在你身上。