博客園客戶端(Universal App)開發隨筆 -- App的精靈:自定義控件


前言

拿到一個App的需求后,對於前端工程師來說,第一步要干什么?做Navigation規划!第二步要干什么?做頁面分解!頁面分解如何做?首先要確定UI Element的容器,其次要抽象UI Element本身,也就是要做一堆自定義控件,最終組成整個頁面。今天我們就說說自定義控件如何實現吧。

感性認識

在我們的博客園UAP的Windows Phone的版本中,一個最重要的自定義控件就是PostControl,它的樣子如下圖中紅色矩形內所示。

image

這個控件在無數頁面中都要用到,而且有幾種變種。上面看到的是在主頁/熱門/精華中所展示的樣子,是界面元素最全的,包括標題,作者,發布時間,閱讀狀態(朕已閱),摘要,屬性(最下方的三組數字),還有最下方的橫線(可不要忽視它喲,它是整體頁面設計的重要組成部分)。

 

第二個變種,是在博客列表中,如下圖所示。細心的人可以發現這個變種中沒有顯示作者,因為這是在博主頁面,上下文中有MS-UAP的作者名稱了,所以沒必要再顯示了,否則會顯得很自戀。

image

 

第三個變種,在分類博客列表里面,最下方的屬性沒有顯示。由於服務器端返回的數據中,推薦/閱讀/評論次數都是0,所以弄3個0在那里感覺很傻,所以可以不顯示了,這樣會覺得自己智商有所提升。

image

 

第四個變種,是在所有列表中都有,就是不顯示閱讀狀態(標題下面的“朕已閱”沒有),表示這是一篇新博客,你還沒有來得及看。

image

 

第五個變種,是不顯示摘要和屬性,如下圖所示。因為這篇博客你已經讀過了(朕已閱),沒必要再把摘要顯示出來占據有限的屏幕空間了,留着地方顯示那些沒讀過的博客。老話兒說,吃肉別吧唧嘴,讓人家沒吃到肉的人聽着難受,顯示咱們有教養。

當然,你如果想看摘要的話,點擊一下標題,摘要就自動優雅地展開了(有個小動畫);如果想看正文,就點擊一下摘要部分,進入到閱讀頁面。

image

 

以上這些變種的邏輯,包括動畫,都是在自定義控件中來實現的,很強大吧?下面讓我看看如何實現它吧。

 

兩種自定義控件的選擇

WinRT SDK有兩種用戶自定義控件的實現方式,一種是User Control, 另一種是 Template Control。在WPF/ASP.NET/WindowsForm中都有這兩個概念,只不過后者可能叫做Custom Control。總之這是一個很古老的概念了。

如何選擇這兩種控件呢?說實話,不知道!但是我們強烈建議你使用Template Control, 因為我們還沒發現它有什么缺點,但是發現User Control有缺點。

 

在Visual Studio 2013中,在你的Project上點擊鼠標右鍵,Add New Item:

image

注意幾個選擇點,下面寫PostControl.cs, 就可以輕輕點擊Add按鈕了。請不要猛擊該按鈕,注意咱們開發人員的素質。

如果一切正常的話,你的項目文件中會出現下面兩個東西:

image

上面那個是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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM