博客园客户端(Universal App)开发随笔 -- App的精灵:自定义控件
前言
拿到一个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:
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