WPF中的验证错误样式,类似于Silverlight

64
默认情况下,WPF中的Validation.ErrorTemplate只是一个没有ToolTip的小红框。在Silverlight 4中,验证错误具有漂亮的已经预置好的样式。以下是在Silverlight 4WPF中发生验证错误的比较。 Silverlight 4
enter image description here
WPF
enter image description here 注意,在我看来,与Silverlight相比,WPF版本具有非常扁平、乏味的外观。是否存在类似于Silverlight版本的验证样式/模板或是否有人创建了精美的验证模板?还是必须从头开始创建?
如果有人想尝试它,请使用以下代码重现上述验证错误,适用于SilverlightWPF
<StackPanel Orientation="Horizontal" Margin="10" VerticalAlignment="Top">
    <TextBox Text="{Binding Path=TextProperty, Mode=TwoWay, ValidatesOnExceptions=True}"/>
    <Button Content="Tab To Me..." Margin="20,0,0,0"/>
</StackPanel>

MainWindow/MainPage.xaml.cs

public MainWindow/MainPage()
{
    InitializeComponent();
    this.DataContext = this;
}

private string _textProperty;
public string TextProperty
{
    get { return _textProperty; }
    set
    {
        if (value.Length > 5)
        {
            throw new Exception("Too many characters");
        }
        _textProperty = value;
    }
}

3
我非常希望能够纠正“To many characters”中的语法错误,但它在一张图片里面!真可恶!:P - Dan J
1
@dajcobson:哎呀,这里的语法错误太多了 :) 我会修复它。 - Fredrik Hedblad
4个回答

114

我研究了Silverlight版本的验证错误模板,并创建了一个WPF版本,它看起来像这样:

enter image description here
在文章底部添加了一个动画GIF,但完成后我注意到由于鼠标在其中移动,可能会让人感到烦恼。如果需要删除,请告诉我..:)

我使用了MultiBinding和BooleanOrConverter来实现当TextBox具有键盘焦点或鼠标悬停在右上角时显示“tooltip-error”的功能。为淡入动画,我使用了DoubleAnimation来控制Opacity以及ThicknessAnimation与BackEase/EaseOut EasingFunction来控制Margin。

可以像这样使用:

<TextBox Validation.ErrorTemplate="{StaticResource errorTemplateSilverlightStyle}" />

错误模板 Silverlight 样式

<ControlTemplate x:Key="errorTemplateSilverlightStyle">
    <StackPanel Orientation="Horizontal">
        <Border BorderThickness="1" BorderBrush="#FFdc000c" CornerRadius="0.7"
                VerticalAlignment="Top">
            <Grid>
                <Polygon x:Name="toolTipCorner"
                         Grid.ZIndex="2"
                         Margin="-1"
                         Points="6,6 6,0 0,0" 
                         Fill="#FFdc000c" 
                         HorizontalAlignment="Right" 
                         VerticalAlignment="Top"
                         IsHitTestVisible="True"/>
                <Polyline Grid.ZIndex="3"
                          Points="7,7 0,0" Margin="-1" HorizontalAlignment="Right" 
                          StrokeThickness="1.5"
                          StrokeEndLineCap="Round"
                          StrokeStartLineCap="Round"
                          Stroke="White"
                          VerticalAlignment="Top"
                          IsHitTestVisible="True"/>
                <AdornedElementPlaceholder x:Name="adorner"/>
            </Grid>
        </Border>
        <Border x:Name="errorBorder" Background="#FFdc000c" Margin="1,0,0,0"
                Opacity="0" CornerRadius="1.5"
                IsHitTestVisible="False"
                MinHeight="24" MaxWidth="267">
            <Border.Effect>
                <DropShadowEffect ShadowDepth="2.25" 
                                  Color="Black" 
                                  Opacity="0.4"
                                  Direction="315"
                                  BlurRadius="4"/>
            </Border.Effect>
            <TextBlock Text="{Binding ElementName=adorner,
                                      Path=AdornedElement.(Validation.Errors)[0].ErrorContent}"
                       Foreground="White" Margin="8,3,8,3" TextWrapping="Wrap"/>
        </Border>
    </StackPanel>
    <ControlTemplate.Triggers>
        <DataTrigger Value="True">
            <DataTrigger.Binding>
                <MultiBinding Converter="{StaticResource BooleanOrConverter}">
                    <Binding ElementName="adorner" Path="AdornedElement.IsKeyboardFocused" />
                    <Binding ElementName="toolTipCorner" Path="IsMouseOver"/>
                </MultiBinding>
            </DataTrigger.Binding>
            <DataTrigger.EnterActions>
                <BeginStoryboard x:Name="fadeInStoryboard">
                    <Storyboard>
                        <DoubleAnimation Duration="00:00:00.15"
                                         Storyboard.TargetName="errorBorder"
                                         Storyboard.TargetProperty="Opacity"
                                         To="1"/>
                        <ThicknessAnimation Duration="00:00:00.15"
                                            Storyboard.TargetName="errorBorder"
                                            Storyboard.TargetProperty="Margin"
                                            FillBehavior="HoldEnd"
                                            From="1,0,0,0"
                                            To="5,0,0,0">
                            <ThicknessAnimation.EasingFunction>
                                <BackEase EasingMode="EaseOut" Amplitude="2"/>
                            </ThicknessAnimation.EasingFunction>
                        </ThicknessAnimation>
                    </Storyboard>
                </BeginStoryboard>
            </DataTrigger.EnterActions>
            <DataTrigger.ExitActions>
                <StopStoryboard BeginStoryboardName="fadeInStoryboard"/>
                <BeginStoryboard x:Name="fadeOutStoryBoard">
                    <Storyboard>
                        <DoubleAnimation Duration="00:00:00"
                                         Storyboard.TargetName="errorBorder"
                                         Storyboard.TargetProperty="Opacity"
                                         To="0"/>
                    </Storyboard>
                </BeginStoryboard>
            </DataTrigger.ExitActions>
        </DataTrigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

BooleanOrConverter

public class BooleanOrConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        foreach (object value in values)
        {
            if ((bool)value == true)
            {
                return true;
            }
        }
        return false;
    }
    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

enter image description here


2
@Brent:没错,我自己从来没有注意到过,但很容易就能重现 :) 我通过在 errorBorder 中添加 IsHitTestVisible="False" 来解决它,但你的修改也可以。感谢更新。 - Fredrik Hedblad
4
如果您是个纯粹主义者,并希望避免绑定异常等问题,请使用以下内容以避免无谓的错误:Text="{Binding ElementName=adorner, Path=AdornedElement.(Validation.Errors).CurrentItem.ErrorContent}"。 - GorillaApe
5
如果传入的值无法转换为布尔型,例如传入的是DependencyProperty.UnsetValue,BooleanOrConverter将抛出异常。但是下面这段代码可以解决这个问题:public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { return values.OfType().Any(value => value); }它将返回一个布尔值,如果values数组中有任何一个值为true,则返回true,否则返回false。 - Kjetil Klaussen
1
可以通过使用一个弹出窗口和触发器来改进它,以便在用户将鼠标悬停在工具提示上时打开弹出窗口:<Popup x:Name="errorPopup" PopupAnimation="Fade" AllowsTransparency="True" Placement="Right">,并且使用<Setter TargetName="errorPopup" Property="IsOpen" Value="True"/>来打开弹出窗口。 - Steven Muhr
1
@FredrikHedblad,非常好,但是当控件位于极右时,错误消息有时会出现在视图区域外。如何从控件模板内部控制它,在这种情况下仅将错误消息显示到底部? - Furqan Safdar
显示剩余10条评论

38

