ListView和MVVM
ListView是Model-View-ViewModel架构的View部分的主要参与者之一。 每当ViewModel包含一个集合时,ListView通常会显示这些项目。
ViewModels的集合
让我们探讨在MVVM中使用ListView的一些数据,这些数据更接近现实生活中的例子。 这是一个关于虚构美术学院的65名虚构学生的信息集合,包括他们过于球形的头像。 这些图像和包含学生姓名和位图参考的XML文件位于http://xamarin.github.io/xamarin-forms-book-samples/ SchoolOfFineArt的网站上。 该网站托管在与本书源代码相同的GitHub存储库中,该网站的内容可以在该存储库的gh-pages分支中找到。
该站点上的Students.xml文件包含有关学校和学生的信息。 这是照片的缩写URL的开头和结尾。
<StudentBody xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<School>School of Fine Art</School>
<Students>
<Student>
<FullName>Adam Harmetz</FullName>
<FirstName>Adam</FirstName>
<MiddleName />
<LastName>Harmetz</LastName>
<Sex>Male</Sex>
<PhotoFilename>http://xamarin.github.io/.../.../AdamHarmetz.png</PhotoFilename>
<GradePointAverage>3.01</GradePointAverage>
</Student>
<Student>
<FullName>Alan Brewer</FullName>
<FirstName>Alan</FirstName>
<MiddleName />
<LastName>Brewer</LastName>
<Sex>Male</Sex>
<PhotoFilename>http://xamarin.github.io/.../.../AlanBrewer.png</PhotoFilename>
<GradePointAverage>1.17</GradePointAverage>
</Student>
__
<Student>
<FullName>Tzipi Butnaru</FullName>
<FirstName>Tzipi</FirstName>
<MiddleName />
<LastName>Butnaru</LastName>
<Sex>Female</Sex>
<PhotoFilename>http://xamarin.github.io/.../.../TzipiButnaru.png</PhotoFilename>
<GradePointAverage>3.76</GradePointAverage>
</Student>
<Student>
<FullName>Zrinka Makovac</FullName>
<FirstName>Zrinka</FirstName>
<MiddleName />
<LastName>Makovac</LastName>
<Sex>Female</Sex>
<PhotoFilename>http://xamarin.github.io/.../.../ZrinkaMakovac.png</PhotoFilename>
<GradePointAverage>2.73</GradePointAverage>
</Student>
</Students>
</StudentBody>
创建此文件时,会随机生成成绩点平均值。
在本书源代码的Libraries目录中,您将找到一个名为SchoolOfFineArt的库项目,该项目访问此XML文件并使用XML反序列化将其转换为名为Student,StudentBody和SchoolViewModel的类。 尽管Student和StudentBody类的名称中没有单词ViewModel,但它们无论如何都符合ViewModel的条件。
Student类派生自ViewModelBase(其副本包含在SchoolOfFineArt库中),并定义与XML文件中的每个Student元素关联的七个属性。 在将来的章节中使用第八个属性。 该类还定义了ICommand类型的四个附加属性和名为StudentBody的最终属性。 最后五个属性不是从XML反序列化设置的,因为XmlIgnore属性指示:
namespace SchoolOfFineArt
{
public class Student : ViewModelBase
{
string fullName, firstName, middleName;
string lastName, sex, photoFilename;
double gradePointAverage;
string notes;
public Student()
{
ResetGpaCommand = new Command(() => GradePointAverage = 2.5m);
MoveToTopCommand = new Command(() => StudentBody.MoveStudentToTop(this));
MoveToBottomCommand = new Command(() => StudentBody.MoveStudentToBottom(this));
RemoveCommand = new Command(() => StudentBody.RemoveStudent(this));
}
public string FullName
{
set { SetProperty(ref fullName, value); }
get { return fullName; }
}
public string FirstName
{
set { SetProperty(ref firstName, value); }
get { return firstName; }
}
public string MiddleName
{
set { SetProperty(ref middleName, value); }
get { return middleName; }
}
public string LastName
{
set { SetProperty(ref lastName, value); }
get { return lastName; }
}
public string Sex
{
set { SetProperty(ref sex, value); }
get { return sex; }
}
public string PhotoFilename
{
set { SetProperty(ref photoFilename, value); }
get { return photoFilename; }
}
public double GradePointAverage
{
set { SetProperty(ref gradePointAverage, value); }
get { return gradePointAverage; }
}
// For program in Chapter 25.
public string Notes
{
set { SetProperty(ref notes, value); }
get { return notes; }
}
// Properties for implementing commands.
[XmlIgnore]
public ICommand ResetGpaCommand { private set; get; }
[XmlIgnore]
public ICommand MoveToTopCommand { private set; get; }
[XmlIgnore]
public ICommand MoveToBottomCommand { private set; get; }
[XmlIgnore]
public ICommand RemoveCommand { private set; get; }
[XmlIgnore]
public StudentBody StudentBody { set; get; }
}
}
ICommand类型的四个属性在Student构造函数中设置,并与short方法相关联,其中三个在StudentBody类中调用方法。 这些将在后面更详细地讨论。
StudentBody课程定义School和Students属性。 构造函数将Students属性初始化为ObservableCollection 对象。 此外,StudentBody定义了三种从Student类调用的方法,这些方法可以从列表中删除学生或将学生移动到列表的顶部或底部:
namespace SchoolOfFineArt
{
public class StudentBody : ViewModelBase
{
string school;
ObservableCollection<Student> students = new ObservableCollection<Student>();
public string School
{
set { SetProperty(ref school, value); }
get { return school; }
}
public ObservableCollection<Student> Students
{
set { SetProperty(ref students, value); }
get { return students; }
}
// Methods to implement commands to move and remove students.
public void MoveStudentToTop(Student student)
{
Students.Move(Students.IndexOf(student), 0);
}
public void MoveStudentToBottom(Student student)
{
Students.Move(Students.IndexOf(student), Students.Count - 1);
}
public void RemoveStudent(Student student)
{
Students.Remove(student);
}
}
}
SchoolViewModel类负责加载XML文件并对其进行反序列化。 它包含一个名为StudentBody的属性,它对应于XAML文件的根标记。 此属性设置为从XmlSerializer类的Deserialize方法获取的StudentBody对象。
namespace SchoolOfFineArt
{
public class SchoolViewModel : ViewModelBase
{
StudentBody studentBody;
Random rand = new Random();
public SchoolViewModel() : this(null)
{
}
public SchoolViewModel(IDictionary<string, object> properties)
{
// Avoid problems with a null or empty collection.
StudentBody = new StudentBody();
StudentBody.Students.Add(new Student());
string uri = "http://xamarin.github.io/xamarin-forms-book-samples" +
"/SchoolOfFineArt/students.xml";
HttpWebRequest request = WebRequest.CreateHttp(uri);
request.BeginGetResponse((arg) =>
{
// Deserialize XML file.
Stream stream = request.EndGetResponse(arg).GetResponseStream();
StreamReader reader = new StreamReader(stream);
XmlSerializer xml = new XmlSerializer(typeof(StudentBody));
StudentBody = xml.Deserialize(reader) as StudentBody;
// Enumerate through all the students
foreach (Student student in StudentBody.Students)
{
// Set StudentBody property in each Student object.
student.StudentBody = StudentBody;
// Load possible Notes from properties dictionary
// (for program in Chapter 25).
if (properties != null && properties.ContainsKey(student.FullName))
{
student.Notes = (string)properties[student.FullName];
}
}
}, null);
// Adjust GradePointAverage randomly.
Device.StartTimer(TimeSpan.FromSeconds(0.1),
() =>
{
if (studentBody != null)
{
int index = rand.Next(studentBody.Students.Count);
Student student = studentBody.Students[index];
double factor = 1 + (rand.NextDouble() - 0.5) / 5;
student.GradePointAverage = Math.Round(
Math.Max(0, Math.Min(5, factor * student.GradePointAverage)), 2);
}
return true;
});
}
// Save Notes in properties dictionary for program in Chapter 25.
public void SaveNotes(IDictionary<string, object> properties)
{
foreach (Student student in StudentBody.Students)
{
properties[student.FullName] = student.Notes;
}
}
public StudentBody StudentBody
{
protected set { SetProperty(ref studentBody, value); }
get { return studentBody; }
}
}
}
请注意,数据是异步获取的。在此类的构造函数完成之后的某个时间,不会设置各种类的属性。但是,INotifyPropertyChanged接口的实现应该允许用户界面对程序启动后某个时间获取的数据作出反应。
BeginGetResponse的回调运行在用于在后台下载数据的相同辅助执行线程中。此回调设置了一些导致PropertyChanged事件触发的属性,从而导致更新数据绑定和更改用户界面对象。这是否意味着从第二个执行线程访问用户界面对象?不应该使用Device.BeginInvokeOnMainThread来避免这种情况吗?
实际上,没有必要。通过数据绑定链接到用户界面对象属性的ViewModel属性的更改不需要编组到用户界面线程。
SchoolViewModel类还负责随机修改学生的GradePointAverage属性,实际上模拟动态数据。因为Student实现了INotifyPropertyChanged(通过从ViewModelBase派生),我们应该能够看到这些值在ListView显示时动态变化。
SchoolOfFineArt库还有一个静态Library.Init方法,如果程序仅从XAML引用该库,则应该调用该方法,以确保程序集正确绑定到应用程序。
您可能想要使用StudentViewModel类来了解嵌套属性以及它们在数据绑定中的表达方式。您可以创建一个新的Xamarin.Forms项目(例如,名为Tryout),在解决方案中包含SchoolOfFineArt项目,并将Tryout的引用添加到SchoolOfFineArt库中。然后创建一个看起来像这样的ContentPage:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:school="clr-namespace:SchoolOfFineArt;assembly=SchoolOfFineArt"
x:Class="Tryout.TryoutListPage">
<ContentPage.Padding>
<OnPlatform x:TypeArguments="Thickness"
iOS="0, 20, 0, 0" />
</ContentPage.Padding>
<ContentPage.BindingContext>
<school:SchoolViewModel />
</ContentPage.BindingContext>
<Label />
</ContentPage>
页面的BindingContext设置为SchoolViewModel实例,您可以在Label的Text属性上试验绑定。 例如,这是一个空绑定:
<Label Text="{Binding StringFormat='{0}'}" />
这将显示继承的BindingContext的完全限定类名:
SchoolOfFineArt.SchoolViewModel
SchoolViewModel类有一个名为StudentBody的属性,因此将绑定的路径设置为:
<Label Text="{Binding Path=StudentBody, StringFormat='{0}'}" />
现在,您将看到StudentBody类的完全限定名称:
SchoolOfFineArt.StudentBody
StudentBody课程有两个属性,名为School和Students。 试试学校的属性:
<Label Text="{Binding Path=StudentBody.School,
StringFormat='{0}'}" />
最后,显示一些实际数据而不仅仅是类名。 它是从XML文件集到School属性的字符串:
美术学院
Binding表达式中不需要StringFormat,因为该属性的类型为string。 现在尝试学生属性:
<Label Text="{Binding Path=StudentBody.Students,
StringFormat='{0}'}" />
这将显示带有Student对象集合的ObservableCollection的完全限定类名:
System.Collections.ObjectModel.ObservableCollection'1[SchoolOfFineArt.Student]
应该可以索引此集合,如下所示:
<Label Text="{Binding Path=StudentBody.Students[0],
StringFormat='{0}'}" />
这是Student类型的对象:
SchoolOfFineArt.Student
如果在绑定时加载了整个学生集合,您应该能够在学生集合中指定任何索引,但索引0始终是安全的。
然后,您可以访问该学生的属性,例如:
<Label Text="{Binding Path=StudentBody.Students[0].FullName,
StringFormat='{0}'}" />
你会看到学生的名字:
Adam Harmetz
或者,尝试GradePointAverage属性:
<Label Text="{Binding Path=StudentBody.Students[0].GradePointAverage,
StringFormat='{0}'}" />
最初,您将看到存储在XML文件中的随机生成的值:
3.01
但是等一会儿,你应该看到它改变了。
你想看一张Adam Harmetz的照片吗? 只需将Label更改为Image,并将目标属性更改为Source,将源路径更改为PhotoFilename:
<Image Source="{Binding Path=StudentBody.Students[0].PhotoFilename}" />
他是2019年的班级:
通过对数据绑定路径的理解,应该可以构建一个页面,其中既包含显示学校名称的Label,也包含显示所有学生的全名,平均成绩和照片的ListView。 ListView中的每个项目都必须显示两段文本和一个图像。 这是ImageCell的理想选择,ImageCell源自TextCell并为两个文本项添加图像。 这是StudentList程序:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:school="clr-namespace:SchoolOfFineArt;assembly=SchoolOfFineArt"
x:Class="StudentList.StudentListPage">
<ContentPage.Padding>
<OnPlatform x:TypeArguments="Thickness"
iOS="0, 20, 0, 0" />
</ContentPage.Padding>
<ContentPage.BindingContext>
<school:SchoolViewModel />
</ContentPage.BindingContext>
<StackLayout BindingContext="{Binding StudentBody}">
<Label Text="{Binding School}"
FontSize="Large"
FontAttributes="Bold"
HorizontalTextAlignment="Center" />
<ListView ItemsSource="{Binding Students}">
<ListView.ItemTemplate>
<DataTemplate>
<ImageCell ImageSource="{Binding PhotoFilename}"
Text="{Binding FullName}"
Detail="{Binding GradePointAverage,
StringFormat='G.P.A. = {0:F2}'}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
</ContentPage>
与实验性XAML文件一样,ContentPage的BindingContext是SchoolViewModel对象。 StackLayout继承了BindingContext,但是将自己的BindingContext设置为StudentBody属性,这是StackLayout子节点继承的BindingContext。 Label的Text属性绑定到StudentBody类的School属性,ListView的ItemsSource属性绑定到Students集合。
这意味着ListView中每个项的BindingContext是一个Student对象,ImageCell属性可以绑定到Student类的属性。 结果是可滚动和可选的,尽管选择以特定于平台的方式显示:
不幸的是,ImageCell的Windows Runtime版本与其他两个平台上的版本略有不同。如果您不喜欢这些行的默认大小,您可能想要设置RowHeight属性,但它在平台上的工作方式不同,唯一一致的解决方案是切换到自定义ViewCell派生,也许就像CustomNamedColorList中的一个,但有一个Image而不是BoxView。
页面顶部的Label与ListView共享StackLayout,以便在滚动ListView时Label保持不变。但是,您可能希望此类标题与ListView的内容一起滚动,并且您可能还想添加页脚。 ListView具有对象类型的页眉和页脚属性,您可以将其设置为字符串或任何类型的对象(在这种情况下,标题将显示该对象的ToString方法的结果)或绑定。
这是一种方法:页面的BindingContext像以前一样设置为SchoolViewModel,但ListView的BindingContext设置为StudentBody属性。这意味着ItemsSource属性可以在绑定中引用Students集合,并且Header可以绑定到School属性:
<ContentPage __ >
__
<ContentPage.BindingContext>
<school:SchoolViewModel />
</ContentPage.BindingContext>
<ListView BindingContext="{Binding StudentBody}"
ItemsSource="{Binding Students}"
Header="{Binding School}">
__
</ListView>
</ContentPage>
这会在带有ListView内容的标题中显示文本“美术学院”。
如果您想格式化该标题,也可以这样做。 将ListView的HeaderTemplate属性设置为DataTemplate,并在DataTemplate标记内定义可视树。 该可视化树的BindingContext是设置为Header属性的对象(在此示例中,为具有学校名称的字符串)。
在下面显示的ListViewHeader程序中,Header属性绑定到School属性。 在HeaderTemplate中是一个仅由Label组成的可视树。 此Label具有空绑定,因此该Label的Text属性绑定到Header属性的文本集:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:school="clr-namespace:SchoolOfFineArt;assembly=SchoolOfFineArt"
x:Class="ListViewHeader.ListViewHeaderPage">
<ContentPage.Padding>
<OnPlatform x:TypeArguments="Thickness"
iOS="0, 20, 0, 0" />
</ContentPage.Padding>
<ContentPage.BindingContext>
<school:SchoolViewModel />
</ContentPage.BindingContext>
<ListView BindingContext="{Binding StudentBody}"
ItemsSource="{Binding Students}"
Header="{Binding School}">
<ListView.HeaderTemplate>
<DataTemplate>
<Label Text="{Binding}"
FontSize="Large"
FontAttributes="Bold, Italic"
HorizontalTextAlignment="Center" />
</DataTemplate>
</ListView.HeaderTemplate>
<ListView.ItemTemplate>
<DataTemplate>
<ImageCell ImageSource="{Binding PhotoFilename}"
Text="{Binding FullName}"
Detail="{Binding GradePointAverage,
StringFormat='G.P.A. = {0:F2}'}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</ContentPage>
标题仅显示在Android平台上: