|
作業本
上節課布置的作業有做嗎?沒人吭聲啊,看來大家都忘了哦,沒事,我們這次弄個作業本出來,大家就有地方記作業了。在開始設計應用程序之前,我們先來看看通常的作業本是怎樣記作業的:
圖 1
從上圖可以看到,作業本有點像日記本,每次記錄時都會寫下當天的日期,每天的作業又會根據課程進行歸類。慢著!我怎么知道這些作業什么時候交?一般情況下,中小學生的作業都是第二天上課時交的,但大學生就不同了,他們的作業可能第二天交,也可能一周之后交,有時甚至幾周之后才交,更重要的是,不同的作業可能在不同的時間交。換句話說,我們的應用程序還需要支持記錄交作業的時間。此外,每當完成一項作業,我們可以在旁邊做個記號,這樣,當我們打開作業本時,即使作業再多也能馬上知道哪些還沒做完。
現在,用Visual Studio打開項目,在Models文件夾里創建一個Assignment類,和上節課的Course類一樣,它也需要實現INotifyPropertyChanged接口。由于我們有很多類都需要實現INotifyPropertyChanged接口,為了避免不必要的重復,你可以考慮創建一個類專門實現這個接口,然后讓有需要的類繼承這個類。這個需求似乎比較常見,因此Prism提供了一個NotificationObject類,我們只需繼承它就行了:
代碼 1
繼承之前別忘了引用Bin/Phone/Microsoft.Practices.Prism.dll類庫和Microsoft.Practices.Prism.ViewModel命名空間哦。根據前面的討論,Assignment類應該包含以下屬性:
屬性名字 | 屬性類型 | 備注 |
Id | Guid | 唯一標識 |
CourseName | string | 課程名稱 |
StartDate | DateTime | 創建日期 |
DueDate | DateTime | 截止日期 |
Content | string | 作業內容 |
IsCompleted | bool | 完成狀態 |
定制數據模板
首先是定制分組標題的數據模板,右擊LongListSelector控件里的任何地方,選擇Edit Additional Templates/Edit GroupHeaderTemplate/Create Empty:
圖 5
在彈出的Create DataTemplate Resource對話框里輸入模板名字,然后按OK關閉對話框:
圖 6
進入模板的編輯狀態之后,你會看到一個空的Grid,從Tools面板把一個TextBlock拖到Grid里,確保TextBlock處于選中狀態(而不是編輯狀態),單擊Text屬性右邊的小正方形,并選擇Data Binding:
圖 7
在彈出的Create Data Binding對話框里選中Use a custom path expression,并在旁邊的編輯框里輸入Key:
圖 8
為什么輸入Key呢?因為通過LINQ的group XXX by YYY創建的分組對象實現了IGrouping<TKey, TElement>接口,而這個接口有個Key屬性保存了分組的依據——創建日期,也就是這里需要的分組標題了。
當你按OK關閉對話框之后,你將會看到:
圖 9
奇怪了!我們明明提供了示例數據啊,而且數據綁定也沒弄錯啊,為什么TextBlock沒有任何顯示?仔細觀察Text屬性下面的DataContext屬性:
圖 10
此時的值應該是分組對象而不是AssignmentListViewModel對象啊!我懷疑LongListSelector控件沒有正確處理DataContext在設計時的傳遞(bug?),導致Expression Blend無法獲取正確的數據。既然這樣,我們只好再弄點示例數據了,單擊Text屬性右邊的編輯框,選擇Reset,然后把Text屬性的值改為"2010/11/29"。接著,在Objects and Timeline面板上選中Grid,單擊Background屬性右邊的小正方形,并選擇System Resource/PhoneAccentBrush:
圖 11
此時,你的Artboard應該是這樣的:
圖 12
退出模板的編輯狀態,保存所有修改,然后重新編譯項目,好了之后就能看到分組標題了:
圖 13
不要奇怪分組標題都是"2010/11/29",這是我們剛才為了編輯的方便硬編碼上去的結果,暫時忍耐一下吧。
接下來是列表項的數據模板,右擊LongListSelector控件里的任何地方,選擇Edit Additional Templates/Edit ItemTemplate/Create Empty,在彈出的Create DataTemplate Resource對話框里輸入模板名字(itemTemplate),然后按OK關閉對話框。現在,我們要思考的問題是,如何更好地顯示作業數據呢?回顧表1,Id屬性為了便于應用程序搜索Assignment對象而創建的,用戶并不需要知曉它的存在,所以我們不必把它呈現在用戶面前,Pivot項的標題已經顯示了CourseName屬性,分組標題也顯示了StartDate屬性,剩下的就是DueDate、Content和IsCompleted三個屬性了,那么我們應該如何顯示這三個屬性?此時,我的腦子里浮現出的第一個想法是這樣的:
圖 14
整個Grid分為兩個Column,左邊是作業內容,自動換行,右邊從上到下分別是截止日期的月、日和完成狀態,一般情況下,創建日期和截止日期的年份都是一樣的,所以我們沒有必要提供重復的信息,即使碰到跨年的情況,用戶也不會因為缺少年份而感到疑惑,除非有個老師布置了一個跨越兩年或以上的作業。想到這里,我的腦子里突然閃出一個問題,表示完成狀態的TextBlock能否去掉,并以其它方式表達這個信息呢?此時,我的腦子里迅速浮現出各種各樣的圖標,但是,還有更好的方式嗎?顏色,突然這個詞兒從我的腦子里掠過,一般而言,與文字相比,我們的大腦對顏色的反應更快更準。有鑒于此,我把列表項的模板改成這樣:
圖 15
右邊部分將會根據作業的不同狀態顯示不同底色。退出模板的編輯狀態,保存所有修改,然后重新編譯項目,好了之后就能看到效果了:
圖 16
顯然,字體的大小、控件之間的間距還不能讓人滿意,我們需要調整一下,這個過程可能有點反復和枯燥,但這卻是我們體貼用戶的重要途徑,我們不但要讓用戶的眼睛感到滿意,還要讓用戶的手指感到滿意(別忘記我們開發的是觸屏應用程序哦),下面是我調整之后的效果:
圖 17
現在,我們可以再次進入模板的編輯狀態,為對應的控件設置數據綁定了,做法和前面為分組標題設置數據綁定的一樣(圖7和圖8),各個控件對應的自定義路徑表達式如下圖所示:
圖 18
好了之后就可以看到我們前面準備的示例數據了:
圖 19
噢,分組標題!我希望只顯示日期,而且是符合中國區域設置的短日期格式,還有月份的顯示,我希望是"十一月"而不是"11"。
這個時候又輪到轉換器出場了。首先,切換到Visual Studio,在Utils文件夾里創建下面兩個類:
代碼 12
代碼 13
需要說明的是,因為我們的綁定是單向的,所以沒有必要實現ConvertBack方法。接著,在AssignmentBookPage.xaml的資源字典里創建它們的實例:
代碼 14
看到這里,你可能會問,這兩個轉換器的Convert方法都使用了culture這個參數,但我們沒有直接調用Convert方法啊,那我們怎么把這個參數傳給它?這可以通過設置綁定表達式的ConverterCulture屬性做到,現在,把那兩個TextBlock的Text屬性的綁定表達式改為"{Binding Key, Converter={StaticResource dateConverter}, ConverterCulture=zh-CN}"和"{Binding DueDate.Month, Converter={StaticResource monthNameConverter}, ConverterCulture=zh-CN}"。
剩下的就是截止日期的底色了,既然轉換器可以把DateTime對象轉換成字符串,它也應該可以把Assignment對象轉換成SolidColorBrush對象,不過,在創建這個轉換器之前,我們得先弄清楚什么狀態對應什么底色。前面我們說過,作業本的主要目的是讓學生對要做哪些作業一目了然,而"未完成"的作業里可能存在一些已經過了截止日期的,這類作業需要馬上處理,所以我們應該單獨為這類作業設置一種底色,以便用戶及時知曉并采取行動。假設這三種狀態及其對應的底色如下表所示(你也可以換成其它底色):
狀態 | 底色 |
已逾期 | Red |
未完成 | #FF1BA1E2 |
已完成 | Green |
插曲 #1
究竟發生了什么事?示例數據和綁定表達式應該都沒問題啊,否則Expression Blend和Visual Studio的設計器也不會正常顯示,那么問題到底出在哪里呢?突然,一個想法在我的腦子里閃過,如果我在DateConverter類的Convert方法里設個斷點,你覺得會怎么樣?試一下吧……結果是,沒有到達這個斷點,換句話說,Convert方法根本沒被調用!這種情況有點像數據綁定找不到分組對象的Key屬性,比如說,我故意把綁定表達式的Key改為Key1,結果Expression Blend的設計器就變成這樣了:
圖 24
我們知道,分組對象實現了IGrouping<TKey, IElement>接口,因此Key屬性肯定存在,否則編譯器會報錯,那么,什么情況下這個屬性是不可見的,或者說,有什么辦法可以讓它不可見?想到這里,一個詞兒突然在我的腦子里冒出來——顯式接口實現!如果Key屬性是顯式實現的,僅當變量的類型是IGrouping<TKey, IElement>時Key屬性才是可見的。看到這里,你可能會說,Silverlight不可能直接調用分組對象的Key屬性,它應該是通過反射獲取這個屬性的。沒錯,當我們在綁定表達式里以字符串的形式給出屬性路徑,PropertyPathConverter對象將會把這個字符串轉換成PropertyPath對象,那么,PropertyPath對象又是如何找到對應的屬性呢?在微軟公開的.NET Framework 4.0源代碼里,我找到了PropertyPath類的實現,里面有個GetPropertyHelper方法負責獲取指定的屬性:
代碼 17
如果Key屬性是顯式實現的話,GetProperty方法就會返回null!換句話說,數據綁定和顯式實現的屬性一起工作的話會出問題。那么,group XXX by YYY返回的分組對象是不是顯示實現Key屬性的呢?我們知道,使用group XXX by YYY實質上就是調用Enumerable類的GroupBy方法,經過一番查找,我發現它返回的分組對象就是Lookup類內部的Grouping類的實例,但Grouping類的Key屬性是隱式實現的,有趣的是,Key屬性上方有一段注釋:
代碼 18
除了Key屬性之外,Grouping類的其它屬性都是顯式實現的,我猜Key屬性原來也是顯式實現的,后來由于數據綁定的問題才改為隱式實現。
這些代碼是WPF 4.0的,而Key屬性上面的注釋也明確提到了WPF,這是不是說Key屬性的值在WPF里可以正確顯示?我們可以設計一個簡單的實驗來驗證一下:
- 創建一個ListBox。
- 定制ListBox的ItemTemplate,里面只放一個TextBlock。
- 把TextBlock的Text屬性設為"{Binding Key}"。
- 通過GroupBy方法創建分組對象的集合,并把它綁到ListBox的ItemsSource屬性。
- 按F5。
我分別在WPF 4.0、SL 4.0和SL for WP7上執行這個實驗,發現只有WPF 4.0能夠正確顯示Key屬性的值,其它兩個的ListBox是一片空白的。我懷疑SL的分支是在這個問題得到修復之前創建的,但我沒有代碼證實這個猜想。
還有一個問題我沒弄明白的,為什么設計器能夠正確顯示而程序真正運行的時候卻不能?難道設計器對顯式實現的屬性有什么特別的照顧?為了驗證這個猜想,我又做了一個實驗,我不直接返回分組對象,而是通過下面這個Grouping類包裝一下再返回:
代碼 19
結果,設計器也不顯示了……我不知道為什么設計器能夠正確顯示GroupBy方法返回的分組對象的Key屬性,這里面肯定有些東西是我不知道的,如果你知道原因,或者先我一步找到原因,那你一定要告訴我哦!
連接前端和后端
既然顯式實現的屬性會對數據綁定造成不良影響,那我們就換成隱式實現吧。首先,在ViewModels文件夾里創建AssignmentGroupViewModel類,并讓它繼承ObservableCollection<Assignment>類:
代碼 20
為什么要繼承ObservableCollection<Assignment>類呢?前面說過,LongListSelector控件硬性規定分組對象至少實現IEnumerable接口,不過,要想獲得更好的效果,僅僅實現IEnumerable接口是不夠的,LongListSelector控件通過內部的GetItemsInGroup方法來獲取分組內容:
代碼 21
從上面代碼不難看出,如果分組對象實現了IList接口,那么每次獲取分組內容時都會免掉一次遍歷。此外,我們還希望當分組內容發生改變時,比如新建/刪除一項作業,分組對象能夠自動通知LongListSelector控件做出相應的更新,為了實現這個效果,分組對象需要實現INotifyCollectionChanged接口。毫無疑問,能夠一次過滿足我們所有要求的最簡單做法就是繼承ObservableCollection<Assignment>類了。
看到這里,你可能會問,IGrouping<TKey, TElement>接口不用實現嗎?不用,LongListSelector控件沒有規定分組對象必須實現這個接口,我們只需簡單地創建一個Key屬性,配合綁定表達式里的屬性路徑就行了:
代碼 22
需要說明的是,ObservableCollection<Assignment>類也實現了INotifyPropertyChanged接口,所以我們可以直接使用它的OnPropertyChanged方法。
接下來是分組對象的初始化,這個過程的主要任務有兩個:
- 查詢數據源,把滿足條件的作業內容添加到自身。
- 監聽數據源,把滿足條件的內容更改反映到自身。
執行這兩個任務的前提是有個可用的數據源,我們可以仿效課程表的做法,在App類里通過靜態屬性提供JsonDataStore<Assignment>對象:
代碼 23
有了數據源我們就可以著手執行第一個任務了:
代碼 24
需要說明的是,這里把判斷條件單獨提取出來了,因為執行第二個任務時還要用到:
代碼 25
需要說明的是,e參數的NewItems和OldItems兩個屬性看起來好像可能包含多個元素,但事實上它們只會包含一個,因為NotifyCollectionChangedEventArgs類的構造函數限制了這個可能,不過這個限制僅存在于Silverlight的現有版本(SL3、SL4、SL for WP7)。另外,這里使用了Lambda語句來創建CollectionChanged事件的處理程序,雖然你也可以通過一個單獨的方法做到,但使用Lambda語句可以利用閉包的特點重用前面的判斷條件,當然,使用匿名方法的語法也是可以的。
還差什么呢?噢,對了,LongListSelector控件內部會調用分組對象的Equals方法進行判等,我們可以重寫AssignmentGroupViewModel類的Equals和GetHashCode兩個方法,使之根據Key屬性來判等以及獲取哈希值。這個任務留給你當課后作業吧。
既然分組對象的類型改了,那AssignmentListViewModel類的AssignmentGroups屬性也得做出相應的調整吧:
代碼 26
由于AssignmentListViewModel類對應用戶界面上的Pivot項,我們還需要給它創建一個Title屬性:
代碼 27
有了這些準備,我們就可以著手實現AssignmentListViewModel類的構造函數了:
代碼 28
看到這里,你可能會說,這條LINQ語句看起來有點復雜嘛!其實不然,想想看,我們的最終目的是什么?創建分組對象并把它們添加到AssignmentGroups屬性。那創建分組對象需要什么條件?課程名稱和創建日期。課程名稱已經有了,創建日期來自哪里?來自數據源。那我們對創建日期有些什么要求?我們只要和指定課程相關的,而且不要重復的。現在,你再看看上面這條LINQ語句,從上往下看,有沒有覺得它像下面這條"流水線"?
圖 25
前面我們說過,當用戶新建一項作業時,它會自動添加到"今天"的分組里,但如果"今天"的分組還沒創建出來呢?那AssignmentListViewModel類就應該為這項新的作業創建"今天"的分組,并把它添加到AssignmentGroups屬性:
代碼 29
當用戶刪除一項作業時,如果這項作業是所屬分組的唯一一項作業,LongListSelector控件會自動隱藏這個分組。而當用戶撤銷所有更改時,AssignmentListViewModel類得把AssignmentGroups屬性清空。
到目前為止,AssignmentBookPage頁里的每個組成部分都有對應的ViewModel類了,現在是時候為它創建一個了。在ViewModels文件夾里創建一個AssignmentBookViewModel類,并創建一個AssignmentLists屬性:
代碼 30
AssignmentBookViewModel類的任務是讀取課程表的數據,然后創建對應的AssignmentListViewModel對象:
代碼 31
看到這里,你可能會問,為什么這里不用監聽數據源的更改?如果你要編輯課程表,一定要進入課程表的用戶界面,一旦離開課程表的用戶界面,課程表的數據就會凍結下來,換句話說,在AssignmentBookViewModel對象的整個生命周期里,課程表的數據是穩定的。
現在,我們可以著手處理數據綁定了。打開AssignmentBookPage.xaml文件,切換到XAML模式,在頁面的資源字典里添加兩個數據模板:
代碼 32
接著,把現有的Pivot項刪除,并在Pivot控件上設置數據模板和數據綁定:
代碼 33
最后在AssignmentBookPage的構造函數里創建一個AssignmentBookViewModel對象,并它把賦給DataContext屬性:
代碼 34
好了,不知不覺又到看效果的時候了!按F5運行應用程序:
圖 26
單擊"課程表"菜單項進入課程表,新建兩個課程,保存,然后按Back鍵返回主菜單:
圖 27
在主菜單里單擊"作業本"菜單項進入作業本,此時,你會看到作業本已經為剛才創建的兩個課程準備了兩個Pivot項:
圖 28
只是作業本上沒有任何內容,也沒有任何途徑可以添加內容……
編輯作業本
作業本支持的操作和課程表一樣,包括新建、編輯、刪除、保存所有更改和撤銷所有更改,其中,新建和保存以ApplicationBarIconButton的方式放在Application Bar上,撤銷所有更改以ApplicationBarMenuItem的方式放在Application Bar上,而編輯和刪除則放在上下文菜單里:
圖 29
為什么這樣安排?當老師布置作業時,我們會掏出作業本記下作業,下課之后,當我們要做作業時,我們會掏出作業本看看要做哪些作業,換句話說,新建、保存和顯示作業內容這三個功能已經可以滿足用戶絕大多數的需求了。新建和保存作為最常用的兩個操作自然應該放在最顯眼的位置,刪除和撤銷所有更改這兩個操作基本上不會用到,至于編輯,一般情況下我們只是用來修改作業的完成狀態,由于編輯和刪除是針對特定作業的,我們把它們放在上下文菜單里,當用戶長按某項作業時將會顯示出來,而撤銷所有更改則隱藏在Application Bar的菜單里。
接著,創建一個Windows Phone Page,并把它命名為NewOrEditAssignmentPage.xaml,這個頁面會在用戶單擊Application Bar上的新建按鈕或者上下文菜單上的編輯菜單項時顯示。完了之后把ApplicationTitle的Text屬性值改為"作業本",但PageTitle保留原樣:
圖 30
那么,這個頁面應該放些什么控件呢?想想看,創建一個完整的Assignment對象需要哪些數據?Id是自動生成的,課程名稱可以從上下文獲取,創建日期可以從DateTime的Today屬性獲取,剩下的就是截止日期、作業內容和完成狀態了。截止日期可以使用SL for WP Toolkit的DatePicker控件,作業內容可以使用TextBox控件(上面的標題需要額外添置TextBlock控件),而完成狀態則可以使用CheckBox控件:
圖 31
看到這里,你可能會問,為什么不把其它信息也顯示出來呢?你可以這樣做,但是,請注意,這個頁面的主要目的是收集而不是顯示信息,我們應該盡可能簡化用戶的輸入過程,在這里放置控件顯示其它信息,尤其是可編輯的控件,可能會耗費用戶額外的注意力,比如說,有些用戶會下意識地檢查所有數據是否輸入正確。創建作業的過程應該是既簡單又快速的,而我們也希望用戶能有這樣的感受,但耗費用戶額外的注意力意味著增加整個操作過程的時間,從而可能導致用戶的感受和我們期望的剛好相反,這是我們不希望看到的。
ViewModel類方面,我們將會仿效課程表的做法,創建NewOrEditAssignmentViewModel、NewAssignmentViewModel和EditAssignmentViewModel三個類:
圖 32
我們知道,NewOrEditAssignmentPage頁有兩個模式,一個是新建模式,另一個是編輯模式,前者對應NewAssignmentViewModel類,而后者則對應EditAssignmentViewModel類。當用戶新建一項作業時,NewAssignmentViewModel類可以從DateTime的Today屬性獲取創建日期,但它沒法獲取課程名稱,所以我們需要通過參數傳給它:
代碼 35
為什么DueDate屬性也要設置呢?想想看,如果我們不給它設置一個值,由于DateTime是值類型,將被自動初始化為"1/1/0001",當用戶看到頁面上的DatePicker控件顯示這樣一個日期可能會感到不友好,再者,老師布置下來的作業一般不會當天交(課堂作業除外),而第二天交的情況則比較常見(當然,計算下一個"上課日"可能更加合理)。而當用戶編輯一項作業時,EditAssignmentViewModel類將會從數據源里查找這項作業的數據,但前提是我們把作業的Id告訴它:
代碼 36
需要說明的是,Assignment類的Id屬性是只讀的,而Assignment類原來的構造函數會在每次調用時創建一個新的Id,這導致了我們無法使用現有的Id,所以我們需要在Assignment類里添加下面這個構造函數:
代碼 37
創建好ViewModel類之后,我們就可以著手處理它們和NewOrEditAssignmentPage頁之間的關聯了。首先是設置數據綁定,需要設置的控件以及對應的綁定表達式如下表所示:
描述 | 類型 | 屬性 | 綁定表達式 |
頁面標題 | TextBlock | Text | {Binding Title} |
截止日期 | DatePicker | Value | {Binding Assignment.DueDate, Mode=TwoWay} |
作業內容 | TextBox | Text | {Binding Assignment.Content, Mode=TwoWay} |
完成狀態 | CheckBox | IsChecked | {Binding Assignment.IsCompleted, Mode=TwoWay} |
插曲 #2
究竟發生了什么事?是數據沒有添加進去?是事件通知沒有發出?還是出現線程安全的問題?我調試了一下,數據已經正確添加進去了,事件通知也正確發出去了,所有操作都在UI線程里執行,而且沒有出現并發問題,那么問題到底出在哪里呢?
帶著這個疑問,我從codeplex.com上下載了SL for WP Toolkit的最新代碼(Change Set 57505),然后調試進去看看。在調試的過程中,我發現每次從NewOrEditAssignmentPage頁返回AssignmentBookPage頁時,LongListSelector控件都會調用Balance方法,但每次都會"跳過"本應執行的大部分代碼,一開始我沒怎么留意,覺得這個方法一下子就返回實在太神奇了,仔細觀察,原來它是通過第一個if里的return悄悄返回的:
代碼 43
難怪LongListSelector控件什么也沒顯示,因為Balance方法后面那些負責調整顯示的代碼一句都沒執行。為什么會這樣?關鍵在于IsReady方法,因為它每次都返回false。當我單步進入IsReady方法時,發現_itemsPanel和ItemsSource都不為null,但ActualHeight的值卻為0.0,從而導致IsReady方法返回false:
代碼 44
為什么會這樣?這是因為,當我們打開NewOrEditAssignmentPage頁時,由于AssignmentBookPage頁暫時無需顯示,Silverlight會把它從主對象樹移除,于是ActualHeight會被"清零",當我們從NewOrEditAssignmentPage頁返回時,Silverlight需要重新測量每個控件的大小(包括頁面本身),并安排它們的位置,ActualHeight的值為0.0意味著Silverlight還沒完成布局處理的工作,換句話說,LongListSelector控件還沒準備好,IsReady方法返回false是正確的。奇怪的是,每次我們從NewOrEditAssignmentPage頁返回時,Balance方法里的IsReady方法沒有一次返回true的,這可能意味著Balance方法的調用時機不對,那什么時候調用才對呢?控件加載完畢的時候,即Loaded事件觸發的時候,那么,LongListSelector控件在Loaded事件觸發的時候做了些啥呢?其實沒什么,只是簡單地把_isLoaded設為true,然后調用EnsureData方法:
代碼 45
這么看來,問題的關鍵就在于EnsureData方法有沒有正確調用Balance方法了。我們來看看EnsureData方法的代碼:
代碼 46
FlattenData和Balance是兩個很重要的方法,前者負責從ItemsSource把數據初始化到_flattenedItems,而后者則負責確定哪些數據需要顯示以及如何顯示。顯然,當我們從NewOrEditAssignmentPage頁返回時,如果我們創建了作業,if里面的語句是不可能執行的,因為_flattenedItems里面包含了我們的作業!?這聽起來很別扭,不是嗎?毫無疑問,LongListSelector控件沒有考慮我們的情況,即打開一個另一個頁面操作數據源,這是不應該的,你不可能指望我們把所有事情都放在同一個頁面里處理吧?
既然知道了原因,問題就不難解決了,把LongListSelector控件的Loaded事件處理程序改成下面這樣:
代碼 47
看到這里,你可能會問,_isLoadedRaisedBefore是干嘛的?我們知道,第一次進入AssignmentBookPage頁和從NewOrEditAssignmentPage頁返回時都會觸發Loaded事件,這是兩種需要區別處理的情況,因為Balance方法里包含了重設_resolvedFirstIndex和_resolvedCount的代碼(參見代碼43),如果我們在后面那種情況下執行這行代碼,LongListSelector控件的顯示就會亂掉,因為它計算不出正確的顯示索引,_isLoadedRaisedBefore的存在就是為了防止這種情況的發生。接著,在Balance方法里用if把重設_resolvedFirstIndex和_resolvedCount的那行代碼包圍起來:
代碼 48
值得提醒的是,每次調用FlattenData方法都會重設_flattenedItems,這對于從NewOrEditAssignmentPage頁返回的情況來說是沒有必要的,所以Loaded事件處理程序里的FlattenData方法需要放在if里,否則,使用ObservableCollection就會變得毫無意義了。
改好之后,編譯一下。注意,如果你是通過MSI安裝SL for WP7 Toolkit的話,你需要先在項目屬性里修改一下版本再編譯,否則待會重新添加引用的時候Visual Studio會自作聰明的引用原來那個dll文件,因為MSI在注冊表里做了手腳。
一切準備就緒之后就可以按F5了。單擊Application Bar上的新建按鈕打開NewOrEditAssignmentPage頁:
圖 36
輸入作業內容,然后按確定返回:
圖 37
噢,終于看到我的作業啦!
編輯作業本·續
回到作業本的操作,接下來我們要實現編輯和刪除兩個操作。前面提到,我打算把它們放在上下文菜單里,那么,如何創建上下文菜單?非常簡單,我們可以使用SL for WP Toolkit的ContextMenu控件:
代碼 49
正如你所看到的,ContextMenu控件只需嵌入目標對象就能工作了,非常方便。
接下來的問題是如何實現它們的事件處理程序。我們知道,這兩個操作有一個共同點,就是要獲取用戶當前選中的作業,怎么獲取呢?有些同學可能會建議,在AssignmentListViewModel類里添加一個SelectedAssignment屬性,并為它和LongListSelector控件的SelectedItem屬性設置雙向綁定,這樣,一旦用戶選中某項作業,我們就可以通過SelectedAssignment屬性獲取作業的Id了。你可以這樣做,不過,這個做法會帶來一個小小的問題,就是用戶在長按某項作業之前得先單擊一下。什么意思?我們知道,手機沒有鼠標右擊的概念,我們是通過長按(Touch and Hold)打開上下文菜單的,但從觸摸手勢的角度來看,長按和單擊(Tap)是兩個不同的觸摸手勢。LonglistSelector控件只會在單擊的時候設置SelectedItem屬性,它不處理長按,所以當我們通過長按打開上下文菜單時,SelectedItem屬性可能為null或者之前選中的其它作業,前者會引發異常,而后者則會為用戶帶來困擾。為了避免這些問題,要么我們再次修改LongListSelector控件的代碼,要么用戶不得不執行一步額外的操作,顯然,這都不是什么好辦法,還有沒有別的選擇?
當然有!你知道嗎,DataContext屬性是一個很特別的屬性,子元素可以從父元素那里繼承這個屬性的值,對照代碼49來看,這意味著MenuItem的DataContext和Grid的有著相同的值,而這個值正是我們苦苦尋找的作業!換句話說,只要我們獲取到用戶單擊的MenuItem對象,就可以通過它的DataContext屬性獲取用戶想要操作的作業。我們知道,事件處理程序的第一個參數就是引發該事件的對象,于是我們可以通過這個參數來訪問MenuItem對象:
代碼 50
這樣,我們既不需要在AssignmentListViewModel類里添加一個SelectedAssignment屬性,也不需要修改LongListSelector控件的代碼,更不需要委屈用戶執行額外的操作,真是一舉三得啊!
現在只剩一個操作了——撤銷所有更改,我相信這對于你來說不是問題,所以我決定把它留給你當課后作業。
好了,又到看效果的時候了!按F5運行應用程序,新建三項作業:
圖 38
長按第三項作業,你會看到這項作業以外的所有東西都縮小了,給人一種向后移動的感覺,這個動畫生動地突出了正在操作的作業以及上下文菜單,不過,不知道是不是動畫的bug,第三項作業的截止日期上面有個瑕疵(試了幾次都是這樣):
圖 39
單擊編輯將會打開NewOrEditAssignmentPage頁,修改一下截止日期:
圖 40
然后按確定返回,你會看到剛才修改的截止日期:
圖 41
接著,長按第二項作業(Textbook. P20. Ex 2),并選擇刪除:
圖 42
作業成功刪除。但是,如果你嘗試刪除(剩下的)第二項作業,你會發現它還在那里!為什么!?我調試了一下,發現此時MenuItem對象的DataContext屬性的值居然是已故的前任第二項(Textbook. P20. Ex 2),而不是我們期望的現任第二項(Textbook. P21. Ex 3)!因為前任第二項已被刪除,所以Remove方法不會觸發CollectionChanged事件,LongListSelector控件自然不會更新顯示。如果你現在嘗試刪除第一項作業(既是前任也是現任),你會成功的,但是,在刪除之后,如果你再次嘗試刪除剩下的唯一一項作業,你會發現此時MenuItem對象的DataContext屬性的值變成剛故的前任第一項(Textbook. P10. Ex 9, 10)!從此以后,剩下的唯一一項作業就再也刪除不了了,除非你返回MainPage頁重新打開AssignmentBookPage頁。由于編輯操作采用了相同的實現思路,如果一項作業刪除不了,那么它也編輯不了。
究竟發生了什么事?是ContextMenu控件的bug嗎?我另外創建了一個新的項目,在同等條件下,分別在ListBox和LongListSelector上測試了ContextMenu,結果,Listbox一方表現正常,而LongListSelector一方問題依舊,有趣的是,即使不用打開新的頁面,結果還是一樣。這讓我不得不再一次懷疑是LongListSelector控件的問題。
我重新運行應用程序,然后單步執行第一次刪除的整個過程。在這個過程里,我發現一個很奇怪的事情,當我刪除第二項時,LongListSelector控件先把第三項的ContentPresenter和Assignment分離開來,并把分離出來的ContentPresenter推入內部的_recycledItems(類型為Stack<ContentPresenter>),接著對第二項做相同的事,然后把第二項從_flattenedItems里刪除,最后重新關聯第三項的ContentPresenter和Assignment,問題就出現在最后一步,它居然直接使用_recycledItems頂部的ContentPresenter,換句話說,它把第二項的ContentPresenter和第三項的Assignment關聯了!見鬼!此時,如果我刪除第一項的話,它會把第一項的ContentPresenter和第三項的Assignment關聯!從這里不難看出,它應該在關聯之前把_recycledItems頂部那個垃圾扔掉!既然知道了原因,問題就不難解決了,在OnRemove方法的相應地方加上紅框里面那句:
代碼 51
重新編譯所有東西,然后運行應用程序,這次沒問題了。
寫完這篇文章之后,我的第一感覺是LongListSelector控件遠未達到產品級別的質量,它的問題導致我無法專注于應用程序本身的功能設計和實現,如果你是本著學習和研究的態度去用它,那沒問題,如果你想用它來做產品,那你就要做好心理準備了。不管怎樣,這次我還是學到了不少東西。LongListSelector控件的補丁我已經提交到codeplex.com了,在官方發布修正版本之前,我只能使用自己修改的版本了→_→
下課了……
it知識庫:WP7有約(二):課后作業,轉載需保留來源!
鄭重聲明:本文版權歸原作者所有,轉載文章僅為傳播更多信息之目的,如作者信息標記有誤,請第一時間聯系我們修改或刪除,多謝。