本答案只是对Fredrik Hedblad的优秀答案进行了扩展。对于WPF和XAML的新手来说,Fredrik的回答为我定义了如何在应用程序中显示验证错误提供了一个跳板。虽然下面的XAML适用于我,但它仍在不断改进中。我还没有进行全面测试,并且我承认我无法完全解释每个标签。带着这些警告,我希望这对他人有所帮助。

虽然动画的TextBlock是一个很好的方法,但它有两个缺点需要解决。

  1. 首先,正如Brent的评论所指出的那样,文本受拥有窗口边界的限制,因此如果无效控件位于窗口边缘,则文本会被截断。 Fredrik建议的解决方案是将其显示“窗口外”。 这对我来说很有意义。
  2. 其次,将TextBlock显示在无效控件的右侧并不总是最佳选择。例如,假设TextBlock用于指定要打开的特定文件,并且其右侧有一个“浏览”按钮。 如果用户键入不存在的文件,则错误的TextBlock将覆盖“浏览”按钮,并可能防止用户单击它以更正错误。对我来说,有意义的是使错误消息沿着无效控件的右上方对角线显示。这样可以实现两个目的。首先,它避免隐藏无效控件右侧的任何伴随控件。它还具有视觉效果,即toolTipCorner会指向错误消息。

这是我的开发环境周围的对话框。

Basic Dialog

如您所见,有两个需要验证的 TextBox 控件。 两个控件都相对靠近窗口的右侧,因此长错误消息很可能会被裁剪。请注意第二个 TextBox 具有浏览按钮,我不希望在发生错误时隐藏该按钮。

这是我的实现方式显示验证错误的样子。

enter image description here

从功能上讲,它与 Fredrik 的实现非常相似。如果 TextBox 获得焦点,错误将可见。一旦失去焦点,错误消息消失。如果用户将鼠标悬停在 toolTipCorner 上,则无论是否聚焦到 TextBox,错误仍将出现。还有一些外观上的变化,例如 toolTipCorner 的大小增加了50%(从6像素增加到9像素)。

显而易见的区别,当然是我的实现使用了 Popup 来显示错误。这解决了第一个缺点,因为 Popup 在自己的窗口中显示其内容,因此不受对话框边界的限制。但是,使用 Popup 也带来了一些需要克服的挑战。

  1. 经过测试和在线讨论,Popup 被认为是最顶层的窗口。 因此,即使我的应用程序被另一个应用程序隐藏,Popup 仍然可见。 这是不太理想的行为。
  2. 另一个需要注意的问题是,如果用户在 Popup 可见时移动或调整对话框,Popup 不会重新定位自己以保持相对于无效控件的位置。

幸运的是,这两个问题都得到了解决。

以下是代码。 欢迎评论和改进!


  • 文件:ErrorTemplateSilverlightStyle.xaml
  • 命名空间:MyApp.Application.UI.Templates
  • 程序集:MyApp.Application.UI.dll
