# 前言

之前本来是跟 开发一个批量图片处理工具 写一块的,用于记录使用 WPF 开发那个工具时,过程中碰到的各种问题,后来发现越记录越多,还是新开一个文档记录了。

# 问题记录

说实话,UI 问题反而是最多的

# 枚举文件

使用 Directory.EnumerateFilesDirectory.GetFiles 更好,包括内存及可开始处理的时间,因为前者通过迭代器返回,后者必须一次性全部搜集完成才能返回值。

例如目录下有 1000 个文件,我们实际上只需要取其中一个,枚举的方式就可以在取得之后直接返回,避免多余的消耗。

更多测试结果见:Batch-Processing-with-Directory-EnumerateFiles

需要注意的是使用 SearchOption 会导致把无权限的文件也取出来,因此最好是配合 EnumerationOptions 使用。

# searchPattern 不支持多个匹配

由于图片格式是多样的,第一次碰到需要过滤多个后缀文件的情况,正常情况下 *.jpg 这种就行了。

以前一直以为可以用: *.jpg||*.png —— 实际上并不支持这种功能,要真传入这种格式直接就抛异常了,网上竟然还有说可以用这种方式的文章,不清楚怎么回事。

需要自己实现,例如:

foreach (var pattern in Filter)
{
    foreach (var item in Directory.EnumerateFiles(dirPath, pattern, SearchOption.AllDirectories))
    {
        return item;
    }
}

# WPF .NetCore 如何调取打开目录界面?

.net core 3.1 WPF 使用 FolderBrowserDialog 对象打开文件资源管理器选择文件夹

项目增加 <UseWindowsForms>true</UseWindowsForms> 引用后,按照正常代码使用 FolderBrowserDialog 即可:

System.Windows.Forms.FolderBrowserDialog folderDialog = new System.Windows.Forms.FolderBrowserDialog();
if (folderDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
    TexturesOriginPathText.Text = folderDialog.SelectedPath;
}

打开文件则直接调用 OpenFileDialog

Microsoft.Win32.OpenFileDialog fileDialog = new Microsoft.Win32.OpenFileDialog();
if ((bool)fileDialog.ShowDialog())
{
    TexturesOriginPathText.Text = fileDialog.FileName;
}

# 降低 ToolTip 显示时间

在目标前添加 ToolTipService.InitialShowDelay="0"

且 ToolTip 可以支持多行:

<Label.ToolTip>
    <StackPanel Orientation="Vertical">
        <TextBlock Text="1"/>
        <TextBlock Text="2"/>
        <TextBlock Text="3"/>
    </StackPanel>
</Label.ToolTip>

# 普通组件也是可以添加事件的

开始想将 Button 表现上设置成 Text 的样子,结果很麻烦,各种自定义样式。后来发现 Text 实际上就可以直接添加点击事件...

# 窗口右上角按钮

例如去掉最大化功能,在 Title 添加: ResizeMode="CanMinimize"

# 限制只能输入数字

TextBox 使用 PreviewTextInput="OnPreviewTextInputLimitNumber 事件:

private void OnPreviewTextInputLimitNumber(object sender, TextCompositionEventArgs e)
{
	e.Handled = !int.TryParse(e.Text, out int x);
}

不过这种方式只能限制英文键盘,对中文键盘就无效了... 只能说聊胜于无,这种简单的功能 WPF 都没提供吗?总感觉以前写界面的时候好像也用过,难道记错了。


最后在做输入改变时,在对应事件给代码的长宽赋值时,发现其实直接在 TextChanged 事件处理一次也行,例如不合法直接清空:

/// <summary>
/// 检测 TextBox 输入是否是纯数字
/// </summary>
/// <param name="text"></param>
private int CheckTextNumberInput(TextBox text)
{
    int height;
    if (int.TryParse(ScaleHeight.Text, out height))
    {
        return height;
    }
    else
    {
        if (!string.IsNullOrEmpty(text.Text))
            MessageBox.Show("请输入数字!", "输入不合法", MessageBoxButton.OK, MessageBoxImage.Error);
        ScaleHeight.Clear();
    }
    return height;
}

注:期间还发现有个 InputScope="Number" 的属性,然而其实完全没什么用,不是限制输入内容的。

# 检测目录权限

.NetCore 可以通过如下方式调用 GetAccessControl :

