博客园客户端(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:

https://www.windowsphone.com/zh-cn/store/app/博客园-uap/500f08f0-5be8-4723-aff9-a397beee52fc (明天会有一个更新)

Windows Store App link:

https://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