当我们把SelectionMode属性的值设为Multiple时,ListBox控件就能支持多选了,如图1所示,此时,我们可以通过ListBox控件的SelectedItems属性获取选中的项。接下来,我很自然就会想到把ListBox控件的ItemsSource和SelectedItems两个属性分别绑到视图模型的对应属性,但是,SelectedItems属性不是依赖属性,无法进行数据绑定,怎么办?
图 1
既然自带的SelectedItems属性不支持数据绑定,我们就自己创建一个支持数据绑定的吧。在Silverlight里,我们可以通过附加属性扩展依赖对象,比如说,我们可以在ListBox控件上设置Grid.Row附加属性,如代码1所示。
代码 1
Grid.Row附加属性不是ListBox控件的属性,却被用来附加额外的数据。在ListBox控件上设置Grid.Row附加属性本身不会产生什么效果,但是,当我们把这个ListBox控件放在一个Grid里时,这个Grid将会根据Grid.Row附加属性的值安排ListBox控件的位置,这是附加属性的常见用途。
回到我们的问题,我希望创建一个SelectedItems附加属性,作为自带的SelectedItems属性和视图模型对应的属性之间的桥梁,如代码2所示。当我们把SelectedItems附加属性绑到视图模型的对应属性时,前者会把后者的数据添加到自带的SelectedItems属性。当用户在用户界面上更改选中的项时,SelectedItems附加属性会把数据更新回视图模型的对应属性。
代码 2
接下来,我们一起看看这个SelectedItems附加属性是如何实现的。创建一个类,然后在里面创建一个SelectedItems附加属性,如代码3所示。在Visual Studio里,你可以通过propa这个代码段快速创建附加属性。值得提醒的是,你必须同时提供GetSelectedItems和SetSelectedItems两个方法,否则无法在XAML里读或写这个附加属性的值。
代码 3
虽然现在已经可以在ListBox控件上把SelectedItems附加属性绑到视图模型的对应属性,但是仅仅这样无法实现我们想要的效果,因为ListBox控件并不认识这个外来的异物。于是,把绑定的数据添加到自带的SelectedItems属性的责任就落到SelectedItems附加属性的身上了。我希望SelectedItems附加属性能在其值发生改变时自动刷新自带的SelectedItems属性的值,因此,在注册SelectedItems附加属性的时候通过PropertyMetadata指定负责刷新的方法。
在刷新之前,我们必须确保目标控件是ListBox控件,并且处于多选模式,如代码4所示,否则就多此一举了。刷新的代码非常简单,只需把自带的SelectedItems属性清空,然后把绑定的数据添加进来就行了。值得提醒的是,listBox.SelectedItems.Clear();必须放在if (collection != null)的外面,否则,当绑定的数据变成null时,ListBox控件仍会显示之前选中的项,这显然是不对的。
代码 4
当用户在用户界面上更改选中的项时,会触发ListBox控件的SelectionChanged事件,我们可以趁此机会把数据更新回数据源,如代码5所示。值得提醒的是,因为SelectedItems附加属性的值有可能从一个有效值变成null,所以我们必须在改变之后的值不为null时才订阅事件,否则,试图更新的时候将会引发NullReferenceException异常。此外,因为向自带的SelectedItems属性添加数据会触发ListBox控件的SelectionChanged事件,所以每次刷新之前需要取消订阅事件(首次除外),否则,相同的数据最终会在数据源出现两次。
代码 5
在SelectionChanged事件的事件处理程序里,我们可以通过SelectionChangedEventArgs对象的AddedItems属性获取用户选中的项,并添加到数据源;通过SelectionChangedEventArgs对象的RemovedItems属性获取用户取消选中的项,并从数据源移除,如代码6所示。
代码 6
这里的实现只在SelectedItems附加属性的值发生改变时才刷新自带的SelectedItems属性的值,如果你的数据源是ObservableCollection
如何选取Image控件的图片?
在Windows Phone里,如果你想为一个联系人选取一个头像,可以在新建/编辑联系人页面上单击Image控件打开PhotoChooserTask选择器,然后选取一张图片。我想在我的应用里使用这种模式,如图2所示,但我不想每次做一个新的应用都要重复实现一次。起初我想通过继承扩展Image控件,无奈它是密封的,不能继承,只好把目光放在Expression Blend行为上。
图 2
我所期望的效果是这样的,假设我们有一个ChoosePhotoBehavior行为,把它从Assets面板拖到一个Image控件上,然后在Properties面板上把PhotoUri属性绑到视图模型的对应属性,并且把它设为双向绑定,如图3所示,这样,当用户单击Image控件时,就会打开PhotoChooserTask选择器,在用户选好图片之后,ChoosePhotoBehavior行为就会更新Image控件,并把PhotoUri属性的值设为图片的路径,由于数据绑定是双向的,视图模型的对应属性也会随之更新。
图 3
接下来,我们一起看看这个ChoosePhotoBehavior行为是如何实现的。创建一个ChoosePhotoBehavior类,并使之继承Behavior<Image>类,如代码7所示。Behavior<T>的泛型参数用来指定这个行为适用于什么对象,这个对象的类型必须是DependencyObject类或其子类。如果你希望一个行为可以用在一个继承体系上,你可以泛型参数设为这个继承体系的基类。
代码 7
接着,创建一个PhotoUri依赖属性,如代码8所示。在Visual Studio里,你可以通过propdp这个代码段快速创建依赖属性。我希望PhotoUri依赖属性能在其值发生改变时自动刷新Image控件,因此,在注册PhotoUri依赖属性的时候通过PropertyMetadata指定负责刷新的方法。
代码 8
HandlePhotoUriPropertyChanged方法会把改变之后的PhotoUri依赖属性的值传给SetPhotoSource方法,由SetPhotoSource方法负责具体的刷新工作,如代码9所示。在ChoosePhotoBehavior行为里,我们可以通过从Behavior<Image>类继承过来的AssociatedObject属性访问Image控件。
代码 9
LoadPhoto方法会读取指定位置的图片,然后返回BitmapImage对象,如代码10所示。由于图片的存放位置可能是应用的安装文件夹或者独立存储区,为了区分这两种路径,这里规定指向独立存储区的URI必须带有“isostore:”前缀,如“isostore:/photo1.png”,而指向安装文件夹的URI则和平时的表示方式保持一致,如“/photo1.png”。
代码 10
ChoosePhotoBehavior行为的主要用途是在用户单击Image控件时打开PhotoChooserTask选择器,并把用户选取的图片显示在Image控件上。为此,我们需要订阅Image控件的Tap事件,而订阅该事件的最佳时机是在OnAttached方法里,如代码11所示。OnAttached方法会在Expression Blend SDK把ChoosePhotoBehavior行为附加到Image控件的时候调用,因此,我们可以趁此机会初始化Image控件。此外,我们可以在OnDetaching方法里取消订阅Image控件的Tap事件,OnDetaching方法会在ChoosePhotoBehavior行为和Image控件分离的时候调用。
代码 11
值得提醒的是,如果PhotoUri依赖属性的值是通过数据绑定获得的,那么HandlePhotoUriPropertyChanged方法会在OnAttached方法之后调用,因为PhotoUri依赖属性的值在创建ChoosePhotoBehavior对象的时候无法确定下来了,必须等到ChoosePhotoBehavior行为附加到Image控件之后才能确定。如果PhotoUri依赖属性的值是一个常量,那么HandlePhotoUriPropertyChanged方法会在OnAttached方法之前调用,因为PhotoUri依赖属性的值在创建ChoosePhotoBehavior对象的时候就能确定下来了。但是,由于此时ChoosePhotoBehavior行为还没附加到Image控件,AssociatedObject属性的值是null,这正是为什么SetPhotoSource方法(参见代码9)要在刷新Image控件之前确保AssociatedObject属性的值不为null。
当用户单击Image控件时,会设置并显示PhotoChooserTask选择器,如代码12所示。在用户选好图片之后,会把图片复制到独立存储区,刷新Image控件,修改PhotoUri依赖属性的值,如代码13所示。
代码 12
代码 13
由于修改PhotoUri依赖属性会导致HandlePhotoUriPropertyChanged和SetPhotoSource两个方法依次被调用,为了避免重复加载图片刷新Image控件,这里通过一个_isChoosingPhoto字段来表示是否处于选取图片的过程,如代码14所示。这样,当PhotoUri依赖属性的值是因为用户选取图片而改变时,SetPhotoSource方法将会跳过刷新Image控件的代码。
代码 14
最后,为了配合ChoosePhotoBehavior行为产生的带有“isostore:”前缀的URI,我特意创建了一个UriToPhotoConverter转换器,如代码15所示,以便图1的Image控件可以正确显示对应的图片。
代码 15
值得提醒的是,当我们把Image控件的Source属性绑到视图模型的某个字符串属性时,Silverlight会帮我们完成从String到ImageSource的类型转换,但是,这仅限于指向安装文件夹的URI,对于指向独立存储区的URI,你要么自己读取图片并创建BitmapImage对象,要么通过转换器处理类型转换。
何时,何者?
附加属性和Expression Blend行为看似两种不同的扩展方式,实质上它们都是基于Silverlight的依赖属性系统,如果你查看图3生成的XAML代码,你会发现ChoosePhotoBehavior行为和Image控件之间隔着一个Interaction.Behaviors附加属性,如代码16所示。附加属性并不仅仅适用于SelectedItems附加属性这种简单情景,如果你细心观察Silverlight for Windows Phone Toolkit的ContextMenu组件,你会发现它也是通过ContextMenuService.ContextMenu附加属性实现的。
代码 16
自定义的附加属性只能通过手动编辑XAML来使用,因为Expression Blend并不认识它们,因此不会在属性面板上显示。Expression Blend行为则不同,它是专为Expression Blend而设的,因此可以通过拖放的方式使用,此外,它的属性也能在属性面板上进行设置,尤其适合使用Expression Blend的前端设计师。
创建附加属性或者Expression Blend行为的一条重要原则是让它们用起来尽可能简单,但这不意味着它们本身也是简单的,它们承担着本该由用户自行处理的复杂性,换句话说,这些复杂性从它们的用户转移到它们的创建者。事物之所以看起来简单是因为与之相关的复杂性已经在内部被处理掉了,无论你开发一个组件还是一个产品,如果你没有做好心理准备迎接那些复杂性,那么这个组件或者产品的发展将会受到限制,因为它们无法承担用户希望摆脱的复杂性。
最后,不得不提的一点是,本文介绍的两个扩展组件可在http://wputils.codeplex.com/下载,代码采用MIT开源协议。