System.Security.AccessControl.DirectorySecurity ds = new DirectoryInfo(folderDialog.SelectedPath).GetAccessControl();

主要是因为 Directory.EnumerateFiles 枚举文件时,会把无权限的隐藏文件也给枚举出来,结果这个安全检测也不好用,改成 EnumerationOptions 去取文件了。

# BitmapImage 不支持重复利用

只能初始化一次,调用已添加过图片的 BeginInit 会直接抛出异常,也就是每次显示图片都必须重新创建一个对象。

同时 BitmapImage.StreamSource 对象被释放也会导致 Image 显示不出来。

# UI 特性问题多

问题真的很多,简单使用还行,要实现复杂的 UI 效果,查半天都没个能用的...

比如数据绑定,我想把一个 TextBox 组件与类中的一个变量绑定起来,使得修改 TextBox 值对应相当于修改变量 —— 我 Unity 直接写,这都是些啥跟啥,查出来的使用方式也都是要么过时、要么根本不能用的 (虽然也可能跟我版本是 WPF .NetCore 有关)。

感觉可能需要把流程先学习下,直接上手撸,做简单功能还行,想做点高级的特性就力不从心了。

下次要么就把这东西学习流程走一遍再说,要么用 WinForm,做点小东西 WPF 感觉比不上 WinForm 效率好。记得当年用 WinForm 写 Demo 速度刷刷的。


# 与代码双向数据绑定

昨晚回家看了下系统教程,数据与自定义类中的字段做双向 数据绑定 其实很简单,而且还直接支持 TextBox数值 绑定,绑定后虽然不会做输入检测,不过不合法会直接在输入框外显示 红框

  1. 首先为 DataContext 赋值
DataContext = _helper.ConvertData;
  1. ConvertData 实现 System.ComponentModel.INotifyPropertyChanged 接口:
public int TestWidth
        {
            get
            {
                return Width;
            }
            set
            {
                Width = value;
                PropertyChanged.Invoke(this, new PropertyChangedEventArgs("TestWidth"));
            }
        }
		private event PropertyChangedEventHandler PropertyChanged;
        event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
        {
            add
            {
                PropertyChanged += value;
            }
            remove
            {
                PropertyChanged -= value;
            }
        }
  1. Xaml 处直接绑定
<TextBox Name="ScaleWidth" Text="{Binding TestWidth}" Width="80"  VerticalContentAlignment="Center" PreviewTextInput="OnPreviewTextInputLimitNumber" InputScope="Number" TextChanged="ScaleWidth_TextChanged"/>

感觉还是挺好用的,了解了方法后,像之前通过主动的事件绑定实现的方式,就可以直接改成数据绑定 + 通知的模式了,而且感觉确实方便不少。
于是简单重构了一波代码,分离成 数据+Controller+界面 这种形式。

# 格式化绑定

TextBox Text 使用 StringFormat
Label Content 使用 ContentStringFormat

多数据源绑定:

<MultiBinding StringFormat="{}啦啦啦{0}x啦啦啦{1}啦啦啦}">
	<Binding Path="Width"/>
	<Binding Path="Height"/>
</MultiBinding>

但是默认提供的只支持 TextBox,其它的类型得实现 IMultiValueConverter 来做。

步骤如下:

  1. 定义一个实现了 IMultiValueConverter 接口的类:
/// <summary>
    /// 支持非 Text 的多数据源绑定
    /// </summary>
    public class MultiContentConverter : IMultiValueConverter
    {
        object IMultiValueConverter.Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            try
            {
                return string.Format(parameter.ToString(), values);
            }
            catch (Exception ex)
            {
                return ex.Message;
            }
        }
        object[] IMultiValueConverter.ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
  1. MainWindow.xaml 顶部定义 Window.Resources :
<Window.Resources>
        <local:MultiContentConverter x:Key="MultiContentConverter" />
    </Window.Resources>
  1. 通过 MultiBindingConverter(转换器)ConverterParameter(参数) 指定:
<Label>
    <MultiBinding  Converter="{StaticResource MultiContentConverter}" ConverterParameter="啦啦啦{0}x啦啦啦{1}啦啦啦">
        <Binding Path="Width"/>
        <Binding Path="Height"/>
    </MultiBinding>
</Label>

# 与组件数据绑定

