前言
拿到一個App的需求后,對於前端工程師來說,第一步要干什么?做Navigation規划!第二步要干什么?做頁面分解!頁面分解如何做?首先要確定UI Element的容器,其次要抽象UI Element本身,也就是要做一堆自定義控件,最終組成整個頁面。今天我們就說說自定義控件如何實現吧。
感性認識
在我們的博客園UAP的Windows Phone的版本中,一個最重要的自定義控件就是PostControl,它的樣子如下圖中紅色矩形內所示。
這個控件在無數頁面中都要用到,而且有幾種變種。上面看到的是在主頁/熱門/精華中所展示的樣子,是界面元素最全的,包括標題,作者,發布時間,閱讀狀態(朕已閱),摘要,屬性(最下方的三組數字),還有最下方的橫線(可不要忽視它喲,它是整體頁面設計的重要組成部分)。
第二個變種,是在博客列表中,如下圖所示。細心的人可以發現這個變種中沒有顯示作者,因為這是在博主頁面,上下文中有MS-UAP的作者名稱了,所以沒必要再顯示了,否則會顯得很自戀。
第三個變種,在分類博客列表里面,最下方的屬性沒有顯示。由於服務器端返回的數據中,推薦/閱讀/評論次數都是0,所以弄3個0在那里感覺很傻,所以可以不顯示了,這樣會覺得自己智商有所提升。
第四個變種,是在所有列表中都有,就是不顯示閱讀狀態(標題下面的“朕已閱”沒有),表示這是一篇新博客,你還沒有來得及看。
第五個變種,是不顯示摘要和屬性,如下圖所示。因為這篇博客你已經讀過了(朕已閱),沒必要再把摘要顯示出來占據有限的屏幕空間了,留着地方顯示那些沒讀過的博客。老話兒說,吃肉別吧唧嘴,讓人家沒吃到肉的人聽着難受,顯示咱們有教養。
當然,你如果想看摘要的話,點擊一下標題,摘要就自動優雅地展開了(有個小動畫);如果想看正文,就點擊一下摘要部分,進入到閱讀頁面。
以上這些變種的邏輯,包括動畫,都是在自定義控件中來實現的,很強大吧?下面讓我看看如何實現它吧。
兩種自定義控件的選擇
WinRT SDK有兩種用戶自定義控件的實現方式,一種是User Control, 另一種是 Template Control。在WPF/ASP.NET/WindowsForm中都有這兩個概念,只不過后者可能叫做Custom Control。總之這是一個很古老的概念了。
如何選擇這兩種控件呢?說實話,不知道!但是我們強烈建議你使用Template Control, 因為我們還沒發現它有什么缺點,但是發現User Control有缺點。
在Visual Studio 2013中,在你的Project上點擊鼠標右鍵,Add New Item:
注意幾個選擇點,下面寫PostControl.cs, 就可以輕輕點擊Add按鈕了。請不要猛擊該按鈕,注意咱們開發人員的素質。
如果一切正常的話,你的項目文件中會出現下面兩個東西:
上面那個是PostControl.cs, 我后來把它移到Controls folder下面的,為了好管理。下面那個是Themes/Generic.xaml, 是系統幫你生成好的,別動它的位置,否則后果自負。這里有個bug,如果你是第二次添加自定義控件,很有可能出現了.cs文件后,在Generic.xaml中沒有新控件的style。此時你可以用仇恨的筆寫一封email發給有關部門控訴這個bug,然后乖乖的在Generic.xaml中自己添加。添加什么東西呢?后面會說到。
Generic.xaml
首先我們看這個文件中的模樣,一堆xaml語法而已:
<Style TargetType="local:PostControl"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:PostControl"> <Border> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
最開始時是上面那個樣子,空白的模板,你要把它寫成你自己想要的樣子。此時你的頭腦中要有PostControl控件的具體樣式,因為這個編輯器不能所見即所得。我最后把他改成了以下樣子:
<Style TargetType="local:PostControl"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:PostControl"> <Border BorderThickness="0,0,0,1" BorderBrush="{ThemeResource CNBlogsLineColor}"> <Grid Margin="15"> <Grid.RowDefinitions> <RowDefinition/> <RowDefinition/> <RowDefinition/> <RowDefinition/> </Grid.RowDefinitions> <TextBlock x:Name="tb_Title" Grid.Row="0" Text="{Binding Title}" Style="{StaticResource PostTitleFont}"/> <Grid Grid.Row="1" Margin="0,5,0,0"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <local:AuthorControl Grid.Column ="0" Visibility="{TemplateBinding AuthorVisible}" NameFontSize="20" NameColor="{ThemeResource CNBlogsAttributionColor}" AvatarHeight="25" Margin="0,0,10,0" /> <TextBlock Grid.Column="1" Text="{Binding PublishTime, Converter={StaticResource TimeCountDownConverter}}" Style="{StaticResource PublishTimeFont}" VerticalAlignment="Center"/> <TextBlock x:Name="tb_Status" Grid.Column="2" Text="{Binding Status, Converter={StaticResource PostStatusConverter}}" FontFamily="Segoe UI Symbol" FontSize="14" HorizontalAlignment="Right" VerticalAlignment="Center"/> </Grid> <!-- used for tapped anywhere on title and attribution --> <Rectangle x:Name="rect_Header" Grid.RowSpan="2" Fill="Transparent"/> <TextBlock x:Name="tb_Summary" Grid.Row="2" Margin="0,5" TextTrimming="CharacterEllipsis" MaxLines="4" FontSize="20" FontFamily="Segoe WP" Foreground="{ThemeResource CNBlogsSummaryColor}" TextWrapping="Wrap" Visibility="Collapsed"> <Run Text="{Binding Summary}"/> <Run Text="..."/> <TextBlock.Resources> <Storyboard x:Name="sb_Summary"> <DoubleAnimation Storyboard.TargetName="tb_Summary" Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:1"/> </Storyboard> </TextBlock.Resources> </TextBlock> <local:AttributionControl x:Name="control_Attribution" Grid.Row="3" HorizontalAlignment="Right" Visibility="{TemplateBinding AttributionVisible}" FontFamily="Global User Interface"/> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
最外面有個Border,它的好處是把邊界設置成{0,0,0,1}就會在最下面顯示分割線。如果你不想在最下面顯示一條橫線來分割兩個博客,可以把這層border去掉。
里面是個Grid,定義了四行:第一行是標題;第二行是作者/發布時間/閱讀狀態。這里作者又是另一個自定義控件,所以寫成了local:AuthorControl;第三行是摘要;第四行是屬性,也是一個自定義控件。
說明幾個刁(雕)民(蟲)小技:
1)控件可以套控件,比如AuthorControl和AttributionControl在PostControl里面。
2)閱讀狀態的HorizontalAlignment=Right,就是右側對齊,這個必須在Grid里才有用,在StackPanel里好像做不到。也許我不夠刁,反正在Windows Phone上沒試出來。
3)必須要知道屏幕的寬度,否則如果顯示內容不夠一屏寬,右對齊就不是真正的右對齊了,這個你自己試試就知道了。如何知道屏幕寬度呢?在PostControl.cs里:
public PostControl() { this.DefaultStyleKey = typeof(PostControl); this.DataContextChanged += PostControl_DataContextChanged; this.Width = CNBlogs.DataHelper.Helper.Functions.GetWindowsWidth(); }
上面那個this.Width,就是根據屏幕寬度指定控件本身的寬度。如果你要兩邊留白,可以想別的辦法得到該控件所屬的容器的寬度,比如listview的寬度。
4)不要在最外層設置Margin,就是Border那一層,比如Margin=20. 如果設置了,你麻煩大了!別緊張,你唯一的麻煩是在調整個頁面的樣子時,遇到一些奇怪的空白,找了一圈才知道是控件自己內部有空白。如果想設置空白,在使用這個控件的XAML里設置,比如ListView的ItemsPanel里。
5)大家可能看到這個:<Rectangle x:Name="rect_Header" Grid.RowSpan="2" Fill="Transparent"/>。挺奇怪的,但作用很大。因為我們設計在點擊上面兩行(標題和作者)時隱藏或顯示摘要,但是這兩行並不會充滿了字,肯定有很多空白。如果你纖細的手指點擊到了空白處,不會觸發任何內部點擊事件。如果加了一層透明的Rectangle,可以點擊任何位置了。
6)里面可以發現有用到TemplateBinding語法的,這個對應的屬性要在PostControl.cs里注冊(下面有說)。
7)寫完所有style后,仔細看一遍,不要有多余的Border, Grid, StackPanel等容器。尤其是在修改了style后,這種情況很有可能發生。其壞處就是讓別人覺得你的程序員素質不高啊
8)可以在style里面定義動畫,就像summary里的Storyboard那樣,然后在PostControl.cs里調用。
9)TextBlock是個好東東,要妥善使用。比如<Run Text/>語法,可以用來拼接字符串,還可以指定不同的字體字號字色,但不建議這樣做,會毀壞的你的UI,讓別人覺得你的素質不高啊(又來了)。
PostControl.cs
自定義屬性
如果想從外面(使用時)控制某些內容,比如顯示或不顯示作者,需要自定義屬性如下:
public Visibility AuthorVisible { get { return (Visibility)GetValue(AuthorVisibleProperty); } set { SetValue(AuthorVisibleProperty, value); } } // Using a DependencyProperty as the backing store for AuthorVisiable. This enables animation, styling, binding, etc... public static readonly DependencyProperty AuthorVisibleProperty = DependencyProperty.Register("AuthorVisible", typeof(Visibility), typeof(PostControl), new PropertyMetadata(Visibility.Visible));
這個語法太難記住了,你可以每次copy/paste/modify,有一個簡單的方式是在空白處鍵入propdp,然后按Tab鍵,會自動生成這些東東,然后再手工改一些關鍵的西西,就是你想要的東西了。有了這個屬性后,在Style里面(Generic.xaml),可以這樣寫:
<local:AuthorControl Grid.Column ="0" Visibility="{TemplateBinding AuthorVisible}" />
表明這個AuthorControl的顯示與否可以在使用時控制。
然后你在使用這個PostControl的page.xaml中這樣寫:
<ListView x:Name="lv_AuthorPosts" Grid.Row="1" Background="{ThemeResource CNBlogsBackColor}" Loaded="lv_AuthorPosts_Loaded"> <ListView.Header> <ListView.ItemTemplate> <DataTemplate> <local:PostControl AuthorVisible="Collapsed" Tapped="PostControl_Tapped"/> </DataTemplate> </ListView.ItemTemplate> </ListView>
PostControl的AuthorVisible=Collapsed,於是乎作者不顯示了,顯示的是我們開發者的情懷(扯遠了)。
事件注冊
在構造函數中,可以注冊你想要的事件,如:
public PostControl() { this.DefaultStyleKey = typeof(PostControl); this.DataContextChanged += PostControl_DataContextChanged; this.Width = CNBlogs.DataHelper.Helper.Functions.GetWindowsWidth(); }
此處注冊了DataContextChanged事件,並在后面要響應這個事件。
還有些事件是不需注冊的,比如OnApplyTemplate(), 表現為一個方法,直接override即可。
顯示控制
如果在一個ListView中顯示一串博文,而且這些博文的狀態可能不一樣,比如有的是”朕已閱“不顯示摘要,有的是要顯示摘要,我們需要在OnApplyTemplate()中控制這個顯示行為。
protected override void OnApplyTemplate() { this.UpdateUI(false); }
注意啦!這里有個問題,你實際運行就知道了,由於ListView有一些”智能“顯示控制,它只會對前幾個博文執行OnApplyTemplate()方法,具體幾個呢?依賴於你的屏幕的高度能顯示幾個博文。對於后面所有的博文,都會無視這個方法,這樣當你卷滾ListView至下方時,悲劇發生了,沒有按照你的意思控制顯示(該隱藏的摘要沒有隱藏)。
怎么辦?再次拿起仇恨的筆寫一封控告信,然后默默地燒掉它。幸好我們注冊了DataContextChanged事件,於是輕松地寫下如下代碼:
void PostControl_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) { this.UpdateUI(false); }
搞定啦!后面的博文也能按照你的邏輯顯示了。具體原因不講了,你自己領會吧。
事件響應
OnTapped事件是我們必須要用到的,處理當用戶點擊此控件的任何一個部分時,你要響應的邏輯。
/// <summary> /// if user click title, control will collapse summary, set status = Skip, next time: show title only /// if user click summary, goto reading page, set status = Read, next time: show title only /// if user click favorite in reading page, set status = Favorite, next time: show title only /// </summary> /// <param name="e"></param> protected override void OnTapped(TappedRoutedEventArgs e) { CNBlogs.DataHelper.DataModel.Post post = this.DataContext as CNBlogs.DataHelper.DataModel.Post; if (post == null) { return; } // click on the title if (e.OriginalSource is Windows.UI.Xaml.Shapes.Rectangle) { this.GotoReadingPage = false; if (this.showSummary) // show summary { this.HideSummary(); this.SetNewStatus(post, DataHelper.DataModel.PostStatus.Skip); } else // hide summary { this.ShowSummary(true); } } else // click on the summary { TextBlock tbSource = e.OriginalSource as TextBlock; if (tbSource.Name == "tb_Summary") { // don't navigate to target page here(in control), need do that in page's viewmodel (.cs) this.GotoReadingPage = true; this.SetNewStatus(post, DataHelper.DataModel.PostStatus.Read); } } base.OnTapped(e); }
我把最上端的注釋也保留了,for your eyes only.
這里特別強調一點,很重要:其實這個點擊事件可以在三個地方響應:
1)在這里的code中響應
2)在ListView的Control中響應(PostControl_Tapped)
<ListView x:Name="lv_BestPosts" Grid.Row="1" Background="{ThemeResource CNBlogsBackColor}"> <ListView.ItemTemplate> <DataTemplate> <local:PostControl Tapped="PostControl_Tapped"/> </DataTemplate> </ListView.ItemTemplate> </ListView>
3)在ListView的ItemClicked事件中響應。
有何區別呢?建議如下:
1)在PostControl.cs中響應事件時,只關注控件本身的樣式變化,比如隱藏摘要,不要做別的事情,否則就會超出你的職責范圍了,讓上層事件無法處理。
2)在控件的PostControl_Tapped中,你可以做上層邏輯了,比如直接顯示博文閱讀頁面。但是PostControl實例是從sender里得到的:
private void PostControl_Tapped(object sender, TappedRoutedEventArgs e) { PostControl postControl = sender as PostControl; if (postControl.GotoReadingPage) { Post post = postControl.DataContext as Post; this.Frame.Navigate(typeof(PostReadingPage), post); } else { }
3)在ListView的ItemClicked事件中(如果有的話),也同樣可以做上層邏輯。記得上面那個透明的Rectangle吧,如果沒有它,ItemClicked事件也會響應,但是底層那兩個事件不一定會響應(如果手指頭太細點在了空白處)。而且對應的類實例是從click事件中得到的,而不是sender:
private void lv_Category_ItemClick(object sender, ItemClickEventArgs e) { Category category = e.ClickedItem as Category; this.Frame.Navigate(typeof(SubCategoriesPage), category); }
如上面代碼中的Category實例,是從e.ClickedItem中得到的。
小結
寫累了,休息一下,我們已經完成了一個自定義控制的樣式定義和邏輯定義,用幾次就熟悉了。某些粗糙的App, 直接用一個TextBlock顯示內容,不加任何修飾,體現不出我程序員們的素質,建議稍微講究一些,用個template control。就拿這個PostControl來說,你盡可以把它拿去稍微修改一下,就可以適應所有閱讀類的需求了。真的,不信你試試,我反正已經用這個Control做了三個App了。
在Windows 8.1上,同樣的道理,可以使用自定義control。但是以博客園為例,由於UI design相差太大,無法復用樣式,但可以部分復用邏輯,所以建議不要把這些control放在Shared里面,而是放在各自的Project內部。
比較Windows 8.1和Windows Phone 8.1的自定義控件,在Windows上,由於顯示面積大,控件要設計得大氣,別扣扣嗦嗦的,可以色彩鮮明些,顯示充分些;但是在Windows Phone上,顯示面積小,要講究精巧,比如隱藏摘要這件事,很適合Windows Phone,但是不適合Windows。
分享代碼,改變世界!
Windows Phone Store App link:
http://www.windowsphone.com/zh-cn/store/app/博客園-uap/500f08f0-5be8-4723-aff9-a397beee52fc (明天會有一個更新)
Windows Store App link:
http://apps.microsoft.com/windows/zh-cn/app/c76b99a0-9abd-4a4e-86f0-b29bfcc51059
(明天會有一個更新)
GitHub open source link:
https://github.com/MS-UAP/cnblogs-UAP
MSDN Sample Code:
https://code.msdn.microsoft.com/CNBlogs-Client-Universal-477943ab