<ResourceDictionary
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
  xmlns:behaviors="clr-namespace:MyApp.Application.UI.Behaviors">

  <ControlTemplate x:Key="ErrorTemplateSilverlightStyle">
    <StackPanel Orientation="Horizontal">
      <!-- Defines TextBox outline border and the ToolTipCorner -->
      <Border x:Name="border" BorderThickness="1.25"
                              BorderBrush="#FFDC000C">
        <Grid>
          <Polygon x:Name="toolTipCorner"
                   Grid.ZIndex="2"
                   Margin="-1"
                   Points="9,9 9,0 0,0"
                   Fill="#FFDC000C"
                   HorizontalAlignment="Right"
                   VerticalAlignment="Top"
                   IsHitTestVisible="True"/>
          <Polyline Grid.ZIndex="3"
                    Points="10,10 0,0"
                    Margin="-1"
                    HorizontalAlignment="Right"
                    StrokeThickness="1.5"
                    StrokeEndLineCap="Round"
                    StrokeStartLineCap="Round"
                    Stroke="White"
                    VerticalAlignment="Top"
                    IsHitTestVisible="True"/>
          <AdornedElementPlaceholder x:Name="adorner"/>
        </Grid>
      </Border>
      <!-- Defines the Popup -->
      <Popup x:Name="placard"
             AllowsTransparency="True"
             PopupAnimation="Fade"
             Placement="Top"
             PlacementTarget="{Binding ElementName=toolTipCorner}"
             PlacementRectangle="10,-1,0,0">
        <!-- Used to reposition Popup when dialog moves or resizes -->
        <i:Interaction.Behaviors>
          <behaviors:RepositionPopupBehavior/>
        </i:Interaction.Behaviors>
        <Popup.Style>
          <Style TargetType="{x:Type Popup}">
            <Style.Triggers>
              <!-- Shows Popup when TextBox has focus -->
              <DataTrigger Binding="{Binding ElementName=adorner, Path=AdornedElement.IsFocused}"
                           Value="True">
                <Setter Property="IsOpen" Value="True"/>
              </DataTrigger>
              <!-- Shows Popup when mouse hovers over ToolTipCorner -->
              <DataTrigger Binding="{Binding ElementName=toolTipCorner, Path=IsMouseOver}"
                           Value="True">
                <Setter Property="IsOpen" Value="True"/>
              </DataTrigger>
              <!-- Hides Popup when window is no longer active -->
              <DataTrigger Binding="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, Path=IsActive}"
                           Value="False">
                <Setter Property="IsOpen" Value="False"/>
              </DataTrigger>
            </Style.Triggers>
          </Style>
        </Popup.Style>
        <Border x:Name="errorBorder"
                Background="#FFDC000C"
                Margin="0,0,8,8"
                Opacity="1"
                CornerRadius="4"
                IsHitTestVisible="False"
                MinHeight="24"
                MaxWidth="267">
          <Border.Effect>
            <DropShadowEffect ShadowDepth="4"
                              Color="Black"
                              Opacity="0.6"
                              Direction="315"
                              BlurRadius="4"/>
          </Border.Effect>
          <TextBlock Text="{Binding ElementName=adorner, Path=AdornedElement.(Validation.Errors).CurrentItem.ErrorContent}"
                     Foreground="White"
                     Margin="8,3,8,3"
                     TextWrapping="Wrap"/>
        </Border>
      </Popup>
    </StackPanel>
  </ControlTemplate>

</ResourceDictionary>


  • 文件名: RepositionPopupBehavior.cs
  • 命名空间: MyApp.Application.UI.Behaviors
  • 程序集: MyApp.Application.UI.dll

(注意:需要引用Expression Blend 4中的System.Windows.Interactivity程序集)

using System;
using System.Windows;
using System.Windows.Controls.Primitives;
using System.Windows.Interactivity;

namespace MyApp.Application.UI.Behaviors
{
    /// <summary>
    /// Defines the reposition behavior of a <see cref="Popup"/> control when the window to which it is attached is moved or resized.
    /// </summary>
    /// <remarks>
    /// This solution was influenced by the answers provided by <see href="https://stackoverflow.com/users/262204/nathanaw">NathanAW</see> and
    /// <see href="https://stackoverflow.com/users/718325/jason">Jason</see> to
    /// <see href="https://dev59.com/V3I-5IYBdhLWcg3w8dYQ">this</see> question.
    /// </remarks>
    public class RepositionPopupBehavior : Behavior<Popup>
    {
        #region Protected Methods

        /// <summary>
        /// Called after the behavior is attached to an <see cref="Behavior.AssociatedObject"/>.
        /// </summary>
        protected override void OnAttached()
        {
            base.OnAttached();
            var window = Window.GetWindow(AssociatedObject.PlacementTarget);
            if (window == null) { return; }
            window.LocationChanged += OnLocationChanged;
            window.SizeChanged     += OnSizeChanged;
            AssociatedObject.Loaded += AssociatedObject_Loaded;
        }

