聊聊软件登录界面的设计与交互

前面说了一堆废话,想看代码的可直接看第二章。

版本记录

日期备注
2020-06-13初稿

零、前言

这个登录界面提取自最近正在做的一个项目,此项目曾被我自豪地称为是公司数采软件的颜值担当,虽然这里面有不少夸大的成分,但也并非担不起这个称号。这个项目能做出今天的程度,是项目组一起努力的结果,并非我一个人的功劳。

纵观公司这么多年的数采软件界面,清一色的都是很刻板、很标准的Windows风格。甚至在嵌入式数采仪上,这么多年来都是沿袭了Win8风格的磁贴界面,一成不变。我并不是说界面古板、简陋、一成不变不好,曾经的界面、功能能沿袭这么多年,最大的原因无非就是稳定。对于工控数采软件来说,稳定性尤其重要,一般都要求7×24小时不间断运行,界面简洁易用就够了。当然这也不是说工控数采软件不配拥有花哨华丽的界面,在我看来,以往一成不变的界面、特别是不管什么业务都复用同一个界面结构,虽然减少了开发工作,但最终出来的产品往往都千篇一律,突出不了软件的特色。

自从我接手柄主刀DATS(即所谓的数据采集传输系统)系列项目开发后,最开始分离了核心数采功能和业务功能,提炼出来一个相对纯粹的DATS内核,业务系统都基于此内核进行开发。

有关数采的内核架构,不是三言两语就能说得清。今天不聊数采,我只想聊聊其中最常见、但放在数采软件上很容易被忽略的的一个功能——用户登录。

一、登录界面的演化

用户登录功能在任何一个B/S、移动App项目上都有,甚至还会花大功夫去设计、打磨出一个耳目一新的界面。但对于公司的数采软件来说,一直都没重视过,有的场合还会带来不好的操作体验。

1.1 数采软件对权限控制的特殊要求

说起登录界面,那首先得聊聊权限控制。

数采软件正常都是无人值守运行的,但当需要维护、修改设置,尤其是需要下置命令修改仪器内部的设置时,如果任何人都有权限去操控,后果会很严重。这时候就需要一套简单的权限控制功能,这个权限控制功能跟B/S项目上的还略有出入。B/S项目中通常基于角色实现权限控制,角色对应的权限可以动态配置,但在缺少上下位一体的云平台的情况下,纯粹基于角色控制权限的思路并不完全适用于DATS。

在DATS的权限控制模块中,定义了四个等级的角色。

  • 普通用户
  • 高级用户
  • 管理员
  • 超级管理员

超级管理员之外,其余三个角色具备的权限由具体地业务系统分配,且分配好了就不能任意更改。这也是和常规B/S系统中的权限控制不同的地方。

超级管理员是系统内最高权限的角色,我们内置了唯一个超级管理员账号,此账号仅用于开发组,不对实施、售后、甚至客户公开。

1.2 前世

简单地提了下权限控制,下面贴几张图,看看之前几个系统的登录界面长啥样。

  • 前辈做的二代数采登录界面,是目前在运行的最古老的数采软件
    EQMS登录界面
  • 第三代数采登录界面
    DATS3登录界面
  • 第四代数采初期登录界面
    DATS.Water登录界面
    这三个界面的时间跨度超过六年,开发语言也从VB6换到C#,但外表完完全全是一个模子里刻出来的,除了简约,看不出别的特点了

1.3 演变

自从去年开始用WPF技术重构整个数采系统的UI,登录界面已经改过一个版本了,就是下面这个样子。
DATS.WasteWater登录界面

操作逻辑是当执行需要进行权限控制的功能前,如果发现尚未登录,则通过消息框提示用户。用户需要手动去点登录按钮,弹出该对话框,通过用户名和密码登录到系统,再返回到刚才要执行的操作,如下(请忽略原先的一个Bug)。
登录流程
这样的操作方式对于用户来说很不友好,特别是来回切换可能会造成上一次执行结果的丢失,让软件的易用性下降不止一个档次。