与父节点绑定:

Width="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=DockPanel}, Path=ActualWidth}"

与指定 Name 的节点绑定:

Width="{Binding ElementName=ScaleWidth,Path=ActualWidth}

# 数据绑定中进行计算

这个需要用到一个插件: CalcBinding

直接 NuGet 搜索就有了,有了这个就可以直接在绑定中进行计算,方便了很多。

下载后,在 MainWindow.xaml 最前面加上 xmlns:c="clr-namespace:CalcBinding;assembly=CalcBinding" ,然后就可以使用 c:Binding 进行计算了。

例如上面组件绑定方法,如果想取自身宽度为父容器的一半,就可以这样写:

<GroupBox Header="原图"  Width="{c:Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=DockPanel}, Path=ActualWidth/2-10}" BorderBrush="Black" Margin="5,5,5,5" BorderThickness="1,1,1,1" >

另外它还支持直接 Bool 变量转 Visibility ,例如我这有个需求是:CheckBox 勾选后隐藏另外一个组件,否则显示,就可以这样实现:

<CheckBox Name="StayInputFormatToggle" IsChecked="True" Content="保留原格式" Height="26" ToolTipService.InitialShowDelay="0" ToolTip="在处理完毕后,保存的图片与输入格式保持一致。例如修改前是 *.png,修改后也是 *.png。" VerticalAlignment="Center" VerticalContentAlignment="Center"/>
<ComboBox Name="OutputFormatComboBox" Visibility="{c:Binding ElementName=StayInputFormatToggle,Path=!IsChecked,FalseToVisibility=Collapsed}" Width="70" Margin="5,0,0,0" VerticalContentAlignment="Center"/>

更多操作可以看这篇文章,写得挺不错的:在绑定表达式添加计算

# 激活最小化后的窗口

我增加了一个新窗口用于显示『更大』的图片对比效果,这个新界面只有一个作用:左边显示原图、右边显示修改后的图。

然后有个需求就是:没有打开的情况下,直接创建新窗口打开,不过已经打开的情况下,点击按钮应该是将这个界面激活至最上层。

然后就发现正常情况下 Window.Activate() 可以正常激活,但要是窗口已经被最小化了,那么就不行。

查了下是 WindowState 参数问题,最简单是激活前先设置为 WindowState= WindowState.Normal 即可。

不过这样的话,如果之前是最大化状态,重新激活后就会还原。

要是想记录原先的状态,则可以通过注册 Window.StateChanged 简单这样做:

_imageCompareView.StateChanged += (o, x) => _imageCompareViewState = (o as Window).WindowState == WindowState.Minimized ? _imageCompareViewState : (o as Window).WindowState;

# DockPanel 自动拉伸对齐方式

DockPanel 默认情况下的作用是:自动排版,最后一个元素会拉伸填满剩余空间

剩余空间 的定义其实不止是顺序排版的情况,如果对元素设置过 DockPanel.Dock ,那么就以设置过的元素停靠为准,最后一个元素填满剩余空间。

如下所示:

<DockPanel>
	<StackPanel DockPanel.Dock="Top"></StackPanel>
	<StackPanel DockPanel.Dock="Bottom"></StackPanel>
	<StackPanel></StackPanel>
</DockPanel>

如上所示,第三个 StackPanel 不设置 DockPanel.Dock ,并放在最后,那么就会直接填满中间区域。
(注:必须放在设置过 DockPanel.Dock 的最后)

上面也是我采用的方法,如图所示:

最后的 关于 说明靠下,设置靠上,中间对比元素则填满中间空隙。

# 集合数据绑定

列表类型的组件元素,不能直接绑定通用的 List 容器 —— 我就尝试了下,就发现当 List 发生变动时,绑定 UI 根本不会同时发生改变。

经过研究发现像 ListBox、ListView、DataGrid 这种容器类组件元素,绑定的数据源必须是 可监听 的,例如 ObservableCollection 才能正确接收集合改变时的通知。

# 子窗口初始位置

可以通过 WindowStartupLocation 进行控制

BatchProgressView view = new BatchProgressView();
view.Owner = this;
view.WindowStartupLocation = WindowStartupLocation.CenterOwner;

需要注意的是 CenterOwner 选项的情况下,必须设置 Owner 才有效,否则也是满屏乱飞。