        void AssociatedObject_Loaded(object sender, RoutedEventArgs e)
        {
            //AssociatedObject.HorizontalOffset = 7;
            //AssociatedObject.VerticalOffset = -AssociatedObject.Height;
        }

        /// <summary>
        /// Called when the behavior is being detached from its <see cref="Behavior.AssociatedObject"/>, but before it has actually occurred.
        /// </summary>
        protected override void OnDetaching()
        {
            base.OnDetaching();
            var window = Window.GetWindow(AssociatedObject.PlacementTarget);
            if (window == null) { return; }
            window.LocationChanged -= OnLocationChanged;
            window.SizeChanged     -= OnSizeChanged;
            AssociatedObject.Loaded -= AssociatedObject_Loaded;
        }

        #endregion Protected Methods

        #region Private Methods

        /// <summary>
        /// Handles the <see cref="Window.LocationChanged"/> routed event which occurs when the window's location changes.
        /// </summary>
        /// <param name="sender">
        /// The source of the event.
        /// </param>
        /// <param name="e">
        /// An object that contains the event data.
        /// </param>
        private void OnLocationChanged(object sender, EventArgs e)
        {
            var offset = AssociatedObject.HorizontalOffset;
            AssociatedObject.HorizontalOffset = offset + 1;
            AssociatedObject.HorizontalOffset = offset;
        }

        /// <summary>
        /// Handles the <see cref="Window.SizeChanged"/> routed event which occurs when either then <see cref="Window.ActualHeight"/> or the
        /// <see cref="Window.ActualWidth"/> properties change value.
        /// </summary>
        /// <param name="sender">
        /// The source of the event.
        /// </param>
        /// <param name="e">
        /// An object that contains the event data.
        /// </param>
        private void OnSizeChanged(object sender, SizeChangedEventArgs e)
        {
            var offset = AssociatedObject.HorizontalOffset;
            AssociatedObject.HorizontalOffset = offset + 1;
            AssociatedObject.HorizontalOffset = offset;
        }

        #endregion Private Methods
    }
}
  • 文件:ResourceLibrary.xaml
  • 命名空间:MyApp.Application.UI
  • 程序集:MyApp.Application.UI.dll
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <ResourceDictionary.MergedDictionaries>

        <!-- Styles -->
        ...

        <!-- Templates -->
        <ResourceDictionary Source="Templates/ErrorTemplateSilverlightStyle.xaml"/>

    </ResourceDictionary.MergedDictionaries>

    <!-- Converters -->
    ...

</ResourceDictionary>


  • 文件名: App.xaml
  • 命名空间: MyApp.Application
  • 程序集: MyApp.exe

<Application x:Class="MyApp.Application.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="Views\MainWindowView.xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/MyApp.Application.UI;component/ResourceLibrary.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>


  • 文件名:NewProjectView.xaml
  • 命名空间:MyApp.Application.Views
  • 程序集:MyApp.exe

<Window x:Class="MyApp.Application.Views.NewProjectView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:views="clr-namespace:MyApp.Application.Views"
        xmlns:viewModels="clr-namespace:MyApp.Application.ViewModels"
        Title="New Project" Width="740" Height="480"
        WindowStartupLocation="CenterOwner">

  <!-- DATA CONTEXT -->
  <Window.DataContext>
    <viewModels:NewProjectViewModel/>
  </Window.DataContext>

  <!-- WINDOW GRID -->
  ...

  <Label x:Name="ProjectNameLabel"
         Grid.Column="0"
         Content="_Name:"
         Target="{Binding ElementName=ProjectNameTextBox}"/>
  <TextBox x:Name="ProjectNameTextBox"
           Grid.Column="2"
           Text="{Binding ProjectName,
                          Mode=TwoWay,
                          UpdateSourceTrigger=PropertyChanged,
                          ValidatesOnDataErrors=True}"
           Validation.ErrorTemplate="{StaticResource ErrorTemplateSilverlightStyle}"/>

  ...