尤其是手头这个项目最终运行在带触摸屏的Windows一体机中,在没有鼠标键盘的情况下,让用户通过软键盘做重复的操作简直是噩梦,这怎么对得起颜值担当的称号呢?

1.4 今生

考虑到操作方式以触摸为主,我参考了手机银行App的交互逻辑,重新设计了如下的登录流程。
旧版登录交互效果
从上图中可以看出,正常运行时系统无需登录,当某个需要登录才能执行操作前,自动弹出登录画面,通过用户名和密码登录到系统后,此页面自动关闭,并继续往下执行原有逻辑。

大概的操作效果如下,交互已经友好多了,基本达到了我期望。
登录页面交互效果

二、登录界面的实现

2.1 思路

要实现上面的效果,其实也不难。说下思路吧。

将登录界面封装为一个用户控件放到主界面底部,通过设置高度为0,确保主界面刚显示的时候隐藏登录界面。绑定IsShow属性来实现调出和隐藏,当需要登录时,设置IsShow=True,通过StoryBoard增加其高度,直到铺满整个操作区域。登录成功或点击左上角的返回按钮,则设置IsShow=False,通过StoryBoard减少高度直至0。

2.2 代码

直接上代码,很简单,一看就懂,懒得写注释了。

首先是登录界面的ViewModel,通过单例模式保证全局共用同一个实例。其中WaitForLogin方法由需要权限控制的界面调用,登录成功后通过回调函数继续执行原有的逻辑。

class LoginViewModel : BaseViewModel
{
    private static LoginViewModel s_Instance = null;
    public static LoginViewModel Current
    {
        get
        {
            if (s_Instance == null)
            {
                s_Instance = new LoginViewModel();
            }

            return s_Instance;
        }
    }

    private string m_LoginName;
    public string LoginName
    {
        get { return m_LoginName; }
        set
        {
            if (m_LoginName != value)
            {
                m_LoginName = value;
                OnPropertyChanged(nameof(LoginName));
            }
        }
    }

    private string m_Password;
    public string Password
    {
        get { return m_Password; }
        set
        {
            if (m_Password != value)
            {
                m_Password = value;
                OnPropertyChanged(nameof(Password));
            }
        }
    }

    private bool m_IsShow = false;
    public bool IsShow
    {
        get { return m_IsShow; }
        set
        {
            if (m_IsShow != value)
            {
                LoginName = "";
                Password = "";

                m_IsShow = value;
                OnPropertyChanged(nameof(IsShow));
            }
        }
    }

    private LoginViewModel()
    {
        Minimize = new SimpleCommand(a => true, OnMinimize);
        Login = new SimpleCommand(a => true, OnLogin);
    }

    public ICommand Minimize { get; private set; }
    private void OnMinimize(object obj)
    {
        IsShow = false;
    }

    public ICommand Login { get; private set; }
    private void OnLogin(object obj)
    {
        if (string.IsNullOrEmpty(LoginName))
        {
            ShowWarning("用户名不能为空");
            return;
        }

        if (string.IsNullOrEmpty(Password))
        {
            ShowWarning("密码不能为空");
            return;
        }

        if (AccountService.LoginByPassword(LoginName, Password, false, out string errMsg))
        {
            this.IsShow = false;
        }
        else
        {
            this.Password = string.Empty;
            ShowError(errMsg);
        }
    }

    public Task WaitForLogin(Action callback)
    {
        return Task.Run(() =>
        {
            if (!LoginAccountInfo.Current.IsLogin)
            {
                IsShow = true;

                do
                {
                    Thread.Sleep(500);
                }
                while (this.IsShow);
            }

            if (LoginAccountInfo.Current.IsLogin)
            {
                App.Current.Dispatcher.Invoke(() =>
                {
                    callback();
                });
            }
        });
    }
}

登录界面布局代码如下。

