Material Design In XAML 中对话框实际使用中遇到的两个问题及解决办法
作者:互联网
接上一篇
如果使用Material Design In XAML中的对话框进行表单提交,那么可能会遇到以下几个问题。
- 使用对话框提交表单时,提交按钮可能会误触(多次点击),导致表单重复点击,多次提交。(我的客户端通过WebAPI访问服务端。)
- 在对话框中放置文本框TextBox,输入法跟随的问题IME。
- 涉及内容修改的时候,直接修改内容,不点击提交而是直接关闭对话框,导致原内容变化。(引用类型导致的)。
先说明一下我的代码结构,为重用代码和统一样式风格,将对话框的通用UI部分抽象为统一的样式文件,在样式文件中添加ControlTemplate,将ViewModel中相似的功能抽象为基类,配合样式文件进行使用。
对话框继承自对话框基类,对话框基类继承于全局的基类,下面基类内容进行适当删减,但不影响使用。
全局基类
using System; using System.ComponentModel; using MaterialDesignThemes.Wpf; using Prism.Events; using Prism.Ioc; using Prism.Modularity; using Prism.Mvvm; using Prism.Regions; using TXCE.TrainEarlyWaringClient.Common.Identity; namespace TXCE.TrainEarlyWaringClient.Common.ViewModels { public class BaseViewModel : BindableBase { protected BaseViewModel(IContainerExtension container) { ContainerExtension = container; EventAggregator = container.Resolve<IEventAggregator>(); GlobalMessageQueue = container.Resolve<ISnackbarMessageQueue>(); RegionManager = container.Resolve<IRegionManager>(); ModuleManager = container.Resolve<IModuleManager>(); } public BaseViewModel() { } /// <summary> /// 已授权客户端连接 /// </summary> public static System.Net.Http.HttpClient AuthClient => AuthHttpClient.Instance;/// <summary> /// 全局消息队列 /// </summary> public ISnackbarMessageQueue GlobalMessageQueue { get; set; } /// <summary> /// 事件汇总器,用于发布或订阅事件 /// </summary> protected IEventAggregator EventAggregator { get; } /// <summary> /// 区域管理器 /// </summary> protected IRegionManager RegionManager { get; } /// <summary> /// 模块管理器 /// </summary> protected IModuleManager ModuleManager { get; } /// <summary> /// 依赖注入容器,包括注册和获取 /// </summary> protected IContainerExtension ContainerExtension { get; } } }
对话框基类
public class BaseDialogViewModelEntity<T> : BaseViewModel where T : ValidateModelBase, new() { public ISnackbarMessageQueue DialogMessageQueue { get; set; } private T _voEntity; public T VoEntity { get => _voEntity; set => SetProperty(ref _voEntity, value); } private bool _isEnabled; public bool IsEnabled { get => _isEnabled; set => SetProperty(ref _isEnabled, value); } public DelegateCommand SubmitCommand => new(() => Submit(), CanSubmit); public BaseDialogViewModelEntity(IContainerExtension container) : base(container) { DialogMessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(2)); VoEntity = new T(); IsEnabled = true; VoEntity.PropertyChanged -= VoEntity_PropertyChanged; VoEntity.PropertyChanged += VoEntity_PropertyChanged; } private void VoEntity_PropertyChanged(object sender, PropertyChangedEventArgs e) => IsEnabled = true; public async virtual Task<bool> Submit() { IsEnabled = false; if (VoEntity.IsValidated) return true; DialogMessageQueue.Enqueue(AlertConstText.InputError); return false; } private bool CanSubmit() => IsEnabled; }
对话框基类中继承的泛型T所继承的ValidateModelBase,将在下一篇说明,这个涉及数据验证。
对话框的统一样式文件
<converters:PackIconKindConverter x:Key="IconKindConverter"/> <Style x:Key="UserControlDialog" TargetType="UserControl"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type UserControl}"> <Border BorderBrush="{TemplateBinding BorderBrush}"> <AdornerDecorator> <Grid> <Grid Margin="24 16"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="8"/> <RowDefinition Height="Auto"/> <RowDefinition Height="24"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <StackPanel Grid.Row="0" HorizontalAlignment="Left" VerticalAlignment="Center" Focusable="False" Orientation="Horizontal"> <materialDesign:PackIcon Kind="{TemplateBinding Tag,Converter={StaticResource IconKindConverter}}" Width="20" Height="20"/> <TextBlock FontSize="15" FontWeight="Bold" Margin="8 0 0 0" Text="{TemplateBinding Name}" Foreground="{DynamicResource PrimaryHueMidBrush}"/> </StackPanel> <Button Grid.Row="0" HorizontalAlignment="Right" VerticalAlignment="Center" IsEnabled="{Binding IsCanClose,FallbackValue=True}" Style="{StaticResource MaterialDesignToolButton}" Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"> <materialDesign:PackIcon Kind="WindowClose"/> </Button> <ContentPresenter Grid.Row="2"> <b:Interaction.Triggers> <b:EventTrigger EventName="GotFocus"> <styles:GotFocusTrigger/> </b:EventTrigger> </b:Interaction.Triggers> </ContentPresenter> <Button Grid.Row="4" x:Name="Button" Content="确 定" Width="160" IsDefault="True" VerticalAlignment="Bottom" Command="{Binding SubmitCommand}" IsEnabled="{Binding IsEnabled,UpdateSourceTrigger=PropertyChanged,Mode=TwoWay}"> <b:Interaction.Triggers> <b:EventTrigger EventName="PreviewMouseDown"> <styles:DisEnableTrigger/> </b:EventTrigger> </b:Interaction.Triggers> </Button> </Grid> <materialDesign:Snackbar MessageQueue="{Binding DialogMessageQueue}" /> </Grid> </AdornerDecorator> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
其中用到的转换器类
using System; using System.Globalization; using System.Windows.Data; using MaterialDesignThemes.Wpf; namespace TXCE.TrainEarlyWaringClient.Common.Converters { public class PackIconKindConverter:IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return value is not PackIcon packIcon ? PackIconKind.Abacus : packIcon.Kind; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { return null; } } }
其中用到的自定义行为触发器类
class DisEnableTrigger : TriggerAction<DependencyObject> { protected override void Invoke(object parameter) { if (((RoutedEventArgs)parameter).Source is not Button button) return; if (button.IsEnabled) { button.Command.Execute(null); } button.IsEnabled = false; } }
这个自定义行为触发器,对应模板中的淡蓝色部分,如果对话框中没有文本框输入或者不在意输入法跟随的问题,可以使用上边这个办法,可以更方便的实现全局的防误触操作。
class GotFocusTrigger : TriggerAction<DependencyObject> { protected override void Invoke(object parameter) { if (((RoutedEventArgs)parameter).OriginalSource is TextBox textBox) { var popuxEx = textBox.TryFindParent<PopupEx>(); var source = (HwndSource)PresentationSource.FromVisual(popuxEx.Child); if (source != null) { SetFocus(source.Handle); textBox.Focus(); } } } [DllImport("User32.dll")] private static extern IntPtr SetFocus(IntPtr hWnd); }
如果对话框中有文本框,需要上述方法来实现所有对话框的输入法问题,不必每个对话框单独处理。但是如果两个扩展全部使用,则会因为焦点导致冲突,因此需要修改ViewModel中命令的方法,在业务代码执行前,先设置Button.IsEnable绑定的属性IsEnable为false。或者像本示例中在基类中进行处理,此时需要将样式文件中蓝色部分删除或者注释,然后删除DisEnableTrigger类。
其中用到的扩展方法
using System.Windows; using System.Windows.Media; namespace TXCE.TrainEarlyWaringClient.Common.Extensions { public static class DependencyObjectExtensions { public static T TryFindParent<T>(this DependencyObject child) where T : DependencyObject { while (true) { DependencyObject parentObject = child.GetParentObject(); if (parentObject == null) return null; if (parentObject is T parent) return parent; child = parentObject; } } public static DependencyObject GetParentObject(this DependencyObject child) { if (child == null) return null; if (child is ContentElement contentElement) { DependencyObject parent = ContentOperations.GetParent(contentElement); if (parent != null) return parent; return contentElement is FrameworkContentElement fce ? fce.Parent : null; } var childParent = VisualTreeHelper.GetParent(child); if (childParent != null) { return childParent; }
if (child is FrameworkElement frameworkElement) { DependencyObject parent = frameworkElement.Parent; if (parent != null) return parent; } return null; } } }
以上就是解决第一个问题和第二个问题的处理方式,下边将说明我首先想到的办法以及为什么没有这么做的原因。
- 点击确定按钮后,触发Click事件,在事件中设置按钮的IsEnable为false。因为所有对话框应用了全局样式,按钮是在ControlTemplate中定义的,因此没有办法使用这种解决办法。
- 在xaml中使用x:code,在xaml中编写C#方法,通过事件将按钮的IsEnable设置为false。因为样式文件中没有x:class,因此也无法使用x:code。
- 输入法无法跟随的原因是因为WPF框架的一个Bug,或者人家就是这么设计的,原因是Material Design In XAML中的对话框本质是一个Popup,Popup不能直接获取焦点,而只能通过父窗口打开,输入法输入的时候,焦点并没有在对话框中,为解决这个问题,需要强制为对话框设置焦点,然后将焦点定位到其中需要输入的文本框上,以此解决输入法跟随的问题。
这个问题的解决办法参考自这篇文章:WPF 弹出 popup 里面的 TextBox 无法输入汉字
现在全部铺垫已经完成,现在来看如何去使用上述方法,打开一个对话框。
首先新建一个UserControl文件,在其中输入以下代码,创建一个新增角色信息的对话框。
<UserControl x:Class="TXCE.TrainEarlyWarningClient.SystemManagement.Dialogs.RoleManagement.Views.RoleAdd" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" prism:ViewModelLocator.AutoWireViewModel="True" Style="{StaticResource UserControlDialog}" x:Name="新增角色信息" Tag="{materialDesign:PackIcon ApplicationCog}"> <Grid> <Grid.RowDefinitions> <RowDefinition/> <RowDefinition Height="16"/> <RowDefinition/> <RowDefinition Height="16"/> <RowDefinition/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition/> <ColumnDefinition Width="8"/> <ColumnDefinition/> </Grid.ColumnDefinitions> <Grid.Resources> <Style TargetType="TextBlock"> <Setter Property="VerticalAlignment" Value="Center"/> <Setter Property="HorizontalAlignment" Value="Right"/> <Setter Property="Foreground" Value="Black"/> </Style> <Style TargetType="TextBox" BasedOn="{StaticResource TextBoxStyleValidationVertical}"> <Setter Property="HorizontalAlignment" Value="Left"/> </Style> </Grid.Resources> <TextBlock Grid.Row="0" Grid.Column="0" Text="角色名称:"/> <TextBox Grid.Row="0" Grid.Column="2" Width="296" Text="{Binding Path=VoEntity.Name,UpdateSourceTrigger=PropertyChanged,ValidatesOnDataErrors=True}"/> <TextBlock Grid.Row="2" Grid.Column="0" Text="备注:"/> <TextBox Grid.Row="2" Grid.Column="2" Width="296" TextWrapping="Wrap" MaxHeight="80" Text="{Binding Path=VoEntity.Remark,UpdateSourceTrigger=PropertyChanged,ValidatesOnDataErrors=True}"/> <TextBlock Grid.Row="4" Grid.Column="0" Text="授权菜单:"/> <ScrollViewer Grid.Row="4" Grid.Column="2" MaxHeight="240"> <TreeView ItemsSource="{Binding VoEntity.Menus}" PreviewMouseWheel="TreeView_OnPreviewMouseWheel"> <TreeView.ItemTemplate> <HierarchicalDataTemplate ItemsSource="{Binding Children, Mode=OneTime}"> <StackPanel Orientation="Horizontal"> <CheckBox Focusable="False" VerticalAlignment="Center" IsChecked="{Binding IsChecked}" Click="CheckBox_OnClick"/> <ContentPresenter Content="{Binding Name, Mode=OneTime}" Margin="2,0"/> </StackPanel> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> </ScrollViewer> </Grid> </UserControl>
在该UserControl中应用之前定义的对话框样式,并在通过x:Name和Tag传递对话框标题和图标。最后通过Prism首先View和ViewModel的绑定。
然后新建一个ViewModel文件
using System; using System.Threading.Tasks; using MaterialDesignThemes.Wpf; using Prism.Ioc; using Refit; using TXCE.TrainEarlyWaringClient.Common.Extensions; using TXCE.TrainEarlyWaringClient.Common.ViewModels; using TXCE.TrainEarlyWaringClient.Common.VO; using TXCE.TrainEarlyWarningClient.SystemManagement.ApiServer; using TXCE.TrainEarlyWarningClient.SystemManagement.Mapper.RoleManagement; using TXCE.TrainEarlyWarningClient.SystemManagement.VO.RoleManagement; namespace TXCE.TrainEarlyWarningClient.SystemManagement.Dialogs.RoleManagement.ViewModels { public class RoleAddViewModel : BaseDialogViewModelEntity<RoleInfo> { private readonly IRoleServer _roleServer; public RoleAddViewModel(IContainerExtension container) : base(container) { _roleServer = RestService .For<IRoleServer>(AuthClient, new RefitSettings(new NewtonsoftJsonContentSerializer())); InitTreeView(); } #region Method 方法 private async void InitTreeView() { var result = await _roleServer.GetMenu().RunApiForDialog(DialogMessageQueue); if(result is { Count: > 0 }) Menu.CreateRoleMenus(result, VoEntity.Menus); } public override async Task<bool> Submit() { var isValidated = await base.Submit(); if (isValidated) { var menus = Menu.GetCheckedMenus(VoEntity.Menus); VoEntity.Id = Guid.Empty.ToString(); var result = await _roleServer.Add(VoEntity.MapToRoleInfoDto(menus)).RunApiForDialog(DialogMessageQueue, true); if (result) DialogHost.CloseDialogCommand.Execute(true, null); } return isValidated; } #endregion } }
该文件中只需要关注基类的继承,其他的可以暂时无需关注,我们可以看到这里使用了上一节中说到的通过代码关闭对话框的一种方法。
最后在对话款所在页面的ViewModel通过代码打开该对话框。因为每个页面也对共用方法进行了抽线,因此看到的方法中包括虚方法的重写。
protected override async void Add()=> await DialogHost.Show(new RoleAdd(), DialogNames.RootDialog,RefreshClosingEventHandler);
RefreshClosingEventHandler在基类中实现,采用上一节中说的通过eventArgs获取返回值的方法拿到返回的结果。
protected virtual void RefreshClosingEventHandler(object sender, DialogClosingEventArgs eventArgs) { if (eventArgs.Parameter is true) GetByPage(PageModel.PageIndex); }
最后打开的页面效果如下:
到此为止,前两个问题的解决办法就已经全部完毕了。因为结合了我的具体实现,因此看起来稍显复杂,其中核心就是设置焦点,通过自定义行为触发器实现全局应用,最后通过在子类中判断基类方法实现防止多次点击。
因为添加完毕以后采用的是
在继续下边一个话题前,还有一个问题需要说明,如果没有使用过Prism,可能会对对话框基类中的
VoEntity.PropertyChanged += VoEntity_PropertyChanged;
存在疑惑,这是事件来源于Prism中实现INotifyPropertyChanged接口的一个类BindableBase,用于实现属性的变更通知,为了避免疑惑,将这个类的源码也贴在这里。
using System; using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; namespace Prism.Mvvm { /// <summary> /// Implementation of <see cref="INotifyPropertyChanged"/> to simplify models. /// </summary> public abstract class BindableBase : INotifyPropertyChanged { /// <summary> /// Occurs when a property value changes. /// </summary> public event PropertyChangedEventHandler PropertyChanged; /// <summary> /// Checks if a property already matches a desired value. Sets the property and /// notifies listeners only when necessary. /// </summary> /// <typeparam name="T">Type of the property.</typeparam> /// <param name="storage">Reference to a property with both getter and setter.</param> /// <param name="value">Desired value for the property.</param> /// <param name="propertyName">Name of the property used to notify listeners. This /// value is optional and can be provided automatically when invoked from compilers that /// support CallerMemberName.</param> /// <returns>True if the value was changed, false if the existing value matched the /// desired value.</returns> protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null) { if (EqualityComparer<T>.Default.Equals(storage, value)) return false; storage = value; RaisePropertyChanged(propertyName); return true; } /// <summary> /// Checks if a property already matches a desired value. Sets the property and /// notifies listeners only when necessary. /// </summary> /// <typeparam name="T">Type of the property.</typeparam> /// <param name="storage">Reference to a property with both getter and setter.</param> /// <param name="value">Desired value for the property.</param> /// <param name="propertyName">Name of the property used to notify listeners. This /// value is optional and can be provided automatically when invoked from compilers that /// support CallerMemberName.</param> /// <param name="onChanged">Action that is called after the property value has been changed.</param> /// <returns>True if the value was changed, false if the existing value matched the /// desired value.</returns> protected virtual bool SetProperty<T>(ref T storage, T value, Action onChanged, [CallerMemberName] string propertyName = null) { if (EqualityComparer<T>.Default.Equals(storage, value)) return false; storage = value; onChanged?.Invoke(); RaisePropertyChanged(propertyName); return true; } /// <summary> /// Raises this object's PropertyChanged event. /// </summary> /// <param name="propertyName">Name of the property used to notify listeners. This /// value is optional and can be provided automatically when invoked from compilers /// that support <see cref="CallerMemberNameAttribute"/>.</param> protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); } /// <summary> /// Raises this object's PropertyChanged event. /// </summary> /// <param name="args">The PropertyChangedEventArgs</param> protected virtual void OnPropertyChanged(PropertyChangedEventArgs args) { PropertyChanged?.Invoke(this, args); } } }
好像又好长了,最后一个问题简单点说吧。
使用情景,在datagrid中有一个编辑按钮,可以对该行数据进行修改提交,但是改了一半,发现改错了,直接关闭对话框而没有点提交按钮,因为是直接将datagrid中的对象直接传递到了对话框,由于引用类型传递的特点,在关闭后会导致datagrid中的内容也跟着变了。
为了解决这个问题,需要在VoEntity中实现ICloneable接口并实现其中的Clone方法
public object Clone() => MemberwiseClone();
然后为所有编辑对话框的ViewModel创建一个新的基类
public class BaseDialogViewModelEntity<T, T1> : BaseViewModel where T : ValidateModelBase, IChecked<T1>, ICloneable,new() { public ISnackbarMessageQueue DialogMessageQueue { get; set; } private T _voEntity; public T VoEntity { get => _voEntity; set => SetProperty(ref _voEntity, value); } private bool _isEnabled; public bool IsEnabled { get => _isEnabled; set => SetProperty(ref _isEnabled, value); } public DelegateCommand SubmitCommand => new(()=>Submit(), CanSubmit); public BaseDialogViewModelEntity(IContainerExtension container) : base(container) { DialogMessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(2)); VoEntity = new T(); IsEnabled = true; } private void VoEntity_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) => IsEnabled = true; public async virtual Task<bool> Submit() { IsEnabled = false; if (VoEntity.IsValidated) return true; DialogMessageQueue.Enqueue(AlertConstText.InputError); return false; } private bool CanSubmit() => IsEnabled; public virtual void TransferParameters(T param) { if (param == null || param.Id.ToString().IsNullOrWhiteSpace()) return; VoEntity = (T)param.Clone(); VoEntity.PropertyChanged -= VoEntity_PropertyChanged; VoEntity.PropertyChanged += VoEntity_PropertyChanged; } }
请忽略其中的IChecked自定义接口。
在进行数据传递的时候使用Clone方法进行传递即可解决这个问题。
在数据传递时并没有使用上一节中所说的几种方法,而是在基类中定义了TransferParameters方法,具体打开时传递参数的方法如下,后来才发现这也是WPF编程宝典中所提及的一种方法。
protected override async void Edit(RoleInfo roleInfo) { var roleEdit = new RoleEdit(); roleInfo.Menus.Clear(); ((RoleEditViewModel)roleEdit.DataContext).TransferParameters(roleInfo); await DialogHost.Show(roleEdit, DialogNames.RootDialog,RefreshClosingEventHandler); }
文章有点长,写的也比较乱,期待与大家交流。
下一篇将对本文中所说的数据验证实现进行说明。
涉及使用软件框架版本如下:
Mvvm框架 Prism8+
UI框架MaterialDesignThemes4.4.0
Net版本 5.0
标签:return,对话框,VoEntity,XAML,Material,value,using,public 来源: https://www.cnblogs.com/IRisingStar/p/16126211.html