</Window>

1
我喜欢这个,但是RepositionPopupBehavior在窗口状态更改(最大化/还原)时不起作用。我认为以与LocationChanged和SizeChanged事件相同的方式添加另一个事件处理程序会解决这个问题,但那也不起作用 :/ - Roman Reiner
有人在更改文本框中的值时遇到过“偏移一”的错误吗?比如我有一个零,那是验证错误,直到我输入“11”(1然后1),而不是“1”,错误框才会消失。 - MrEdmundo

3
我已经在其中一个项目中创建了自定义错误修饰器,以便在文本框下方显示带有错误消息的错误修饰器。您只需要在文本框默认样式中设置属性“Validation.ErrorTemplate”,并将其保存在应用程序资源中,以便它应用于应用程序中的所有文本框。

注意:我在此处使用了一些笔刷,请使用您自己想要的笔刷集替换它们以用于您的修饰器消息。也许这可以帮到您:

<Setter Property="Validation.ErrorTemplate">
              <Setter.Value>
                <ControlTemplate>
                  <StackPanel>
                    <!--TextBox Error template-->
                    <Canvas Panel.ZIndex="1099">
                      <DockPanel>
                        <Border BorderBrush="{DynamicResource HighlightRedBackgroundBrush}" BorderThickness="2" Padding="1" CornerRadius="3">
                          <AdornedElementPlaceholder x:Name="ErrorAdorner" />
                        </Border>
                      </DockPanel>
                      <Popup IsOpen="True" AllowsTransparency="True" Placement="Bottom" PlacementTarget="{Binding ElementName=ErrorAdorner}" StaysOpen="False">
                        <Border Canvas.Bottom="4"
                Canvas.Left="{Binding Path=AdornedElement.ActualWidth, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Adorner}}}"
                BorderBrush="{DynamicResource HighlightRedBackgroundBrush}"
                BorderThickness="1"
                Padding="4"
                CornerRadius="5"
                Background="{DynamicResource ErrorBackgroundBrush}">
                          <StackPanel Orientation="Horizontal">
                            <ContentPresenter Width="24" Height="24" Content="{DynamicResource ExclamationIcon}" />
                            <TextBlock TextWrapping="Wrap"
                   Margin="4"
                   MaxWidth="250"
                   Text="{Binding Path=AdornedElement.(Validation.Errors)[0].ErrorContent, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Adorner}}}" />
                          </StackPanel>
                        </Border>
                      </Popup>
                    </Canvas>
                  </StackPanel>
                </ControlTemplate>
              </Setter.Value>
            </Setter>

1
我们团队非常喜欢 Silverlight 版本的错误模板,所以我为 WPF 重新创建了它。无论如何还是谢谢! - Fredrik Hedblad
好的。很酷。那只是根据我们项目需求的一个样本模板。 - Rohit Vats
喜欢你在模板中的动画部分。 :) 给你的帖子点赞。我也会建议将其用于我的项目中。 - Rohit Vats
当文本框失去焦点后,再次获取焦点时,弹出窗口不会显示。 - YukiSakura

0
我在尝试将其应用于我正在开发的wpf项目时遇到了问题。如果您在运行该项目时遇到以下问题:
“System.Windows.Markup.XamlParseException”类型的异常在PresentationFramework.dll中发生,但未在用户代码中处理
您需要在资源中创建booleanOrConverter类的实例(在app.xaml中):
<validators:BooleanOrConverter x:Key="myConverter" />

同时不要忘记在文件顶部(在应用程序标签中)添加命名空间:

xmlns:validators="clr-namespace:ParcelRatesViewModel.Validators;assembly=ParcelRatesViewModel"


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接