<UserControl x:Class="DATS.WasteWater.UI.Views.Permission.LoginView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:local="clr-namespace:DATS.WasteWater.UI.Views.Permission"
             xmlns:hc="https://handyorg.github.io/handycontrol"
             xmlns:input="clr-namespace:System.Windows.Input;assembly=PresentationCore"
             xmlns:ViewModel="clr-namespace:DATS.WasteWater.UI.ViewModels.Permission"
             xmlns:ctrlib="http://sinoyd.gitlab.com/ctrlib"
             mc:Ignorable="d"
             x:Name="uc"
             d:DataContext="{d:DesignInstance ViewModel:LoginViewModel,IsDesignTimeCreatable=False}"
             d:DesignHeight="350" d:DesignWidth="300" Background="Transparent">

    <UserControl.Resources>
        <Style x:Key="masklayerBackButton" TargetType="Button">
            <Setter Property="Height" Value="30" />
            <Setter Property="Width" Value="75" />
            <Setter Property="Background" Value="Transparent" />
            <Setter Property="Foreground" Value="White" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Border x:Name="grdMain" 
                                Padding="{TemplateBinding Padding}" 
                                Background="{TemplateBinding Background}" 
                                Width="{TemplateBinding Width}" 
                                Height="{TemplateBinding Height}" 
                                SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}">
                            <StackPanel Orientation="Horizontal" 
                                        HorizontalAlignment="Center">
                                <TextBlock Style="{StaticResource fa_angle_left}" 
                                           Margin="0 0 5 0" 
                                           VerticalAlignment="Center" 
                                           HorizontalAlignment="Center" />
                                <TextBlock Text="返回" 
                                           VerticalAlignment="Center" 
                                           HorizontalAlignment="Center" />
                            </StackPanel>
                        </Border>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        <Style.Triggers>
            <Trigger Property="IsPressed" Value="True">
                <Setter Property="Background" Value="#3FFF" />
            </Trigger>
        </Style.Triggers>
    </Style>
    </UserControl.Resources>
    
    <UserControl.Style>
        <Style TargetType="UserControl">
            <Style.Triggers>
                <DataTrigger Binding="{Binding IsShow}" Value="True">
                    <DataTrigger.EnterActions>
                        <StopStoryboard BeginStoryboardName="collapseStoryBoard" />
                        <BeginStoryboard x:Name="expandStoryBoard">
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetProperty="Height" 
                                                 BeginTime="00:00:00" 
                                                 From="0" 
                                                 To="718" 
                                                 DecelerationRatio="1" 
                                                 Duration="00:00:00.300"/>
                                <BooleanAnimationUsingKeyFrames BeginTime="00:00:00.300" 
                                                                Storyboard.TargetProperty="IsEnabled">
                                    <DiscreteBooleanKeyFrame Value="False" KeyTime="0:0:0" />
                                    <DiscreteBooleanKeyFrame Value="True" KeyTime="0:0:0.1" />
                                </BooleanAnimationUsingKeyFrames>
                            </Storyboard>
                        </BeginStoryboard>
                    </DataTrigger.EnterActions>
                    <DataTrigger.ExitActions>
                        <StopStoryboard  BeginStoryboardName="expandStoryBoard"/>
                        <BeginStoryboard x:Name="collapseStoryBoard">
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetProperty="Height" 
                                                 BeginTime="00:00:00" 
                                                 From="718" 
                                                 To="0" 
                                                 DecelerationRatio="1" 
                                                 Duration="00:00:00.300"/>
                                <BooleanAnimationUsingKeyFrames BeginTime="00:00:00.300" 
                                                                Storyboard.TargetProperty="IsEnabled">
                                    <DiscreteBooleanKeyFrame Value="False" KeyTime="0:0:0" />
                                </BooleanAnimationUsingKeyFrames>
                            </Storyboard>
                        </BeginStoryboard>
                    </DataTrigger.ExitActions>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </UserControl.Style>

    <Grid>
        <!--遮罩层-->
        <Grid Background="#9000"></Grid>

        <Button x:Name="backBtn" 
                Margin="0 10 0 0" 
                HorizontalAlignment="Left" 
                VerticalAlignment="Top" 
                Command="{Binding Minimize}" 
                Style="{StaticResource masklayerBackButton}" />

        <StackPanel VerticalAlignment="Center" 
                    HorizontalAlignment="Center">
            <Image Height="100" Width="100" 
                   HorizontalAlignment="Center" 
                   Source="/DATS.WasteWater.UI;component/Resources/Images/userIcon.jpg" 
                   Margin="0 0 0 50">
                <Image.Clip>
                    <GeometryGroup FillRule="Nonzero">
                        <EllipseGeometry RadiusX="50" RadiusY="50" Center="50,50" />
                    </GeometryGroup>
                </Image.Clip>
            </Image>

            <ContentControl x:Name="ctLoginName" 
                            Grid.Row="1" 
                            VerticalAlignment="Center" 
                            Height="40" 
                            Grid.Column="1" 
                            HorizontalAlignment="Stretch" 
                            Width="220" 
                            IsTabStop="False">
                <Border BorderBrush="Silver" 
                        BorderThickness="1" 
                        Background="White" 
                        Padding="10 0 2 0" 
                        CornerRadius="5">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="25"/>
                            <ColumnDefinition/>
                        </Grid.ColumnDefinitions>
                        <TextBlock Style="{StaticResource fa_account}" 
                                   Margin="0,0,5,0" 
                                   VerticalAlignment="Center" 
                                   HorizontalAlignment="Center" 
                                   FontSize="18" />
                        <TextBox x:Name="txtLoginName" 
                                 Grid.Column="1" 
                                 Text="{Binding LoginName}" 
                                 Style="{StaticResource TextBoxExtend}" 
                                 hc:InfoElement.Placeholder="用户名" 
                                 hc:InfoElement.Necessary="True" 
                                 HorizontalAlignment="Stretch" 
                                 FontSize="15" 
                                 VerticalContentAlignment="Center" 
                                 BorderThickness="0" 
                                 MaxLength="15" 
                                 TabIndex="1" 
                                 input:InputMethod.IsInputMethodEnabled="False" 
                                 IsEnabledChanged="txtLoginName_IsEnabledChanged"/>
                    </Grid>
                </Border>
            </ContentControl>

            <ContentControl x:Name="ctPassword" 
                            Grid.Row="2" 
                            VerticalAlignment="Center" 
                            Margin="10" 
                            Height="40" 
                            Grid.Column="1" 
                            HorizontalAlignment="Stretch" 
                            Width="220" 
                            IsTabStop="False">
                <Border BorderBrush="Silver" 
                        BorderThickness="1" 
                        Background="White" 
                        Padding="10 0 2 0" 
                        CornerRadius="5">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="25"/>
                            <ColumnDefinition/>
                        </Grid.ColumnDefinitions>
                        <TextBlock Style="{StaticResource fa_lock}" 
                                   Margin="0,0,5,0" 
                                   VerticalAlignment="Center" 
                                   HorizontalAlignment="Center" 
                                   FontSize="18" />
                        <PasswordBox x:Name="txtPassword" 
                                     Grid.Column="1" 
                                     Style="{StaticResource PasswordBoxExtend}" 
                                     hc:InfoElement.Placeholder="密码" 
                                     hc:InfoElement.Necessary="True" 
                                     HorizontalAlignment="Stretch" 
                                     FontSize="15" 
                                     VerticalContentAlignment="Center" 
                                     BorderThickness="0" 
                                     MaxLength="15" 
                                     TabIndex="2"
                                     ctrlib:PasswordBoxHelper.Attach="True" 
                                     ctrlib:PasswordBoxHelper.Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                    </Grid>
                </Border>
            </ContentControl>

            <Button Height="35" Content="登录" 
                    Style="{StaticResource ButtonPrimary}" 
                    FontSize="14" 
                    HorizontalAlignment="Center" 
                    Name="btnLogin" 
                    VerticalAlignment="Center" 
                    Width="220" 
                    Grid.Row="3" 
                    TabIndex="3" 
                    BorderBrush="DarkGray" 
                    FontWeight="Normal" 
                    Command="{Binding Login}" 
                    IsDefault="True" />
        </StackPanel>
    </Grid>
