[UWP]为番茄钟应用设计一个平平无奇的状态按钮

简介: [UWP]为番茄钟应用设计一个平平无奇的状态按钮

1. 为什么需要设计一个状态按钮

OnePomodoro应用里有个按钮用来控制计时器的启动/停止,本来这应该是一个包含“已启动”和“已停止”两种状态的按钮,但我以前在WPF和UWP上做过太多StateButton、ProgressButton之类的东西,已经厌倦了这种控件,所以我在OnePomodoro应用里只是简单地使用两个按钮来实现这个功能:

<Button Content="&#xE768;"
        Visibility="{x:Bind ViewModel.IsTimerInProgress,Converter={StaticResource NegationBoolToVisibilityConverter}}"
        Command="{Binding StartTimerCommand}" />
<Button Content="&#xE769;"
        Visibility="{x:Bind  ViewModel.IsTimerInProgress,Converter={StaticResource BoolToVisibilityConverter}}"
        Command="{Binding StopTimerCommand}" />

颇有花花公子玩腻了找个良家结婚的意味。但两个按钮实际用起来很不顺手,手感也不好,尤其状态切换时会有种撕裂的感觉,越用越不爽,最后还是花时间又做了一个状态按钮PomodoroStateButton。这个按钮目标是要低调又炫丽,可以匹配OnePomodoro的多个主题。期间试玩了很多种技术,最后留下了这个成果:

看起来简直就是平平无奇。

下面说说实现细节。

2. 按钮状态

我做自定义控件一定会先写代码部分,然后再写XAML部分,功能和外观要做到解耦,写起来也不会乱。

PomodoroStateButton 继承自Button,除了Button本身的CommonStates,PomodoroStateButton还包含以下两组VisualState:

  • ProgressStates:Idle为番茄钟计时器正在计时,Busy为番茄钟停止的状态。
  • PromodoroStates:Inwork为正处于工作状态,Break为休息状态。

虽然是一个放飞自我的控件,但基本的规则还是要遵守的,VisualState对应的TemplateVisualState不能省:

[TemplateVisualState(GroupName = ProgressStatesName, Name = IdleStateName)]
[TemplateVisualState(GroupName = ProgressStatesName, Name = BusyStateName)]
[TemplateVisualState(GroupName = PromodoroStatesName, Name = InworkStateName)]
[TemplateVisualState(GroupName = PromodoroStatesName, Name = BreakStateName)]
 
public class PomodoroStateButton : Button
{
    private const string ProgressStatesName = "ProgressStates";
    private const string IdleStateName = "Idle";
    private const string BusyStateName = "Busy";
 
    private const string PromodoroStatesName = "PromodoroStates";
    private const string InworkStateName = "Inwork";
    private const string BreakStateName = "Break";
 
    protected virtual void UpdateVisualStates(bool useTransitions)
    {
        VisualStateManager.GoToState(this, IsInPomodoro ? InworkStateName : BreakStateName, useTransitions);
        VisualStateManager.GoToState(this, IsTimerInProgress ? BusyStateName : IdleStateName, useTransitions);
    }

有了这些按钮基本就满足番茄钟的需求了。

3. ICommand

需要支持Start和Stop两个Command。要实现ICommand支持,控件中要执行如下步骤:

  • 定义Command和CommandParameter属性。
  • 监视Command的CanExecuteChanged事件。
    在CanExecuteChanged的事件处理函数及CommandParameter的PropertyChangedCallback中,根据Command.CanExecute(CommandParameter)的结果设置控件的IsEnabled属性。
    在某个事件(Click或者ValueChanged)中执行Command。

这篇文章里有详细介绍:了解模板化控件(7):支持Command

因为从需求来说这个按钮不需要CommandParameter,也不需要监视CanExecuteChanged事件,所以实现得简单些:

public ICommand StartCommand
{
    get => (ICommand)GetValue(StartCommandProperty);
    set => SetValue(StartCommandProperty, value);
}
 
public ICommand StopCommand
{
    get => (ICommand)GetValue(StopCommandProperty);
    set => SetValue(StopCommandProperty, value);
}
 
private void OnClick(object sender, RoutedEventArgs e)
{
    if (IsTimerInProgress)
    {
        if (StopCommand != null && StopCommand.CanExecute(this))
            StopCommand.Execute(this);
    }
    else
    {
        if (StartCommand != null && StartCommand.CanExecute(this))
            StartCommand.Execute(this);
    }
}

4. 变形

写完代码部分才开始写XAML部分。

PomodoroStateButton的ControlTempalte中最核心的是一个Polygon,在计时器启动和停止之间按钮图标需要改变它的形状,本来是三角形,需要被用户变成正方形的形状。这部分的操纵在ProgressStates里做。如果只是简单地隐藏/显示或者更换Points会很无聊,这里我使用了以前介绍过的ProgressToPointCollectionBridge,具体可以见 用Shape做动画(2) 使用与扩展PointAnimation 这篇文章。为了让变形流畅些我让三角形先变成圆形再变形到正方形,还加入了旋转动画:

<VisualTransition From="Idle" To="Busy">
    <Storyboard >
        <DoubleAnimation Storyboard.TargetName="ProgressToPointCollectionBridge" Storyboard.TargetProperty="Progress" To="1" EnableDependentAnimation="True" Duration="0:0:0.3">
            <DoubleAnimation.EasingFunction>
                <CubicEase EasingMode="EaseOut"/>
            </DoubleAnimation.EasingFunction>
        </DoubleAnimation>
        <DoubleAnimation Storyboard.TargetName="ShapeCompositeTransform" Storyboard.TargetProperty="Rotation" To="180" EnableDependentAnimation="True" Duration="0:0:0.3">
            <DoubleAnimation.EasingFunction>
                <CubicEase EasingMode="EaseOut"/>
            </DoubleAnimation.EasingFunction>
        </DoubleAnimation>
 
    </Storyboard>
</VisualTransition>
 
<Border.Resources>
    <controls:ProgressToPointCollectionBridge x:Name="ProgressToPointCollectionBridge"
                   Progress="0">
        <PointCollection>三角形的点</PointCollection>
        <PointCollection>圆型的点</PointCollection>
        <PointCollection>正方形的点</PointCollection>
    </controls:ProgressToPointCollectionBridge>
</Border.Resources>
 
<Polygon Points="{Binding Source={StaticResource ProgressToPointCollectionBridge},Path=Points}"/>

顺便提一下其它的变形方案。

HandyControl提供了GeometryAnimation,可以像使用其它线性动画那样使用变形动画:

<hc:GeometryAnimationUsingKeyFrames Storyboard.TargetProperty="Data" Storyboard.TargetName="PathDemo">
    <hc:DiscreteGeometryKeyFrame KeyTime="0:0:0.7" Value="{StaticResource FaceBookGeometry}"/>
    <hc:EasingGeometryKeyFrame KeyTime="0:0:1.2" Value="{StaticResource TwitterGeometry}">
        <hc:EasingGeometryKeyFrame.EasingFunction>
            <QuarticEase EasingMode="EaseInOut"/>
        </hc:EasingGeometryKeyFrame.EasingFunction>
    </hc:EasingGeometryKeyFrame>
</hc:GeometryAnimationUsingKeyFrames>

也可以使用MorphSVG,或类似的SVG变形库:

5. 传递AlphaMask

我在使用GetAlphaMask制作阴影这篇文章里介绍了如何使用GetAlphaMask函数获取元素的AlphaMask,在 PomodoroStateButton里我也使用这个函数获取了ControlTemplate中的Polygon(就是上面变形的部分)的AlphaMask,并使用这个AlphaMask创建阴影、处理MouseEnter/MouseLeave的动画、Pressed的状态变换、还有Inwork/Break状态切换的动画。这还真是累坏它了,而要在一个元素上处理这个多动画我也会累,所以我没有使用DropShadowPanel那种ContentControl的方案,因为那样只能由ContentControl自己拥有Polygon的AlphaMask。而是创建了多个ButtonDecorator控件,让它们都用RelativeElement="{Binding ElementName=Shape}"的方式关联Polygon,然后再通过GetAlphaMask函数获取Polygon的AlphaMask,做到人手一份Polygon的AlphaMask,然后各自进行动画,这样避免了动画太过复杂。XML大致这样:

<controls:ButtonDecorator  x:Name="Shadow"
                           RelativeElement="{Binding ElementName=Shape}" 
                           Style="{StaticResource Shadow}"/>
<controls:ButtonDecorator RelativeElement="{Binding ElementName=Shape}"
                          x:Name="Outline"
                          Style="{StaticResource Outline}"/>
<controls:ButtonDecorator RelativeElement="{Binding ElementName=Shape}"
                          Style="{StaticResource Glow}"
                          IsInPomodoro="{TemplateBinding IsInPomodoro}"/>
<Polygon Points="{Binding Source={StaticResource ProgressToPointCollectionBridge},Path=Points}"
         StrokeThickness="4"
         Stretch="None" 
         StrokeEndLineCap="Round"
         x:Name="Shape"/>

6. 传递ButtonState

<VisualState x:Name="Pressed">
    <VisualState.Setters>
        <Setter Target="RootGrid.(RevealBrush.State)" Value="Pressed" />
        <Setter Target="RootGrid.Background" Value="{ThemeResource ButtonRevealBackgroundPressed}" />
        <Setter Target="ContentPresenter.BorderBrush" Value="{ThemeResource ButtonRevealBorderBrushPressed}" />
        <Setter Target="ContentPresenter.Foreground" Value="{ThemeResource ButtonForegroundPressed}" />
    </VisualState.Setters>
 
    <Storyboard>
        <PointerDownThemeAnimation Storyboard.TargetName="RootGrid" />
    </Storyboard>
</VisualState>

上面是是ButtonRevealStyle的部分XAML,应用了ButtonRevealStyle样式的按钮有很复杂的外观,但它的Style写得倒很简洁,这是因为它把状态传递给RevealBrush由它去处理动画(还有PointerDownThemeAnimation之类的),这样分解了复杂的XAML。我也为ButtonDecorator添加了State属性,它是一个ButtonState枚举类型的属性:

public enum ButtonState
{
    //
    // 摘要:
    //     元素处于其默认状态。
    Normal = 0,
    //
    // 摘要:
    //     指针在元素上。
    PointerOver = 1,
    //
    // 摘要:
    //     已按下元素。
    Pressed = 2
}

PomodoroStateButton在CommonStates的个状态间转变时会做轮廓的Outward和Inward动画,阴影也会变颜色,但因为通过传递ButtonState分离了复杂的XAML,所以CommonStates的XAML倒是写得很简单:

<VisualState x:Name="Normal" >
    <VisualState.Setters>
        <Setter Target="Outline.State" Value="Normal"/>
        <Setter Target="Shadow.State" Value="Normal"/>
    </VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver">
    <VisualState.Setters>
        <Setter Target="Outline.State" Value="PointerOver"/>
        <Setter Target="Shadow.State" Value="PointerOver"/>
    </VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
    <VisualState.Setters>
        <Setter Target="Outline.State" Value="Pressed"/>
        <Setter Target="Shadow.State" Value="Pressed"/>
        <Setter Target="Shape.Opacity" Value="0.7"/>
    </VisualState.Setters>
</VisualState>

7. 圆周动画

PomodoroStateButton在Inwork和Break之间切换的时候让左右两边的蓝色和红色阴影做半圈圆周运动交换位置,虽然也可以将就些,但当时太闲了就讲究起来了。

之前 介绍ProgressRing的文章 里说过怎么做圆周运动,简单来说就是把元素放到一个大的容器里,对整个容器做旋转。

<Page.Resources>
    <Storyboard RepeatBehavior="Forever" x:Key="Sb" >
        <DoubleAnimation Storyboard.TargetName="E1R" BeginTime="0" Storyboard.TargetProperty="Angle" Duration="0:0:4" To="360"/>
    </Storyboard>
</Page.Resources>
<Grid Background="White">
    <Canvas RenderTransformOrigin=".5,.5" Height="100" Width="100">
        <Canvas.RenderTransform>
            <RotateTransform x:Name="E1R" />
        </Canvas.RenderTransform>
        <Rectangle  Width="20"  Height="20"  Fill="MediumPurple"  />
    </Canvas>
</Grid>

但是这样的话里面的元素也会跟着旋转,其中一种解决方法是里面的元素用同样的速度向着反方向做旋转,抵消外层的旋转。但那时我太闲用了另一种方法,也就是平移:

<Page.Resources>
    <Storyboard RepeatBehavior="Forever" x:Key="Sb" >
        <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Translate1" Storyboard.TargetProperty="X" EnableDependentAnimation="True">
            <EasingDoubleKeyFrame KeyTime="0:0:4" Value="120">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseInOut"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
            <EasingDoubleKeyFrame KeyTime="0:0:8" Value="0">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseInOut"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
        </DoubleAnimationUsingKeyFrames>
 