</UserControl>

设计时的效果如下,在运行的时候会铺满整个屏幕。
DATS.WasteWater登录界面设计时效果

2.3 调用

到这一步,我们已经实现了一套沉浸式的用户登录界面以及交互流程,在业务模块中只需要通过这几行代码即可调出登录界面。

await LoginViewModel.Current.WaitForLogin(() =>
{
    // 登录成功后执行的业务逻辑
});

三、增加背景高斯模糊效果

仔细的读者可能会注意到上面的效果图中,背景做了模糊效果。没错,就是这个样子。
登录界面静态效果
而刚才的代码并没有实现这么个效果。其实这个高斯模糊(也就是BlurEffect)并没有加在登录界面上,了解WPF的同学都清楚,在页面的根元素上加入BlurEffect会导致该节目整个都被模糊了。

因此,高斯模糊只能加在背景层上。我这边是通过StroyBoard在显示的时候逐步增加模糊程度,关闭的时候逐步减少模糊程序,实现了渐变的效果。对于用户来说,有一个平滑的过度,视觉上不会很突兀。

代码如下

<Grid x:Name="grdBody" Grid.Row="1">
    <Grid.Effect>
        <BlurEffect x:Name="bodyBlurEffect" Radius="0" RenderingBias="Performance" />
    </Grid.Effect>
    <Grid.Style>
        <Style TargetType="Grid">
            <Style.Triggers>
                <DataTrigger Binding="{Binding LoginViewModel.IsShow}" Value="True">
                    <DataTrigger.EnterActions>
                        <StopStoryboard BeginStoryboardName="unBlurStoryBoard" />
                        <BeginStoryboard x:Name="blurStoryBoard">
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetProperty="(Grid.Effect).(BlurEffect.Radius)" 
                                                 BeginTime="00:00:00" 
                                                 From="0" 
                                                 To="30" 
                                                 DecelerationRatio="1" 
                                                 Duration="00:00:00.300"/>
                            </Storyboard>
                        </BeginStoryboard>
                    </DataTrigger.EnterActions>
                    <DataTrigger.ExitActions>
                        <StopStoryboard BeginStoryboardName="blurStoryBoard" />
                        <BeginStoryboard x:Name="unBlurStoryBoard">
                            <Storyboard>
                                <DoubleAnimation Storyboard.TargetProperty="(Grid.Effect).(BlurEffect.Radius)" 
                                                 BeginTime="00:00:00" 
                                                 From="30" 
                                                 To="0" 
                                                 DecelerationRatio="1" 
                                                 Duration="00:00:00.300"/>
                            </Storyboard>
                        </BeginStoryboard>
                    </DataTrigger.ExitActions>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </Grid.Style>
    <Frame x:Name="frameHost" 
           Visibility="{Binding IsRealMonitorVisible, Converter={StaticResource booleanToUnVisibilityConverter}}" 
           Source="{Binding DataContext.CurrentUrl, ElementName=mainWindow, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
           NavigationUIVisibility="Hidden" 
           FocusVisualStyle="{x:Null}" 
           Background="{DynamicResource DefaultBrush}" />
    <Frame x:Name="frameRealTimeMonitor" 
           Visibility="{Binding IsRealMonitorVisible, Converter={StaticResource booleanToVisibilityConverter}}" 
           Source="/DATS.WasteWater.UI;component/Views/RealTimeMonitor/FactorViewPage.xaml" 
           NavigationUIVisibility="Hidden" 
           FocusVisualStyle="{x:Null}" 
           Background="{DynamicResource DefaultBrush}" />
</Grid>

四、尾声

到此为止,一个能配地上颜值担当称号的登录界面就完成了。本文的主要目的只是陈述下思路,贴的代码中还引用了第三方库,直接复制是没法运行的,而且暂时也没有抽取Demo的计划。

2020年6月13日星期六

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值