        <DoubleAnimationUsingKeyFrames Storyboard.TargetName="Translate1" Storyboard.TargetProperty="Y" EnableDependentAnimation="True">
            <EasingDoubleKeyFrame KeyTime="0:0:2" Value="60">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseOut"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
            <EasingDoubleKeyFrame KeyTime="0:0:4" Value="0">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseIn"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
            <EasingDoubleKeyFrame KeyTime="0:0:6" Value="-60">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseOut"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
            <EasingDoubleKeyFrame KeyTime="0:0:8" Value="0">
                <EasingDoubleKeyFrame.EasingFunction>
                    <QuadraticEase EasingMode="EaseIn"/>
                </EasingDoubleKeyFrame.EasingFunction>
            </EasingDoubleKeyFrame>
        </DoubleAnimationUsingKeyFrames>
    </Storyboard>
</Page.Resources>
<Grid Background="White">
    <Grid Height="100" Width="100">
        <Rectangle Width="20" Height="20" Fill="MediumPurple"  RenderTransformOrigin=".5,.5"  HorizontalAlignment="Left" VerticalAlignment="Center">
            <Rectangle.RenderTransform>
                <TranslateTransform x:Name="Translate1" X="0" Y="0" />
            </Rectangle.RenderTransform>
        </Rectangle>
    </Grid>
</Grid>

选择QuadraticEase,搭配得宜的话可以做到漂亮的圆周运动,效果如下:

当然实际上我使用了CircleEase,效果更调皮些,PomodoroStateButton在Inwork和Break之间切换后的效果如下:

(虽然搞这么复杂也没什么意义。)

8. 结语

这样一个手感还不错,看上去很收敛实际上用了一大堆代码的状态按钮就完成了,使用了两个月下来感觉手感还算好,而且很容易和各种主题的番茄钟搭配。

可以安装我的番茄钟应用试玩一下,安装地址:

一个番茄钟

9. 源码

OnePomodoro_Controls at master

相关文章
|
存储 Serverless API
基于百炼平台构建智能体应用——十分钟构造能主动提问的导购智能体
本文介绍了如何使用阿里云百炼大模型服务平台构建一个多智能体的智能导购应用,并将其部署到钉钉。通过百炼的Assistant API,您可以快速构建一个包含规划助理、手机导购、冰箱导购和电视导购的智能导购系统。文章详细讲解了从创建函数计算应用、访问网站、验证智能导购效果到将商品检索应用集成到智能导购中的全过程,帮助您快速实现智能导购功能。
1954 1
基于百炼平台构建智能体应用——十分钟构造能主动提问的导购智能体
|
10月前
|
人工智能 自然语言处理 供应链
国产与国外CRM系统:功能与优势全解析
随着企业数字化转型加速,CRM系统成为提升竞争力的关键工具。国产CRM系统如销售易、神州云动、八骏科技等,以高性价比、本地化服务和灵活定制见长;国外CRM系统如Salesforce、Zoho CRM、Microsoft Dynamics 365等,则在功能创新、全球化支持和技术成熟度上表现突出。企业在选择时应综合考虑自身需求,选取最适合的CRM系统,助力业务高质量增长。
四步教你快速解决UE5文件迁移失败❗️
不知道大家在用UE5迁移文件时,有没有发现这个问题:如果文件输出的路径选择了非项目路径,那么UE会提示无法迁移。在UE4中,这样做是不存在问题的,只要选择「忽略」就可以继续完成。但UE5会一直提示此对话框,如果不能快速找到解决方法,是非常浪费大家时间的。与大家分享我们发现的四步快速解决UE5文件迁移报错的方法~
|
10月前
|
存储 监控 调度
应对IDC资源紧缺:ACK Edge如何解决LLM推理服务的弹性挑战
应对IDC资源紧缺:ACK Edge如何解决LLM推理服务的弹性挑战
252 5
|
弹性计算 负载均衡 监控
防御DDoS攻击:策略与技术深度解析
【6月更文挑战第12天】本文深入探讨了防御DDoS攻击的策略和技术。DDoS攻击通过僵尸网络耗尽目标系统资源,特点是分布式、高流量和隐蔽性。防御策略包括监控预警、流量清洗、负载均衡、弹性伸缩及灾备恢复。技术手段涉及IP信誉系统、深度包检测、行为分析、流量镜像与回放及云防护服务。综合运用这些方法能有效提升抗DDoS攻击能力,保障网络安全。
|
SQL 关系型数据库 MySQL
MySQL语法
MySQL语法
292 4
|
存储 缓存 Dragonfly
如何进行容器镜像加速?| 深度揭秘阿里云 Serverless Kubernetes(3)
容器相比虚拟机最突出的特点之一便是轻量化和快速启动。相比虚拟机动辄十几个 G 的镜像,容器镜像只包含应用以及应用所需的依赖库,所以可以做到几百 M 甚至更少。但即便如此,几十秒的镜像拉取还是在所难免,如果镜像更大,则耗费时间更长。
1804 0
如何进行容器镜像加速?| 深度揭秘阿里云 Serverless Kubernetes(3)
|
Web App开发 缓存
谷歌浏览器(Chrome):前进后退&清除缓存_掌握这几个浏览器快捷键,提高五倍工作效率。
谷歌浏览器(Chrome):前进后退&清除缓存_掌握这几个浏览器快捷键,提高五倍工作效率。
1631 0
|
前端开发 JavaScript
「寒草的中秋献礼🥮,实现30s前端创意动画」陪你看日落和月升|与你赏星空和诗歌
「寒草的中秋献礼🥮,实现30s前端创意动画」陪你看日落和月升|与你赏星空和诗歌
489 1
|
传感器 数据采集 监控
资料转发分享【毕业设计】单片机和stm32设计选题,proteues仿真、程序完整资料
资料转发分享【毕业设计】单片机和stm32设计选题,proteues仿真、程序完整资料 基于单片机寻迹巡线避障智能小车系统设计 基于单片机体温心率脉搏检测仪系统设计 基于单片机温湿度光照自动窗帘系统设计 基于单片机环境监测温湿度PM2.5系统设计 基于51单片机的波形发生器(四种波形) 基于单片机SO2 NO2 PM温湿度空气质量检测仪 基于51单片机冰箱温度控制器设计
788 1
资料转发分享【毕业设计】单片机和stm32设计选题,proteues仿真、程序完整资料