WPF

WPF是微软推出的一项基于windows操作系统,.net平台的c/s客户端构建技术,最大的特征就是可以快速构建项目从而达到节约项目成本的目的.

相关推荐书籍: <<WPF编程宝典使用c#2012和.net4.5 第四版>> <<c#高级编程>> <<CLR Via C#>> <<精通C#>> <<你必须知道的.NET>>

.net core下载

通过使用.NET Core和跨平台UI框架Avalonia,可以在Mac上编写和运行类似于WPF的桌面应用程序

wpf微软官方教程

有前端经验很简单 不过wpf的绑定设计的太拉了 双向绑定麻烦的要死 xaml循环也不好用 第一次用wpf没看文档和教程直接写的 list没法响应式 研究了n久最后还是在vs隐藏提示里发现的要用bindlist!简单的双向绑定还要字段属性双重设计还得在set用函数通知 看似很美好用起来着实很折寿 xaml遍历后传参也麻烦的要死 不改进基本没希望了 怪不得没人用 设计成razor那种简化版多好

wpf参考文章

WPF源码参考

完整的WPF对象层次结构

image-20250104170044642
  1. Object:由于WPF是使用.NET创建的,因此WPF UI类继承的第一个类是.NET对象类

  2. Dispatcher:此类确保所有WPF UI对象只能由拥有它的线程直接访问,其他不拥有他的线程必须通过dispatcher对象

    Dispatcher 是 WPF 线程模型的核心部分。它提供了一个机制,允许在 UI 线程之外执行代码,并在适当的时机将代码调度到 UI 线程上执行。Dispatcher 管理着线程的状态,包括 idle(空闲)、active(活动)和 completed(完成)等

  3. Dependency:WPF UI元素使用XML格式的XAML表示.在任何时候,WPF元素都被其他WPF元素包围,并且包围的元素可以影响此元素,因为这个类才使可能.例如,如果TextBox被Panel包围,则Panel背景色很可能被文本框继承

  4. Visual:这是使WPF UI具有视觉表示的类

  5. UI Element:此类支持诸如事件,输入,布局等功能

  6. Framework Element:此类支持诸如templating,styles,binding,resources等功能

最后所有WPF控件,TextBox,Button,Grid等任何东西都继承自Framework Element类

Dispatcher

下面实现一个简单的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace CustomThreadDispatcher
{
public class ThreadDispatcher : IDisposable
{
private readonly Thread _workerThread;
private readonly BlockingCollection<Action> _taskQueue;
private readonly CancellationTokenSource _cts;

public ThreadDispatcher()
{
_taskQueue = new BlockingCollection<Action>();
_cts = new CancellationTokenSource();

_workerThread = new Thread(Run)
{
IsBackground = true // 确保程序退出时不阻止主线程结束
};

_workerThread.Start();
}

// 向指定线程调度任务
public void Invoke(Action action)
{
if (action == null)
throw new ArgumentNullException(nameof(action));

_taskQueue.Add(action);
}

// 线程主循环,处理调度的任务
private void Run()
{
try
{
foreach (var action in _taskQueue.GetConsumingEnumerable(_cts.Token))
{
try
{
action();
}
catch (Exception ex)
{
Console.WriteLine($"Task execution failed: {ex.Message}");
}
}
}
catch (OperationCanceledException)
{
// 停止时退出循环
}
}

// 停止调度器
public void Dispose()
{
_cts.Cancel();
_taskQueue.CompleteAdding();

try
{
_workerThread.Join(); // 等待线程结束
}
catch (ThreadStateException)
{
// 已经结束则忽略
}

_cts.Dispose();
_taskQueue.Dispose();
}
}

class Program
{
static void Main(string[] args)
{
Console.WriteLine($"Main thread ID: {Thread.CurrentThread.ManagedThreadId}");

using (var dispatcher = new ThreadDispatcher())
{
// 模拟在指定线程上调度任务
dispatcher.Invoke(() => Console.WriteLine($"Task 1 executed on thread {Thread.CurrentThread.ManagedThreadId}"));
dispatcher.Invoke(() => Console.WriteLine($"Task 2 executed on thread {Thread.CurrentThread.ManagedThreadId}"));

// 延迟执行
Thread.Sleep(500);

dispatcher.Invoke(() => Console.WriteLine($"Task 3 executed on thread {Thread.CurrentThread.ManagedThreadId}"));
}

Console.WriteLine("Dispatcher stopped. Press any key to exit...");
Console.ReadKey();
}
}
}

//输出如下:
Main thread ID: 1
Task 1 executed on thread 4
Child thread ID: 4
Task 2 executed on thread 4
Child thread ID: 4
Task 3 executed on thread 4
Child thread ID: 4
Dispatcher stopped. Press any key to exit...

流程分析图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant MainThread as Main Thread
participant ThreadDispatcher as ThreadDispatcher
participant WorkerThread as Worker Thread

MainThread ->> ThreadDispatcher: Create instance
ThreadDispatcher ->> WorkerThread: Start thread
WorkerThread -->> WorkerThread: Run()

MainThread ->> ThreadDispatcher: Invoke(Action)
ThreadDispatcher ->> WorkerThread: Add action to queue
WorkerThread ->> WorkerThread: Dequeue and execute action

MainThread ->> ThreadDispatcher: Dispose()
WorkerThread ->> WorkerThread: Stop and exit loop
ThreadDispatcher ->> MainThread: Release resources

上面的代码,多个Dispatcher是各自启动一个线程,如果想让所有线程中执行的该代码都在调度到同一个线程中执行,应该使用单例类,确保所有ThreadDispatcher实例共享同一个线程和任务队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace CustomThreadDispatcher
{
// 单例类,管理共享的线程和任务队列
public class SharedDispatcher : IDisposable
{
// 使用 Lazy<T> 实现单例模式,确保 SharedDispatcher 只有一个实例
private static readonly Lazy<SharedDispatcher> _instance = new Lazy<SharedDispatcher>(() => new SharedDispatcher());

// 提供对单例实例的访问
public static SharedDispatcher Instance => _instance.Value;

// 工作线程,用于执行任务队列中的任务
private readonly Thread _workerThread;

// 任务队列,用于存储待执行的任务
private readonly BlockingCollection<Action> _taskQueue;

// 取消令牌源,用于停止工作线程
private readonly CancellationTokenSource _cts;

// 私有构造函数,确保外部无法直接实例化 SharedDispatcher
private SharedDispatcher()
{
// 初始化任务队列
_taskQueue = new BlockingCollection<Action>();

// 初始化取消令牌源
_cts = new CancellationTokenSource();

// 创建工作线程,并设置其为后台线程(确保程序退出时线程不会阻止主线程结束)
_workerThread = new Thread(Run)
{
IsBackground = true
};

// 启动工作线程
_workerThread.Start();
}

// 向指定线程调度任务
public void Invoke(Action action)
{
if (action == null)
throw new ArgumentNullException(nameof(action));

// 将任务添加到任务队列中
_taskQueue.Add(action);
}

// 线程主循环,处理调度的任务
private void Run()
{
try
{
// 从任务队列中获取任务并执行
foreach (var action in _taskQueue.GetConsumingEnumerable(_cts.Token))
{
try
{
// 执行任务
action();
}
catch (Exception ex)
{
// 捕获任务执行过程中的异常并输出
Console.WriteLine($"Task execution failed: {ex.Message}");
}
}
}
catch (OperationCanceledException)
{
// 当取消令牌被触发时,退出循环
}
}

// 停止调度器
public void Dispose()
{
// 取消任务执行
_cts.Cancel();

// 标记任务队列为完成状态,不再接受新任务
_taskQueue.CompleteAdding();

try
{
// 等待工作线程结束
_workerThread.Join();
}
catch (ThreadStateException)
{
// 如果线程已经结束,则忽略异常
}

// 释放取消令牌源和任务队列
_cts.Dispose();
_taskQueue.Dispose();
}
}

// 继承自 SharedDispatcher 的类
public class ThreadDispatcher : IDisposable
{
// 共享的 SharedDispatcher 实例
private readonly SharedDispatcher _sharedDispatcher;

public ThreadDispatcher()
{
// 获取 SharedDispatcher 的单例实例
_sharedDispatcher = SharedDispatcher.Instance;
}

// 向指定线程调度任务
public void Invoke(Action action)
{
// 通过 SharedDispatcher 实例调度任务
_sharedDispatcher.Invoke(action);
}

// 停止调度器
public void Dispose()
{
// 这里不需要释放 SharedDispatcher,因为它是一个单例
}
}

class Program
{
static void Main(string[] args)
{
Console.WriteLine($"Main thread ID: {Thread.CurrentThread.ManagedThreadId}");

// 创建两个 ThreadDispatcher 实例
using (var dispatcher1 = new ThreadDispatcher())
using (var dispatcher2 = new ThreadDispatcher())
{
// 模拟在指定线程上调度任务
dispatcher1.Invoke(() => Console.WriteLine($"Task 1 executed on thread {Thread.CurrentThread.ManagedThreadId}"));
dispatcher2.Invoke(() => Console.WriteLine($"Task 2 executed on thread {Thread.CurrentThread.ManagedThreadId}"));

// 延迟执行
Thread.Sleep(500);

dispatcher1.Invoke(() => Console.WriteLine($"Task 3 executed on thread {Thread.CurrentThread.ManagedThreadId}"));
}

Console.WriteLine("Dispatcher stopped. Press any key to exit...");
Console.ReadKey();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
sequenceDiagram
participant MainThread as Main Thread
participant Dispatcher1 as ThreadDispatcher (dispatcher1)
participant Dispatcher2 as ThreadDispatcher (dispatcher2)
participant SharedDispatcher as SharedDispatcher
participant WorkerThread as Worker Thread

MainThread ->> Dispatcher1: Create dispatcher1
MainThread ->> Dispatcher2: Create dispatcher2
Dispatcher1 ->> SharedDispatcher: Access Instance
Dispatcher2 ->> SharedDispatcher: Access Instance

MainThread ->> Dispatcher1: Invoke(Task 1)
Dispatcher1 ->> SharedDispatcher: Add Task 1 to queue

MainThread ->> Dispatcher2: Invoke(Task 2)
Dispatcher2 ->> SharedDispatcher: Add Task 2 to queue

WorkerThread ->> SharedDispatcher: Dequeue Task 1
WorkerThread ->> WorkerThread: Execute Task 1

WorkerThread ->> SharedDispatcher: Dequeue Task 2
WorkerThread ->> WorkerThread: Execute Task 2

MainThread ->> Dispatcher1: Invoke(Task 3)
Dispatcher1 ->> SharedDispatcher: Add Task 3 to queue

WorkerThread ->> SharedDispatcher: Dequeue Task 3
WorkerThread ->> WorkerThread: Execute Task 3

MainThread ->> Dispatcher1: Dispose()
MainThread ->> Dispatcher2: Dispose()
MainThread ->> SharedDispatcher: Dispose()
SharedDispatcher ->> WorkerThread: Stop and exit

XAML

XAML(Extensible Application Markup Language)是微软创造的一种新的可扩展应用程序标记语言,是WPF中专门用于设计UI的语言,是一种单纯的声明型语言

在XAML语言中,使用标签声明一个元素,每个元素对应内存中的一个对象。可以通过标签的语法进一步声明元素的特征(Attribute)和内容物。有两种标签表现形式。

1
2
<Tag Attribute1 = "Value1" Attribute2 = "Value2">Content </Tag> <!--非空标签-->
<Tag Attribute1 = "Value1" Attribute2 = "value2" ½> <!--空标签-->

Attribute不仅可以写成上面这样,也可以把 Tag.Attribute直接写成标签的形式

对xaml的理解

将xaml和类对应起来理解,xaml只是在描述这些类

Content等同于被包裹其中的内容:

1
2
3
4
5
<window Content="..."/>
<!--等同于-->
<window>
...
</window>

Color展开案例

1
2
3
4
5
6
7
<Grid>
<Grid.Background>
<SolidColorBrush Color="Aqua"/> <!--此处的Aqua是颜色类型-->
</Grid.Background>
</Grid>
<!--等同于-->
<Grid Background="Aqua"/><!--此处的Aqua实际上是画刷属性-->

Binding展开案例

1
2
3
4
5
6
7
8
9
<Grid>
<Grid.Background>
<Binding>
<Binding.RelativeSource>
<RelativeSource Mode="TemplatedParent"/>
</Binding.RelativeSource>
</Binding>
</Grid.Background>
</Grid>

xmlns

xmlns即XML-Namespace,其一个使用优点是,当要引用的来源不同类重名时,可以使用命名空间加以区分。XAML中命名空间的语法与C#是不同的,在C#中,我们使用using关键字在代码顶部调用命名空间,在XAML当中的语法格式如下

1
2
3
4
5
6
xmlns="[命名空间]" <!--无映射前缀-->
xmlns:[映射前缀]="[命名空间]" <!--有映射前缀-->

<!--例子-->
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:bb="clr-namespace:testGrid">

为什么xmlns的赋值是一个类似于URL地址的字符串?
这种表达是一种统一资源名称,使得对于多个命名空间的引用能够集合在一个唯一标识上,而创建可读的全局唯一标识符的可靠方法,就是使用类似于网站的URL表达。对于一个集合标识的引用声明就包含了对多个命名空间的引用,使得代码方便、简洁和统一。

在使用XAML语法声明元素标签时,其对应声明的对象与对象之间的关系要么是包含,要么是并列关系,所以我们又说,XAML的语法结构是一种树形的包含或并列结构

iShot_2024-06-14_11.15.00
1
2
3
4
5
6
7
<Rectangle x:Name="rectangle1" Width="200" Height="100" Fill="Blue"/>
<!--上面写法Fill只提供了颜色,本质上没有提供类型,只是纯色没关系,如果想要写渐变色却不行,因此可以改成下面这种更全能的形式,可以指定类型-->
<Rectangle x:Name="rectangle1" Width="200" Height="100">
<Rectangle.Fill>
<SolidColorBrush Color="Blue"/><!--此处指定了类型SolidColorBrush-->
</Rectangle.Fill>
</Rectangle>

上面两种写法对应的c#代码实际上就都是

1
2
3
4
5
6
SolidCoLorBrush SCB1 = new SolidColorBrush();
SCB1.Color = Colors. Blue;
this.rectangle.Fill = SCB1;
this.Name = "rectangle1";
this.Width = 200;
this.Height = 100;
  • 如果使用XAML对一个类的属性进行特征声明,那么其逻辑代码中必然要有将特征赋值字符串值转换为属性值的赋值机制。
  • 在XAML中对特征进行声明使用的是字符串,如果要对属性进行复杂赋值或内部精确赋值,那么就会使得XAML设计者在为此编写转换机制时十分困难。

XAML命名空间

XAML命名空间是一种用于在XAML标记中引用不同的类型和属性的机制

XAML命名空间可以将XAML元素和属性与对应的.NET类和成员映射,从而实现对象构造和属性赋值

XAML命名空间中包含的工具如下图

iShot_2024-06-14_13.55.08

常见特征功能

x:Class

这个特征功能,适用于告诉编译器将当前XAML标签的编译结果与后台中指定的类进行合并。在使用这个功能声明时需要满足以下要求:

  • 此特征功能仅能在根节点声明
  • 根节点类型要与所指示的合并类型保持一致
  • 所指示的类型在声明时必须使用partial关键字

x:Class使得XAML文件和后台代码文件能够合并为一个类,从而实现逻辑和界面的分离。x:Class只能用于根元素,并且必须与后台代码文件中的分部类名称一致。

x:ClassModifier

告诉XAML编译器该便签生成的类具有怎样的访问级别。对于整个程序集来说,internal和private是等价的。

1
2
3
<Window x:Class="..."
x:ClassModifier="internal">
</Window>

对应的c#文件的窗体类的访问级别也需要改成 internal,即需要对应,否则就会导致含有冲突访问修饰符的编译错误

一般WPF程序中都会包含有一个映射前缀为x的命名空间

x:Name

告诉XAML编译器为当前的标签生成一个实例,并为这个实例声明一个引用变量

它可以为XAML定义的对象指定一个唯一的标识符,以便在代码中访问它。x:Name不仅会为对象创建一个引用变量,还会为对象的Name属性(如果有的话)赋值,并将该值注册到XAML名称范围中。XAML名称范围是一种用于查找和引用XAML创建的对象的机制,它可以支持数据绑定、动画、模板等功能。

Name 与 x:Name 的区别

Name

  • 使用场景Name 属性是 WPF 控件的一个标准属性,通常用于为控件指定一个名称,以便在代码中引用
  • 作用域:在 XAML 中,Name 属性只能用于某些控件,例如 ButtonTextBox 等。
  • 代码中引用:在代码后面(C#)中,可以直接使用 myButton 来引用这个按钮。

x:Name

  • 使用场景x:Name 是 XAML 的一部分,属于 XAML 命名空间的定义,提供了一个更通用的方式来为任何 XAML 元素指定名称。
  • 作用域:可以用于所有 XAML 元素,包括那些没有 Name 属性的元素。
  • 代码中引用:在代码后面(C#)中,同样可以直接使用 myButtonmyTextBlock 来引用它们。
x:FieldModifier

为引用变量设置访问级别。默认情况下,字段的访问级别按照面向对象的封装原则被设置成了internal。

x:Key 与 x:Shared

x:Key可以为XAML定义的资源指定一个唯一的标识符,以便在XAML中引用它。x:Key只能用于资源字典中的对象,例如样式、模板、画刷等。你可以使用静态资源标记扩展或动态资源标记扩展来通过 x:Key引用资源。

当多次检索到一个对象时,若想得到的都是同一个对象,则x:Shared的值设为true;若想得到的是该对象的多个副本,则设置x:Shared的值为false。

XAML编译器会为资源隐式添加x:Shared = “true”。

我们经常会把需要多次使用的内容提取出来放到资源词典中,需要使用这个资源的时候就是使用对应的key检索出来

x:Shared="true"

用于指示某个资源或元素是可以在多个地方共享的。

它的主要用途是在以下情况下

  • 资源共享:如果有一些资源(如图像、样式等)在多个地方使用,设置为共享可以避免重复创建。
  • 性能优化:减少资源的重复加载和初始化。

常见标记拓展功能

x:Type

用于一些需要指定类型的属性或参数

当我们需要引用的不是数据类型的实例,而是数据类型本身时,就可以使用此标记拓展功能

"{x:Type TypeName=wins:SuggestionComment}"{x:Type wins:SuggestionComment}

x:Type的值是一个类型的名称,可以带有命名空间前缀,也可以省略命名空间前缀,如果省略,则默认使用当前XAML文件的默认命名空间。x:Type可以用于一些需要指定类型的属性或参数。

x:Null

可以显式地对一个特征或属性赋一个空值,常用于清除一些设置

Background = "{x:Null}"将背景色置为空值

它可以在XAML中表示null值。x:Null没有任何参数,只能用于属性语法。x:NuII可以用于一些需要清除或重置属性值的场景,例如取消全局样式、清除绑定源、设置空背景等。

x:Static

用于一些需要使用静态字段或属性的属性或参数

x:Static可以用于一些需要使用静态字段或属性的属性或参数,例如常量、枚举值、资源键等。

常用指令元素功能

x:Code

可以在XAML中嵌入一些C#代码,以便在XAML文件中实现一些逻辑或事件处理 . x:Code必须用于根元素,并且必须包含在CDATA节中,需要使用XML语言的 <! [CDATA[...]]>转移标签,写法为:

1
2
3
4
5
6
7
8
9
<Window>
<Grid>
</Grid>
<x:Code>
<![CDATA[
...代码内容...
]]>
</x:Code>
</Window>

这个功能几乎没用,不符合设计哲学

x:Data

在XAML中定义一个XML数据源,以便在XAML中使用数据绑定或数据模板。x:Data可以用于一些需要指定数据源的属性或参数,例如XmlDataProvider.Source、XmlDataProvider.XPath、
XmiDataProvider.Document等。

XAML注释

<!-- 注释内容 -->

  • XAML的注释只能出现在标签的内容区域
  • XAML的注释不能用于注释掉标签的特征赋值
  • XAML的注释不能嵌套

XAML优点

  • XAML通常比等效代码更简洁、更易读
  • XAML 以更高的视觉清晰度模拟用户界面对象的父子层次结构
  • XAML 可由程序员轻松手动编写,但也使它成为可视化设计工具的可操作性和生成工具

XAML可视化视觉设计工具

Blend

Blend是一款用于设计应用用户界面的可视化工具,用于UI设计,它可以支持拖拽式创建控件,与PhotoShop的使用类似,可以快速、精确地绘制图形界面,并自动生成XAML代码。

使用 Visual Studio中视图--在Blend中设计

从简化XAML的角度来看,使用Blend来设计UI会有哪些冗余细节?

  • 取值过于精确
  • 默认值会被直接写出

一般情况下,Blend会用于复杂的界面设计和绘图动画创作,可以先在Blend里进行绘制,再回到Visual Studio的XAML代码中进行调整,要保证不影响效果的情况下尽可能提高代码可读性。

借助Blend快速入门WPF动画以及行为视频讲解

设计时特性

设计时才能看到的数据源

1
2
3
4
5
6
7
8
9
<ListBox ItemsSource="{Binding Items}">
<d:ListBox.ItemsSource>
<x:Array Type="sys:string">
<sys:String>Item1</sys:String>
<sys:String>Item2</sys:String>
<sys:String>Item3</sys:String>
</x:Array>
</d:ListBox.ItemsSource>
</ListBox>

自动提供假数据

1
<ListBox d:Source="{d:SampleData ite}"

布局

布局控件:可以理解为一个容器,容器内可以嵌套容器。可以嵌套N层

上述的基本容器都是继承于Panel

wpf中源码提及的Generator的概念可以这么理解:

ListBox中的item叫ListBoxItem,ListBox就是ListBoxItem的生成器

Grid

表格控件,类似HTML中的Table标签

image-20240610203936967

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Grid Height="435" VerticalAlignment="Top">
#这里定义了纵向的三个空行
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<!--因为只有一列,下面三行实际可写可不写-->
<Grid.ColumnDefinitions>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
#通过Grid.Row设置放在哪一行
<TextBox Width="300" Height="30"></TextBox>
<TextBox Grid.Row="1" Width="300" Height="30"></TextBox>
<Button x:Name="button" Content="登录" Margin="358,0,358,0" Grid.Row="2" VerticalAlignment="Center" Height="38"/>
</Grid>

Grid.Row指定行序号, Grid.Column指定列序号

继承Panel

Grid下有很重要的附加属性Grid.ZIndex可以用于设置其内部的控件上下层级顺序

注意Grid定义好后,空间就划好了,没办法通过设置其中控件的Visibility为Collapsed来不占用空间

Grid还可以使用GridSplitter来使用分隔条控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- 布局如下 -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="4*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="5*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<!-- 垂直分隔线 -->
<GridSplitter
Grid.Row="0"
Grid.RowSpan="3"
Grid.Column="1"
Width="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="LightGray"
ResizeDirection="Columns" />

<!-- 水平分隔线 -->
<GridSplitter
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="3"
Height="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="LightGray"
ResizeDirection="Rows" />

嵌套Grid共享宽度

共享宽度(Shared Size Group)在 WPF 中的作用是让多个 Grid 中的列或行共享相同的大小,而不是限制内容的大小

  • SharedSizeGroup

    用于标识需要共享尺寸的列或行,通过指定相同的组名(如 SharedSizeGroup="TextLabel")实现跨 Grid 的尺寸同步

    仅当列宽或行高为 Auto\* 时生效,固定数值(如 Width="100")不参与共享

  • Grid.IsSharedSizeScope

    需在父容器上设置为 True,声明该容器内的所有子 Grid 共享同一尺寸作用域

    父容器可以是 GridStackPanelDockPanel 等,但必须显式设置该属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<DockPanel Grid.IsSharedSizeScope="True">  <!-- 父容器设置 IsSharedSizeScope -->
<!-- 子 Grid 1 -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelColumn"/> <!-- 共享列 -->
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Content="Name:"/>
<TextBox Grid.Column="1"/>
</Grid>

<!-- 子 Grid 2 -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelColumn"/> <!-- 同名共享组 -->
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Content="Email:"/>
<TextBox Grid.Column="1"/>
</Grid>
</DockPanel>

对于多层嵌套的 Grid(如主 Grid 包含多个子 Grid,每个子 Grid 又包含其他控件),可通过以下结构实现共享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<Grid Grid.IsSharedSizeScope="True">
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>

<!-- 子 Grid 1 -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="GroupA"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
</Grid>

<!-- 子 Grid 2 -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="GroupA"/> <!-- 跨行共享列宽 -->
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
</Grid>
</Grid>

StackPanel

将自身内容横向或纵向排列的容器

1
2
3
4
5
6
7
8
<StackPanel HorizontalAlignment="Center">#水平居中
<!--...-->
</StackPanel>

#设置为横向的
<StackPanel Orientation="Horizontal">
<!-- 这里添加子元素 -->
</StackPanel>

内置的控件将会纵向排列

StackPanel 会将子元素按顺序排列,因此想要其内部的控件按照VerticalAlignment来排列不可行

VirtualizingStackPanel

VirtualizingStackPanel 支持虚拟化,仅渲染可见的子元素。未在可视区域内的元素不会被创建,这样可以显著提高性能,尤其是在处理大量数据时,适用于需要显示大量数据的场景

如ListView,ListBox,ComboBox等也是基于 VirtualizingStackPanel,因此也支持虚拟化

WrapPanel

控件自动的在一行里,如果需要换行则规定好WrapPanell的布局控件的宽度,如果布局内容超出了这个宽度则会自动换行。

1
2
3
<WrapPanel HorizontalAlignment="Center">#水平居中
<!--...-->
</WrapPanel>

DockPanel

DockPanel 是WPF中的一种布局容器,用于将子控件沿容器的边缘排列。DockPanel 允许子控件依次对齐到容器的顶部、底部、左侧或右侧,并且可以让最后一个子控件填充剩余的空间。DockPanel 非常适合需要将控件固定在特定位置的布局需求。

DockPanel 有几个重要的属性,可以帮助开发者灵活地控制子控件的排列方式:

  • DockPanel.Dock: 附加属性,控制子控件在 DockPanel 中的停靠位置,取值为 Top、Bottom、Left 或 Right。
  • LastChildFill: 控制最后一个子控件是否填充 DockPanel 的剩余空间,默认为 true。
image-20240828115050359

优点
灵活性高:DockPanel 允许子控件依次停靠在容器的边缘,非常适合需要固定控件位置的布局。
自动填充:DockPanel 可以自动填充剩余空间,简化了布局管理。
直观简单:对于需要将控件固定在特定位置的简单布局,DockPanel 使用非常直观简单。

缺点
不适合复杂布局:对于复杂布局或需要精确控制控件位置的场景,DockPanel 的能力有限。
性能问题:在包含大量子控件时,DockPanel 可能会导致性能问题,因为它需要动态计算控件的位置和大小。

Canvas

Canvas比较特殊,它属于”任意布局”的一种概念,就是你拖控件到UI上的时候你把它放在哪里它就在哪里

1
2
3
<Canvas>
#...(这个里面的控件会自动生成Canvas.Left等数值)
</Canvas>

UniformGrid

均匀分布的网格

可以通过Rows和Columns指定网格的行数或列数,有效防止宽或高特别大

可以通过FirstColumn指定第一个子元素应该放置的列索引。这个属性在需要偏移第一个元素的位置时很有用。

控件

在WPF中,控件是一个涵盖性术语,适用于在窗口中可视化、可交互、具有用户界面并实现某些行为,设计好的控件能给用户带来更好的交互体验。

控件的基础属性宽、高、背景色、字体颜色、字体大小、禁用、启用、显示、隐藏等、控件显示的值内蓉有的叫Content、Text、Value等这一些东西基本上不会随着wpf的版本迭代有面目全非的变化所以是可以通过长期使用慢慢积累到控件属性。从而掌握它们的使用方式或重写、编写自定义控件

Name属性可以给控件起名字,得以索引到这个控件

WPF控件类关系图

image-20240827092504643

内置的WPF控件

此处可以参考各种内置控件的实现源码

按钮

  • Button:基本的按钮控件,用于执行点击事件。
  • RepeatButton:类似于按钮,但会在长按时反复触发点击事件。

数据显示

  • DataGrid:用于显示和操作表格数据,可以进行排序、过滤和编辑。
  • ListView:显示数据集合的控件,支持多种视图模式,如 GridView。
  • TreeView:用于显示层次化数据的控件。

日期显示和选择

  • Calendar:显示一个月历,用于选择日期。
  • DatePicker:提供日期选择功能,可以手动输入或通过下拉日历选择。

对话框

  • OpenFileDialog:用于打开文件的对话框。
  • PrintDialog:用于打印文件的对话框。
  • SaveFileDialog:用于保存文件的对话框。

数字墨迹

  • InkCanvas:提供数字墨迹输入功能的画布。
  • InkPresenter:展示和管理数字墨迹输入。

文档

  • DocumentViewer:查看文档的控件,支持多种文档格式。
  • FlowDocumentPageViewer:分页显示 FlowDocument 的控件。
  • FlowDocumentReader:提供阅读和导航 FlowDocument 的功能。
  • FlowDocumentScrollViewer:滚动显示 FlowDocument 的控件。
  • StickyNoteControl:显示和编辑数字便笺。

输入

  • TextBox:单行文本输入控件。
  • RichTextBox:支持富文本格式的多行文本输入控件。
  • PasswordBox:用于输入密码的控件,输入内容以掩码显示。

布局

  • Border:绘制边框,包含单一子元素。
  • BulletDecorator:显示项目符号和内容。
  • Canvas:允许通过绝对坐标指定子元素位置。
  • DockPanel:将子元素停靠在其边缘(上、下、左、右)。
  • Expander:可扩展和折叠的面板。
  • Grid:基于行和列的布局,支持复杂排列。
  • GridView:ListView 的视图模式,用于显示表格数据。
  • GridSplitter:允许调整 Grid 的行和列大小。
  • GroupBox:带标题的容器,用于分组内容。
  • Panel:所有布局控件的基类。
  • ResizeGrip:调整窗口大小的控件。
  • Separator:用于分隔菜单项或工具栏按钮的线条。
  • ScrollBar:提供滚动功能。
  • ScrollViewer:使子元素内容可滚动。
  • StackPanel:将子元素堆叠在一起,水平或垂直方向。
  • Thumb:滑块控件的一部分,支持拖动。
  • Viewbox:缩放和调整子元素大小。
  • VirtualizingStackPanel:类似 StackPanel,但支持虚拟化,提高性能。
  • Window:表示一个窗口。
  • WrapPanel:顺序排列子元素,空间不足时自动换行。

媒体

  • Image:显示图片。
  • MediaElement:播放音频或视频。
  • SoundPlayerAction:播放音频文件的操作。

菜单

  • ContextMenu:右键上下文菜单。
  • Menu:应用程序主菜单。
  • ToolBar:工具栏,用于放置工具按钮。

导航

  • Frame:显示和导航内容页面。
  • Hyperlink:超链接,导航到指定 URL。
  • Page:表示导航的页面。
  • NavigationWindow:窗口,支持导航功能。
  • TabControl:选项卡控件,允许切换多个页面。

选项

  • CheckBox:复选框,表示一个开关选项。
  • ComboBox:下拉列表,允许选择一个项目。
  • ListBox:列表框,允许选择一个或多个项目。
  • RadioButton:单选按钮,成组使用时只能选择一个。
  • Slider:滑块控件,用于选择范围值。

信息

  • AccessText:带有快捷键的文本标签。
  • Label:显示文本标签。
  • Popup:弹出窗口。
  • ProgressBar:进度条,显示任务进度。
  • StatusBar:状态栏,显示应用程序状态信息。
  • TextBlock:显示文本内容。
  • ToolTip:工具提示,显示额外信息。

依赖属性与普通属性

依赖属性(Dependency Property)是一种特殊的属性系统,通常用于WPF(Windows Presentation Foundation)应用程序中。依赖属性允许属性的值能够从多个来源继承、动态更新和通知变化,同时提供了一种有效的方式来管理属性值的继承、样式化、数据绑定和动画

赖属性有一个特性,当你没有为控件的某个属性显式地赋值时,它就会把自己所在容器的属性值拿过来,当作自己的属性值

依赖属性:在wpf主要扮演数据驱动中的重要角色,它能配合绑定一起实时数据更新UI显示,动画,自定义控件等

1
2
3
<TextBox Width="300" Height="30" Name="tbAccount"></TextBox>
<TextBox Grid.Row="1" Width="300" Height="30" Text="{Binding Path=Text, ElementName=tbAccount}"></TextBox>
#当第一个TextBox的文本发生变化时,第二个TextBox的文本也会同步变化。这种方式被称为元素绑定,它允许一个控件的属性同步于另一个控件的属性。

依赖属性的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace testGrid
{
public class CustomButton:Button
{
public CornerRadius ButtonCornerRadiu
{
get { return (CornerRadius)GetValue(ButtonCornerRadiuProperty); }
set { SetValue(ButtonCornerRadiuProperty, value); }
}

// Using a DependencyProperty as the backing store for ButtonCornerRadiu. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ButtonCornerRadiuProperty =
DependencyProperty.Register("ButtonCornerRadiu", typeof(CornerRadius), typeof(CustomButton), new PropertyMetadata(0));// PropertyMetadata(0)表示默认值为0
}
}
//自己定义了一个按钮,按钮中添加了依赖属性ButtonCornerRadiu,后续可以在xaml中使用
//<CustomButton ButtonCornerRadiu="20"></CustomButton>

普通属性也能实现实时数据更新,但是需要额外实现一套通知机制叫做INotifyPropertyChanged,不支持其他操作

依赖属性内置了通知机制

可以输入propdp再按两下tab键生成一个依赖属性的模板

属性变化的回调

如果您想在属性值变化时执行某些操作,可以提供一个 PropertyChangedCallback 函数作为 PropertyMetadata 的参数。例如:

1
2
3
4
5
6
7
8
9
10
public static readonly DependencyProperty ButtonCornerRadiuProperty =
DependencyProperty.Register("ButtonCornerRadiu", typeof(CornerRadius), typeof(CustomButton), new PropertyMetadata(new CornerRadius(0), OnButtonCornerRadiuChanged));

private static void OnButtonCornerRadiuChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
// 处理属性变化的逻辑
CustomButton button = d as CustomButton;
CornerRadius newRadius = (CornerRadius)e.NewValue;
// 在这里可以添加响应逻辑,例如更新 UI
}

依赖属性详解

CLR属性:CLR属性主要实现了面向对象的封装。CLR属性是通过get和set访问器方法来实现的,这些方法允许你在读取或写入属性时执行自定义代码。

1
2
3
4
5
// 字段
private string name;

// CLR属性
public string Name { get; set; }

对内存的使用机制
• CLR属性是常见的.NET属性类型,每个对象实例都有自己的一套CLR属性。每当创建一个新的对象实例时,都会为该实例的所有CLR属性分配内存。
• 依赖属性是WPF中的特殊属性类型,它的设计目标是减少内存使用。依赖属性并不为每个对象实例分配内存,而是将属性值存储在全局的哈希表中。对于拥有依赖属性的类来说,其实例化的对象可以称作为依赖对象(Dependency Object),WPF中允许依赖对象在被创建时并不包含用于数据存储的空间,只保留在需要用到数据时能够获得默认值或借用其他对象的数据,具有实施分配空间的能力。

自定义依赖属性

在编写一般属性时,通常是声明字段,然后添加get和set块封装为CLR属性。而在编写依赖属性时,需要进行下面四个步骤。

在WPF当中, 所有支持绑定的属性本质上它都是封装后的依赖属性。那么也就是说, 只有依赖属性才可以进行绑定

一定注意,MVVM架构中,这个依赖属性是写在xaml的后台文件中的,这样可以将数据绑定逻辑和 UI 逻辑分开,保持代码的清晰性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//1.使类型继承DependencyObject类
public class Student : DependencyObject
{
// 依赖属性:2. 声明只读的DependencyProperty字段
public static readonly DependencyProperty GradeProperty =
DependencyProperty.Register("Grade", typeof(int), typeof(Student));
//上面3. 注册依赖属性

// 依赖属性包装器 4.使用属性封装,get和set块内部使用GetValue()和SetValue()操作属性值 ,需要通过GetValue和SetValue对依赖属性进行操作,因此真正操作依赖属性通过Grade来操作(Grade就是对依赖属性封装为CLR属性)
public int Grade
{
get { return (int)GetValue(GradeProperty); }
set { SetValue(GradeProperty, value); }
}
}

声明依赖属性的所在位置的对象必须直接或简介继承于DependencyObject对象, 这样它才具备GetValue/SetValue方法。

DependencyObject继承关系

img

DependencyProperty.Register

  • 参数1: CLR属性名: 表示以哪个CLR属性作为这个依赖属性的包装器
  • 参数2: 属性类型: 依赖属性用来存储什么类型的值
  • 参数3: 宿主类型: 依赖属性的宿主类型
  • 参数4: 指定依赖属性的其他功能的,例如默认值,回调函数(属性变更后的操作),继承,双向绑定,是否可以绑定等等
  • 参数5: 一个实现校验功能的委托: 可以给依赖属性赋值的时候进行有效验证

只读依赖属性

在WPF中只读的依赖属性的定义方式与一般依赖属性的定义方式基本一样,只读依赖属性仅仅是用 DependencyProperty.RegisterReadonly替换了 DependencyProperty.Register

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// 内部使用SetValue来设置值
SetValue(counterKey, 8);
}

// 属性包装器,只提供GetValue,你也可以设置一个private的SetValue进行限制。
public int Counter
{
get { return (int)GetValue(counterKey.DependencyProperty); }
}

// 使用RegisterReadOnly来代替Register来注册一个只读的依赖属性
private static readonly DependencyPropertyKey counterKey =
DependencyProperty.RegisterReadOnly("Counter",
typeof(int),
typeof(MainWindow),
new PropertyMetadata(0));
}

依赖属性的继承

元素可以从其在对象树中的父级继承依赖属性的值。属性值继承是一种机制,通过这种机制,依赖属性值可以在包含该属性的元素树中从父元素传播到子元素。

依赖属性的优先级

WPF每访问一个依赖属性,它都会按照下面的顺序由高到底处理该值。具体优先级从最高到最低排序如下:

动画>绑定>本地值>自定义Style Trigger>自定义Template Trigger>自定义Style Setter>默认Style Trigger>默认 Style Setter>继承值>默认值

附加属性

附加是一种特殊的依赖属性。附加属性是说,一个属性本来不属于某个对象,但是由于某种需求而被后来附加上,表现出来的就是被环境赋予的属性。

如:Grid.Column,Canvas.Top这样的属性

自定义附加属性和定义一般的依赖属性一样没什么区别,只是用RegisterAttached方法代替了Register方法。下面代码演示了附加属性的定义。

iShot_2024-07-02_15.28.55
1
2
3
4
5
6
7
8
9
10
11
12
13
public static int GetMyProperty(DependencyObject obj)
{
return (int)obj.GetValue(MyPropertyProperty);
}

public static void SetMyProperty(DependencyObject obj, int value)
{
obj.SetValue(MyPropertyProperty, value);
}

// Using a DependencyProperty as the backing store for MyProperty. This enables animation, styling, binding, etc...
public static readonly DependencyProperty MyPropertyProperty =
DependencyProperty.RegisterAttached("MyProperty", typeof(int), typeof(ownerclass), new PropertyMetadata(0));

可以输入propa再按两下tab键生成一个附加属性的模板

声明附加属性的对象无需继承于DependencyObject, 因为这个时候DependencyObject对象作为方法参数传递

附加属性案例

不改变不继承Button的情况下,做一个圆角控制

定义附加属性ButtonHelper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace WpfApp1.Helper;

public class ButtonHelper
{
//附加属性 主人是ButtonHelper
public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.RegisterAttached(
"CornerRadius", typeof(CornerRadius), typeof(ButtonHelper), new PropertyMetadata(new CornerRadius(0)));

public static void SetCornerRadius(DependencyObject element, CornerRadius value)
{
element.SetValue(CornerRadiusProperty, value);
}

public static CornerRadius GetCornerRadius(DependencyObject element)
{
return (CornerRadius)element.GetValue(CornerRadiusProperty);
}
}

使用该附加属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xmlns:hhhh="clr-namespace:WpfApp1.Helper"

<Button Content="你好,世界" hhhh:ButtonHelper.CornerRadius="5">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border
BorderBrush="Black"
BorderThickness="1"
CornerRadius="{TemplateBinding hhhh:ButtonHelper.CornerRadius}">
<ContentPresenter Content="{TemplateBinding Content}" />
</Border>
</ControlTemplate>
</Button.Template>
</Button>

附加属性与依赖属性使用场景

  • 依赖属性: 当您需要单独创建控件时, 并且希望控件的某个部分能够支持数据绑定时, 你则可以使用到依赖属性。
  • 附加属性: 这种情况很多, 正因为WPF当中并不是所有的内容都支持数据绑定, 但是我们希望其支持数据绑定, 这样我们就可以创建基于自己声明的附加属性,添加到元素上, 让其元素的某个原本不支持数据绑定的属性间接形成绑定关系
  • 例如:为PassWord定义附加属性与PassWord进行关联。例如DataGrid控件不支持SelectedItems, 但是我们想要实现选中多个条目进行数据绑定, 这个时候也可以声明附加属性的形式让其支持数据绑定。

很常用的一些附加属性如 TextElement.Foreground,可以通过此附加属性在模板中设置Foreground,而不需要设置ContentPresenter中的Foreground

一般情况特性

对于许多控件来说,

  • Text 属性通常用于表示其显示的文本内容,这是比较常见和直观的。
  • Button 控件的文本内容确实常常通过 Content 属性来设置。
  • 而对于像 ComboBox 这样可以包含多个可选项的数据控件,设置 ItemsSource 等Items开头的属性来指定数据源是一种常见的方式(Items开头的属性一般都是集合形式的),通过这种方式可以方便地管理和显示多个数据项。

x详解

类似于winform中代码是通过给控件的属性Name设置索引名称来找到控件,在WPF中通过属性 x:可以取到各种属性,其中就包括Name属性,即设置 x: Name = "btnThis",之后在代码中可以通过btnThis直接操作该控件

容器及其子项的对应

Generator和对应的Container的罗列:

Container Generator/item
ListBox ListBoxItem
TreeView TreeViewItem
TabControl TabItem

DataGrid详解

在 WPF 中,DataGrid 是一个用于显示和编辑表格数据的控件。与 ListView 类似,DataGrid 也支持数据绑定,但它提供了更多的功能,尤其是在处理表格数据时。DataGrid 适合用于显示大量数据,并支持排序、筛选、分页和编辑等功能

image-20241206171018922

DataGrid默认会显示所有数据列,默认会显示所有成员

DataGrid数据绑定演示

1
2
3
4
5
6
7
8
9
10
11
<DataGrid x:Name="DG_BlackBox" FontSize="20" Grid.Row="1" VerticalScrollBarVisibility="Visible" Margin="5" ItemsSource="{Binding ViewSource}" CanUserAddRows="False" CanUserDeleteRows="False" CanUserReorderColumns="False" CanUserResizeRows="False" CanUserSortColumns="False" AutoGenerateColumns="False" IsReadOnly="True" ScrollViewer.CanContentScroll="True" VirtualizingPanel.ScrollUnit="Item" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Code}" Width="100" Header="代码"/>
<DataGridTextColumn Binding="{Binding Name}" Width="150" Header="名称"/>
<DataGridTextColumn Binding="{Binding Type}" Width="150" Header="类型"/>
<DataGridTextColumn Binding="{Binding UserName}" Width="150" Header="用户"/>
<DataGridTextColumn Binding="{Binding DateTime,Mode=OneWay,StringFormat={}{0:yyyy-MM-dd HH:mm:ss}}" Width="240" Header="开始时间"/>
<DataGridTextColumn Binding="{Binding ResolveDateTime,Mode=OneWay,StringFormat={}{0:yyyy-MM-dd HH:mm:ss}}" Width="240" Header="解决时间"/>
<DataGridTextColumn Binding="{Binding Description}" Width="auto" Header="描述"/>
</DataGrid.Columns>
</DataGrid>

这些绑定确保了 DataGrid 能够从视图模型中的集合属性 ViewSource 获取数据,并将每个数据项的特定字段显示在相应的列中。这是 MVVM 设计模式的一个典型应用,通过数据绑定将模型数据展示在视图上,同时保持视图和模型的解耦

UserName 以及其他通过 Binding 属性指定的字段(如 Code, Name, Type, DateTime, ResolveDateTime, Description)都是 ViewSource 集合中元素的属性

DataGrid属性例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
x:Name="DG_BlackBox"
x:Name属性用于为XAML元素指定名称,以便在后台代码中引用该元素。在这里,DataGrid被命名为"DG_BlackBox",可以在后台代码中使用该名称来操作或访问DataGrid。
FontSize="20"
FontSize属性用于设置控件的字体大小。在这里,DataGrid的字体大小被设置为20
Grid.Row="1"
Grid.Row属性用于指定控件在Grid布局中所在的行索引。在这里,DataGrid被放置在Grid布局的第1行。
VerticalScrollBarVisibility="Visible"
VerticalScrollBarVisibility属性用于设置垂直滚动条的可见性。在这里,设置垂直滚动条始终可见。
Margin="5"
Margin属性用于设置控件的外边距。在这里,DataGrid的外边距被设置为5个逻辑单位。
ItemsSource="{Binding ViewSource}"
ItemsSource属性用于绑定DataGrid的数据源。在这里,DataGrid绑定到名为"ViewSource"的数据源。
CanUserAddRows="False"
CanUserAddRows属性用于设置用户是否可以添加新行。在这里,设置用户不能添加新行。
CanUserDeleteRows="False"
CanUserDeleteRows属性用于设置用户是否可以删除行。在这里,设置用户不能删除行。
CanUserReorderColumns="False"
CanUserReorderColumns属性用于设置用户是否可以重新排序列。在这里,设置用户不能重新排序列。
CanUserResizeRows="False"
CanUserResizeRows属性用于设置用户是否可以调整行的大小。在这里,设置用户不能调整行的大小。
CanUserSortColumns="False"
CanUserSortColumns属性用于设置用户是否可以对列进行排序。在这里,设置用户不能对列进行排序。
AutoGenerateColumns="False"
AutoGenerateColumns属性用于设置是否自动生成列。在这里,设置为不自动生成列。
IsReadOnly="True"
IsReadOnly属性用于设置DataGrid是否为只读。在这里,设置DataGrid为只读,用户不能编辑数据。
ScrollViewer.CanContentScroll="True"
ScrollViewer.CanContentScroll属性用于指定ScrollViewer是否按内容单位滚动。在这里,设置为按内容单位滚动。
VirtualizingPanel.ScrollUnit="Item"
VirtualizingPanel.ScrollUnit属性用于设置虚拟化面板的滚动单位。在这里,设置为按项滚动。
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.IsVirtualizing属性用于启用或禁用虚拟化。在这里,启用虚拟化以提高性能。
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.VirtualizationMode属性用于设置虚拟化模式。在这里,设置为"Recycling"以重用可视元素。

AutoGenerateColumns

AutoGenerateColumns 属性用于控制 DataGrid 控件中列的自动生成行为。它的主要作用是在数据绑定时决定是否自动根据数据源的属性生成列

AutoGenerateColumns:这个属性是一个布尔值,默认为 true。当设置为 true 时,DataGrid 会根据绑定的数据源(通常是集合类型,比如 ObservableCollection)的属性自动生成列。每个属性都会对应一个列,列的标题会使用属性名,列的数据类型会根据属性的类型自动推断。

如果你希望手动定义列,而不使用自动生成,可以将 AutoGenerateColumns 设置为 false。这时,你需要显式地定义 DataGrid 的列:

1
2
3
4
5
6
<DataGrid AutoGenerateColumns="False" ItemsSource="{Binding YourCollection}">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding Name}" />
<DataGridTextColumn Header="Age" Binding="{Binding Age}" />
</DataGrid.Columns>
</DataGrid

使用DataGrid实现一个全新的TreeGrid

参考文章

RowDetailsTemplate

可以实现分层显示细节模板

CollectionView

CollectionView是WPF中的一个类,用于对数据进行排序、过滤、分组等操作

CollectionView是一个抽象基类,它定义了数据源、排序规则、过滤规则、分页规则等属性和方法。CollectionView的实现类包括ListView、GridView、DataGrid等。这些类分别用于显示不同的数据布局和样式。

例如,ListView用于显示一个列表,DataGrid用于显示一个表格,GridView用于显示一个图标网格。这些类都继承自CollectionView,并实现了CollectionView的接口,从而具有相同的方法和属性。

使用CollectionView类可以方便地处理数据,例如:

排序:通过设置CollectionView的SortDescriptors属性,可以实现数据的自定义排序。
过滤:通过设置CollectionView的FilterConditions属性,可以实现数据的自定义过滤。
分页:通过设置CollectionView的PageSize和CurrentPage属性,可以实现数据的分页显示。
导航:通过实现CollectionView的Navigate方法,可以实现数据的导航操作,例如上一页、下一页、首页等。
总之,CollectionView是WPF应用程序中用于显示数据的一种控件,它提供了一种灵活的方式来处理数据,包括排序、过滤、分页、导航等

ProgressBar

进度条案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ProgressBar Height="25" Style="{StaticResource ProgressBarInfoStripe}" Minimum="0" Maximum="100" Value="{Binding Progress}">
<ProgressBar.Triggers>
<EventTrigger RoutedEvent="ProgressBar.ValueChanged">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Value"
From="{Binding RelativeSource={RelativeSource Self}, Path=Value}"
To="{Binding Progress,RelativeSource ={RelativeSource Self}}"
Duration="0:0:0.1"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</ProgressBar.Triggers>
</ProgressBar>

虚拟化/延迟滚动

  • 启用虚拟化:VirtualizingStackPanel.IsVirtualizing="True"

    • 动态回收/创建容器(VirtualizationMode.Standard)
    • 复用容器(VirtualizationMode.Recycling)

    VirtualizingStackPanel.VirtualizationMode="Recycling" 虚拟化容器复用机制可提升展开/折叠动画流畅度

  • 延迟滚动:ScrollViewer.IsDeferredScrollingEnabled="True"

    • 需要精确显示滚动位置时需禁用
    • 与触摸屏滚动存在兼容性问题时需测试

虚拟化: 仅实例化可视区域内的节点容器(如TreeViewItem),而非一次性创建全部容器

延迟滚动: 用户拖动滚动条时不实时更新内容,释放滚动块后才刷新,避免滚动过程中的频繁布局计算和渲染(快速滚动时不会出现”白屏”现象)

使用场景

场景特征 推荐方案 原因说明
纯展示型TreeView 关闭虚拟化 简化样式开发
含复杂数据绑定的业务系统 开启虚拟化 避免未来扩展时的重构成本
需要触摸屏操作 开启延迟滚动 提升滑动操作流畅度
节点含动态加载内容 开启虚拟化+延迟滚动 预防子项异步加载时的卡顿

数据量较少的情况下不开启的主要原因盘点如下

  1. 性能提升幅度 < 样式开发复杂度增加
  2. 可能引入不必要的渲染异常(如部分阴影效果失效)
  3. 在DevExpress等第三方控件中,虚拟化可能破坏内置样式

TreeView

非常适合用于展示有层级多层嵌套的数据

TreeView控件的主要功能包括:

  • 显示层次化的数据结构。
  • 支持节点折叠和展开。
  • 支持选中和取消选中节点。
  • 支持拖放节点重排。
  • 支持 CheckBox 显示,以支持复选框功能。

使用到一个叫层次结构数据模板来绑定元素

参考将json数据显示未TreeView

treeview详解

treeview中显示json数据转换器

TreeView原理探寻

主要属性

  • ItemsSource:指定TreeView控件的数据源,注意必须为一个集合
  • DisplayMemberPath:指定用于显示节点文本的属性路径。
  • SelectedItem:指定当前选中的节点。
  • IsExpanded:指定节点是否展开。
  • IsChecked:指定节点是否被选中。

TreeView如果要传递选中项,可以通过绑定Command的过程中传递参数来实现,如下:

1
2
3
4
5
6
<i:Interaction.Triggers>
<i:EventTrigger EventName="SelectedItemChanged">
<prism:InvokeCommandAction Command="{Binding SelectionChanged}"
TriggerParameterPath="NewValue" />
</i:EventTrigger>
</i:Interaction.Triggers>

常用方法

  • AddNode:在指定的父节点下添加一个新的子节点。
  • RemoveNode:删除指定的节点。
  • ClearNodes:清除指定节点的所有子节点。
  • ExpandAll:展开所有节点。
  • CollapseAll:折叠所有节点。

使用

注意TreeView如果直接使用ItemsSource绑定一个集合,其只能显示集合本身的数据,默认调用的是集合中每个对象的toString方法,如果没有重写对象的toString方法,就会显示类型信息的文本.

最重要的一点是,TreeView只使用ItemsSource是不能具备层次结构的,需要配合HierarchicalDataTemplate使用

使用案例如下

1
2
3
4
Classes --> Classes 包含 Groups  --> Group  包含 Students --> Student
Calssed: ObservableCollection<Class>
Groups: ObservableCollection<Group>
Students: ObservableCollection<Student>

两层案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<TreeView Height="100" ItemsSource="{Binding Groups}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Students}">
<!-- 单个Group项目录显示的名字 -->
<TextBlock Text="{Binding Students.Count}" />
<!-- 定义每个Group展开显示的模板 -->
<HierarchicalDataTemplate.ItemTemplate>
<DataTemplate DataType="{x:Type vm:Student}">
<WrapPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Margin="5,0,0,0" Text="{Binding Age}" />
</WrapPanel>
</DataTemplate>
</HierarchicalDataTemplate.ItemTemplate>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>

两层案例还有如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<TreeView Width="300" ItemsSource="{Binding LoopMastersManaget.LoopMasters}">
<TreeView.Resources>
<!-- 第一层模板:LoopMasterInfo -->
<HierarchicalDataTemplate DataType="{x:Type d:LoopMasterInfo}" ItemsSource="{Binding DeviceInfos}">
<TextBlock
Margin="8,0"
FontWeight="Bold"
Foreground="#333"
Text="{Binding Description}" />
</HierarchicalDataTemplate>
<!-- 第二层模板:DeviceInfo -->
<DataTemplate DataType="{x:Type d:DeviceInfo}">
<WrapPanel>
<TextBlock Text="{Binding DeviceHeader.DeviceTypeStr}" />
<TextBlock Text="{Binding DeviceHeader.Description}" />

</WrapPanel>
</DataTemplate>
</TreeView.Resources>
</TreeView>

三层案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<TreeView Height="100" ItemsSource="{Binding Classes}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Groups}">
<!-- 第一层:显示Class信息 -->
<TextBlock Text="{Binding Groups.Count}" />
<HierarchicalDataTemplate.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type vm:Group}" ItemsSource="{Binding Students}">
<!-- 第二层:显示Group信息 -->
<TextBlock Text="{Binding Students.Count}" />
<HierarchicalDataTemplate.ItemTemplate>
<!-- 第三层:显示Student信息 -->
<DataTemplate DataType="{x:Type vm:Student}">
<WrapPanel>
<TextBlock Text="{Binding Name}" />
</WrapPanel>
</DataTemplate>
</HierarchicalDataTemplate.ItemTemplate>
</HierarchicalDataTemplate>
</HierarchicalDataTemplate.ItemTemplate>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>

自定义选择层级显示实现案例

只是将Classes中包含的所有的Group中的所有Student罗列出来,只显示Group和Student两级,但是要显示Classes中所有的Class内的这些两级关系

以下是实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<TreeView Height="100" ItemsSource="{Binding AllGroups}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type vm:Group}"
ItemsSource="{Binding Students}">
<!-- 第一层:显示Group信息 -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding GroupName}" />
<TextBlock Text=" (" />
<TextBlock Text="{Binding Students.Count}" />
<TextBlock Text=" 个学生)" />
</StackPanel>
<!-- 第二层:显示Student信息 -->
<HierarchicalDataTemplate.ItemTemplate>
<DataTemplate DataType="{x:Type vm:Student}">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</HierarchicalDataTemplate.ItemTemplate>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>

样式模板

其模板使用了两个固定的名称:

  • ItemsHost 用于ItemsPresenter的名字表示内一级
  • PART_Header 用于ContentPresenter的名字表示上一级

自定义TreeView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<TreeView Height="100" ItemsSource="{Binding Groups}">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Students}">
<!-- 单个Group项目录显示的是其中学生的数量 -->
<TextBlock Text="{Binding Students.Count}" />
<!-- 定义每个Group展开显示的模板 -->
<HierarchicalDataTemplate.ItemTemplate>
<DataTemplate DataType="{x:Type vm:Student}">
<WrapPanel>
<TextBlock Text="{Binding Name}" />
<TextBlock Margin="5,0,0,0" Text="{Binding Age}" />
</WrapPanel>
</DataTemplate>
</HierarchicalDataTemplate.ItemTemplate>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
<!-- 下面设定样式 -->
<TreeView.Resources>
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TreeViewItem}">
<Border
x:Name="PART_Border"
HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
VerticalAlignment="{TemplateBinding VerticalAlignment}"
Background="{TemplateBinding Background}">
<!-- 下面是放入内容的意思 -->
<!-- TreeView的ItemSources绑定的是一个集合,有一个集合的迭代器一样,遍历的Item是什么类型呢?Item是一个上下文是你的成员ItemSources所对应迭代器具体对应的具体的实例值的TreeViewItem -->
<StackPanel>
<!-- TemplateBinding IsEnabled不行,必须是双向绑定的 -->
<CheckBox x:Name="PART_CheckBox" IsChecked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsExpanded, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Header" Content="{TemplateBinding Header}" />
<!-- 子节点的缩进关键是这个Margin -->
<ItemsPresenter
x:Name="ItemsHost"
Margin="10,0,0,0"
DataContext="{TemplateBinding ItemsSource}"
Visibility="Collapsed" />
</StackPanel>
</Border>
<!-- 支持展开收缩是如下代码 -->
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="ItemsHost" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsExpanded" Value="False">
<Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</TreeView.Resources>
</TreeView>

IsChecked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsExpanded, Mode=TwoWay}"

IsChecked="{TemplateBinding IsExpanded}"

注意:上面二者的区别在于后者是单向绑定(因为TemplateBinding都是单向绑定)

TreeView中如何获取选中值

对于ItemsControl控件我们通常可以去绑定SelectedItem来处理这个值

但由于TreeView存在父子结构,因此没法通过SelectedItem来处理值,因此在MVVM中获取TreeView选中的值就要使用到ICommand

ScrollViewer

拥有属性 VerticalScrollBarVisibility用于控制滚动条的显示与隐藏

注意该控件如果置于StackPanel中会失去滚动条效果,如果本身被限制在border中,可以置于Grid

Thumb

他所在的命名空间是 System.Windows.Controls.Primitives

表示可由用户拖动的控件,主要的三个事件分别是

  • DragDelta—一当 Thumb 控件具有逻辑焦点和鼠标捕获时,随看鼠标位置更改发生一次或多次。拖的过程中不断触发的事件
  • DragStarted—一在 Thumb 控件接收逻辑焦点和鼠标捕获时发生。开始拖的事件
  • DragCompleted—一在 Thumb 控件失去鼠标捕获时发生。结束拖的事件

Theumb和Drop拖动的区别就在于:后者没有拖动过程中的中间状态

弹出窗口

使用IsOpen属性控制窗口是否弹出

常用属性

  • AllowsTransparency 是否允许透明
  • PopupAnimation 出现动画
    • Slide 上往下滑动 ,正是comboBox下拉的默认动画
    • Scroll 缩放

自制Popup

添加更好的出现动画

添加自定义控件继承Popup

官方Popup所在为: System.Windows.Controls.Primitives

1
2
3
4
5
6
7
//弹性动画
protected override void OnOpened(EventArgs e)
{
var newanimation = new ThicknessAnimation(new Thickness(10),new Thickness(0),new Duration(TimeSpan.FromMilliseconds(400)));
newanimation.EasingFunction = new ElasticEase();//弹性
Child.BeginAnimation(MarginProperty,newanimation);
}

Tooltip

Tooltip 是一种用于显示附加信息的控件,当用户将鼠标悬停在某个控件上时,会弹出一个小窗口来显示这些信息

Tooltip是通过对Popup进行了一层小小的封装实现的

ComboBox

基本使用方式

1
2
3
<ComboBox ItemsSource="{Binding Students}" SelectedItem="{Binding Students[0]}" />
<!-- 也可以设置SelectedIndex为0,如下 -->
<ComboBox ItemsSource="{Binding Students}" SelectedIndex="0" />

内部构成

简述其构成:

  • ToggleButton 触发下拉框打开关闭
  • Popup 作为下拉框的框体

自定义子项模板样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<Style x:Key="ComboBoxItem" TargetType="ComboBoxItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<!-- 展开的选项卡的背景色设置为灰色 -->
<Border x:Name="PART_Border" Background="Gray">
<ContentControl Content="{TemplateBinding Content}" />
</Border>
<ControlTemplate.Triggers>
<!-- 设置鼠标悬浮颜色为橙色 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="PART_Border" Property="Background" Value="Orange" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

<!--使用方式-->
<ComboBox
ItemContainerStyle="{StaticResource ComboBoxItem}"
ItemsSource="{Binding Students}"
SelectedIndex="0" />

ItemsControl

用于显示数据集合的wpf控件,用于简单的列表显示

ItemsControl内部需要绑定ViewModel中的内容可以参考此处

ItemsControl中的每一项都是一个ContentPresenter

ItemsControl中的ItemTemplate属性会传递给每一个ContentPresenter的ContentTemplate属性,即表明每一个ContentPresenter应该如何显示

ItemsControl中有ItemsPanel则是控制每个ContentPresenter的布局

简单列表显示案例

1
2
3
4
5
6
7
8
<ItemsControl ItemsSource="{Binding Value.iDiValue}">
<!--ItemTemplate定义了如何显示每个数据项。在这里,每个数据项将使用DataTemplate中定义的模板进行显示。-->
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Margin="20,0,0,0"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

在使用ItemsControl的时候,先使用ItemsControl.ItemsPanel

1
2
3
4
5
6
7
<ItemsControl ItemsSource="{Binding Team.Students}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel Orientation="Vertical"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

一般来说如果只有一层,可以使用StackPanel,但是如果嵌套层级很复杂,就不要多个ItemsControl嵌套了,直接使用Treeview

VirtualizingStackPanel 是一个用于在垂直或水平方向上排列子元素的面板,它支持虚拟化。虚拟化的主要目的是提高性能,尤其是在处理大量数据时

虚拟化

  • 当使用 VirtualizingStackPanel 时,只有可见的元素会被创建和渲染。未在可视区域内的元素不会被创建,这样可以显著减少内存使用和提高性能。
  • 当用户滚动视图时,面板会动态地创建和释放元素,以确保只有当前可见的元素在界面上。

如果要显示的是结构体中的各个成员,方式如下

结构体中多成员显示含命令案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<ItemsControl ItemsSource="{Binding Students}">
<!-- ItemsControl.ItemsPanel描述怎么组织子项 -->
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal"/>
<!-- 也可以使用wrapPanel等 -->
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<!-- ItemsControl.ItemTemplate描述单个子项是怎么样的 -->
<ItemsControl.ItemTemplate>
        <!-- 此处指明DataType,才有成员补全功能-->
<DataTemplate DataType="{x:Type vm:Student}">
<StackPanel Orientation="Horizontal">
<TextBlock Foreground="red"
Text="{Binding Age}"/>
<Rectangle Width="10"
Fill="Aqua"/>
<TextBlock Foreground="Blue"
Text="{Binding Name}"/>
<Rectangle Width="10"
Fill="Aqua"/>
<TextBlock Foreground="Blue"
Text="{Binding Height}"/>
<Rectangle Width="40"
Fill="Transparent"/>
<Button Command="{Binding TestClick}"
CommandParameter="{Binding}">点击测试</Button>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

一般都配合 ScrollViewer使用,意义在于数据超出显示窗体的时候可以滚动

获取某一项的下标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class IndexConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
// 处理ListViewItem情况
var listViewItem = value as ListViewItem;
if (listViewItem != null)
{
var listView = ItemsControl.ItemsControlFromItemContainer(listViewItem) as ListView;
if (listView != null)
{
return listView.ItemContainerGenerator.IndexFromContainer(listViewItem);
}
}

// 处理ContentPresenter情况
var contentPresenter = value as ContentPresenter;
if (contentPresenter != null)
{
var itemsControl = ItemsControl.ItemsControlFromItemContainer(contentPresenter);
if (itemsControl != null)
{
return itemsControl.ItemContainerGenerator.IndexFromContainer(contentPresenter);
}
}

// 处理任何其他类型的项容器
var container = value as System.Windows.Controls.Primitives.Selector;
if (container != null)
{
var itemsControl = ItemsControl.ItemsControlFromItemContainer(container);
if (itemsControl != null)
{
return itemsControl.ItemContainerGenerator.IndexFromContainer(container);
}
}

return -1; // 返回一个无效的索引
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

使用Menu控件作为菜单栏的容器,并在其中添加MenuItem控件作为菜单项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Menu VerticalAlignment="Top" Background="LightGray">
<MenuItem Header="设置">
<MenuItem Command="{Binding NewCommand}" Header="_New" />
<MenuItem Command="{Binding OpenCommand}" Header="_Open" />
<MenuItem Command="{Binding SaveCommand}" Header="_Save" />
<Separator />
<MenuItem Command="{Binding ExitCommand}" Header="E_xit" />
</MenuItem>
<MenuItem Header="_Edit">
<MenuItem Command="{Binding UndoCommand}" Header="_Undo" />
<MenuItem Command="{Binding RedoCommand}" Header="_Redo" />
</MenuItem>
<MenuItem Header="帮助">
<MenuItem Command="{Binding AboutCommand}" Header="关于" />
</MenuItem>
</Menu>

ContentControl

使用 ContentControlDataTemplate 可以通过模板轻松切换整个内容

使用按钮动态切换几何图形可以使用到ContentControl

比如说port是一个抽象基类DevicePort的对象

1
<ContentControl Content="{Binding port}" />

定义的模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<Window.Resources>
<!-- SwitchPort -->
<DataTemplate DataType="{x:Type local:SwitchPort}">
<StackPanel>
<TextBlock Text="Switch Port Properties" FontWeight="Bold"/>
<TextBlock Text="First Control:"/>
<TextBox Text="{Binding firstCtl, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Second Control:"/>
<TextBox Text="{Binding secondCtl, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="First Feedback:"/>
<TextBox Text="{Binding firstFbk, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Second Feedback:"/>
<TextBox Text="{Binding secondFbk, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</DataTemplate>

<!-- AnalogMonitorPort -->
<DataTemplate DataType="{x:Type local:AnalogMonitorPort}">
<StackPanel>
<TextBlock Text="Analog Monitor Port" FontWeight="Bold"/>
<TextBlock Text="Port:"/>
<TextBox Text="{Binding port, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</DataTemplate>

<!-- ControllerPort -->
<DataTemplate DataType="{x:Type local:ControllerPort}">
<StackPanel>
<TextBlock Text="Controller Port" FontWeight="Bold"/>
<TextBlock Text="Control Set:"/>
<TextBox Text="{Binding controlSet, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Control Feedback:"/>
<TextBox Text="{Binding controlFdbk, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</DataTemplate>

<!-- 其他 DevicePort 子类的 DataTemplate... -->

</Window.Resources>

WPF 会根据 port 运行时的子类,自动选择 DataTemplate

ContextMenu

在 WPF 中,上下文菜单 ContextMenu 是一个独立的视觉树,不会自动继承父元素的 DataContext

解决方案

  • 使用 PlacementTarget
  • 使用 Tag 传递 DataContext

以在TextBlock中添加为例

1
2
3
4
5
6
7
8
<TextBlock>
<TextBlock.ContextMenu>
<ContextMenu>
<MenuItem Header="菜单项1"/>
<MenuItem Header="菜单项2"/>
</ContextMenu>
</TextBlock.ContextMenu>
</TextBlock>

注意,如果要绑定内容的话,要使用PlacementTarget,参考如下:

1
2
3
4
5
6
7
8
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem
Command="{Binding PlacementTarget.DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.SelectedItem, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Header="复制" />
</ContextMenu>
</ListBox.ContextMenu>

PlacementTarget

PlacementTarget是连接ContextMenu与父级控件的桥梁,解决了数据上下文隔离和参数传递问题。通过灵活结合RelativeSourceBindingProxy及代码动态设置,可在MVVM模式下高效实现菜单交互逻辑

PlacementTargetContextMenu的一个依赖属性,类型为UIElement。它表示触发ContextMenu显示的父级控件(如ListViewItemButton等)。通过该属性,可以定位到与菜单关联的UI元素

核心作用如下:

  • 定位菜单位置:确定ContextMenu相对于哪个控件弹出(默认基于鼠标右键点击的控件)。
  • 数据上下文传递:由于ContextMenu独立于可视化树,无法直接继承父级控件的DataContext,而PlacementTarget提供了访问父级数据上下文的途径

案例

绑定到父级控件的DataContext

由于ContextMenu默认无法继承父级控件的DataContext,导致无法直接绑定到ViewModel中的命令或属性

通过PlacementTarget访问父级控件的DataContext

1
2
3
4
5
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Command="{Binding DeleteCommand}"
CommandParameter="{Binding PlacementTarget.SelectedItem,
RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
</ContextMenu>
  • DataContext绑定到触发菜单的控件(如ListViewItem)的DataContext(即ViewModel)。
  • CommandParameter通过PlacementTarget.SelectedItem获取当前选中项

BindingProxy

注意,使用PlacementTarget只能往上找弹出元素的父级,因此可以利用父级控件的Tag存储ViewModel的Context,然后通过PlacementTarget来找到这个Tag来获得ViewModel的上下文

解决跨视觉树绑定问题

BindingProxy的作用: 将窗口或用户控件的DataContext(即ViewModel)传递给ContextMenu,确保Command绑定到正确的对象

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<UserControl.Resources>
<helper:BindingProxy x:Key="BindingProxy" Data="{Binding}" />
<helper:BindingProxy x:Key="HardwareManagerProxy" Data="{Binding HardwareManager}" />
</UserControl.Resources>

<!--针对ContextMenu-->
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu DataContext="{Binding Source={StaticResource BindingProxy}, Path=Data}">
<MenuItem Command="{Binding DeleteVirtualDevice}" Header="删除" />
<Separator />
</ContextMenu>
</Setter.Value>
</Setter>

<!--针对ItemsControl-->
<Label MinWidth="100">
<Label.Content>
<MultiBinding Converter="{StaticResource LoopMasterValueByIndexConfigConverter}">
<Binding Path="Data" Source="{StaticResource HardwareManagerProxy}" /><!--这里!!!-->
<!--与下面效果等同-->
<!-- <Binding Path="DataContext.HardwareManager" RelativeSource="{RelativeSource AncestorType=UserControl}" />-->
<Binding Path="Config" />
</MultiBinding>
</Label.Content>
</Label>

ListView

本身不直接支持分级显示,但可以结合 HierarchicalDataTemplateGroupStyle可以在WPF中实现分级的列表视图

  • ListView 是 WPF 中用于显示列表数据的控件。它可以使用 GridView 来显示多列数据。
  • GridView 允许您定义多个列,每列可以绑定到不同的数据字段。
1
2
3
4
5
6
7
8
9
10
11
<ListView Height="130" Margin="5">
<ListView.View>
<!--使用GridView来设计-->
<GridView>
<!--定义多个表格列-->
<GridViewColumn Header="Id" Width="40" DisplayMemberBinding="{Binding XPath=@Id}"/>
<GridViewColumn Header="Name" Width="100" DisplayMemberBinding="{Binding XPath=Name}"/>
<GridViewColumn Header="Price" Width="70" DisplayMemberBinding="{Binding XPath=Price}"/>
</GridView>
</ListView.View>
</ListView>

DisplayMemberBinding 属性用于指定绑定到数据源中某个字段的路径。在这个例子中,使用了 XPath 语法来指定数据的路径。

ListView中插入自增列案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<ListView
AlternationCount="{Binding CurrentDeviceInfo.Di.Count}"
ItemsSource="{Binding CurrentDeviceInfo.Di}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListView.View>
<GridView>
<!-- 自增序号 -->
<GridViewColumn Header="序号">
<!--利用CellTemplate来自定义每一项内容-->
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Text>
<!--↓IndexConverter刚需-->
<Binding
Converter="{StaticResource IndexConverter}"
Path="."
RelativeSource="{RelativeSource Mode=FindAncestor,
AncestorType={x:Type ListViewItem}}"
StringFormat="Di{0}" />
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>

<!-- Value Column -->
<GridViewColumn Header="状态">
<GridViewColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="4,0" Text="{Binding}" />
<Ellipse
Width="8"
Height="8"
Margin="4,0"
Fill="{Binding Converter={StaticResource Bool2Color}}" />
</StackPanel>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>

索引生成的核心机制在于下面:

<Binding Converter="{StaticResource IndexConverter}" Path="." RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType={x:Type ListViewItem}}"/>

  • 通过RelativeSource定位到当前项的ListViewItem容器
  • 使用自定义的IndexConverter转换器获取索引位置

IndexConverter转换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;

namespace Communicator.Converter
{
public class IndexConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (value is ListViewItem listViewItem)
{
var listView = ItemsControl.ItemsControlFromItemContainer(listViewItem) as ListView;
if (listView != null)
{
// 确保容器生成完成
if (listView.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated)
{
var index = listView.ItemContainerGenerator.IndexFromContainer(listViewItem);
//Debug.WriteLine($"Found index: {index}");
return index >= 0 ? $"{index}" : "";
}
}
}
return "";
}

public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

TabControl

通常用于在一个窗口中显示多个选项卡(Tab),选项卡中可以包含不同的内容,用户可以通过点击不同的选项卡来切换显示的内容.TabControl在许多应用程序中都很常见,特别是在需要组织大量信息或功能时,比如设置界面,文档管理等

组织形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<TabControl ItemsSource="{Binding Students}" />

<!--TabStripPlacement用于设置标签的排列位置,默认是Top-->
<TabControl TabStripPlacement="Left">
<!-- Header定义选项卡的名称显示 -->
<TabItem DataContext="{Binding Students[0]}" Header="{Binding Name}">
<!-- Content属性是个Object类型的,什么都可以填,用于表示选项卡中的内容是 -->
<ContentControl Content="{Binding}" DataContext="{Binding}" />
</TabItem>
<TabItem DataContext="{Binding Students[1]}" Header="{Binding Name}">
<ContentControl Content="{Binding}" DataContext="{Binding}" />
</TabItem>
<TabItem DataContext="{Binding Students[2]}" Header="{Binding Name}">
<ContentControl Content="{Binding}" DataContext="{Binding}" />
</TabItem>
</TabControl>

TabControl中,其模板中内含一个TabPanel,用于陈列标签;

自定义子项模板如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 <Style x:Key="TabItem.NormalStyle" TargetType="TabItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border
x:Name="PART_Border"
Margin="10,0,0,0"
Background="Orange">
<ContentControl Content="{TemplateBinding Header}" />
</Border>
<ControlTemplate.Triggers>
<!-- 设置选中颜色为紫色 -->
<!-- IsSelected也可以写成Selector.IsSelected -->
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="PART_Border" Property="Background" Value="Purple" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>

</Setter.Value>
</Setter>
</Style>

<!--使用上面的子项模板样式如下-->
<ListBox ItemContainerStyle="{StaticResource ListBoxItem.NormalStyle}" ItemsSource="{Binding Students}" />

ListBox

列表框

ListBox是一个Selector的ItemControls

1
<ListBox ItemsSource="{Binding Students}" />

自定义ListBox:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!--此处定义的样式,在下方的ListBox中使用-->
<Window.Resources>
<Style x:Key="ListBoxItem.NormalStyle" TargetType="ListBoxItem">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Border
x:Name="PART_Border"
Height="20"
Background="Aqua"
BorderBrush="Black"
BorderThickness="2">
<!-- 必须加这个才可以显示具体的内容 -->
<ContentPresenter Content="{TemplateBinding ContentControl.Content}" />
</Border>
<ControlTemplate.Triggers>
<!-- 通过选择器的属性设置选中变色 -->
<Trigger Property="Selector.IsSelected" Value="True">
<Setter TargetName="PART_Border" Property="Background" Value="Pink" />
</Trigger>
<!-- 通过选择器的属性设置鼠标悬浮变色 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="PART_Border" Property="Background" Value="Green" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>

<!--注意设置集合容器的模板样式,都是通过ItemContainerStyle属性-->
<ListBox ItemContainerStyle="{StaticResource ListBoxItem.NormalStyle}" ItemsSource="{Binding Students}" />

ListBox的ItemSource绑定的是 ObservableCollection<ObservableCollection<byte>>,那么每个 ObservableCollection<byte>使用下面的方式告诉他如何解析

1
2
3
4
5
6
7
8
9
10
<ListBox
Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding RecvHistories}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ByteCollectionToStringConverter}}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

ListBox的item没法直接使用MVVM的方式绑定到命令,因此需要在后台文件中进行中转,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!--Handycontrol基础上的黑白条纹间隔加点击触发Command实现-->
<Window.Resources>
<Style
x:Key="ZebraListBoxItemStyle"
BasedOn="{StaticResource ListBoxItemBaseStyle}"
TargetType="ListBoxItem">
<Style.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<!-- 0表示奇数项灰色,1表示偶数项灰色 -->
<Condition Property="ItemsControl.AlternationIndex" Value="0" />
<Condition Property="IsSelected" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="Background" Value="#F5F5F5" />
</MultiTrigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsSelected" Value="False" />
</MultiTrigger.Conditions>
<!-- 鼠标悬浮的灰色在handycontrol基础上调整: 加深一些 -->
<Setter Property="Background" Value="#E0E0E0" />
</MultiTrigger>
</Style.Triggers>
</Style>
</Window.Resources>

<ListBox
Grid.Column="0"
AlternationCount="2"
ItemsSource="{Binding ButtonList}"
SelectedValuePath="Value"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock
Margin="10,5"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Key}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style BasedOn="{StaticResource ZebraListBoxItemStyle}" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</ListBox.ItemContainerStyle>
<i:Interaction.Triggers>
<i:EventTrigger EventName="SelectionChanged">
<prism:InvokeCommandAction Command="{Binding NavigateCommand}" CommandParameter="{Binding SelectedValue, RelativeSource={RelativeSource AncestorType=ListBox}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</ListBox>

ListBox中的Item无法直接绑定Command,因此需要通过xmlns:i="http://schemas.microsoft.com/xaml/behaviors"中转,代码如下:

ListView

用于显示列表项的控件

  • ListBox 的一个扩展,提供更强大的功能。
  • 支持多种视图(如详细视图、列表视图等),可以显示更复杂的数据结构。
  • 允许使用数据模板和列定义,适合展示更丰富的信息。

使用场景而言,与ListBox的区别如下:

  • 如果你的需求只是简单的选择列表项,ListBox 是一个合适的选择。
  • 如果你需要展示更复杂的数据,或者需要对数据进行更详细的控制和展示,ListView 更为合适。

基本使用案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ListView ItemsSource="{Binding Students}">
<ListView.View>
<GridView>
<GridViewColumn
Width="120"
DisplayMemberBinding="{Binding Name}"
Header="Name" />
<GridViewColumn
Width="120"
DisplayMemberBinding="{Binding Height}"
Header="Hegiht" />
<GridViewColumn
Width="120"
DisplayMemberBinding="{Binding Age}"
Header="Age" />
</GridView>
</ListView.View>
</ListView>

image-20241206170211560

进阶用法案例

内含在ListView中获取下标的使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<ListView
Grid.Row="1"
Grid.Column="1"
AlternationCount="{Binding CurrentDeviceInfo.Aout.Count}"
ItemsSource="{Binding CurrentDeviceInfo.Aout}"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListView.View>
<GridView>
<!-- Index Column -->
<GridViewColumn Width="80" Header="序号">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Text>
<Binding
Converter="{StaticResource IndexConverter}"
Path="."
RelativeSource="{RelativeSource Mode=FindAncestor,
AncestorType={x:Type ListViewItem}}"
StringFormat="AOUT{0}" />
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>

<!-- Value Column -->
<GridViewColumn Header="十进制值">
<GridViewColumn.CellTemplate>
<DataTemplate>
<hc:NumericUpDown
Width="80"
Maximum="65535"
Minimum="0"
Value="{Binding Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="十六进制值">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Width="60" Text="{Binding Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Converter={StaticResource HexBindingConverter}}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>

<!-- Description Column -->
<GridViewColumn Width="160" Header="描述">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Text>
<MultiBinding Converter="{StaticResource IndexAndListToDescriptionConverter}">
<Binding
Converter="{StaticResource IndexConverter}"
Path="."
RelativeSource="{RelativeSource Mode=FindAncestor,
AncestorType={x:Type ListViewItem}}" />
<!-- AoutNameList 绑定 -->
<Binding Path="DataContext.CurrentDeviceInfo.DeviceHeader.Config.AoutNameList" RelativeSource="{RelativeSource AncestorType=Window}" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>

<!-- 动作列 -->
<GridViewColumn Header="动作">
<GridViewColumn.CellTemplate>
<DataTemplate>
<UniformGrid Columns="2" Rows="1">
<Button
Command="{Binding DataContext.SetAoutValue, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding Path=., RelativeSource={RelativeSource AncestorType=ListViewItem}, Converter={StaticResource IndexConverter}}"
Content="设置值" />
</UniformGrid>

</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>

</GridView>
</ListView.View>
</ListView>

CheckBox

自定义CheckBox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<CheckBox Content="hello world">
<CheckBox.Template>
<ControlTemplate TargetType="CheckBox">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<!-- 框体 -->
<Border Background="Transparent">
<Rectangle x:Name="RectMark" Fill="Red" />
</Border>
<!-- 文字部分 -->
<ContentPresenter Grid.Column="1" Content="{TemplateBinding Content}" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="RectMark" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsChecked" Value="False">
<Setter TargetName="RectMark" Property="Visibility" Value="Hidden" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</CheckBox.Template>

</CheckBox>

这里写的版本是很简陋的,主要是体现CheckBox的点击切换效果

HierarchicalDataTemplate

HierarchicalDataTemplate是能够帮助层级控件显示层级数据的模板,一般多用于MenuItem和TreeViewItem,也可自己实现层级数据结构。

层级结构——它是能描述层次关系的结构,即有上下级关系

参考TreeView

TextBlock

文本块标签:用于显示文本

run标签

其中允许run标签:

Run 标签是一个用于在文本块中显示文本的内联元素。Run 标签不能单独使用,它必须嵌套在其他文本相关的容器内,例如 TextBlockRichTextBoxFlowDocument

1
2
3
4
<TextBlock Width="120">
<Run Text="再生状态:"/>
<Run Text="{Binding HardwareManager.BokePumpController.Pump1.RegenerateStatus}"/>
</TextBlock>

如果想添加带边框的文本,可以使用label控件

Image

用于展示图片的控件

1
<Image RenderOptions.BitmapScalingMode="HighQuality" Source="images/log.png" Height="30" Width="30"/>

label

可以带边框的文本控件

其可以直接设置BorderBrush和BorderThickness属性,也可以额设置Padding属性(内边距)

TextBox

textbox可以使用下面的语法监听键盘事件:

1
2
3
4
5
6
7
<TextBox Width="60"
Text="{Binding Path=Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Converter={StaticResource HexBindingConverter}}">
<TextBox.InputBindings>
<KeyBinding Command="{Binding ConfirmCommand}" Gesture="Ctrl+Enter" />
<MouseBinding Command="{Binding ClickCommand}" Gesture="LeftClick" />
</TextBox.InputBindings>
</TextBox>

InputBindings 是一种优雅的方式,将用户输入和命令绑定解耦,使得代码结构更清晰,更易于维护,特别适用于 MVVM 模式

只会作用于当前拥有焦点的控件。

自定义TextBox

微软用到的特殊的模板名字叫 PART_ContentHost

只有Decorator或ScrollViewer元素可以用作 PART_ContentHost,只要起名为这个就可以输入

在绘制的代码中,会找到名为这个的控件

手写一份TextBox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 <TextBox SelectionBrush="Orange" Width="100">
<TextBox.Template>
<ControlTemplate TargetType="TextBox">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Decorator x:Name="PART_ContentHost"/>
<Rectangle
Grid.Row="1"
Height="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Fill="{Binding SelectionBrush, RelativeSource={RelativeSource TemplatedParent}}" />
<!--👆🏻此Fill跟随SelectionBrush的变化而变化-->
</Grid>
</ControlTemplate>
</TextBox.Template>
</TextBox>

<!--可将模板拆解到样式中如下-->
<Window.Resources>
<Style x:Key="WPFTest.TextBox.NormalStyle" TargetType="TextBox">
<Setter Property="SelectionBrush" Value="Orange" />
<Setter Property="Template">
<Setter.Value>
<!--此处再次写TargetType="TextBox"的原因是为了让底下如果存在的TemplateBinding或Trigger的属性可以找到TextBox的字段(但是Trigger有点bug,不会智能提示)-->
<ControlTemplate TargetType="TextBox">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Decorator x:Name="PART_ContentHost"/>
<Rectangle
Grid.Row="1"
Height="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Fill="{Binding SelectionBrush, RelativeSource={RelativeSource TemplatedParent}}" />
<!--👆🏻此Fill跟随SelectionBrush的变化而变化-->
<!--上方的ControlTemplate如果写了TargetType,使用TemplateBinding才能找到对应的属性:Fill="{TemplateBinding SelectionBrush}"-->
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>

<TextBox Style="{StaticResource WPFTest.TextBox.NormalStyle}" Width="100"/>

继续完善(输入文本超出界限并未处理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<Window.Resources>
<Style x:Key="WPFTest.TextBox.NormalStyle" TargetType="TextBox">
<Setter Property="SelectionBrush" Value="Orange" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border
x:Name="PART_Border"
BorderBrush="{TemplateBinding SelectionBrush}"
BorderThickness="1"
CornerRadius="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Decorator x:Name="PART_ContentHost" />
<Rectangle
x:Name="PART_Rectangle"
Grid.Row="1"
Height="2"
Margin="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Fill="{TemplateBinding SelectionBrush}" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Trigger.Setters>
<Setter TargetName="PART_Rectangle" Property="Margin" Value="2" />
<Setter TargetName="PART_Border" Property="BorderThickness" Value="2" />
</Trigger.Setters>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>

Button

自定义Button

可以参考官方Button的源码

将会发现Button中的Click其实就是通过OnMouseLeftButtonUp和OnMouseLeftButtonDown实现的一个事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 <Window.Resources>
<Style x:Key="NormalStyleForButton" TargetType="Button">
<Setter Property="Background" Value="White"></Setter>
<Setter Property="BorderBrush" Value="Orange"></Setter>
<Setter Property="BorderThickness" Value="5"></Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="PART_Border"
Margin="0"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"> <!--此处由于外边控件实际上没有CornerRadius属性,因此需要自己提供一个依赖属性封装一个新的控件就可以在外界设置这个属性了-->
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
TextElement.FontFamily="{TemplateBinding FontFamily}"
TextElement.FontSize="{TemplateBinding FontSize}"
TextElement.Foreground="{TemplateBinding Foreground}"
/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Button Margin="30" Content="Click Me" Style="{StaticResource NormalStyleForButton}"/>

ToggleButton

自定义ToggleButton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<ToggleButton
Width="400"
Height="200"
Content="hello">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border
BorderBrush="Black"
BorderThickness="1"
CornerRadius="100">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Ellipse
x:Name="InnerShape"
Width="150"
Height="150"
Fill="Black" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="true">
<Setter TargetName="InnerShape" Property="Grid.Column" Value="0" />
</Trigger>
<Trigger Property="IsChecked" Value="false">
<Setter TargetName="InnerShape" Property="Grid.Column" Value="1" />
</Trigger>
</ControlTemplate.Triggers>

</ControlTemplate>
</ToggleButton.Template>

</ToggleButton>

同样需要做精修,主要是体现实现原理

RichTextBox

一个可支持显示或编辑丰富内容(包括段落、超链接和内联图像)的控件

两个RichTextBox同步滚动

如果是写在xaml.cs文件中,只需要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace searchTest.Views
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}

private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
var richTextBox = FindName("richTextBox") as RichTextBox;
var richTextBoxSearch = FindName("richTextBoxSearch") as RichTextBox;

if (richTextBox != null && richTextBoxSearch != null)
{
var richTextBoxScrollViewer = GetScrollViewer(richTextBox);
var richTextBoxSearchScrollViewer = GetScrollViewer(richTextBoxSearch);

if (richTextBoxScrollViewer != null && richTextBoxSearchScrollViewer != null)
{
richTextBoxScrollViewer.ScrollChanged += (s, ev) => SyncScroll(richTextBoxScrollViewer, richTextBoxSearchScrollViewer);
richTextBoxSearchScrollViewer.ScrollChanged += (s, ev) => SyncScroll(richTextBoxSearchScrollViewer, richTextBoxScrollViewer);
}
}
}

private void SyncScroll(ScrollViewer source, ScrollViewer target)
{
target.ScrollToVerticalOffset(source.VerticalOffset);
target.ScrollToHorizontalOffset(source.HorizontalOffset);
}

private ScrollViewer GetScrollViewer(DependencyObject dep)
{
if (dep is ScrollViewer) return dep as ScrollViewer;

for (int i = 0; i < VisualTreeHelper.GetChildrenCount(dep); i++)
{
var child = VisualTreeHelper.GetChild(dep, i);
var result = GetScrollViewer(child);
if (result != null) return result;
}
return null;
}
}
}

RichTextBox详解

主要属性

WPF中RichTextBox控件的主要属性如下:

  1. Text:用于获取或设置RichTextBox中的纯文本内容。
  2. Document:用于获取或设置RichTextBox中的文档内容,这可以是一个FlowDocument对象。
  3. IsReadOnly:用于获取或设置RichTextBox是否为只读模式。
  4. IsDocumentEnabled:用于获取或设置RichTextBox是否启用文档功能。
  5. Selection:用于获取或设置RichTextBox中选中文本的范围。
  6. VerticalScrollBarVisibility:用于获取或设置RichTextBox中垂直滚动条的可见性。
  7. HorizontalScrollBarVisibility:用于获取或设置RichTextBox中水平滚动条的可见性。
  8. AcceptsTab:用于获取或设置RichTextBox是否可以接受Tab键输入。
  9. Background:用于获取或设置RichTextBox的背景色。
  10. Foreground:用于获取或设置RichTextBox中前景色(文本颜色)。
  11. FontSize:用于获取或设置RichTextBox中文本的字号大小。
  12. FontFamily:用于获取或设置RichTextBox中文本的字体。
  13. FontWeight:用于获取或设置RichTextBox中文本的字重。
  14. FontStyle:用于获取或设置RichTextBox中文本的字体样式(如斜体、加粗等)。
  15. TextWrapping:用于获取或设置RichTextBox中文本的换行方式。

WPF中的RichTextBox控件常用于以下场景:

  1. 编辑富文本内容:可以让用户在控件中编辑富文本内容,包括文字、图像、表格等。
  2. 显示富文本内容:可以在控件中显示富文本内容,包括从外部文件加载的内容或通过编程动态生成的内容。
  3. 实现文本格式化:可以对富文本内容进行格式化,例如加粗、斜体、下划线、字体、字号、颜色等。
  4. 实现输入验证:可以对用户输入的文本进行验证,例如限制输入的字符类型、长度、格式等。
  5. 实现文本搜索和替换:可以对富文本内容进行搜索和替换,方便用户快速定位和修改内容。
  6. 实现语法高亮:可以在富文本内容中实现语法高亮显示,例如在代码编辑器中显示关键字、注释等。

RichTextBox控件是一个非常强大和灵活的控件,可以满足各种富文本编辑和显示的需求。

常用属性罗列

Margin

间隔

1
2
3
4
5
6
<Button Margin="10"/>
<!--表示左右隔开10-->
<Button Margin="10,20"/>
<!--表示左右隔开10,上下隔开20-->
<Button Margin="10,20,30,40"/>
<!--分别表示左上右下;左隔开10.上隔开20,右隔开30,下隔开40-->

Width/Height

宽/高

1
2
3
<Button Width="1*"></Button>
<Button Width="9*"></Button>
<!--在当前空间中第一个按钮宽占1/10,第二个按钮宽占9/10-->

自动适应内容大小,可以将 WidthHeight设置为 "auto"

VerticalAlignment/HorizontalAlignment

垂直对齐/水平对齐

用于控制元素在容器中的垂直和水平对齐方式的属性。

设置为Stretch,表示在对应方向拉伸缩填满

像button这样的控件还有 VerticalContentAlignment/HorizontalContentAlignment用于控制其中的文本在按钮中的对齐方式的属性

控件组织结构

逻辑树可以在类的构造函数中遍历

视觉树必须在经过至少一次的布局后才能形成,所以它不能在构造函数遍历。通常是在OnContentRendered进行,这个函数为在布局发生后被调用

这里涉及到与WinForms的显著差异

  • WinForm中使用的是控件层次结构来描述控件的组织方式,每个空间都通过父控件与子控件形成层次关系
  • WPF中使用逻辑树与视觉树来组织控件

逻辑树的结构

辑树中的每个节点代表一个 DependencyObject,通常是 UI 元素(如控件、窗口等)。这些元素之间通过父子关系连接在一起

辑树用于处理数据绑定和事件路由。数据绑定通常是基于逻辑树的结构进行的,事件的路由也是从逻辑树的根节点向下传播

视觉树表示实际渲染的元素,包括所有的视觉表现(如形状、颜色等),而逻辑树则关注元素的结构和关系。逻辑树中的元素不一定在视觉树中都有对应的可视元素

查找控件

FindName

FindName 方法用于在 XAML 名称范围内查找具有特定名称的元素。它通常用于在代码隐藏文件中查找由 x:Name 标识的控件。

优点:

  • 简单直接,易于使用。
  • 性能较好,因为它直接在名称范围内查找控件。

缺点:

  • 只能查找当前名称范围内的控件,不能跨越不同的名称范围。
  • 如果控件在不同的命名空间或模板中,可能无法找到。
1
2
//使用方法如下
RichText = System.Windows.Application.Current.MainWindow.FindName("richTextBox") as RichTextBox;

逻辑树与视觉树

  • 视觉树是一种具体的UI结构,它表示了UI元素的渲染细节和视觉效果,例如布局、样式、动画、变换等。
  • 逻辑树是一种抽象的UI结构,它表示了UI元素之间的逻辑关系和层次关系,例如父 子关系、资源继承、事件路由等。

下图展示了XAML的逻辑树(无阴影)与视觉树(有阴影)

image-20250104165723110
视觉树

System.Windows.Media.VisualTreeHelper

视觉树(Visual Tree)是 WPF 中的一个概念,它表示应用程序中所有可视元素的层次结构。视觉树包含了所有的 UI 元素,包括用户能看到的控件和一些用户看不到的辅助元素。

VisualTreeHelper 是一个静态类,用于遍历和操作视觉树。它允许你递归地查找子元素,适用于更复杂的场景。

  • 逻辑树: 包含了应用程序中所有的元素,包括控件、数据对象和资源。它反映了 XAML 文件中的元素层次结构。
  • 视觉树: 仅包含可视元素,反映了实际渲染的元素层次结构。视觉树是逻辑树的一个子集,但包含更多的细节,如模板生成的元素。

视觉树的作用:

  • 渲染: 视觉树用于渲染 UI 元素。
  • 事件路由: 视觉树用于路由输入事件(如鼠标和键盘事件)。
  • 布局: 视觉树用于计算和安排元素的布局。

优点:

  • 可以递归查找子元素,适用于复杂的控件层次结构。
  • 不受名称范围限制,可以跨越不同的模板和命名空间。

缺点:

  • 代码相对复杂,需要递归遍历视觉树。
  • 性能可能较差,特别是在视觉树很深的情况下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/// <summary>
/// 在视觉树中递归查找具有特定名称的一个子元素
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="parent">作为查找的起点,方法会从这个元素开始递归查找其子元素</param>
/// <param name="childName">要查找的子元素的名称。这个名称是通过 x:Name 属性在 XAML 中定义的</param>
/// <returns></returns>
private T FindChild<T>(DependencyObject parent, string childName) where T : DependencyObject
{
if (parent == null) return null;

T foundChild = null;

int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < childrenCount; i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
T childType = child as T;
if (childType == null)
{
foundChild = FindChild<T>(child, childName);
if (foundChild != null) break;
}
else if (!string.IsNullOrEmpty(childName))
{
var frameworkElement = child as FrameworkElement;
if (frameworkElement != null && frameworkElement.Name == childName)
{
foundChild = (T)child;
break;
}
}
else
{
foundChild = (T)child;
break;
}
}
return foundChild;
}
//使用方式
RichText = FindChild<RichTextBox>(System.Windows.Application.Current.MainWindow, "richTextBox");
逻辑树

System.Windows.LogicalTreeHelper

在 WPF 中,逻辑树(Logical Tree)表示应用程序中所有元素的层次结构,包括控件、数据对象和资源。逻辑树反映了 XAML 文件中的元素层次结构。与视觉树不同,逻辑树更关注元素之间的关系,而不是它们的渲染细节。

LogicalTreeHelper 类提供了一些静态方法,用于遍历和操作逻辑树。你可以使用这些方法递归查找逻辑树中的子元素。
常用方法:

  • LogicalTreeHelper.GetChildren(DependencyObject parent): 获取指定元素的子元素集合。
  • LogicalTreeHelper.GetParent(DependencyObject child): 获取指定元素的父元素。
  • LogicalTreeHelper.FindLogicalNode(DependencyObject logicalTreeNode, string elementName): 在逻辑树中查找具有指定名称的元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//递归查找逻辑树中的一个指定x:Name的控件
private T FindLogicalChild<T>(DependencyObject parent, string childName) where T : DependencyObject
{
if (parent == null) return null;

foreach (object child in LogicalTreeHelper.GetChildren(parent))
{
if (child is DependencyObject depChild)
{
if (depChild is T && ((FrameworkElement)depChild).Name == childName)
{
return (T)depChild;
}

T result = FindLogicalChild<T>(depChild, childName);
if (result != null)
{
return result;
}
}
}
return null;
}

自绘控件

WPF的底层不是实时绘制更新的,而是事件驱动的绘制更新.最终会触发windows的paint消息,然后交给设备上绘制相关的api

ArrangeOverride是调用OnRender

可以在OnRender中绘制东西,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyPanel: Panel
{
protected override Size ArrangeOverride(Size arrangeBounds)
{
return base.ArrangeOverride(arrangeBounds);
}

protected override void OnRender(DrawingContext dc)
{
dc.DrawRectangle(Background,null,new Rect(0,0,Width,Height));
dc.DrawEllipse(Brushes.LightGreen,null,new Point(100,100),50,50);
}
}

Panel–继承于–>FrameworkElement–继承于–>UIElement–继承于–>Visual

OnRender就来源于Visual

完全自定义窗口

完全自定义窗口参考下面的做法:

1
2
AllowsTransparency="True"
WindowStyle="None"

上面两行Window标签中的属性设置是为了实现去除窗口默认标题栏的效果

1
2
3
4
<!--WindowChrome是一个作用于Window的附加属性,有了WindowChrome之后就可以拉伸拖拽等种种功能-->
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeighty="100"/>
</WindowChrome.WindowChrome>

但是WindowChrome有缺陷,会导致全屏的时候显示不完全,一般要对界面中的内容设置Margin为10或13 或 WindowChrome的GlassFrameThickness设置为10或13

简易反调试检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[DllImport("kernelbase.dll", SetLastError = true)]
static extern bool IsDebuggerPresent();

static void Main(string[] args)
{
Console.ReadLine();
var isAttached = IsDebuggerPresent();
if (isAttached)
{
Console.WriteLine("/(ToT)/~~ 小心,我被附加了 调试器!");
}
else
{
Console.WriteLine("O(n_n)O 程序很安全!");
}
Console.ReadLine();
}

最基本的反附加调试检测

窗口/页面/用户控件

窗口(Window)

  • 表示一个独立的顶级窗口,通常是应用程序的主要界面。
  • 可以包含其他控件和布局。
  • 具有标题栏、边框等窗口特征。
  • 可以最小化、最大化、关闭。

页面(Page):

  • 通常用于导航框架中的页面。
  • 与窗口类似,但可能具有特定的导航行为和约定。
  • 常用于单页应用程序或多页面应用程序。

用户控件(User Control):

  • 可重用的自定义控件。
  • 可以包含其他控件和布局。
  • 用于封装特定的功能或界面部分。
  • 可在多个窗口或页面中重复使用。

窗口

窗口上面含的东西大致如下:

img

自定义拖拽控制窗体大小

自定义的调整大小手柄,放置在窗口的右下角

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SendMessage 函数的 P/Invoke 声明,允许我们从 C# 代码调用 Windows API 函数。
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr m(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

private void ResizeWindow(ResizeDirection direction)
{
WindowInteropHelper helper = new WindowInteropHelper(this);
SendMessage(helper.Handle, 0x112, (IntPtr)(61440 + direction), IntPtr.Zero);
//事件处理:这个方法处理 ResizeGrip 的鼠标点击事件。单击时调整大小,双击时最大化或还原窗口。
private void ResizeGrip_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
{
ToggleMaximize();
}
else
{
ResizeWindow(ResizeDirection.BottomRight);
}
}

这个方法使用 Windows API 来调整窗口大小。具体来说:

  • WindowInteropHelper 用于获取窗口的句柄(Handle)。
  • SendMessage 是一个 Windows API 函数,用于向窗口发送消息。
  • 消息 0x112 (WM_SYSCOMMAND) 用于执行系统命令。
  • 61440 + direction 指定了调整大小的方向(例如,61448 表示从右下角调整大小)。

绑定

绑定的底层实现注意事项

WPF 的数据绑定是运行时绑定,不是编译时绑定。当 XAML 解析器遇到绑定表达式{Binding SelectedDeviceConfig.port.controlSet.LeftLogicRange}时,它会:

  • 在运行时找到 SelectedDeviceConfig 对象
  • 通过反射获取 port 属性的实际值
  • 继续通过反射访问 controlSet 属性
  • 最后获取 LeftLogicRange 属性

因此即使port是个基类,PowerPort中是继承port的子类,controlSet这个属性是PowerPort中的属性,也可以直接用port点出来

在绑定之前所使用的是通过设置 x:Name设置索引名,然后在c#代码中使用设置的Name来获取该控件,下面介绍更方便的绑定方式

绑定顾名思义,是将我们获取到的数据和UI上的控件绑定起来利用数据的变化来更新界面所看到的内容 把绑定分为五步

  1. 绑定目标 就是要操作绑定的控件,如:Button,TextBox

  2. 绑定属性 就是依赖项属性

    <TextBox Width="200" Height="25" Text="{Binding Name}"></TextBox>

    Text是绑定属性,Bingding是绑定关键字,而后面的Name就是要绑定的数据源的变量名(Name是声明好的属性变量)

  3. 绑定模式

    <TextBox Width="200" Height="25" Text="{Binding Name,Mode=绑定模式}"></TextBox>

    • TwoWay 双向绑定模式:源和目标相互影响。当源发生变化时目标更新,同时目标属性的更改也会反馈到源

    • OneWay 单向绑定模式:源的变化会自动传播到目标属性,但目标属性的更改不会影响到源。这是比较常用的模式,用于显示数据源的实时变化

    • OneTime 仅当应用程序启动时或DataContext进行更改时更新目标属性

      当数据上下文(DataContext)发生改变时,比如在不同的视图或页面之间切换,导致绑定所关联的数据上下文对象被替换为新的,这时在新的数据上下文下,绑定会按照 OneTime 模式的规则进行一次更新。

    • OneWayToSource 反向的单向绑定:与 OneWay模式相反,当目标属性发生更改时,会自动更新源属性

    • Default 模式根据实际情况来定,如果目标属性是可编辑的就是TwoWay,目标属性是只读的就是OneWay

可以类比为Vue的单向绑定 v-bind和双向绑定 v-model

  1. 绑定数据源

    可以是单个变量,也可以是一个数据集(List)等等

  2. 关联资源

    在每一个窗体中,都有一个DataContext,他是一个object类型,主要用于存储绑定资源

    DataContext 用于将数据库映射为实体,它是实现数据绑定的基础。在 MVVM 架构模式中,通常将 ViewModel 设置为控件或整个界面的 DataContext,这样 UI 控件可以通过绑定直接访问 ViewModel 中的数据和命令。

  3. 绑定时机

    还可以使用 Text = "{Binding Acount,UpdateSourceTrigger = 绑定时机}"

    在 WPF 中,UpdateSourceTrigger是一个枚举,用于确定绑定源(比如视图模型中的属性)的更新时机。它对于数据绑定非常重要,因为它控制着用户界面上的更改何时反映到绑定的数据源属性上。

    UpdateSourceTrigger的值包括

    • Default:使用绑定目标属性的默认更新触发器。对于大多数依赖属性是 LostFocus,但对于TextBox.Text属性是 PropertyChanged
    • PropertyChanged:每当目标属性的值发生变化时,都会更新源属性。
    • LostFocus:当控件失去焦点时,更新源属性。
    • Explicit:只有在调用BindingExpression.UpdateSource方法时,才更新源属性。

绑定的目标与数据源必须是同类型

Binding源的指定

  • 使用自定义类作为源

    一个对象只要通过属性公开自己的数据,就可以作为Binding的源。作为Binding源的对象需要实现INotifyPropertyChanged接口并激发PropertyChanged事件才能使属性具有自动通知Binding发生了变化的能力。除了使用自定义类作为源以外,还有其他不同的形式。

  • 将控件作为源(关键词ElementName)

  • DataContext作为源

    当我们使用DataContext作数据源时,它会自动将UI元素的数据源设置为当前窗口或用户控件的DataContext属性。所以在XAML文件中使用 {Binding}语法来绑定时而无需指定任何其他源,因此这种形式也称作无源数据绑定。

    使用DataContext不仅可以绑定公开属性,还可以访问命令对象以及资源中定义的属性

    在Binding通过元素树向根结点处方向寻找DataContext的现象,其实只是一种描述效果。真正的过程其实是结点上的属性值向下传递了,这是因为DataContext是一个依赖属性,而依赖属性有一个特性,当你没有为控件的某个属性显式地赋值时,它就会把自己所在容器的属性值拿过来,当作自己的属性值。

  • 使用DataSourceProvider类的子类成员作为数据源

  • LINQ查询结果作为数据源

DataSourceProvider

DataSourceProvider类是一个抽象基类,它定义了一些公共属性和方法,用来执行某些查询,生成可以用作绑定源的单个对象或对象列表。DataSourceProvider类支持标准的Windows窗体数据绑定模型,可以处理不同类型的数据源,例如SQL数据库,XML文档,数组集合等。DataSourceProvider类还实现了INotifyPropertyChanged和ISupportlnitialize接口,用来提供对绑定和初始化的支持。

提供一种将不同类型的数据源用作绑定源的方式,使用他的好处是可以简化数据绑定的过程,无需编写额外的代码来加载和查询数据

XmlDataProvider

XML是一种可扩展标记语言,它可以用来存储和传输数据。XML的特点是它可以自定义标签,用来描述数据的结构和含义。XML可以与其他技术一起使用来处理和转換数据。XML也可以用作配置文件,元数据,富文档等。NET提供了DOM(Document Obiject Model)文档对象模型类库用于处理XML数据,包括XmlDoucument、XmlElement等类,是传统的、功能强大的类库。

展示xml文档的数据,可以使用ListBox或ListView

1
2
3
4
5
6
7
8
9
10
11
<ListView Height="130" Margin="5" x:Name="ListView_Goods">
<ListView.View>
<!--使用GridView来设计-->
<GridView>
<!--定义多个表格列-->
<GridViewColumn Header="Id" Width="40" DisplayMemberBinding="{Binding XPath=@Id}"/>
<GridViewColumn Header="Name" Width="100" DisplayMemberBinding="{Binding XPath=Name}"/>
<GridViewColumn Header="Price" Width="70" DisplayMemberBinding="{Binding XPath=Price}"/>
</GridView>
</ListView.View>
</ListView>

XPath 是一种用于在 XML 文档中查找信息的语言。在 WPF 中,XPath 可以用于绑定 XML 数据。

XPath 中,@ 符号用于表示属性(特征)。例如,XPath=@Id 表示绑定到 XML 元素的 Id 属性。

绑定代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//载入XML文件对象
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.Load(@"E:\GoodsData.xml");

//XmlDataProvider用于支持对XML数据的访问,以便用于数据绑定
XMLDataProvider xmlDataProvier = new XmlDataProvider();
XMLDataProvider.Document = xmlDocument;
XMLDataProvider.XPath=@"/GoodsList/Goods";

this.ListView_Goods.DataContext = xmlDataProvider;
this.ListView_Goods.SetBinding(ListView.ItemsSourceProperty,new Binding());

//上述全部代码可简化为:
XMLDataProvider xmlDataProvier = new XmlDataProvider();
XMLDataProvider.Source = new Uri(@"E:\GoodsData.xml");
XMLDataProvider.XPath=@"/GoodsList/Goods";

this.ListView_Goods.DataContext = xmlDataProvider;
this.ListView_Goods.SetBinding(ListView.ItemsSourceProperty,new Binding());

假设有一个 XML 文档如下:

1
2
3
4
5
6
7
8
9
10
<Products>
<Product Id="1">
<Name>Apple</Name>
<Price>1.2</Price>
</Product>
<Product Id="2">
<Name>Banana</Name>
<Price>0.8</Price>
</Product>
</Products>

ObjectDataProvider

ObjectDataProvider能够将对象进行包装,作为数据源提供给Binding。被包装的对象作为ObjectDataProvider的Objectlnstance属性,我们可以通过MethodName属性来指明要调用的方法,使用MethodParameters属性来传入参数,其是一个集合,因此可以使用下标形式指定和访问。运行方法的结果会保存在Data属性中。

支持根据参数调用重载方法

image-20241223090153461

LINQ查询结果作为数据源

LINQ的查询结果是可以作为数据绑定的数据源的,这是因为其查询结果是一个 IEnumerable `````对象,所以可以作为列表控件的ItemSource来使用。

LINQ的优点是可以提高数据处理的能力和开发效率,使查询成为了C#语言的一部分,LINK还可以使用声明式查询语法或方法语法来编写查询表达式.这些表达式可以在编译时进行类型检查和优化

1
this.ListView_Employee.ItemsSource=from emp in employeeList where emp.Name.StartsWith("L") select emp;

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System.Collections.ObjectModel;
using System.Linq;

public class EmployeeViewModel : INotifyPropertyChanged
{
private ObservableCollection<Employee> _employeeList;
private ObservableCollection<Employee> _filteredEmployees;

public ObservableCollection<Employee> EmployeeList
{
get { return _employeeList; }
set
{
_employeeList = value;
OnPropertyChanged(nameof(EmployeeList));
FilterEmployees(); // 每当员工列表更改时,重新过滤
}
}

public ObservableCollection<Employee> FilteredEmployees
{
get { return _filteredEmployees; }
set
{
_filteredEmployees = value;
OnPropertyChanged(nameof(FilteredEmployees));
}
}

public EmployeeViewModel()
{
// 初始化员工列表
EmployeeList = new ObservableCollection<Employee>
{
new Employee { Name = "John", Age = 30, Position = "Manager" },
new Employee { Name = "Linda", Age = 25, Position = "Developer" },
new Employee { Name = "Liam", Age = 22, Position = "Intern" },
new Employee { Name = "Paul", Age = 35, Position = "Developer" }
};

FilterEmployees();
}

// 过滤员工列表,只保留名字以"L"开头的员工
private void FilterEmployees()
{
FilteredEmployees = new ObservableCollection<Employee>(
EmployeeList.Where(emp => emp.Name.StartsWith("L"))
);
}

public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

xaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<Window x:Class="MVVMExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Employee List" Height="350" Width="525">
<Window.DataContext>
<local:EmployeeViewModel />
</Window.DataContext>

<Grid>
<ListView ItemsSource="{Binding FilteredEmployees}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="150"/>
<GridViewColumn Header="Age" DisplayMemberBinding="{Binding Age}" Width="100"/>
<GridViewColumn Header="Position" DisplayMemberBinding="{Binding Position}" Width="200"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</Window>

RelativeSource

当我们不能确定源对象的名称,但是能知道其与目标对象在UI布局上有层级关系时,可以使用RelativeSource属性。RelativeSource属性数据类型为RelativeSource类,其
Mode属性的类型是RelativeSourceMode枚举,其取值有PreviousData、TemplateParent、Self和FindAncestor。对于前三个枚举值还有同名的静态属性,它们的类型是RelativeSource类。

不仅可以按照层级来寻找数据源对象,还支持控件自身与自身的属性进行绑定

  • PreviousData 绑定到集合中当前项的前一项的数据。
  • TemplateParent 绑定到应用了控件模板的控件的属性。
  • Self 绑定到绑定目标本身的属性。
  • FindAncestor 绑定到绑定目标的某个祖先元素(结点或根元素)的属性。
1
2
3
<TextBox x:Name="TextBox1" FontSize="16" Margin="15"
Background="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, AncestorLevel=2}, Path=Background}"
Text="{Binding RelativeSource={RelativeSource Mode=Self}, Path=Name}" />

绑定解释

在您的 ListView 中的 GridViewColumn 绑定如下:

  • Id 列:

    1
    <GridViewColumn Header="Id" Width="40" DisplayMemberBinding="{Binding XPath=@Id}"/>
    • 这里的 XPath=@Id 表示绑定到 Product 元素的 Id 属性。
  • Name 列:

    1
    <GridViewColumn Header="Name" Width="100" DisplayMemberBinding="{Binding XPath=Name}"/>
    • 这里的 XPath=Name 表示绑定到 Product 元素的 Name 子元素。
  • Price 列:

    1
    <GridViewColumn Header="Price" Width="70" DisplayMemberBinding="{Binding XPath=Price}"/>
    • 这里的 XPath=Price 表示绑定到 Product 元素的 Price 子元素。

Binding路径的指定

Binding 支持多级路径,即可以一直点下去

绑定和窗体xmal.cs中操作的区别

  • 窗体后台文件直接访问控件的操作是事件驱动的,即如果没有事件的存在是改变不了界面的
  • 绑定的操作是以数据本身的变化来通知界面显示改变的

因此绑定可以实现前后端分离

使用绑定需要的步骤

1
2
3
4
5
6
7
8
9
//首先要在窗体文件中设定窗口的数据上下文(比如说MainWindow中)
public MainWindow()
{
InitializeComponent();
//给当前界面指定一个数据上下文(窗口必须有这一句,才能在该窗口中的控件实现绑定)
//此处的上下文不一定得是this,UserName等变量在哪里就指定什么
this.DataContext = this;//!!!!!!!!
}
public string UserName { get; set; }//设定的数据,用于被控件绑定//!!!!!!!!

输入账户的textBox控件的Text属性绑定UserName变量

1
<TextBox Text ="{Binding UserName}"/>

这样只能实现修改TextBox.Text,更新值到UserName变量中;但是反过来,UserName中的值被修改不能反映到TextBox.Text中

按照下面来改动才可以,让数据上下文支持变化通知

数据上下文支持变化通知

将控件绑定一个数据上下文,控件默认只能单向绑定到数据上下文,通过修改数据上下文中被绑定的变量默认无法影响到设置了绑定的控件

要真正支持双向绑定,需要修改数据上下文 的类以支持数据变动时的通知

需要数据上下文的类继承自 INotifyPropertyChanged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public partial class MainWindow:Window,INotifyPropertyChanged
{
public MainWindow()
{
InitializeComponent();
this.DataContext = this;//此处以数据上下文指定自己为例子
}
// 定义 PropertyChanged 事件,用于通知属性值的更改
public event PropertyChangedEventHandler PropertyChanged;

// 触发属性更改通知的方法
private void RaisePropertyChanged(string propertyName)
{
// 将 PropertyChanged 事件的委托复制给临时变量 handler
PropertyChangedEventHandler handler = PropertyChanged;

// 如果 handler 不为 null,则调用委托,传递当前对象和属性名称参数
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
//针对所有需要支持双向绑定的属性,需要在set中去调用RaisePropertyChanged方法
private string _UserName;
public string UserName
{
get { return _UserName; }
set {
_UserName = value;
RaisePropertyChanged("UserName");//SetProperty内含这两行代码
}
}

}

这样修改后,才可以让ViewModel下的所有属性真正可以支持变量到控件的变化通知(即普通属性也支持通知机制了,因为依赖属性本身就内置了通知机制)

绑定xml文件中的值

假设xml文件名为 Data.xml

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<Colors>
<Color name="Red">#FF243A</Color>
<Color name="Blue">#007ACC</Color>
<Color name="Yellow">#FFFF00</Color>
<User>
<Age name="gao" key="test">540岁</Age>
</User>
</Colors>

App.xaml中需要引用XmlDataProvider

1
2
3
<Application.Resources>
<XmlDataProvider x:Key="ColorsXML" Source="Data.xml"/>
</Application.Resources>

绑定如下:(平时使用的是Path或省略(一个意思),现在要用XPath)

1
<Border Background="{Binding XPath='/Colors/Color@name=Red',Source={StaticResource ColorsXML}}"/>

通过Source找到定义好的资源,通过XPath找到具体的值

绑定控件的方法

未完善

1
< xxx="{binding Element Name =AllRecipesListBox,Path=SelectedValue}"/>

绑定到AllRecipesListBox中的SelectedValue方法

ElementName,Source,RelativeSource

ElementName,Source,RelativeSource 为三种不同的绑定方式

特性 ElementName Source RelativeSource
绑定目标 UI 元素的 x:Name 静态对象或资源 自身、模板化父元素、祖先元素等
常见应用场景 同一个 XAML 文件中的元素之间的绑定 绑定到非 UI 的对象或静态属性 在模板或复杂层次结构中绑定相关元素
可见性要求 必须在同一个逻辑树内 不依赖可视元素,直接绑定到对象 通常查找祖先元素,适合复杂的层次结构
典型用例 控件之间的数据共享 绑定到静态资源或静态属性 在控件模板中绑定到父级数据,或绑定到上层容器的属性

下面两种绑定效果相同:(利于理解本质)

  • source = {binding}

    {binding} 不设定明确的绑定的source,这样binding就去从本控件类为开始根据可视树的层次结构自下而上查找不为空的Datacontext属性的值

  • source = {binding RelativeSource={RelativeSource self},Path=DataContext}

    绑定的source为控件自身,这样binding 就绑定了自身控件的Datacontext

ElementName

ElementName 允许你通过指定目标元素的名称来进行数据绑定。它通常用于同一 XAML 文件内的不同控件之间的数据绑定,尤其是在需要从其他 UI 元素获取值时。

特点

  • 只能绑定到具有 x:Name 属性的元素。
  • 必须在同一个可见的 XAML 逻辑树内(同一个视图文件)。

适用场景: 需要在同一个视图内从某个元素获取值

1
2
3
<!--TextBlock显示的内容就是TextBox的Text中的文本-->
<TextBox x:Name="sourceTextBox" Text="Some Text"/>
<TextBlock Text="{Binding Text, ElementName=sourceTextBox}"/>

下面有助于理解ElementName

1
2
3
4
5
6
7
8
9
10
11
<Window x:Name="MainWindow">
<Grid>
<Button Background=”{Binding ElementName=MainWindow, Path=Background}”/>
</Grid>
</Window>
<!--效果等同于-->
<Window>
<Grid>
<Button Background=”{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window},Path=Background}”/>
</Grid>
</Window>

Source

Source 属性允许你直接绑定到某个指定的对象,而不是绑定到 UI 元素。它可以用于在 XAML 中绑定到某个数据源对象,通常是静态的对象或在代码中初始化的对象

适用场景: 绑定到非 UI 元素的对象,比如后台代码中的对象、静态资源、静态属性等。

用法1 绑定到静态对象

1
<TextBlock Text="{Binding Source={StaticResource MyData}}"/>

TextBlock绑定到了XAML资源字典中的MyData对象

用法2 绑定到静态属性:

1
<TextBlock Text="{Binding Source={x:Static local:MyClass.MyProperty}}"/>

这里绑定的是静态类 MyClass 的静态属性 MyProperty

RelativeSource

RelativeSource 是一种特殊的绑定方式,它允许你相对于绑定目标元素来查找数据源。使用时可以指定查找方向(祖先、同级元素等),它提供了更灵活的方式来绑定元素之间的关系。

适用场景:

  • 需要绑定到和目标元素相关的父级、同级或子级元素。
  • 适用于复杂的 UI 结构 或 在模板中需要从模板外部访问数据的情况。

常规写法及完整展开如下:

1
2
3
Fill="{Binding SelectionBrush, RelativeSource={RelativeSource TemplatedParent}}" />
<!--可展开为-->
Fill="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},Path=SelectionBrush}" />

主要模式

Self模式

绑定到自身,这种情况下,数据源就是绑定目标元素自己

1
<TextBox Text="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"/>

TextBox 的 Text 属性绑定到了自身的 Tag 属性

TemplatedParent模式

绑定到控件模板的父元素,通常在 ControlTemplate 或 DataTemplate 中使用

1
2
3
<ControlTemplate TargetType="Button">
<TextBlock Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Content}"/>
</ControlTemplate>

这里 TextBlock 绑定到按钮的 Content 属性,这是因为 Button 是 ControlTemplate 的模板化父元素

FindAncestor模式

按照层次结构查找指定类型的祖先元素。常用于需要从父元素中获取属性的场景

1
<TextBlock Text="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}, Path=Title}"/>

这里 TextBlock 的 Text 绑定到了最近的 Window 祖先的 Title 属性

PreviousData模式

PreviousData 模式用于绑定到数据绑定集合(如 ItemsControl、ListBox 等)中的上一个数据项。这个模式通常在集合控件的模板中使用,用于访问前一项的数据。它允许你在当前项目的绑定上下文中引用上一个数据对象的属性值

适用场景: PreviousData 主要用于模板化控件(如 ItemsControl、ListBox 或 DataGrid)中的 ItemTemplate,让你在当前项的模板中能够绑定到前一项的数据对象。这在需要上下文关联的绑定时比较有用,例如你需要根据前一项的数据来展示当前项的样式或行为时

特点:

  • PreviousData 模式只在模板化控件的数据绑定中有意义,比如 ItemsControl、ListBox、DataGrid。
  • 它让你在模板内的当前数据项中可以引用前一项的数据。
  • 如果当前项是集合中的第一个项,则没有上一个数据项,绑定会返回 null,需要考虑这种情况

假设我们有一个 ListBox 控件,里面显示一组数字。如果我们希望每个项能够显示当前数字以及前一个数字的和,我们可以使用 RelativeSource.PreviousData 进行绑定

1
2
3
4
5
6
7
8
9
10
11
12
<ListBox ItemsSource="{Binding Numbers}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<!--绑定到Numbers中的单个元素使用{Binding .}-->
<TextBlock Text="{Binding .}" />
<TextBlock Text=" + " />
<TextBlock Text="{Binding DataContext, RelativeSource={RelativeSource Mode=PreviousData}}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

带边界处理的案例

这个示例展示了如何使用 PreviousData 绑定到前一项的数据,并通过 DataTrigger 来处理边界情况,即当没有前一个数据时(如在第一项上),显示替代文本 “N/A”。这种边界处理在集合绑定和模板化控件中是常见需求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<Window x:Class="PreviousDataExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="PreviousData Example" Height="350" Width="525">

<Window.DataContext>
<local:MainViewModel />
</Window.DataContext>

<Grid>
<ListBox ItemsSource="{Binding Numbers}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<!-- 当前项的数字 -->
<TextBlock Text="{Binding}" />

<!-- 前一个项的数字,带有边界处理 -->
<TextBlock Text=" (Previous: " />
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding DataContext, RelativeSource={RelativeSource Mode=PreviousData}}" />
<Style.Triggers>
<!-- 如果前一项不存在(null),显示 'N/A' -->
<DataTrigger Binding="{Binding DataContext, RelativeSource={RelativeSource Mode=PreviousData}}" Value="{x:Null}">
<Setter Property="Text" Value="N/A" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text=")" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>

将会显示为如下:

1
2
3
4
5
10 (Previous: N/A)
20 (Previous: 10)
30 (Previous: 20)
40 (Previous: 30)
50 (Previous: 40)

控制绑定数据的显示格式

1
2
3
<TextBlock Text="{Binding HardwareManager.DoseControl.DoseControlData.CenterPos,StringFormat={}{0:0.000}}"
VerticalAlignment="Center"
Margin="5"/>

StringFormat={}{0:0.000}:这是一个格式化字符串,用于指定如何显示绑定的数据。{0:0.000} 表示将数值格式化为小数点后三位的浮点数。

数据校验与转换

若在数据传输的过程中,需要对数据进行限制,可以在Binding上设置数据校验;若要对数据类型进行转换,可以在Binding上设置数据转换,编写数据转换器。WPF在Binding上提供了Validation进行校验,Converter进行转换。

WPF提供了两种数据转化的方式

  • 值转换器方法(Converter),绑定后,触发转换器,转换器负责把值转换成需要的内容。转换器需要实现System.Windows.Data命名空间的IValueConverter接口或IMultiValueConverter接口,分别用于单值转换和多值转换。转换器可以在XAML中定义为资源,并在绑定中引用。
  • DataTrigger方法,直接在XAML里面对数据进行处理,展示所需要的内容。DataTrigger可以根据绑定的数据的值或条件来改变控件的属性或样式。DataTrigger可以在Style或ControlTemplate中定义。

Validation

Binding用于数据校验的属性是ValidationRules,在WPF中,要实现Binding的数据校验,需要以下几个步骤:

  1. 定义一个继承自ValidationRule的类,重写其Validate方法,根据自己的逻辑和条件,返回一个ValidationResult对象,表示数据是否有效,以及无效时的错误信息。

  2. 在Binding对象中添加一个或多个ValidationRule对象,表示要对数据应用哪些校验规则。

    可以设置ValidationRules属性的ValidatesOnTargetUpdated属性为true,表示要在目标更新时也进行校验。

  3. 在Binding对象中设置NotifyOnValidationError属性为true,表示当数据校验失败时,要通知绑定目标或其祖先元素。

  4. 编写校验失败时执行的事件处理器(方法),获取事件参数,获取错误信息,编写显示错误信息的代码或其它执行内容。

  5. 在目标或祖先元素上添加一个事件处理器,用于处理Validation.ErrorEvent事件,传入委托并指定方法。

总结来说就是:

  1. 编写校验规则类
  2. 为Binding添加校验规则
  3. 打开校验通知开关
  4. 编写校验失败事件处理器
  5. 处理校验失败事件

优势: 使用WPF数据检验可以提高代码的质量和效率,增强用户体验和交互性。

  • WPF数据检验将校验功能独立出来,可以将验证逻辑和视图逻辑分离,使得代码
    更加清晰和可维护。
  • 利用Binding对象的各种属性和事件,实现数据的自动更新和错误的通知。
  • 数据检验可以使用不同的验证规则,实现不同的验证需求,使用预定好的类来编
    写验证功能更加规范和方便。也可以为一个绑定添加多个规则,实现重用。
  • WPF数据检验可以使用ErrorTemplate属性,实现对验证失败时的视觉反馈,例
    如显示红色的感叹号或边框等。

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<Window x:Class="WpfValidationExample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF Validation Example" Height="200" Width="300">

<Grid>
<!-- 标签 -->
<Label Content="Enter a number:" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="20,20,0,0"/>

<!-- 文本框,用于输入数字,绑定到属性,启用验证 -->
<TextBox Name="NumberTextBox" HorizontalAlignment="Left" VerticalAlignment="Top"
Margin="20,50,0,0" Width="240" Validation.ErrorTemplate="{StaticResource ValidationTemplate}">
<TextBox.Text>
<Binding Path="InputNumber" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<!-- 验证规则:输入的内容必须是有效数字 -->
<local:NumericValidationRule/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>

<!-- 错误提示区域 -->
<TextBlock Foreground="Red" Margin="20,90,0,0" Text="{Binding ElementName=NumberTextBox, Path=(Validation.Errors), Converter={StaticResource ErrorToStringConverter}}"/>
</Grid>
</Window>

**创建一个自定义的验证规则 ** NumericValidationRule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System.Globalization;
using System.Windows.Controls;

namespace WpfValidationExample
{
public class NumericValidationRule : ValidationRule
{
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
// 检查输入是否为空
if (value == null || string.IsNullOrWhiteSpace(value.ToString()))
{
return new ValidationResult(false, "Input cannot be empty.");
}

// 尝试将输入转换为数字
if (double.TryParse(value.ToString(), out _))
{
return ValidationResult.ValidResult;//返回默认校验结果为true的ValidationResult对象
}
else
{
return new ValidationResult(false, "Please enter a valid number.");
}
}
}
}

MVVM架构中通过错误转换器,创建一个 IValueConverter,将 Validation.Errors 转换为可显示的错误信息

转换器代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Globalization;
using System.Linq;
using System.Windows.Data;
using System.Windows.Controls;

namespace WpfValidationExample
{
public class ErrorToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var validationErrors = value as System.Collections.Generic.IEnumerable<ValidationError>;
return validationErrors?.FirstOrDefault()?.ErrorContent?.ToString();
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

INotifyDataErrorInfo

INotifyDataErrorInfo 提供了一个灵活的机制来管理模型中的错误

INotifyDataErrorInfo 是一个接口,主要用于在 MVVM 架构中管理和处理数据模型的验证错误。它允许你在视图模型中集中管理错误,并通过事件通知机制将错误反馈给视图层。在 WPF 中,当你需要在数据绑定时显示错误或验证输入时,INotifyDataErrorInfo 是一个非常有用的工具。

  • ValidationRule 更适合在控件级别执行单一字段的验证
  • INotifyDataErrorInfo 可以在视图模型中集中处理多字段验证

INotifyDataErrorInfo 接口包含以下重要成员:

  • HasErrors:返回模型是否有任何验证错误。
  • **GetErrors(string propertyName)**:返回指定属性的错误列表。
  • ErrorsChanged:当某个属性的错误发生变化时,触发此事件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;

namespace WpfValidationExample
{
public class MainViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private string _inputNumber;

// 数据属性
public string InputNumber
{
get => _inputNumber;
set
{
if (_inputNumber != value)
{
_inputNumber = value;
OnPropertyChanged(nameof(InputNumber));
ValidateInputNumber();
}
}
}

// 存储错误信息的字典
private readonly Dictionary<string, List<string>> _errors = new();

// 检查是否有错误
public bool HasErrors => _errors.Any(e => e.Value.Any());

// 获取指定属性的错误信息
public IEnumerable<string> GetErrors(string propertyName)
{
return _errors.ContainsKey(propertyName) ? _errors[propertyName] : null;
}

// 验证逻辑
private void ValidateInputNumber()
{
// 清除错误
ClearErrors(nameof(InputNumber));

// 验证输入是否为空
if (string.IsNullOrWhiteSpace(InputNumber))
{
AddError(nameof(InputNumber), "Input cannot be empty.");
}
// 验证是否为有效数字
else if (!double.TryParse(InputNumber, out _))
{
AddError(nameof(InputNumber), "Please enter a valid number.");
}
}

// 添加错误
private void AddError(string propertyName, string error)
{
if (!_errors.ContainsKey(propertyName))
{
_errors[propertyName] = new List<string>();
}

_errors[propertyName].Add(error);
OnErrorsChanged(propertyName);
}

// 清除错误
private void ClearErrors(string propertyName)
{
if (_errors.ContainsKey(propertyName))
{
_errors[propertyName].Clear();
OnErrorsChanged(propertyName);
}
}

// 触发属性变化事件
public event PropertyChangedEventHandler PropertyChanged;

protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

// 触发错误变化事件
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

protected virtual void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
}
}

Convert

Convert可以将源数据和目标数据之间进行特定的转化。定义转换器,需要继承接口IValueConverter

下图的vm指的是ViewModel

iShot_2024-06-13_15.08.31

当我们遇到需要自行编写转换器的情况时,我们需要创建一个类并让这个类实现
IValueConverter接口,该接口的定义如下,当数据从源流向目标时,Convert()方法会被调用,反之调用 ConvertBack()

  • Convert: 会进行源属性传给目标属性的特定转化
  • ConvertBack: 会进行目标属性传给源属性的特定转化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ForeColorConverter : IValueConverter
{
// 源属性传给目标属性时,调用此方法 Convert
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return null; // 这里应该实现转换逻辑
}

// 目标属性传给源属性时,调用此方法 ConvertBack
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return null; // 这里应该实现反向转换逻辑
}
}

比如说实现一个根据bool值转换成前景色

案例

一个用于反转布尔值的值转换器

创建一个新的 C# 文件, Converters/InverseBooleanConverter.cs,并添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Globalization;
using System.Windows.Data;

namespace YourNamespace.Converters
{
//需要实现IValueConverter的接口
public class InverseBooleanConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolean)
{
return !boolean;
}
return value;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolean)
{
return !boolean;
}
return value;
}
}
}

在 XAML 中使用 InverseBooleanConverter
接下来,在 XAML 文件中使用这个转换器。首先,需要在 XAML 文件的资源字典中定义这个转换器:

1
2
3
4
5
6
7
8
9
<Window x:Class="YourNamespace.YourWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:YourNamespace.Converters">
<Window.Resources>
<local:InverseBooleanConverter x:Key="InverseBooleanConverter"/>
</Window.Resources>
<!-- 其他 XAML 代码 -->
</Window>

使用转换器用法如下

1
2
<CheckBox Content="示例复选框"
IsChecked="{Binding SomeBooleanProperty, Converter={StaticResource InverseBooleanConverter}}"/>

多重绑定

1
2
3
4
5
6
<cc:InfoShowGridUnit.ShowStrings>
<MultiBinding Converter="{StaticResource MultiValueConverter}">
<Binding Path="ShowStrings[0]" />
<Binding Path="ShowStrings[1" />
</MultiBinding>
</cc:InfoShowGridUnit.ShowStrings>

多重绑定不能嵌套

但多重绑定下的每个绑定也可以设置转换器

多重绑定的更新规则

  • 每个项的变更都会触发更新: MultiBindingConverter 会在其绑定的任何一个子绑定(Binding)的值发生变化时被重新调用。但前提是每个子绑定都正确实现了属性变更通知(如 INotifyPropertyChanged

  • 依赖项监控:如果子绑定的路径(如 Path=PropertyA)对应的属性实现了 INotifyPropertyChanged,当该属性值变化时,绑定系统会自动通知 MultiBinding 更新

    但是要注意必须绑定的是直接点到PropertyA,而不能只绑定了父元素,参考更新规则陷阱

更新规则陷阱

1
2
3
4
<MultiBinding>
<Binding Path="PropertyA" /> <!-- ClassA.PropertyA -->
<Binding Path="Child.PropertyC" /> <!-- 明确绑定到嵌套属性 -->
</MultiBinding>

陷阱:如果 MultiBinding 的子绑定路径是 ClassA.Child(而不是 Child.PropertyC),且 Converter 内部访问了 Child.PropertyC,则即使 PropertyC 变化,绑定系统不会自动更新,因为 ClassA.Child 的引用未变,且绑定系统未监控 Child.PropertyC

绑定路径的监控粒度

WPF 的绑定系统只会监控绑定路径中明确指定的属性。
例如:

  • 如果绑定路径是 Data.PropertyA,则系统会监控 PropertyA 的变化。
  • 如果绑定路径仅是 Data(即整个对象),则系统只监控 Data 的引用是否变化,而不会深入监控其内部属性。

自制脉冲手动触发解决方案

在多重绑定中多绑定一个自己启动计时器自增的脉冲变量,可以强制让多重绑定器触发

代码操作绑定

WPF中操作绑定的所有相关函数

获取绑定表达式

1
2
3
4
5
6
7
8
9
10
11
// 1. GetBindingExpression - 获取绑定表达式
var binding = control.GetBindingExpression(DependencyProperty);

// 2. GetBinding - 获取绑定对象
var binding = BindingOperations.GetBinding(control, DependencyProperty);

// 3. GetMultiBinding - 获取多重绑定
var multiBinding = BindingOperations.GetMultiBinding(control, DependencyProperty);

// 4. GetBindingExpressionBase - 获取基础绑定表达式
var bindingBase = BindingOperations.GetBindingExpressionBase(control, DependencyProperty);

设置绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. SetBinding - 设置绑定
BindingOperations.SetBinding(control, DependencyProperty, binding);

// 2. SetMultiBinding - 设置多重绑定
BindingOperations.SetMultiBinding(control, DependencyProperty, multiBinding);

// 3. AddBinding - 添加绑定
BindingOperations.AddBinding(control, DependencyProperty, binding);

// 4. ClearBinding - 清除绑定
BindingOperations.ClearBinding(control, DependencyProperty);

// 5. ClearAllBindings - 清除所有绑定
BindingOperations.ClearAllBindings(control);

绑定更新操作

1
2
3
4
5
6
7
8
9
10
11
// 1. UpdateSource - 更新源
bindingExpression.UpdateSource();

// 2. UpdateTarget - 更新目标
bindingExpression.UpdateTarget();

// 3. ValidateWithoutUpdate - 验证但不更新
bindingExpression.ValidateWithoutUpdate();

// 4. GetValue - 获取绑定值
object value = bindingExpression.GetValue();

绑定验证

1
2
3
4
5
6
7
8
// 1. HasError - 检查是否有错误
bool hasError = bindingExpression.HasError;

// 2. HasValidationError - 检查是否有验证错误
bool hasValidationError = bindingExpression.HasValidationError;

// 3. GetValidationError - 获取验证错误
ValidationError error = bindingExpression.GetValidationError();

绑定状态检查

1
2
3
4
5
6
7
8
// 1. IsDirty - 检查值是否已更改
bool isDirty = bindingExpression.IsDirty;

// 2. IsInUpdate - 检查是否正在更新
bool isInUpdate = bindingExpression.IsInUpdate;

// 3. Status - 获取绑定状态
BindingStatus status = bindingExpression.Status;

绑定路径操作

1
2
3
4
5
6
7
8
// 1. GetPath - 获取绑定路径
PropertyPath path = bindingExpression.GetPath();

// 2. ResolvedSource - 获取解析后的源
object source = bindingExpression.ResolvedSource;

// 3. ResolvedSourcePropertyName - 获取解析后的源属性名
string propertyName = bindingExpression.ResolvedSourcePropertyName;

绑定模式设置

1
2
3
4
5
6
7
8
9
10
11
// 1. 单向绑定
binding.Mode = BindingMode.OneWay;

// 2. 双向绑定
binding.Mode = BindingMode.TwoWay;

// 3. 一次性绑定
binding.Mode = BindingMode.OneTime;

// 4. 单向到源
binding.Mode = BindingMode.OneWayToSource;

绑定更新触发

1
2
3
4
5
6
7
8
// 1. 属性变化时更新
binding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;

// 2. 失去焦点时更新
binding.UpdateSourceTrigger = UpdateSourceTrigger.LostFocus;

// 3. 显式更新
binding.UpdateSourceTrigger = UpdateSourceTrigger.Explicit;

绑定转换器

1
2
3
4
5
6
7
8
// 1. 设置简单路径
binding.Path = new PropertyPath("PropertyName");

// 2. 设置复杂路径
binding.Path = new PropertyPath("Property1.Property2[0].Property3");

// 3. 设置索引器
binding.Path = new PropertyPath("Items[0]");

绑定验证规则

1
2
3
4
5
6
// 1. 添加验证规则
binding.ValidationRules.Add(new MyValidationRule());

// 2. 设置验证规则
binding.ValidationRules.Clear();
binding.ValidationRules.Add(new MyValidationRule());

绑定错误处理

1
2
3
4
5
6
7
// 1. 设置错误处理
binding.NotifyOnValidationError = true;
binding.ValidatesOnDataErrors = true;
binding.ValidatesOnExceptions = true;

// 2. 绑定错误事件
control.AddHandler(Validation.ErrorEvent, new EventHandler<ValidationErrorEventArgs>(OnValidationError));

绑定源设置

1
2
3
4
5
6
7
8
// 1. 设置绑定源
binding.Source = source;

// 2. 设置相对源
binding.RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(Window), 1);

// 3. 设置元素名称
binding.ElementName = "elementName";

绑定路径设置

1
2
3
4
5
6
7
8
// 1. 设置简单路径
binding.Path = new PropertyPath("PropertyName");

// 2. 设置复杂路径
binding.Path = new PropertyPath("Property1.Property2[0].Property3");

// 3. 设置索引器
binding.Path = new PropertyPath("Items[0]");

绑定延迟

1
2
// 设置绑定延迟
binding.Delay = 500; // 500毫秒延迟

绑定回退值

1
2
3
// 设置绑定回退值
binding.FallbackValue = "默认值";
binding.TargetNullValue = "空值";

内部自行修改绑定的控件实现案例

做到了下面的点:

  • 在非编辑模式下实时反映 绑定属性 的变化
  • 在编辑模式下正常编辑
  • 在应用后正确显示新的值

几点注意事项:

  • 依赖属性添加了 FrameworkPropertyMetadataOptions.BindsTwoWayByDefault 选项这确保了 Value 属性默认支持双向绑定,使得值的变化能够正确传播到UI
  • 在值变化时,确保在非编辑模式下强制更新ui,使用Dispatcher.BeginInvoke确保在UI线程上执行更新,通过UpdateTarget()强制刷新绑定
  • 只在编辑模式下覆盖Text属性,并且使用UpdateSourceTrigger = PropertyChanged确保编辑时的实时更新

源码参考

界面文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<UserControl
x:Class="Core.UI.UserControls.NumericControl"
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:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:Core.UI.UserControls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:prism="http://prismlibrary.com/"
xmlns:specificcvt="clr-namespace:UI.Application.Share.SpecificConverters;assembly=UI.Application.Share"
mc:Ignorable="d">
<UserControl.Resources>
<specificcvt:DisplayDataConverter x:Key="DisplayDataCvt" />
<Style TargetType="{x:Type local:NumericControl}">
<Setter Property="Maximum" Value="1.7976931348623157E+308" />
<Setter Property="Minimum" Value="-1.7976931348623157E+308" />
<Setter Property="TextBoxBackground" Value="Transparent" />
<Setter Property="EditingBackground" Value="#AEEA00" />
<Setter Property="TextBoxWidth" Value="87" />
<Setter Property="TextBoxHeight" Value="36" />
<Setter Property="ButtonWidth" Value="18" />
<Setter Property="ButtonHeight" Value="15" />
<Setter Property="ApplyButtonWidth" Value="36" />
<Setter Property="ApplyButtonHeight" Value="36" />
<Setter Property="ButtonMargin" Value="1" />
<Setter Property="ApplyButtonMargin" Value="3" />
<Setter Property="StackPanelMargin" Value="-21,5,0,5" />
<Setter Property="MainStackPanelMargin" Value="4,0,0,0" />
</Style>
</UserControl.Resources>
<StackPanel
Margin="{Binding MainStackPanelMargin, RelativeSource={RelativeSource AncestorType=UserControl}}"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBox
x:Name="PART_ValueTextBox"
Width="{Binding TextBoxWidth, RelativeSource={RelativeSource AncestorType=UserControl}}"
Height="{Binding TextBoxHeight, RelativeSource={RelativeSource AncestorType=UserControl}}"
Padding="{Binding TextBoxPadding, RelativeSource={RelativeSource AncestorType=UserControl}}"
VerticalAlignment="Center"
FontSize="{Binding FontSize, RelativeSource={RelativeSource AncestorType=UserControl}}"
Foreground="{Binding TextBoxForeground, RelativeSource={RelativeSource AncestorType=UserControl}}"
TextAlignment="Center">
<TextBox.Style>
<Style BasedOn="{StaticResource TextBoxBaseBaseStyle}" TargetType="TextBox">

<Setter Property="Background" Value="{Binding TextBoxBackground, RelativeSource={RelativeSource AncestorType=UserControl}}" />
<Setter Property="Text">
<Setter.Value>
<MultiBinding Converter="{StaticResource DisplayDataCvt}">
<Binding Path="Value" RelativeSource="{RelativeSource AncestorType=UserControl}" />
<Binding Path="ConvertCoefficient" RelativeSource="{RelativeSource AncestorType=UserControl}" />
<Binding Path="Digits" RelativeSource="{RelativeSource AncestorType=UserControl}" />
<Binding Path="IsScientificNotation" RelativeSource="{RelativeSource AncestorType=UserControl}" />
</MultiBinding>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="True">
<Setter Property="Background" Value="{Binding EditingBackground, RelativeSource={RelativeSource AncestorType=UserControl}}" />
<Setter Property="Text" Value="{Binding EditingValue, RelativeSource={RelativeSource AncestorType=UserControl}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
<i:Interaction.Triggers>
<i:EventTrigger EventName="PreviewMouseLeftButtonDown">
<i:InvokeCommandAction Command="{Binding MouseEnterCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" />
</i:EventTrigger>
<i:EventTrigger EventName="PreviewMouseLeftButtonUp">
<prism:InvokeCommandAction Command="{Binding SelectionChangedCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" TriggerParameterPath="Source" />
</i:EventTrigger>
</i:Interaction.Triggers>
</TextBox>

<StackPanel Margin="{Binding StackPanelMargin, RelativeSource={RelativeSource AncestorType=UserControl}}">
<i:Interaction.Triggers>
<i:EventTrigger EventName="PreviewMouseLeftButtonDown">
<i:InvokeCommandAction Command="{Binding MouseEnterCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<RepeatButton
Width="{Binding ButtonWidth, RelativeSource={RelativeSource AncestorType=UserControl}}"
Height="{Binding ButtonHeight, RelativeSource={RelativeSource AncestorType=UserControl}}"
Margin="{Binding ButtonMargin, RelativeSource={RelativeSource AncestorType=UserControl}}"
Padding="0,-6,0,-5"
Command="{Binding IncrementCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
Content="▲"
FontSize="9"
Foreground="Gray"
Interval="100" />
<RepeatButton
Width="{Binding ButtonWidth, RelativeSource={RelativeSource AncestorType=UserControl}}"
Height="{Binding ButtonHeight, RelativeSource={RelativeSource AncestorType=UserControl}}"
Margin="{Binding ButtonMargin, RelativeSource={RelativeSource AncestorType=UserControl}}"
Padding="0,-6,0,-5"
Command="{Binding DecrementCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
Content="▼"
FontSize="9"
Foreground="Gray"
Interval="100" />
</StackPanel>

<Button
x:Name="PART_ApplyButton"
Width="{Binding ApplyButtonWidth, RelativeSource={RelativeSource AncestorType=UserControl}}"
Height="{Binding ApplyButtonHeight, RelativeSource={RelativeSource AncestorType=UserControl}}"
Margin="{Binding ApplyButtonMargin, RelativeSource={RelativeSource AncestorType=UserControl}}"
Padding="0,-6,0,-5"
Click="OnApply"
Content="应用"
FontSize="14">
<Button.Style>
<Style BasedOn="{StaticResource ButtonDefault}" TargetType="Button">
<Setter Property="IsEnabled" Value="False" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="True">
<Setter Property="IsEnabled" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</UserControl>

后台逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using Prism.Commands;
using System;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using Core.UI;
using System.Diagnostics; // 添加App类的命名空间引用

namespace Core.UI.UserControls
{
public partial class NumericControl : UserControl
{
public NumericControl()
{
InitializeComponent();

// 初始化内部命令
MouseEnterCommand = new DelegateCommand(OnMouseEnter);
SelectionChangedCommand = new DelegateCommand<object>(OnSelectionChanged);
IncrementCommand = new DelegateCommand(OnIncrement);
DecrementCommand = new DelegateCommand(OnDecrement);

// 不要在这里初始化ApplyCommand,让它通过XAML绑定
//Console.WriteLine("NumericControl constructor called");
}

private bool _isUpdatingValue = false;
private int _lastCaretIndex = 0; // 添加字段保存上次的光标位置

private void OnMouseEnter()
{
if (!IsEditing)
{
IsEditing = true;
if (Value == null)
{
EditingValue = "0.00";
}
else
{
double value = Convert.ToDouble(Value);
EditingValue = value.ToString($"F{Digits}");
}
// 恢复上次保存的光标位置
PART_ValueTextBox.CaretIndex = _lastCaretIndex;
}
}

private void OnSelectionChanged(object source)
{
// 如果是TextBox,检查是否是全选操作
if (source is TextBox textBox)
{
// 如果选择了部分文本,保持选择状态
if (textBox.SelectionLength > 0)
{
return;
}

// 只有在没有选择的情况下,才移除选择并保持光标位置
int currentPosition = textBox.CaretIndex;
textBox.Select(currentPosition, 0);
}
}

private void OnIncrement()
{
// 如果不在编辑模式,先进入编辑模式
if (!IsEditing)
{
IsEditing = true;
if (Value == null)
{
EditingValue = "0.00";
}
else
{
double currentValue = Convert.ToDouble(Value);
EditingValue = currentValue.ToString($"F{Digits}");
}
// 优先使用上次的光标位置,如果没有则设置到最右边
PART_ValueTextBox.CaretIndex = _lastCaretIndex > 0 ? _lastCaretIndex : EditingValue.Length;
}

string value = EditingValue;
if (string.IsNullOrEmpty(value)) return;

if (double.TryParse(value, out double parsedValue))
{
// 检查是否已达到最大值
if (parsedValue >= Maximum)
{
return;
}
}

int cursorPosition = PART_ValueTextBox.CaretIndex;
int decimalPosition = value.IndexOf('.');

// 如果光标在最右边,找到最后一个数字的位置
if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i]))
{
cursorPosition = i;
break;
}
}
}

// 找到要修改的数字位置
int digitPosition = -1;
int intDigits = decimalPosition == -1 ? value.Length : decimalPosition;
if (decimalPosition != -1)
{
if (cursorPosition == 0)
{
for (int i = 0; i < decimalPosition; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i > decimalPosition; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == decimalPosition)
{
for (int i = decimalPosition - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition < decimalPosition)
{
for (int i = cursorPosition; i < decimalPosition; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else
{
for (int i = cursorPosition; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
}
else
{
if (cursorPosition == 0)
{
for (int i = 0; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else
{
for (int i = cursorPosition; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
}

if (digitPosition >= 0 && digitPosition < value.Length)
{
// 计算原始digitPosition对应的权重
double weight = 1.0;
if (decimalPosition != -1)
{
if (digitPosition < decimalPosition)
{
weight = Math.Pow(10, decimalPosition - digitPosition - 1);
}
else
{
weight = Math.Pow(10, decimalPosition - digitPosition);
}
}
else
{
weight = Math.Pow(10, value.Length - digitPosition - 1);
}

// 始终以原始digitPosition为基准进行加法
if (double.TryParse(value, out double currentValue))
{
double newValue = currentValue + weight;
// 检查范围限制
newValue = Math.Max(Minimum, Math.Min(Maximum, newValue));
// 格式化数值,保持小数位数
EditingValue = newValue.ToString($"F{Digits}");
}
}
PART_ValueTextBox.CaretIndex = cursorPosition;
}

private void OnDecrement()
{
// 如果不在编辑模式,先进入编辑模式
if (!IsEditing)
{
IsEditing = true;
if (Value == null)
{
EditingValue = "0.00";
}
else
{
double currentValue = Convert.ToDouble(Value);
EditingValue = currentValue.ToString($"F{Digits}");
}
// 优先使用上次的光标位置,如果没有则设置到最右边
PART_ValueTextBox.CaretIndex = _lastCaretIndex > 0 ? _lastCaretIndex : EditingValue.Length;
}

string value = EditingValue;
if (string.IsNullOrEmpty(value)) return;

if (double.TryParse(value, out double parsedValue))
{
// 检查是否已达到最小值
if (parsedValue <= Minimum)
{
return;
}
}

int cursorPosition = PART_ValueTextBox.CaretIndex;
int decimalPosition = value.IndexOf('.');

// 如果光标在最右边,找到最后一个数字的位置
if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i]))
{
cursorPosition = i;
break;
}
}
}

// 找到要修改的数字位置
int digitPosition = -1;
int intDigits = decimalPosition == -1 ? value.Length : decimalPosition;
if (decimalPosition != -1)
{
if (cursorPosition == 0)
{
for (int i = 0; i < decimalPosition; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i > decimalPosition; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == decimalPosition)
{
for (int i = decimalPosition - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition < decimalPosition)
{
for (int i = cursorPosition; i < decimalPosition; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else
{
for (int i = cursorPosition; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
}
else
{
if (cursorPosition == 0)
{
for (int i = 0; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else if (cursorPosition == value.Length)
{
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
else
{
for (int i = cursorPosition; i < value.Length; i++)
{
if (char.IsDigit(value[i])) { digitPosition = i; break; }
}
}
}

if (digitPosition >= 0 && digitPosition < value.Length)
{
double weight = 1.0;
if (decimalPosition != -1)
{
if (digitPosition < decimalPosition)
{
weight = Math.Pow(10, decimalPosition - digitPosition - 1);
}
else
{
weight = Math.Pow(10, decimalPosition - digitPosition);
}
}
else
{
weight = Math.Pow(10, value.Length - digitPosition - 1);
}

if (double.TryParse(value, out double currentValue))
{
double newValue = currentValue - weight;
// 检查范围限制
newValue = Math.Max(Minimum, Math.Min(Maximum, newValue));
EditingValue = newValue.ToString($"F{Digits}");
}
}
PART_ValueTextBox.CaretIndex = cursorPosition;
}

private int GetDigitPosition(string value, int cursorPosition, bool lookRight)
{
if (string.IsNullOrEmpty(value)) return -1;

// 如果光标在最右边,返回最后一个数字的位置
if (cursorPosition >= value.Length)
{
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) return i;
}
return -1;
}

// 如果光标在最左边,返回第一个数字的位置
if (cursorPosition == 0)
{
for (int i = 0; i < value.Length; i++)
{
if (char.IsDigit(value[i])) return i;
}
return -1;
}

// 根据lookRight参数决定查找方向
if (lookRight)
{
// 向右查找下一个数字
for (int i = cursorPosition; i < value.Length; i++)
{
if (char.IsDigit(value[i])) return i;
}
// 如果右边没有数字,返回最后一个数字
for (int i = value.Length - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) return i;
}
}
else
{
// 向左查找前一个数字
for (int i = cursorPosition - 1; i >= 0; i--)
{
if (char.IsDigit(value[i])) return i;
}
// 如果左边没有数字,返回第一个数字
for (int i = 0; i < value.Length; i++)
{
if (char.IsDigit(value[i])) return i;
}
}

return -1;
}

private void HandleCarry(StringBuilder value, int position)
{
// 处理进位
bool needNewDigit = true;
for (int i = position - 1; i >= 0; i--)
{
if (value[i] == '.') continue;

if (value[i] == '9')
{
value[i] = '0';
}
else if (char.IsDigit(value[i]))
{
value[i] = (char)(value[i] + 1);
needNewDigit = false;
break;
}
}

// 如果需要新的位数(比如从99变成100)
if (needNewDigit)
{
// 找到第一个数字的位置
int firstDigitPos = 0;
while (firstDigitPos < value.Length && !char.IsDigit(value[firstDigitPos]))
{
firstDigitPos++;
}

// 在第一个数字前插入1
value.Insert(firstDigitPos, '1');
}
}

private void HandleBorrow(StringBuilder value, int position)
{
// 处理借位
bool needRemoveDigit = true;
for (int i = position - 1; i >= 0; i--)
{
if (value[i] == '.') continue;

if (value[i] == '0')
{
value[i] = '9';
}
else if (char.IsDigit(value[i]))
{
value[i] = (char)(value[i] - 1);
needRemoveDigit = false;
break;
}
}

// 如果需要移除前导零
if (needRemoveDigit)
{
// 找到第一个数字的位置
int firstDigitPos = 0;
while (firstDigitPos < value.Length && !char.IsDigit(value[firstDigitPos]))
{
firstDigitPos++;
}

// 如果第一个数字是0,则移除所有前导零
if (firstDigitPos < value.Length && value[firstDigitPos] == '0')
{
int decimalPos = value.ToString().IndexOf('.');
if (decimalPos == -1)
{
// 没有小数点,移除所有前导零直到第一个非零数字
while (firstDigitPos < value.Length && value[firstDigitPos] == '0')
{
value.Remove(firstDigitPos, 1);
}
// 如果所有数字都被移除了,保留一个0
if (firstDigitPos >= value.Length)
{
value.Insert(0, '0');
}
}
else
{
// 有小数点,移除小数点前的所有前导零
while (firstDigitPos < decimalPos && value[firstDigitPos] == '0')
{
value.Remove(firstDigitPos, 1);
decimalPos--;
}
// 如果小数点前没有数字了,保留一个0
if (firstDigitPos >= decimalPos)
{
value.Insert(decimalPos, '0');
}
}
}
}
}

private void OnApply(object sender, RoutedEventArgs e)
{
if (!IsEditing || _isUpdatingValue)
{
return;
}

if (double.TryParse(EditingValue, out double newValue))
{
try
{
_isUpdatingValue = true;

// 保存当前光标位置
_lastCaretIndex = PART_ValueTextBox.CaretIndex;

// 应用转换系数
newValue /= ConvertCoefficient;

// 检查范围限制
newValue = Math.Max(Minimum, Math.Min(Maximum, newValue));

// 格式化数值
newValue = Math.Round(newValue, Digits);

// 保存旧值
object oldValue = Value;

// 更新Value属性
Value = newValue;

// 触发值变化事件
RoutedPropertyChangedEventArgs<object> args =
new RoutedPropertyChangedEventArgs<object>(oldValue, newValue, ValueChangedEvent);
RaiseEvent(args);

// 执行命令
if (ApplyCommand != null)
{
try
{
bool canExecute = ApplyCommand.CanExecute(ApplyCommandParameter);
if (canExecute)
{
ApplyCommand.Execute(ApplyCommandParameter);
}
}
catch (Exception ex)
{
Debug.WriteLine(ex, "执行ApplyCommand时发生异常");
}
}
}
finally
{
_isUpdatingValue = false;
// 退出编辑模式
Dispatcher.BeginInvoke(new Action(() =>
{
// 先设置IsEditing为false
IsEditing = false;
// 清空编辑值
EditingValue = string.Empty;
// 强制更新UI
PART_ValueTextBox?.GetBindingExpression(TextBox.TextProperty)?.UpdateTarget();
PART_ValueTextBox?.GetBindingExpression(TextBox.BackgroundProperty)?.UpdateTarget();
// 强制更新Value绑定
var valueBinding = GetBindingExpression(ValueProperty);
if (valueBinding != null)
{
valueBinding.UpdateSource();
}
}));
}
}
}

#region 依赖属性

public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(object), typeof(NumericControl),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnValueChanged));

public static readonly DependencyProperty TextBoxWidthProperty =
DependencyProperty.Register("TextBoxWidth", typeof(double), typeof(NumericControl), new PropertyMetadata(87.0));

public static readonly DependencyProperty TextBoxHeightProperty =
DependencyProperty.Register("TextBoxHeight", typeof(double), typeof(NumericControl), new PropertyMetadata(36.0));

public static readonly DependencyProperty TextBoxPaddingProperty =
DependencyProperty.Register(nameof(TextBoxPadding), typeof(Thickness), typeof(NumericControl),
new PropertyMetadata(new Thickness(-5, 8, 8, 0)));

public static readonly DependencyProperty FontSizeProperty =
DependencyProperty.Register(nameof(FontSize), typeof(double), typeof(NumericControl), new PropertyMetadata(14.0));

public static readonly DependencyProperty TextBoxForegroundProperty =
DependencyProperty.Register(nameof(TextBoxForeground), typeof(Brush), typeof(NumericControl),
new PropertyMetadata(Brushes.Blue));

public static readonly DependencyProperty IsEditingProperty =
DependencyProperty.Register(nameof(IsEditing), typeof(bool), typeof(NumericControl), new PropertyMetadata(false));

public static readonly DependencyProperty EditingValueProperty =
DependencyProperty.Register(nameof(EditingValue), typeof(string), typeof(NumericControl),
new PropertyMetadata(string.Empty));

public static readonly DependencyProperty TextBoxBackgroundProperty =
DependencyProperty.Register(nameof(TextBoxBackground), typeof(Brush), typeof(NumericControl),
new PropertyMetadata(Brushes.Transparent));

public static readonly DependencyProperty EditingBackgroundProperty =
DependencyProperty.Register(nameof(EditingBackground), typeof(Brush), typeof(NumericControl),
new PropertyMetadata(new SolidColorBrush(Color.FromRgb(0xAE, 0xEA, 0x00))));

public static readonly DependencyProperty ConvertCoefficientProperty =
DependencyProperty.Register(nameof(ConvertCoefficient), typeof(double), typeof(NumericControl),
new PropertyMetadata(1.0));

public static readonly DependencyProperty DigitsProperty =
DependencyProperty.Register(nameof(Digits), typeof(int), typeof(NumericControl), new PropertyMetadata(2));

public static readonly DependencyProperty IsScientificNotationProperty =
DependencyProperty.Register(nameof(IsScientificNotation), typeof(bool), typeof(NumericControl),
new PropertyMetadata(false));

public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(
nameof(Maximum),
typeof(double),
typeof(NumericControl),
new FrameworkPropertyMetadata(
double.MaxValue,
FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.AffectsMeasure,
OnMaximumChanged));

public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(
nameof(Minimum),
typeof(double),
typeof(NumericControl),
new FrameworkPropertyMetadata(
double.MinValue,
FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.AffectsMeasure,
OnMinimumChanged));

public static readonly DependencyProperty DisplayConverterProperty =
DependencyProperty.Register(nameof(DisplayConverter), typeof(IValueConverter), typeof(NumericControl));

public static readonly DependencyProperty ApplyCommandProperty =
DependencyProperty.Register(nameof(ApplyCommand), typeof(ICommand), typeof(NumericControl),
new PropertyMetadata(null, OnApplyCommandChanged));

public static readonly DependencyProperty ApplyCommandParameterProperty =
DependencyProperty.Register(nameof(ApplyCommandParameter), typeof(object), typeof(NumericControl),
new PropertyMetadata(null));

public static readonly DependencyProperty ButtonWidthProperty =
DependencyProperty.Register("ButtonWidth", typeof(double), typeof(NumericControl), new PropertyMetadata(18.0));

public static readonly DependencyProperty ButtonHeightProperty =
DependencyProperty.Register("ButtonHeight", typeof(double), typeof(NumericControl), new PropertyMetadata(15.0));

public static readonly DependencyProperty ApplyButtonWidthProperty =
DependencyProperty.Register("ApplyButtonWidth", typeof(double), typeof(NumericControl), new PropertyMetadata(36.0));

public static readonly DependencyProperty ApplyButtonHeightProperty =
DependencyProperty.Register("ApplyButtonHeight", typeof(double), typeof(NumericControl), new PropertyMetadata(36.0));

public static readonly DependencyProperty ButtonMarginProperty =
DependencyProperty.Register("ButtonMargin", typeof(Thickness), typeof(NumericControl), new PropertyMetadata(new Thickness(1)));

public static readonly DependencyProperty ApplyButtonMarginProperty =
DependencyProperty.Register("ApplyButtonMargin", typeof(Thickness), typeof(NumericControl), new PropertyMetadata(new Thickness(3)));

public static readonly DependencyProperty StackPanelMarginProperty =
DependencyProperty.Register("StackPanelMargin", typeof(Thickness), typeof(NumericControl), new PropertyMetadata(new Thickness(-21, 5, 0, 5)));

public static readonly DependencyProperty MainStackPanelMarginProperty =
DependencyProperty.Register("MainStackPanelMargin", typeof(Thickness), typeof(NumericControl), new PropertyMetadata(new Thickness(4, 0, 0, 0)));

#endregion

#region 属性

public object Value
{
get => GetValue(ValueProperty);
set
{
SetValue(ValueProperty, value);
// 强制更新绑定
if (!IsEditing)
{
Dispatcher.BeginInvoke(new Action(() =>
{
var binding = PART_ValueTextBox?.GetBindingExpression(TextBox.TextProperty);
if (binding != null)
{
binding.UpdateTarget();
}
}));
}
}
}

public double TextBoxWidth
{
get => (double)GetValue(TextBoxWidthProperty);
set => SetValue(TextBoxWidthProperty, value);
}

public double TextBoxHeight
{
get => (double)GetValue(TextBoxHeightProperty);
set => SetValue(TextBoxHeightProperty, value);
}

public Thickness TextBoxPadding
{
get => (Thickness)GetValue(TextBoxPaddingProperty);
set => SetValue(TextBoxPaddingProperty, value);
}

public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}

public Brush TextBoxForeground
{
get => (Brush)GetValue(TextBoxForegroundProperty);
set => SetValue(TextBoxForegroundProperty, value);
}

public bool IsEditing
{
get => (bool)GetValue(IsEditingProperty);
set
{
Console.WriteLine($"IsEditing changing from {IsEditing} to {value}");
SetValue(IsEditingProperty, value);
}
}

public string EditingValue
{
get => (string)GetValue(EditingValueProperty);
set => SetValue(EditingValueProperty, value);
}

public Brush TextBoxBackground
{
get => (Brush)GetValue(TextBoxBackgroundProperty);
set
{
Console.WriteLine($"TextBoxBackground changing to: {value}");
SetValue(TextBoxBackgroundProperty, value);
}
}

public Brush EditingBackground
{
get => (Brush)GetValue(EditingBackgroundProperty);
set
{
Console.WriteLine($"EditingBackground changing to: {value}");
SetValue(EditingBackgroundProperty, value);
}
}

public double ConvertCoefficient
{
get => (double)GetValue(ConvertCoefficientProperty);
set => SetValue(ConvertCoefficientProperty, value);
}

public int Digits
{
get => (int)GetValue(DigitsProperty);
set => SetValue(DigitsProperty, value);
}

public bool IsScientificNotation
{
get => (bool)GetValue(IsScientificNotationProperty);
set => SetValue(IsScientificNotationProperty, value);
}

public double Maximum
{
get => (double)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}

public double Minimum
{
get => (double)GetValue(MinimumProperty);
set => SetValue(MinimumProperty, value);
}

public IValueConverter DisplayConverter
{
get => (IValueConverter)GetValue(DisplayConverterProperty);
set => SetValue(DisplayConverterProperty, value);
}

public ICommand ApplyCommand
{
get => (ICommand)GetValue(ApplyCommandProperty);
set => SetValue(ApplyCommandProperty, value);
}

public object ApplyCommandParameter
{
get => GetValue(ApplyCommandParameterProperty);
set => SetValue(ApplyCommandParameterProperty, value);
}

public double ButtonWidth
{
get => (double)GetValue(ButtonWidthProperty);
set => SetValue(ButtonWidthProperty, value);
}

public double ButtonHeight
{
get => (double)GetValue(ButtonHeightProperty);
set => SetValue(ButtonHeightProperty, value);
}

public double ApplyButtonWidth
{
get => (double)GetValue(ApplyButtonWidthProperty);
set => SetValue(ApplyButtonWidthProperty, value);
}

public double ApplyButtonHeight
{
get => (double)GetValue(ApplyButtonHeightProperty);
set => SetValue(ApplyButtonHeightProperty, value);
}

public Thickness ButtonMargin
{
get => (Thickness)GetValue(ButtonMarginProperty);
set => SetValue(ButtonMarginProperty, value);
}

public Thickness ApplyButtonMargin
{
get => (Thickness)GetValue(ApplyButtonMarginProperty);
set => SetValue(ApplyButtonMarginProperty, value);
}

public Thickness StackPanelMargin
{
get => (Thickness)GetValue(StackPanelMarginProperty);
set => SetValue(StackPanelMarginProperty, value);
}

public Thickness MainStackPanelMargin
{
get => (Thickness)GetValue(MainStackPanelMarginProperty);
set => SetValue(MainStackPanelMarginProperty, value);
}

#endregion

#region 命令

public static readonly DependencyProperty MouseEnterCommandProperty =
DependencyProperty.Register(nameof(MouseEnterCommand), typeof(ICommand), typeof(NumericControl));

public static readonly DependencyProperty SelectionChangedCommandProperty =
DependencyProperty.Register(nameof(SelectionChangedCommand), typeof(ICommand), typeof(NumericControl));

public static readonly DependencyProperty IncrementCommandProperty =
DependencyProperty.Register(nameof(IncrementCommand), typeof(ICommand), typeof(NumericControl));

public static readonly DependencyProperty DecrementCommandProperty =
DependencyProperty.Register(nameof(DecrementCommand), typeof(ICommand), typeof(NumericControl));

public ICommand MouseEnterCommand
{
get => (ICommand)GetValue(MouseEnterCommandProperty);
set => SetValue(MouseEnterCommandProperty, value);
}

public ICommand SelectionChangedCommand
{
get => (ICommand)GetValue(SelectionChangedCommandProperty);
set => SetValue(SelectionChangedCommandProperty, value);
}

public ICommand IncrementCommand
{
get => (ICommand)GetValue(IncrementCommandProperty);
set => SetValue(IncrementCommandProperty, value);
}

public ICommand DecrementCommand
{
get => (ICommand)GetValue(DecrementCommandProperty);
set => SetValue(DecrementCommandProperty, value);
}

#endregion

public static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent("ValueChanged", RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<object>), typeof(NumericControl));

public event RoutedPropertyChangedEventHandler<object> ValueChanged
{
add { AddHandler(ValueChangedEvent, value); }
remove { RemoveHandler(ValueChangedEvent, value); }
}

private static void OnApplyCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is NumericControl control)
{
//Console.WriteLine($"ApplyCommand changed: {e.NewValue?.GetType().FullName}");
if (e.OldValue is ICommand oldCommand)
{
oldCommand.CanExecuteChanged -= control.OnApplyCommandCanExecuteChanged;
}
if (e.NewValue is ICommand newCommand)
{
newCommand.CanExecuteChanged += control.OnApplyCommandCanExecuteChanged;
//Console.WriteLine("New command registered for CanExecuteChanged");
}
}
}

private void OnApplyCommandCanExecuteChanged(object sender, EventArgs e)
{
if (PART_ApplyButton != null)
{
bool canExecute = false;
if (ApplyCommand != null)
{
if (ApplyCommandParameter is string param)
{
var commandParam = ValueTuple.Create(param, Value);
canExecute = ApplyCommand.CanExecute(commandParam);
}
else
{
canExecute = ApplyCommand.CanExecute(ApplyCommandParameter);
}
}
PART_ApplyButton.IsEnabled = canExecute && IsEditing;
}
}

private static void OnMaximumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is NumericControl control)
{
// 确保最大值不小于最小值
if ((double)e.NewValue < control.Minimum)
{
control.Maximum = control.Minimum;
}
}
}

private static void OnMinimumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is NumericControl control)
{
// 确保最小值不大于最大值
if ((double)e.NewValue > control.Maximum)
{
control.Minimum = control.Maximum;
}
}
}

private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is NumericControl control)
{
// 如果不在编辑模式下,更新显示值
if (!control.IsEditing)
{
control.Dispatcher.BeginInvoke(new Action(() =>
{
var binding = control.PART_ValueTextBox?.GetBindingExpression(TextBox.TextProperty);
if (binding != null)
{
binding.UpdateTarget();
}
}));
}
}
}
}
}

命令

为了把业务都整合放到ViewModel中,因此不仅希望属性绑定,还希望事件也能实现类似的效果,命令正是为此诞生的

命令由许多可变的部分组成,但它们都具有以下4个重要元素:

  • 命令:命令表示应用程序任务,并且跟踪任务是否能够被执行。然而,命令实际上不包含执行应用程序任务的代码。
  • 命令绑定:每个命令绑定针对用户界面的具体元素,将命令连接到相关的应用程序逻辑。这种分解的设计是非常重要的,因为单个命令可用于应用程序中的多个地方,并且在每个地方具有不同的意义。为处理这一问题,需要将同一命令与不同的命令绑定。
  • 命令源:命令源触发命令。例如,button就是命令源。单击它们都会执行绑定命令。
  • 命令目标:命令目标是在执行过程中涉及到的其他元素。

命令详解

  • 命令对象:代表一个具体的可执行操作,通常具有名称,执行逻辑等属性
  • 命令的绑定:通用讲命令与控件进行绑定,使控件可以响应命令.在意在XAML中直接设置控件的Command属性来建立绑定关系
  • 命令的执行:当满足触发条件时(如按钮点击等),相应的命令被执行,执行逻辑可以在代码中自定义编写
  • 命令的启用/禁用状态: 可以根据特定条件动态控制命令是否可用,比如某些操作在特定场景下不可用
  • 命令路由:命令在控件层次结构中传播和处理,允许父控件或更高级别的元素也能对命令做出响应

如何实现一个命令

ICommand

WPF命令的核心是System.Windows.Input.ICommand接口,该接口定义了命令的工作原理.该接口包含两个方法和一个事件

1
2
3
4
5
6
7
8
9
10
public interface ICommand
{
//包含引用程序的任务逻辑
void Execute(object parameter);
//返回命令的状态,可用为true,不可用为false;
bool Canexecute(object parameter);
//当命令状态改变时引发该事件.对于使用命令的任何控件,这是指示信号,表示他们应当调用CanExecute方法检查命令的状态.通过使用该事件,当命令可用时,命令源(如button)可自动启用自身;当命令不可用时,禁用自身
event EventHandler CanExecuteChanged;
}
//Execute和Canexecute方法都接受一个附加的对象参数,可使用该对象传递所需的任何附加信息

可以直接使用RoutedCommand触发无参的命令

如果需要传一个参,可构造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CustomCommand<T> : ICommand
{
private readonly Action<T> action;

public CustomCommand(Action<T> action)
{
this.action = action;
}

public bool CanExecute(object parameter)
{
return true;
}

public event EventHandler CanExecuteChanged;

public void Execute(object parameter)
{
action((T)parameter);
}
}

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;

namespace testGrid
{
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Predicate<T> _canExecute;

public RelayCommand(Action<T> execute, Predicate<T> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}

public bool CanExecute(object parameter)
{
return _canExecute?.Invoke((T)parameter) ?? true;
}

public void Execute(object parameter)
{
_execute((T)parameter);
}

public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
}

如何让控件支持命令

支持命令的实现是在”命令源”这个环节实现的,即要让某个元素能够作为命令的发起源并执行相关操作,就需要在这个元素上实现 ICommandSource 接口。这个接口定义了与命令相关的一些必要属性和方法。.不是每个控件都直接支持command绑定的,需要将他们关联到实现了 ICommandSource接口的控件

天然实现了 ICommandSource 接口的控件包括继承自ButtonBase类的控件(button和Checkbox等).它们可以直接作为命令源来进行命令的绑定和操作,这是因为这些控件的本质特性决定了它们在交互中常常扮演着触发命令的角色。

ICommandSource

定义了三个属性

  • Command 指向连接的命令,这是唯一必须的细节
  • CommandParamter: 提供其他希望随命令发送的数据
  • CommandTarget: 确定将在其中执行命令的元素
1
2
3
4
5
6
7
<Button
Width="200"
Height="30"
Margin="3"
Command="{Binding CancelRemberCommand}"
CommandParameter="{Binding ElementName=ChkboxPwd,Path=IsChecked}"
Content="检查"/>

ElementName 是一个标记扩展,它允许您引用 XAML 中定义的其他控件。Path表示控件下的哪个属性

开发者如何使用

如何使用

定义一个自己的针对Command类型,支持针对一个参数的泛型类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CustomCommand<T> : ICommand
{
private readonly Action<T> action;

public CustomCommand(Action<T> action)
{
this.action = action;
}

public bool CanExecute(object parameter)
{
return true;
}

public event EventHandler CanExecuteChanged;

public void Execute(object parameter)
{
action((T)parameter);
}
}

使用该类型

1
2
3
4
5
6
7
8
9
//CancelAction是定义好的方法,这里的参数是下面的CommandParameter传过来的,即chkboxPwd.IsChecked传过来了
private void CancelAction(bool obj){
MessageBox.Show($"check status {obj}");
}

//先定义一个成员:
public ICommand CancelRemberCommand = new CustomCommand<bool>(CancelAction);
//这样去构造
CancelRemberCommand = new RelayCommand<bool>(CancelAction);

在yaml中这样去连接

1
2
3
4
5
6
7
<Button
Width="200"
Height="30"
Margin="3"
Command="{Binding CancelRemberCommand}"
CommandParameter="{Binding ElementName=ChkboxPwd,Path=IsChecked}"
Content="检查"/>

上面的部分也可以用如下c#代码实现来代替,如下:

1
2
3
4
//假设这个button1就是上面的button
button1.Command = CancelRemberCommand;
button1.CommandTarget = ChkboxPwd;
//...

这样,当点击该button的时候,就会自动调用CancelAction函数了

就像前面说的,这里之所以能这样去使用,有一个隐含的条件是在于button已经实现了ICommandSource接口,才可以这样用

p.s. 目前来看,这样开发比事件驱动要麻烦,但长远考虑更好

事件

事件和命令是两种处理用户交互的机制

  • 事件: 是一种传统的.net机制,允许一个对象通知另一个对象发生了某种情况
  • 命令: 是一种更高级的机制,一个对象请求另一个对象执行某种任务,并且可以跟踪该任务是否可以执行

事件处理机制模型

WPF应用程序开发人员和组件创建者可以使用路由事件,通过元素树来传播事件,并在树中的多个侦听器上调用事件处理程序。公共语言运行时 事件(CLR事件,也称作直接事件),没有这些功能。

直接事件

直接事件的前身是消息机制,事件模型隐藏了消息机制的很多细节,使得开发逻辑变得简单

  • 事件的拥有者:也就是消息的发送者(Sender)。事件的宿主在某些条件下激发它拥有的事件,触发后使得消息被发送。
  • 事件的响应者:将消息接收和处理,使用事件处理器(Event Handler)进行响应。
  • 事件的订阅:事件的响应者需要订阅事件才能实现事件从触发到处理的完整过程。
image-20240902174655806

E.g. 在一个窗体上有一个按钮Button_1和一个文本框TextBox_1,按下按钮后,文本框出现文字“Hello World!”

  • 事件的拥有者:Button_1
  • 事件:Button_1_Click
  • 事件的响应者:Window
  • 事件处理器:private void Button_1_Click(this.Button_1, EventArgs e){}
  • 订阅关系:this.Button_1.CLick += new System.EventHandLer(this.Button_1_Click);

直接事件的问题在于: 事件的拥有者和事件的响应者必须点对点建立订阅关系,也就是说,事件的拥有者必须能直接访问事件的响应者

路由事件

在路由事件中,事件的拥有者和响应者之间没有直接显式的订阅关系,事件的拥有者只负责激发事件,事件由谁进行响应它并不关心,事件的响应者通过事件侦听器针对某一类事件进行监听,当监听到有事件传递过来时,就可以使用事件处理器来响应事件,并决定事件是否继续传递给其他对象。

需要先了解逻辑树与视觉树

详细解释可以参考此处

WPF事件可以分为3种类型

事件的触发顺序是:隧道事件 → 直接事件 → 冒泡事件

  • 直接事件

    事件在源头引发并在源头本身进行处理

  • 冒泡事件

    事件沿着视觉树层次向上传播.例如,”MouseDown”就是一个冒泡事件

    从目标元素向根元素传播,父级控件可以在子控件处理完事件后进行处理

  • 隧道事件

    事件沿着视觉树层次结构向下传播.例如,”PreviewKeDown”是一个隧道事件

    从根元素向目标元素传播,通常以 “Preview” 前缀命名。

image-20250104165405186

假设有一个 Window,里面有一个 StackPanel,而 StackPanel 中有一个 Button。当用户点击 Button 时,事件的触发顺序如下:

  1. 隧道事件

    • Window(根元素)接收到 PreviewMouseDown
    • StackPanel 接收到 PreviewMouseDown
    • Button 接收到 PreviewMouseDown
  2. 直接事件

    • Button 接收到 Click 事件
  3. 冒泡事件

    • Button 接收到 MouseDown
    • StackPanel 接收到 MouseDown
    • Window 接收到 MouseDown

在冒泡事件处理中取消事件继续处理方式如下:

1
2
3
4
5
private void MouseLeftButtonUp(object sender,MouseButtonEventArgs e)
{
e.Handled = true;//取消事件继续处理(隧道事件和冒泡事件都是这么处理)
//其他处理...
}

但注意如果在隧道事件中设置了 e.Handled = true,那么该事件将不会继续传播到目标元素,导致目标元素的事件不会被触发。

针对窗口的事件

WPF事件执行顺序

  1. BeginInit 初始化开始
  2. EndInit 初始化结束
  3. OnInitialized 触发初始化时间
  4. MeasureOverride 计算内部控件所需空间
  5. ArrangeOverride 排列内部控件
  6. GetLayoutClip 计算控件实际显示大小,如果元素大小超过可用的显示空间,将自动进行剪切
  7. OnRender 渲染窗口
  8. OnRenderSizeChanged 更新窗口的ActualHeight和ActualWidth
  9. MeasureOverride
  10. ArrangeOverride
  11. Loaded 控件加载
  12. OnContentRendered 内部渲染

Initialized

触发时机: 当控件被初始化时触发。
说明: 这是控件的构造函数完成后立即触发的事件。此时控件的属性已经设置,但还没有加载到视觉树中。

Initialized 事件是自底向上触发,先子后父

Loaded

触发时机: 当控件及其所有子元素加载完成并添加到视觉树中时触发,所以在这里面查找控件可以到视觉树中查找
说明: 这是一个常用的事件,用于执行需要在控件加载完成后进行的初始化逻辑。

Loaded事件是自顶向下以广播方式触发,从根节点,由父向子触发

这个元素不仅被构造并初始化完成,布局也运行完毕,数据也绑上来了,它现在连到了渲染面上(rendering surface),马上就要被渲染的节奏.到这个时候,就可以通过 Loaded 事件从根元素开始画出整棵树. 这个事件与 IsLoaded 属性绑定.

LayoutUpdated

触发时机: 每次布局系统更新布局时触发。
说明: 这是一个频繁触发的事件,用于确保所有子元素都已加载并完成布局。可以用于在控件完全加载后执行逻辑。

SizeChanged

触发时机: 当控件的大小发生变化时触发。
说明: 这是一个在控件的 Width 或 Height 属性发生变化时触发的事件。
示例:

DataContextChanged

触发时机: 当控件的数据上下文发生变化时触发。
说明: 这是一个在控件的 DataContext 属性发生变化时触发的事件,通常用于处理数据绑定逻辑。

Unloaded

触发时机: 当控件及其所有子元素从视觉树中移除时触发。
说明: 这是一个在控件被卸载时触发的事件,通常用于清理资源或取消订阅事件。

顺序盘点

  1. 控件初始化:
    Initialized
  2. 控件加载:
    Loaded
  3. 布局更新:
    LayoutUpdated (可能多次触发)
  4. 大小变化:
    SizeChanged (如果控件大小发生变化)
  5. 数据上下文变化:
    DataContextChanged (如果数据上下文发生变化)
  6. 控件卸载:
    Unloaded
事件名称 事件描述 使用场景
Loaded 窗口或页面完全加载后触发。 用于在 UI 准备好后执行初始化任务。
Unloaded 窗口或页面即将卸载时触发。 用于在 UI 被移除时执行清理任务。
Initialized 组件初始化时触发,在加载到视觉树之前。 用于在控件渲染之前执行操作。
Activated 窗口成为活动窗口时触发。 当窗口获得焦点时执行操作。
Deactivated 窗口失去焦点时触发。 当窗口不再是活动窗口时执行操作。
Closing 窗口关闭之前触发。 可以在此事件中取消窗口关闭或执行清理操作。
Closed 窗口关闭后触发。 用于在窗口关闭后执行最终清理操作。
ContentRendered 窗口内容渲染完成后触发。 用于依赖于内容完全渲染后的操作。
StateChanged 窗口状态改变时触发(例如,最小化、最大化)。 用于响应窗口状态变化。
SizeChanged 窗口大小改变时触发。 用于响应窗口大小调整事件。
LocationChanged 窗口位置改变时触发。 用于响应窗口位置变化。
KeyDown 窗口获得焦点时按下键盘按键时触发。 用于处理键盘输入。
KeyUp 窗口获得焦点时释放键盘按键时触发。 用于处理键盘输入。

Loaded:窗口或页面完全加载并即将显示时触发

Unloaded:在窗口或页面即将卸载或关闭时触发

1
2
3
4
5
6
7
8
9
10
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
<!--接到上面的命名空间后-->
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding LoadedCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="Unloaded">
<i:InvokeCommandAction Command="{Binding UnloadedCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>

针对控件和元素的事件

事件名称 事件描述 使用场景
Click 当用户点击按钮时触发。 用于按钮点击事件处理。
MouseEnter 当鼠标光标进入控件区域时触发。 用于实现鼠标悬停效果。
MouseLeave 当鼠标光标离开控件区域时触发。 用于实现鼠标离开效果。
MouseMove 当鼠标在控件上移动时触发。 用于实时响应鼠标移动事件。
TextChanged 当文本框中的文本发生变化时触发。 用于处理用户输入的文本变化。
SelectionChanged 当选择的项发生变化时触发(例如在列表框中)。 用于处理用户选择的变化。
Checked 当复选框被选中时触发。 用于处理复选框的选中状态。
Unchecked 当复选框被取消选中时触发。 用于处理复选框的取消选中状态。
ValueChanged 当滑块的值发生变化时触发。 用于处理滑块控件的值变化。
Drop 当拖放操作完成时触发。 用于处理拖放操作。
GotFocus 当控件获得焦点时触发。 用于处理控件获得焦点的事件。
LostFocus 当控件失去焦点时触发。 用于处理控件失去焦点的事件。

输入事件

这些事件与用户输入相关,通常用于响应键盘或鼠标操作。

事件名称 事件描述 使用场景
PreviewMouseDown 在鼠标按下事件被处理之前触发。 用于在鼠标点击前执行一些操作。
PreviewMouseUp 在鼠标释放事件被处理之前触发。 用于在鼠标释放前执行一些操作。
PreviewKeyDown 在键盘按下事件被处理之前触发。 用于在键盘按下前执行一些操作。
PreviewKeyUp 在键盘释放事件被处理之前触发。 用于在键盘释放前执行一些操作。

焦点事件

这些事件与控件的焦点状态相关。

事件名称 事件描述 使用场景
GotKeyboardFocus 当控件获得键盘焦点时触发。 用于处理控件获得焦点的事件。
LostKeyboardFocus 当控件失去键盘焦点时触发。 用于处理控件失去焦点的事件。

布局事件

这些事件与控件的布局和大小变化相关。

事件名称 事件描述 使用场景
LayoutUpdated 当布局更新完成时触发。 用于在布局更新后执行操作。
SizeChanged 当控件大小发生变化时触发。 用于响应控件的大小变化。
LocationChanged 当控件位置发生变化时触发。 用于响应控件位置变化。

数据事件

这些事件与数据绑定和数据上下文相关。

事件名称 事件描述 使用场景
DataContextChanged 当控件的数据上下文发生变化时触发。 用于处理数据上下文变化的事件。
BindingGroupChanged 当绑定组发生变化时触发。 用于处理绑定组变化的事件。

MVVM项目架构

[[开发项目管理#MVVM|MVVM详解]]

iShot_2024-06-12_16.55.10

每个界面包括三个文件夹

  • Models
  • ViewModels 可以理解成view的属性映射层
  • Views 这个里面可以放窗体可以放控件

注意文件夹,文件的命名,每个文件的拜放都是有讲究的切忽乱放

ViewModel可以聚合N个Model,ViewModel可以对应多个View

App.xaml是程序启动的配置文件

1
2
3
4
5
6
7
8
9
<Application x:Class="testWPF.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:testWPF"
StartupUri="MainWindow.xaml">
<Application.Resources>

</Application.Resources>
</Application>

如果需要更换起始运行窗体则需要修改 StartupUri = "Views/xxxView.xaml"即可(要注意这里是相对路径)

MainWindow.xaml主窗体的位置比较特殊,它可以单独游离在所有文件夹之外

如何连接三种文件

比如说有LoginView窗体类的构造函数中,通过 DataContext = new LoginViewModel(this);来连接LoginViewModel文件,所有的变量字段,命令,都定义在这个LoginViewModel中

在LoginViewModel中添加属性 LoginModel model = new LoginModel();(model 得存为一个属性)和LoginModel连接上

演变:

屏幕截图 2024-06-14 095350

上图是过渡方式,下图才是最终采纳的三种文件的关系图:

屏幕截图 2024-06-14 095327

代码分离

  • Model 包含各种控件绑定的属性,需要支持
  • ViewModel 包含通知代码,应含有属性Model和属性View,包含各种供绑定的属性接口与Model交互,也包含各种命令
  • View 构造函数中初始化DataContext为ViewModel(即所有的被绑定的命令和属性会到ViewModel中去找)

主要职责如下:

Model(模型层)

  • 定义业务对象和数据模型。

ViewModel(视图模型层)

  • 属性,用于绑定到视图。
  • 命令,处理用户交互。
  • 业务逻辑和数据验证。

View(视图层)

  • XAML 布局和控件定义。
  • 数据绑定到 ViewModel 属性。

最小MVVM案例

暂略(使用[[prism|prism框架]]实现MVVM要方便许多)

事件驱动与数据驱动

事件驱动

事件驱动通过“事件一订阅一事件处理”的关系组织应用程序。事件驱动下,用户进行每一个操作会激发程序发生的一个事件,事件发生后,用于响应事件的事件处理器就会执行。

事件驱动对应的表示模式正是MVC

事件驱动下界面控件占主动地位的,界面逻辑与业务逻辑之间的桥梁是事件.界面逻辑和业务逻辑多多少少都会有关联性.数据是处于被动地位的,是静态的,等待着控件的事件去驱动他们,当事件逻辑变得多且复杂的时候,代码就会很容易变得复杂难懂,遇到bug的时候难以排除

数据驱动

数据驱动的理念下,数据占主动地位,也就是由内容决定形式。数据驱动的桥梁是双向数据绑定,通过Data Binding可以实现数据流向界面,界面也可以将数据流回数据源。

当用户对控件进行操作带来数据变更时,通过双向数据绑定会更新在model中的数据.在这个过程中,控件和控件事件对程序的控制会被弱化,他们只参与页面逻辑而不再主导业务逻辑.可见在数据驱动的理念下,数据占主动地位,即由内容决定形式

MVVM下代码操作控件

x:Name必须是在xaml对应的后台vs文件中才能直接使用

而在 MVVM 模式中,视图模型(ViewModel)不直接操作视图(View)。但是,你可以通过绑定和命令来间接操作视图中的控件

可以通过Command

有些控件无法通过Command绑定命令的,可以通过双向绑定数据到一个属性,然后在属性的set方法中添加处理事件,如下:

1
2
3
4
5
6
7
8
9
10
11
12
//此属性绑定是ComboBox的SelectedItem="{Binding SelectedUser}",这样变相绑定了命令
private string _SelectedUser;
public string SelectedUser
{
get { return _SelectedUser; }
set {
if (SetProperty(ref _SelectedUser, value))
{
FilterCommand.Execute();//执行命令
}
}
}

behaviour

Behavior 是一种可以附加到 WPF 控件的功能,它允许开发者将特定的交互逻辑封装在一个可重用的组件中。Behavior 使得控件能够以更灵活的方式响应用户输入和其他事件。

功能:Behavior 可以封装各种功能,包括但不限于:

  • 响应事件(如点击、鼠标悬停等)
  • 执行动作(如动画、数据绑定等)
  • 处理输入(如拖放、键盘输入等)

XAML行为库(XAML Behaviors Library)是一组用于在XAML中实现交互式行为的库,它提供了一些附加功能和行为,如触发器(Triggers)、动作(Actions)、交互触发器(Interaction Triggers)等,用于简化在XAML中实现交互性功能的开发。

包含三个部分

  1. Actions(动作)
    • Actions用于定义在触发器触发时要执行的操作或动作。例如,可以使用Action来执行命令、触发动画、导航到其他页面等。Actions通常与Triggers结合使用,以在特定条件下执行相应的操作。
  2. Triggers(触发器)
    • Triggers用于定义触发某些操作或动作的条件或事件。当定义的条件或事件发生时,触发器会触发与之关联的操作(Action)。常见的触发器包括事件触发器(如Loaded、Click)、属性触发器(如属性值变化)等。
  3. Interaction Triggers(交互触发器)
    • Interaction Triggers是XAML行为库中的一种特殊触发器,用于在XAML中实现交互性功能。它提供了一些特定的触发器类型,如EventTrigger、DataTrigger、PropertyChangedTrigger等,以便在特定事件发生或属性变化时执行相应的操作。

案例

本质上通过注册事件来实现类似的效果

演示一个将事件接到viewmodel的案例:

1
2
3
4
5
6
7
8
<!--添加此命名空间-->
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
<!--给ComboBox控件的SelectionChanged事件绑定FilterCommand命令-->
<i:Interaction.Triggers>
<i:EventTrigger EventName="SelectionChanged">
<i:InvokeCommandAction Command="{Binding FilterCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>

触发事件后直接修改样式案例:

1
2
3
4
5
6
7
8
9
10
<i:Interaction.Triggers>
<i:EventTrigger EventName="MouseEnter">
<i:ChangePropertyAction PropertyName="FontSize" Value="32"/>
<i:ChangePropertyAction PropertyName="Content" Value="Hover"/>
</i:EventTrigger>
<i:EventTrigger EventName="MouseLeave">
<i:ChangePropertyAction PropertyName="FontSize" Value="18/>
<i:ChangePropertyAction PropertyName="Content" Value="hello"/>
</i:EventTrigger>
</i:Interaction.Triggers>

behaviour中的Trigger与Style中的Trigger的不同在于,behaviour中的Trigger要写成对的,而Style中的Trigger可以有默认样式

在 WPF 中,当你使用 Style 来为控件定义一些视觉外观时,如果你为控件定义了 Trigger(触发器)来动态更改某些属性,它的优先级低于你直接在 XAML 中为该控件设置的属性值,但在 Template 中设置的 Trigger 例外,而behaviour中的Trigger由于本质原理是通过注册事件来实现的,因此没有这个限制

虽然Style中也有EventTrigger,但是他只可以用于路由事件,比如ListBox的selectChanged事件就不是路由事件;而behaviour中却只要是事件都可以绑定

自定义行为

WPF中的行为(Behavior)是指一种可以附加到控件上的功能,行为的作用是扩展控件的功能,而不需要修改控件本身的代码。行为通过绑定附加属性,能够方便地应用于不同控件。行为可以被复用和配置,使得控件的交互逻辑更加灵活。

通常,WPF行为有以下两种主要类型:

  • 附加行为:通过附加属性来实现。
  • 拖放行为:用于实现拖放功能。

自定义行为的实现方式

要使用行为,首先需要安装Microsoft.Xaml.Behaviors.Wpf库。这是微软提供的标准行为库,允许你轻松创建和附加行为。

1
Install-Package Microsoft.Xaml.Behaviors.Wpf

自定义行为通常继承自 Behavior<T>,其中T是行为所附加的控件类型。我们可以通过重写OnAttached和OnDetaching方法,在行为附加到控件时初始化功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using Microsoft.Xaml.Behaviors;
using System.Windows.Controls;

public class CustomTextBoxBehavior : Behavior<TextBox>
{
protected override void OnAttached()
{
base.OnAttached();
// 在行为附加时执行的代码
this.AssociatedObject.TextChanged += OnTextChanged;
}

protected override void OnDetaching()
{
base.OnDetaching();
// 在行为移除时执行的代码
this.AssociatedObject.TextChanged -= OnTextChanged;
}

private void OnTextChanged(object sender, TextChangedEventArgs e)
{
// 自定义的逻辑,比如自动转换文本为大写
var textBox = sender as TextBox;
textBox.Text = textBox.Text.ToUpper();
}
}

使用行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Window x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:WpfApp"
Title="MainWindow" Height="350" Width="525">
<Grid>
<TextBox Width="200" Height="30">
<i:Interaction.Behaviors>
<local:CustomTextBoxBehavior />
</i:Interaction.Behaviors>
</TextBox>
</Grid>
</Window>

在上面的例子中,自定义行为CustomTextBoxBehavior会自动将TextBox的输入文本转换为大写。

除了简单的文本处理,你还可以实现更复杂的行为,例如:

  • 拖放功能:让控件支持拖放操作。
  • 动画效果:当用户与控件交互时启动动画。
  • 命令行为:将命令绑定到控件的事件中。

案例

假设你需要在多个按钮上实现点击时的动画效果。你可以创建一个 ButtonClickAnimationBehavior 类,封装按钮点击时的动画逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ButtonClickAnimationBehavior : Behavior<Button>
{
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Click += OnButtonClick;
}

protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Click -= OnButtonClick;
}

private void OnButtonClick(object sender, RoutedEventArgs e)
{
// 实现动画效果
var animation = new DoubleAnimation
{
To = 1.0,
Duration = TimeSpan.FromSeconds(0.5)
};
AssociatedObject.BeginAnimation(Button.OpacityProperty, animation);
}
}

然后在 XAML 中使用这个 Behavior:

1
2
3
4
5
<Button Content="Click Me">
<i:Interaction.Behaviors>
<local:ButtonClickAnimationBehavior />
</i:Interaction.Behaviors>
</Button>

Behavior 在 WPF 中的作用是将特定的功能逻辑封装成独立的组件,以便于重用和维护。通过使用 Behavior,开发者可以更灵活地扩展控件的功能,同时保持代码的整洁和模块化。

MultiDataTrigger

MultiDataTrigger 是一个用于在 WPF 中根据多个数据绑定条件来控制 UI 元素属性的触发器。它的主要作用是在所有指定的条件都满足时,执行特定的操作(如设置属性值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<cc:LightLabel.Style>
<Style TargetType="cc:LightLabel">
<Setter Property="Status" Value="False" />
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions> <!--满足条件-->
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door1Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door2Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door3Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door4Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door5Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door6Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door7Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door8Lock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.DoorLock.Status}" Value="Close" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door1Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door2Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door3Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door4Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door5Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door6Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door7Close.Port.Port}" Value="false" />
<Condition Binding="{Binding HardwareManager.CombineDataProvider.UDPData.Model.Door8Close.Port.Port}" Value="false" />
</MultiDataTrigger.Conditions>
<!--触发-->
<Setter Property="Status" Value="True" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
</cc:LightLabel.Style>

单个Conditions中多个Condition是的关系,每个Conditions间是的关系

MVVM常见误区

两个常见误区

  • 对于 WPF开发还不够了解,很多思维停留在WinForms阶段,比如在代码后台修改控件的样式
  • 对于 MVVM 开发还不够了解,认为MVVM 就是要用绑定和把 Click 事件改成 Command

常见错误例子:

  1. Button的功能虽然使用了Command,但是按钮是否可用是IsEnabled绑定了另外一个bool属性

    不再额外写一个bool属性用来控制Button的IsEnabled,而是使用Command本身的CanExecute,CanExecute本质上也是控制button的使能,会让控件变灰

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    private bool _isEnableLongTimeClick = true;
    public bool isEnableLongTimeClick
    {
    get { return _isEnableLongTimeClick; }
    set
    {
    SetProperty(ref _isEnableLongTimeClick, value);
    _LongTimeClick.RaiseCanExecuteChanged();
    }
    }
    private DelegateCommand _LongTimeClick;
    public DelegateCommand LongTimeClick =>
    _LongTimeClick ?? (_LongTimeClick = new DelegateCommand(ExecuteLongTimeClick, CanExecuteLongTimeClick));

    async void ExecuteLongTimeClick()
    {

    isEnableLongTimeClick = false;
    await Task.Delay(2000);
    isEnableLongTimeClick = true;
    }

    bool CanExecuteLongTimeClick()
    {
    return isEnableLongTimeClick;
    }

    比方说执行处理时间中变灰,处理完自动亮起,async的Relaycommand本身就可以处理这个逻辑 (但要注意,CommunityToolkit.Mvvm才支持该功能)

  2. 想要让Button在鼠标置于上方时就实现交互操作(相当于让按钮不再被点击后才触发Click事件)

    直接修改Button的ClickMode值,ClickMode中有Hover,Press,Release

  3. 如果想要实现Button在鼠标置于上方时修改文字内容,会想办法注册MouseEnter和MouseLeave事件,然后再写一个string属性用于绑定Button.Content

    使用Style+Trigger (注意上方的Hover的ClickMode会处理掉Trugger处理的MouseEnter/MouseLeave事件,即二者同时存在的时候,只会处理ClickMode为Hover的Command,无视后者)

  4. 当鼠标置于Button上方时,实时输出鼠标在Button控件中的坐标

    虽然这里可以通过command的参数把自身传到viewModel中进行处理,但其实是不符合MVVM架构设计逻辑的,错误做法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <Button Width="200"
    Height="80"
    FontSize="18"
    Content="HeLlo">
    <i:Interaction.Triggers>
    <i:EventTrigger EventName="MouseEnter">
    <!--此处不用手写CommandParameter,直接使用PassEventArgsToCommand会自动传递EventArgs-->
    <i:InvokeCommandAction Command="{Binding MouseEnterCommand}" PassEventArgsToCommand="True" />
    <i:EventTrigger>
    <i:Interaction.Triggers>
    < Button>

    viewmodel中t

    1
    2
    3
    4
    void MouseMove(MouseEventArgs e)
    {
    //直接就可以处理了
    }

    但这种做法依旧是不对的,违背了viewmodel不处理view的原则,正确做法✅如下:

    写一个自定义行为,并通过绑定的DP,用于传出信息

资源和样式

WPF资源系统是一种保管一系列有用对象(如常用的画刷、样式和模板)的简单方法,从而使您可以更容易地重用这些对象。每个元素都有Resources属性,该属性存储了一个资源字典集合(它是ResourceDictionary类的实例)。资源集合可包含任意类型的对象,根据字符串编写索引。可以在任何元素上定义资源,可通过 x:Key指令来分配唯一键

查找资源的方向是: 沿着逻辑树向上查找

资源包含

  • 重复的xaml内容
  • 控件风格样式
  • 数据模板
  • 动画
1
2
3
4
5
6
7
8
9
10
11
<!--窗体的写法-->
<Window>
<Window.Resources>
</Window.Resources>
</Window>

<!--控件的写法-->
<Control>
<Control.Resources>
</Control.Resources>
</Control>

上面两种资源的作用范围都局限于当前窗口或控件下,如果需要设置全局样式,参考此处

给单个控件添加样式很简单,直接是通过给属性设置值的方式来设置样式,但如果需要针对多个或同一类控件都设置为同一个样式呢,单个添加属性的方法就很繁琐了,此时就需要用到样式

静态资源和动态资源

  • 静态资源使用(StaticResource)指的是在程序载入内存时,对资源的一次性使用,之后就不再去访问这个资源了;

  • 动态资源使用(DynamicResource)指的是在程序运行过程中,仍然会去访问该资源。

    只要是需要运行中改变的资源都得用动态资源,例如最常见的改变颜色

资源管理形式

在WPF 项目中,我们一般有以下几种方法来管理 Window.Resources:

• 直接在 窗口对应的xaml 中定义资源,这样可以方便地在主窗口中使用资源,但是如果资源很多或者需要在其他窗口或用户控件中使用,就不太合适。
• 在 App.xaml 中定义资源,这样可以让资源在整个应用程序的范围内共享,实现一致的主题和外观,但是如果资源很多或者需要根据不同的窗口或用户控件进行区分,就不太合适。
• 创建文件夹并创建内容元素为ResourceDictionary的xaml页面,这样可以将资源分组和模块化,方便管理和维护,也可以根据需要在不同的窗口或用户控件中引用或合并资源。

ResourceDictionary引用方法

在App.xaml中使用MergedDictionaries属性来合并多个ResourceDictionary文件(资源字典),这样可以让资源在整个应用程序的范围内共享

1
2
3
4
5
6
7
8
9
10
11
12
<Application x:Class="MyApp.App"
xmLns="http://schemas.microsoft.com/winfx/2006/xamL/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xamL">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Resources/Brushes.xamL"/>
<ResourceDictionary Source="/Resources/strings.xamL"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

样式

样式是修改View(窗体、控件)样式的主要手段,主要作用更改控件的外观以及增强用户体验。

样式(Style)是一种将一组特征属性值应用到多个元素的方法,用于设置控件的外观属性如长宽、颜色、字体、大小等。WPF中的各类控件元素,都可以自由的设置其样式。在设定Style时,我们需要使用 x:Key声明键名,并设置目标类型TargetType,使用x:Type将类型传入。在Style标签的内容区通过Setter标签,设置Property属性和属性值Value。

案例可参考TextBox切换输入与绑定模式

Style类的属性

  • Setters: 设置属性值以及自动关联事件处理程序的Setter对象或EventSetter对象的集合是Style类中最重要的属性,但并非唯一属性 - SetterBase类主要用于控制控件的静态外观风格
  • Triggers: 继承自TriggerBase类能自动改变样式设置的对象集合。例如,当另一个属性改变时,或者当发生某个时间时,可以修改样式 - TriggerBase类主要用于控制控件的动态行为风格
  • Resources:希望用于样式的资源集合
  • BasedOn:通过该属性可创建继承自其它样式设置的更具体的样式,虽然可以继承,但建议继承关系尽量简单(不建议过多使用)
  • TargetType:该属性标识应用样式的元素类型。通过该属性可创建只影响特定类型元素的设置器,还可以创建能够为恰当的元素类型自动起作用的设置器
  • IsSealed: 是否允许“派生”出其他样式,也就是是否密封

Setter

Setter具有两个主要属性

  • Property(属性):指定要设置的属性名称。可以是任何依赖属性(DependencyProperty)或依赖对象 (DependencyObject)的属性。
  • Value(值):指定要为属性设置的值。

样式案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--下面内容包含于windows标签中-->
<Window.Resources>
<!--针对这个窗口下的所有Button, TargetType="Button"也能写成TargetType="{x:Type Button}"-->
<Style TargetType="Button">
<!-- 默认设置(这个默认设置实际上没用,因为Trigger的优先级要比他高) -->
<Setter Property="Foreground" Value="White"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Foreground" Value="BlueViolet"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="Red"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>

上面是作用于一批控件,如果想指定样式呢,可以在控件上指定样式,通过 x:Key属性,如果不添加 x:Key属性,那么就是针对当前作用范围内的全部指定控件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Window.Resources>
<!-- 重点是这里的x:Key,他的值作为该Style的唯一标识 -->
<Style x:Key="TriggerStyle" TargetType="Button">
<!-- 默认设置(这个默认设置实际上没用,因为Trigger的优先级要比他高) -->
<Setter Property="Foreground" Value="White"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Foreground" Value="BlueViolet"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="Red"/>
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>

在控件上通过下面属性使用该Style(注意Window.Resources标签必须定义在控件定义之前,即写在上面)

1
<Button Content="登录" Width="30" Style="{StaticResource TriggerStyle}"/>

此处使用的是 StaticResource,表示静态资源,即当程序运行起来后,这个样式是不会发生变化的(即使后续修改了样式,真实的效果也不会变化)

  • StaticResource StaticResource 在应用程序启动时就进行加载,启动时消耗更多的资源,但在运行时性能较好
  • DynamicResource DynamicResource 在需要时才加载,支持在运行时动态更新资源,提供了更大的灵活性

Style可以使用 BasedOn沿用之前的Style,代码如下:

1
2
3
4
<!--继承原本Button的样式去设立新样式,控件中使用样式索引名来使用该新增样式-->
<Style x:Key="样式索引名" BaseOn="{StaticResource {x:Type Button}}">
<!--...-->
</Style>

全局样式

项目上右键添加资源字典选择资源词典(wpf)

比如说新建资源词典: baseStyle.xaml

在App.xaml中编写如下代码,引用Style文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Application x:Class="testWPF.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:testWPF"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!--全局引用Style文件-->
<ResourceDictionary Source="/testWPF;component/baseStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

由于是在 App.xaml中引入了Style文件,因此作用范围是整个程序

定义样式

1
2
3
4
5
6
7
8
9
10
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="GroupBoxTab" TargetType="GroupBox">
<!-- 定义样式属性 -->
<Setter Property="Background" Value="LightGray"/>
<Setter Property="BorderBrush" Value="Black"/>
<Setter Property="BorderThickness" Value="1"/>
<!-- 其他样式设置 -->
</Style>
</ResourceDictionary>

引入样式写法

1
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/>
  1. <ResourceDictionary>:

    • 这是一个 WPF 资源字典,用于定义和组织可重用的资源,如样式、控件模板、颜色等。
  2. Source 属性:

    • Source 属性指定了资源字典的路径。路径使用了 pack:// URI 语法,这是 WPF 中用于引用资源的标准方式。
  3. pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml:

    • pack://application:: 表示这是一个应用程序包 URI,它通常用于引用应用程序中的资源。
    • ,,,: 这是 URI 语法的一部分,用于分隔不同的 URI 组件。
    • /HandyControl;component/Themes/SkinDefault.xaml
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50

      : 这是资源的具体路径。

      - `/HandyControl`: 表示资源所在的程序集(assembly)名。
      - `;component`: 表示这是程序集中的一个组件。
      - `/Themes/SkinDefault.xaml`: 表示资源在程序集中的具体路径。

      这行代码的作用是将 `HandyControl` 程序集中 `Themes` 文件夹下的 `SkinDefault.xaml` 文件中的资源字典合并到当前的资源字典中。这样,`SkinDefault.xaml` 中定义的资源(如样式、模板等)就可以在当前上下文中使用了。

      ### 在原有的样式上添加

      以下面的代码为例:

      ```xaml
      <Style TargetType="hc:NumericUpDown"
      x:Key="IsChangedDifferenceStyle"
      BasedOn="{StaticResource {x:Type hc:NumericUpDown}}">
      <!--上面为在原有样式基础上添加的样式-->
      <Style.Triggers>
      <DataTrigger Value="True">
      <DataTrigger.Binding>
      <MultiBinding Converter="{StaticResource IsValueChangedDifferenceMultiConverter}">
      <Binding Path="Value"
      RelativeSource="{RelativeSource Self}"/>
      <Binding Path="Tag"
      RelativeSource="{RelativeSource Self}"/>
      </MultiBinding>
      </DataTrigger.Binding>
      <Setter Property="Effect">
      <Setter.Value>
      <DropShadowEffect Color="Red"
      BlurRadius="10"
      ShadowDepth="0"/>
      </Setter.Value>
      </Setter>
      </DataTrigger>
      <DataTrigger Value="False">
      <DataTrigger.Binding>
      <MultiBinding Converter="{StaticResource IsValueChangedDifferenceMultiConverter}">
      <Binding Path="Value"
      RelativeSource="{RelativeSource Self}"/>
      <Binding Path="Tag"
      RelativeSource="{RelativeSource Self}"/>
      </MultiBinding>
      </DataTrigger.Binding>
      <Setter Property="Effect"
      Value="{x:Null}"/>
      </DataTrigger>
      </Style.Triggers>
      </Style>

上面代码案例也可以了解一下通过转换器来进行的多绑定 MultiBinding,将自身元素的Value和自身元素的Tag属性提供给MultiBinding来判断是否触发效果

主题切换方案

实现主题切换功能的基本思路是使用动态资源(DynamicResource)来定义控件的样式,然后在运行时更换不同的资源字典(ResourceDictionary)来改变控件的外观。

1
2
3
4
5
6
7
8
9
10
11
12
13
private void Button_Click(object sender, RoutedEventArgs e)
{
ResourceDictionary resource = new ResourceDictionary();
if (Application.Current.Resources.MergedDictionaries[0].Source.ToString() == "pack://application:,,,/YourProject;component/Resources/Colors.xaml")
{
resource.Source = new Uri("pack://application:,,,/YourProject;component/Resources/DarkColors.xaml");
}
else
{
resource.Source = new Uri("pack://application:,,,/YourProject;component/Resources/Colors.xaml");
}
Application.Current.Resources.MergedDictionaries[0] = resource;
}

模板

  • 控件模板(ControlTemplate)

    控件模板定义了控件在界面上的呈现方式,包括控件的布局、样式、触发器和绑定等。通过修改控件模板,可以自定义控件的外观,使其符合特定的设计需求和用户体验要求。在控件模板中,可以使用各种控件和容器元素来构建控件的可视化结构。

  • 数据模板(DataTemplate)

数据模板和控件模板的关系

  • ContentPresenter对象通常被用来作为数据及数据模板的载体,其只有ContentTemplate属性,而没有Template属性。ControlTemplate生成的控件树其根节点就是目标控件,也就是要针对什么控件去设计模板
  • DataTemplate生成的控件树根节点是一个ContentPresenter控件。ContentPresenter控件是控件模板上的一个结点

所以数据模板是控件模板的一颗子树

image-20240827102917217

相关构成

Border元素经常被用于作为控件的底层包裹物,并提供边框和背景样式

ContentPresenter用于呈现其他控件或对象的内容。它通常用作控件模板中的占位符,用于呈现控件的内容,并根据模板中的属性设置进行对齐和布局。ContentPresenter既可以呈现对象的内容,又可以是其他控件。

属性名 类型 用法
Template ControlTemplate 在 Control 控件中的 Template 属性,是 WPF 最为基础的内容, 是所有控件的可复用性的保障, 通常和 Style 搭配, 编写 ControlTemplate 就好比在 xaml 中编写函数一样, 具有非常重要的工程意义。
ContentTemplate DataTemplate 所有 ContentControl(如 Button)实现, 会在控件模板 ControlTemplate 中的某些 ContentPresenter 将会承担解析和呈现它们的任务, 前提是你需要传递过去。
ItemTemplate DataTemplate 集合容器之所以能够呈现内容就是因为它每一项都是一个 ContentPresenter, 容器将会把定义的 ItemTemplate 信息交给每一个 ContentPresenter 的 ContentTemplate 中, 把每一项的数据信息交给 ContentPresenter 的 Content 中, 最后实现列表项的呈现, 具体会有 ItemsPresenter 的参与。
ItemsPanel ItemsPanelTemplate 属于 ItemsControl 和 ListBox 等数据呈现容器, 用于描述每一项应该如何排布, 默认是 StackPanel Vertical 布局, 你完全可以改成 WrapPanel、 Canvas、 StackPanel Horizontal、 Grid 等等的布局容器。
CellTemplate DataTemplate WPF 出于 DataGrid 更好可视化的角度为你提供的办法。
1
2
3
4
5
6
7
8
9
10
11
12
<Button>
<!--设置Button的Template属性,设置为ControlTemplate的实例-->
<Button.Template>
<!--ControlTemplate负责描述Button-->
<ControlTemplate TargetType="Button">
<!--...-->
<!--模板内的元素通过TemplateBinding绑定外界的属性,如下绑定的是Button的Background-->
<Grid Background="{TemplateBinding Background}"/>
<!--...-->
</ControlTemplate>
</Button.Template>
</Button>

如,TextBlock中的Text属性是string类型,而Button的Content确实object类型,这意味着能包容万物

1
2
3
var Button? button = new Button();
var TextBlock? textblock = new TextBlock();
//就可以查看textblock.Text和button.Content的类型了

Content是object类型,所以甚至可以在标签中内嵌其他控件

1
2
3
4
5
6
7
<Button Content="123"/>
<!--等同于-->
<Button>123</Button>

<Button>
<TextBlock Text="321"/>
</Button>

ContentPresenter的存在意义就是为了承载object类型的Content

Content中只要传入的类型不是string或控件类型,则全部视为调用ToString()处理

Content可以传入一切,比方说是一个自定义数据类型呢?如何告诉xaml如何解析呢?

Button下存在一个ContentTemplate属性,该属性是专门服务于ContentPresenter,表明了ContentPresenter应该如何解析

ContentPresenter中的ContentTemplate可以使用TemplateBinding绑定Button的ContentTemplate,就可以直接在Button中控制ContentPresenter的解析方式

1
2
3
4
5
6
7
<Button>
<Button.ContentTemplate>
<DataTemplate DataType="local:Person">
<!--...-->
</DataTemplate>
</Button.ContentTemplate>
</Button>

ContentPresenter可以使你在固定的模板中的槽位提供绝佳的拓展自由行

ContentPresenter

ControlTemplate 可以使用 ContentPresenter(中心内容呈现控件) 或 ItemsPresenter 来呈现控件的内容或子元素,从而保留控件的基本功能。

[注意] 该控件内含foreground属性

ContentPresenter绑定外界控件案例

1
2
3
4
5
6
7
8
<ContentPresenter
<!--HorizontalContentAlignment和VerticalContentAlignment是专门服务于ContentPresenter的-->
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
TextElement.FontFamily="{TemplateBinding FontFamily}"
TextElement.FontSize="{TemplateBinding FontSize}"
/>

参考按钮开发案例

ItemsControl中的每一项都是一个ContentPresenter

DataGrid中的每一列中的每一个单元格: DataGridtemplateColumn.CellTemplate也是提供给ContentPresenter的模板

TemplateBinding

TemplateBinding(模板绑定)用于在控件模板中绑定模板内部元素的属性与外部控件的属性之间建立关联。使用TemplateBinding,可以在控件模板内部直接引用外部控件的属性,而无需手动编写绑定表达式,以一种简洁、直接的方式访问外部控件的属性,从而实现属性在父子层级中的传递和同步

模板中的绑定注意项

模板中不能使用 TemplateBinding写法,取而代之的语法是 {Binding 属性名,RelativeSource={RelativeSource TemplatedParent}}

通过这种方式找MainWindowViewModel下的DeleteCommand例子为:

1
2
3
4
5
6
7
8
9
10
<Button Command = "{Binding RelativeSource={RelativeSource Ancestortype=local:MainWindow},Path=DataContext.DeleteCommand}">
<!--
上面的local是自已定义的命名空间
Ancestortype: 指定了要查找的祖先元素的类型,会在视觉树中向上查找,直到找到类型为MainWindow的祖先元素
Path: 指定了要绑定的属性路径
综合理解: 查找类型为MainWindow的祖先元素,然后从该祖先元素的DataContext中获取名为DeleteCommand的属性或命令,并将其绑定到按钮的Command属性上
-->

<!--或者类似下面这样-->
<Button Command="{Binding DataContext.Link, RelativeSource={RelativeSource AncestorType=UserControl}}">连接</Button>

控件模板

WPF 的 ControlTemplate 是一种用于定义和自定义控件的外观和结构的模板,它可以完全替换控件的默认模板,实现个性化和复杂的效果。WPF 的 ControlTemplate 有以
下几个特点:

• ControlTemplate是一个XAML元素,它可以包含任何类型的UI 元素,如布局、形状、图像、文本等,这些元素构成了控件的视觉树(VisualTree)。
• ControlTemplate 可以使用 TemplateBinding 或 Binding 来绑定控件的属性或数据,从而实现动态的显示和更新。
• ControlTemplate 可以使用 Triggers 来定义控件对不同的条件或事件的响应,如改变属性、播放动画、执行操作等。
• ControlTemplate 可以使用 ContentPresenter(中心内容呈现控件) 或 ItemsPresenter 来呈现控件的内容或子元素,从而保留控件的基本功能。
• ControlTemplate 可以在 Style 或 ResourceDictionary 中定义,并应用到一个或多个控件上,从而实现资源的重用和管理。

查看默认模板: 右键-编辑模板-编辑副本: 弹框点击确定,这样就会在windows resource下边生成一个样式片段(包含各种属性以及模板中含有的各种控件)

如简单自定义一个按钮的外观:

1
2
3
4
5
6
7
8
9
<Button Width="200" Height="50" Content="点击我">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="LightBlue" CornerRadius="10">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Button.Template>
</Button>

触发器

WPF 的 Trigger 是一种用于定义和管理 XAML 资源的触发器,它可以根据不同的条件或事件来改变控件的属性或行为。以下是触发器的类型:

  • 基本触发器(Trigger):这种触发器是根据控件自身的依赖属性的值来触发的,例如,当鼠标移动到按钮上时,改变按钮的背景色。
  • 数据触发器(DataTrigger):这种触发器是根据绑定的数据的值来触发的,例如,当绑定的数据为真时,显示一个图标。
  • 多路数据触发器(MultiDataTrigger):与数据触发器类似,但可以同时满足多个数据绑定的值
  • 事件触发器(EventTrigger):这种触发器是根据控件的路由事件来触发的,例如,当按钮被点击时,播放一个动画。
  • 多条件触发器(MultiTrigger、MultiDataTrigger):这种触发器是根据多个条件的组合来触发的,例如,当控件的属性和绑定的数据同时满足某些值时,改变控件的样式。
基本触发器

基本触发器控制鼠标经过或按下时的案例:

1
2
3
4
5
6
7
8
9
10
11
12
<Style.Triggers>
<!--鼠标经过时-->
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="White"/>
<Setter Property="Foreground" Value="#0072C8"/>
</Trigger>
<!--鼠标按下时-->
<Trigger Property="IsMouseCaptured" Value="True">
<Setter Property="Background" Value="#9972C8"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</Style.Triggers>
多条件触发器
1
2
3
4
5
6
7
8
9
10
11
12
13
<Style.Triggers>
<MultiTrigger>
<!--下面条件满足-->
<MultiTrigger.Condition> <!--一个Condition相当于Trigger中的 <Trigger Property="IsMouseOver" Value="True">这一行-->
<Condition Property="IsFocused" Value="True"/>
<Condition Property="Text" Value="WPF"/>
</MultiTrigger.Condition>
<!--才触发下面改变-->
<MultiTrigger.Setters>
<Setter Property="background" Value="LightPink"/>
</MultiTrigger.Setters>
</MultiTrigger>
</Style.Triggers>

当条件同时满足时,才会触发

数据触发器

该触发器无非就是多了Binding属性

1
2
3
4
5
<Style.Triggers>
<DataTrigger Binding="{Binding RelativeSource={x:Static RelativeSource.Self},Path=Text}" Value="WPF">
<Setter Property="Background" Value="LightPink"/>
</DataTrigger>
</Style.Triggers>
多路数据触发器
1
2
3
4
5
6
7
8
9
10
11
12
<Style.Triggers>
<MultiDataTrigger>
<!--下面条件满足-->
<MultiDataTrigger.Conditions>
<Condition Property="{Binding Path=Sex}" Value="Man" />
<Condition Property="IsEnabled" Value="False" />
</MultiDataTrigger.Conditions>
<!--才触发下面改变-->
<Setter Property="Background" Value="Red" />
<Setter Property="Foreground" Value="White" />
</MultiDataTrigger>
</Style.Triggers>
事件触发器

以窗口的加载事件为例

1
2
3
4
5
6
7
8
9
10
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
<!--接到上面的命名空间后-->
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding LoadedCommand}"/>
</i:EventTrigger>
<i:EventTrigger EventName="Unloaded">
<i:InvokeCommandAction Command="{Binding UnloadedCommand}"/>
</i:EventTrigger>
</i:Interaction.Triggers>

这里值得注意的是: Unloaded在窗口关闭的时候并不会触发,应该用Closing绑定的事件才能触发

控件模板案例

以按钮的自定义模板为例

通过 {TemplateBinding xxx}来获取使用模板传入的属性xxx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Button Width="300" Height="100" Content="自定义按钮" >
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<!--设置模板的外观-->
<Border x:Name="border" Background="{TemplateBinding Background}" BorderBrush="Black" BorderThickness="1" CornerRadius="10">
<TextBlock Text="{TemplateBinding Content}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!--设置模板的交互-->
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="Black"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>

上面这种写法只能作用于该Button在App.xaml中引用新添加的资源词典,在资源词典中写样式,就可以用于全部控件.

也可以新建一个CustomButton继承Button,资源词典中的样式就针对CustomButton来编写

上描述的资源词典写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:testGrid">
<!-- 上面引入项目的命名空间 -->
<!--下面可以添加x:Key指定引用名,只让某些元素引用-->
<Style TargetType="{x:Type local:CustomButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:CustomButton}">
<Border Name="buttonBorder" Background="Gray" CornerRadius="{TemplateBinding ButtonCornerRadius}" Width="{TemplateBinding Width}" Height="{TemplateBinding Height}">
<TextBlock Text="{TemplateBinding Content}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
<!--触发器中不能使用TemplateBinding写法,取而代之的语法是{Binding 属性名,RelativeSource={RelativeSource TemplatedParent}}-->
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="buttonBorder" Property="Background" Value="{Binding BackgroundHover,RelativeSource={RelativeSource TemplatedParent}}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="buttonBorder" Property="Background" Value="white"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

注意

数据模板

在WPF中,有两种类型的控件支持数据模板:

image-20240827101114770

内容控件通过ContentTemplate属性支持数据模板。内容模板用于显示任何放置在Content属性中的内容
列表控件(继承自ItemsControl类的控件)通过ItemTemplate或者CelTemplate属性(DataGrid使用)支持数据模板。这个模板 用于显示作为ItemsSource提供的集合中的每个项(或者来自DataTable的每一行)

定义数据模板

1
2
3
<DataTemplate x:Key="xxxTemplate" DataType="{x:Type local:Employee}>
<!--放置什么都可以-->
</DataTemplate>

通过DataType直接绑定某个类型,在DataTemplate内部就可以直接绑定该类型内部的成员,如:Text="{Binding Job}"例子中的Job是Employee类型的一个成员

使用数据模板

1
<UserControl ContentTemplate ContentTemplate="{StaticResource xxxTemplate}"/>

数据模板盘点

  1. DataTemplate
  • 用途:用于定义如何显示单个数据对象的视觉表示。可以用于任何控件,例如 ListBoxComboBoxContentControl 等。

  • 示例

    1
    2
    3
    <DataTemplate>
    <TextBlock Text="{Binding Name}" />
    </DataTemplate>
  1. HierarchicalDataTemplate
  • 用途:用于表示层级数据结构,特别是在树形控件(如 TreeView)中。可以定义父项及其子项的显示方式。

  • 示例

    1
    2
    3
    <HierarchicalDataTemplate ItemsSource="{Binding Children}">
    <TextBlock Text="{Binding Name}" />
    </HierarchicalDataTemplate>
  1. ItemTemplate
  • 用途:用于定义集合中每个项的显示方式。通常用于 ItemsControlListBoxComboBox 等控件。

  • 示例

    1
    2
    3
    4
    5
    <ListBox.ItemTemplate>
    <DataTemplate>
    <TextBlock Text="{Binding Name}" />
    </DataTemplate>
    </ListBox.ItemTemplate>
  1. ControlTemplate
  • 用途:用于定义控件的外观和行为。可以完全替换控件的视觉树。

  • 示例

    1
    2
    3
    4
    5
    <ControlTemplate TargetType="Button">
    <Border Background="{TemplateBinding Background}">
    <ContentPresenter />
    </Border>
    </ControlTemplate>
  1. Style
  • 用途:虽然不是严格意义上的模板,但样式可以定义控件的外观和行为,包括触发器和属性设置。

  • 示例

    1
    2
    3
    4
    <Style TargetType="Button">
    <Setter Property="Background" Value="Blue" />
    <Setter Property="Foreground" Value="White" />
    </Style>
  1. DataTemplateSelector
  • 用途:用于根据特定条件选择不同的数据模板。可以在运行时决定使用哪个模板。

  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class MyTemplateSelector : DataTemplateSelector
    {
    public DataTemplate Template1 { get; set; }
    public DataTemplate Template2 { get; set; }

    public override DataTemplate SelectTemplate(object item, DependencyObject container)
    {
    if (item is Type1)
    return Template1;
    else
    return Template2;
    }
    }
DataTemplateSelector

在 WPF 中,DataTemplateSelector 是一个非常有用的工具,它允许你根据数据对象的属性或类型,动态选择不同的数据模板来渲染 UI 元素

  • DataTemplate:定义如何显示某种数据类型的外观。
  • DataTemplateSelector:一个逻辑判断器,根据数据对象的特征,返回对应的 DataTemplate。

常见应用场景

  • 列表中的不同数据类型需要不同 UI(如文件类型图标差异)
  • 根据数据状态切换模板(如未读消息高亮)
  • 多主题切换时动态选择模板

注意事项

  1. 性能优化:频繁切换模板时,确保 SelectTemplate 方法高效。
  2. 模板作用域:DataTemplate 需要定义在父容器资源中,确保 Selector 能访问到。
  3. 备用模板:可在 Selector 中设置一个默认模板,处理未知数据类型。
  4. 与 DataTriggers 区别:TemplateSelector 适合完全不同结构的 UI,而 DataTriggers 更适合同一模板内的样式微调
实现步骤

ListBox 数据项 → TemplateSelector → 根据条件选择 → 应用对应的 DataTemplate
(例如:数据对象.IsSpecial = true → 使用 TemplateA)

步骤1: 自定义 TemplateSelector 类:创建一个继承自 DataTemplateSelector 的类,并重写 SelectTemplate 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyTemplateSelector : DataTemplateSelector
{
// 定义两个不同的 DataTemplate 属性(稍后在 XAML 中赋值)
public DataTemplate TemplateA { get; set; }
public DataTemplate TemplateB { get; set; }

public override DataTemplate SelectTemplate(object item, DependencyObject container)
{
// 根据 item 的属性或类型决定返回哪个模板
if (item is MyDataClass data && data.IsSpecial)
return TemplateA;
else
return TemplateB;
}
}

步骤2: 在 XAML 中定义 DataTemplates: 在资源中定义两种不同的 DataTemplate,并指定它们的 DataType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Window.Resources>
<!-- 模板A:用于特殊项 -->
<DataTemplate x:Key="TemplateA" DataType="{x:Type local:MyDataClass}">
<Border Background="LightGreen">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
</Border>
</DataTemplate>

<!-- 模板B:默认模板 -->
<DataTemplate x:Key="TemplateB" DataType="{x:Type local:MyDataClass}">
<Border Background="LightGray">
<TextBlock Text="{Binding Name}"/>
</Border>
</DataTemplate>
</Window.Resources>

步骤3:声明 TemplateSelector 实例: 在资源中实例化你的 MyTemplateSelector,并将模板分配给它。

1
2
3
4
5
6
<Window.Resources>
<!-- ...其他资源... -->
<local:MyTemplateSelector x:Key="MySelector"
TemplateA="{StaticResource TemplateA}"
TemplateB="{StaticResource TemplateB}"/>
</Window.Resources>

层级数据模板

HierarchicalDataTemplate

WPF中的HierarchicalDataTemplate是一种用于定义具有层次结构的数据的显示方式的模板。它可以用于支持HeaderedltemsControl的控件,例如TreeView或Menu。HierarchicalDataTemplate有一个ItemsSource属性,用于指定子节点的数据源,以及一个ItemTemplate属性,用于指定子节点的模板

image-20240827104004481
1
2
3
4
5
6
7
8
9
<HierarchicalDataTemplate DataType="{x:Type local:CompanyData}" ItemsSource="{Binding Path=DepartmentDatas}">
<TextBlock Text="{Binding Name}"/>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:DepartmentData}" ItemsSource="{Binding Path=EmployeeDatas}">
<RadioButton Content="{Binding Name}"/>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:EmployeeData}">
<CheckBox Content="{Binding Name}"/>
</HierarchicalDataTemplate>

自定义数据项模板

暂时放这,不知道是否合适

ItemsControl 是 WPF 中的一个控件,用于显示一组数据项。它提供了一种灵活的方式来定义如何呈现和布局这些数据项。以下是 ItemsControl 的一些主要用途和功能:

  1. 数据绑定
    ItemsControl 通常与数据绑定一起使用,通过 ItemsSource 属性绑定到一个集合(如 List、ObservableCollection 等)。每个数据项都会生成一个对应的 UI 元素。
1
2
3
<ItemsControl ItemsSource="{Binding MyDataCollection}">
<!-- 定义数据项的模板 -->
</ItemsControl>
  1. 自定义数据项模板
    通过 ItemTemplate 属性,可以定义每个数据项的显示模板。DataTemplate 用于描述每个数据项的外观。
1
2
3
4
5
6
7
<ItemsControl ItemsSource="{Binding MyDataCollection}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
  1. 样式和外观
    可以通过 ItemContainerStyle 属性自定义每个数据项容器的样式。
1
2
3
4
5
6
7
<ItemsControl ItemsSource="{Binding MyDataCollection}">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Margin" Value="5" />
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>

ObservableCollection

ObservableCollection<T>是 WPF 提供的一个类,它表示一个动态数据集合,当集合中的项添加、移除或刷新时,它会提供通知。
• 当集合发生变化时,会触发CollectionChanged 事件;
• 当集合的属性(如 Count 或 Item[])发生变化时,就会触发PropertyChanged 事件。

使用ObservableCollection后,框架本身也就可以支持使用该

注意注意:ObservableCollection的Clear方法不会触发ui更新,要尤其注意.可以重新new一个ObservableCollection来赋值作为Clear

类似AddRange这种原本不属于ObservableCollection,而是属于Linq语法的,也不支持自动更新ui,使用下面原始语法例子代替AddRange

1
2
3
4
5
6
foreach (var b in bytes)
{
RecvMessage.Add(b);//能更新绑定的ui
}
//代替
RecvMessage.AddRange(bytes);//不能更新绑定的ui

模板的内部访问

控件模板的内部访问

ControlTemplate和DataTemplate两个类均派生自FrameworkTemplate类,这个类有个名为 FindName的方法提供我们检索其内部的控件。

控件模板的内部访问非常简单,核心在于针对目标控件的Template属性使用FindName方法,并使用as关键字指定类型即可。

1
2
3
TextBox? tbx = this.uc.Template.FindName("textBox"this.uc) as TextBox;
Slider? sl = this.up.Template.FindName("'slider"this.uc) as Slider;
StackPaneL? sp = tbx.Parent as StackPaneL;

其实就是去找到对应控件的Template属性,再调用它的FindName方法

数据模板的内部访问

数据模板的内部访问核心在于对于视觉树的检索,通常需要自建一个 FindVisualChild<T>方法,通过指定需要访问的目标类型,借助VisualTreeHelper 类的 GetChild 方法,递归地遍历 ListBoxltem 的可视化树,直到找到你想要的控件。

属性优先级规则

WPF 有一套明确的属性优先级规则,大致如下(从高到低):

  • 控件的直接属性设置(XAML 中手动设置的值)
  • 动画和数据绑定的值
  • 控件模板 (ControlTemplate) 中的 Trigger 设置
  • Style 中的 Setter Trigger。
  • 默认值

自定义控件与用户控件

  • 自定义控件(CustomControl)通常涉及对控件的更底层的渲染和行为进行定义。这包括从Control类或其他更具体的控件类继承以创建控件类,定义控件的默认样式和模板,添加新的依赖属性,根据需要重写方法以自定义控件的行为,以及定义和触发自定义事件等步骤。这样的控件通常是从头开始创建或扩展现有控件功能的,可以具有很高的灵活性和自定义程度。
  • 用户控件(User Control)则是通过组合其他控件来构建的。用户控件允许开发者在其应用程序中轻松地添加和删除控件,以实现特定功能。创建用户控件需要创建一个新的类,并继承自System.Windows.Controls. UserControl,然后在用户控件类中添加所需的UI元素,如按钮、文本框等,还可以添加事件处理程序和方法以实现自定义功能。同样可以添加依赖属性

使用难度来说: 自定义控件的开发过程相对复杂,因它们涉及到底层的渲染和行为定义,且没有设计器提供设计支持。而用户控件的开发过程则相对简单,并且有设计器提供设计支持,使得开发者能够更直观地构建和编辑控件。

自定义控件,一般用于控件的继承,用于将一个控件继承出一个新的控件,出来的将会是一个cs文件

用户控件/自定义控件案例

以一个自定义控件MyCustomControl为例:

MyCustomControl.xaml

1
2
3
4
5
6
7
<UserControl x:Class="YourNamespace.MyCustomControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<TextBlock Text="{Binding MyProperty, RelativeSource={RelativeSource AncestorType=UserControl}}" />
</Grid>
</UserControl>

MyCustomControl.xaml.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Windows;
using System.Windows.Controls;

namespace YourNamespace
{
public partial class MyCustomControl : UserControl
{
public MyCustomControl()
{
InitializeComponent();
}

// 注册依赖属性
public static readonly DependencyProperty MyPropertyProperty =
DependencyProperty.Register("MyProperty", typeof(string), typeof(MyCustomControl), new PropertyMetadata(string.Empty));

// CLR 属性包装器
public string MyProperty
{
get { return (string)GetValue(MyPropertyProperty); }
set { SetValue(MyPropertyProperty, value); }
}
}
}

使用方式: <local:MyCustomControl MyProperty="你好,世界!" />

解释:

  1. 依赖属性注册:在 MyCustomControl 类中,使用 DependencyProperty.Register 方法注册一个名为 MyProperty 的依赖属性。这个属性的类型是 string,并且它的默认值是一个空字符串。
  2. CLR 属性包装器:定义一个 CLR 属性 MyProperty,它使用 GetValueSetValue 方法来访问和设置依赖属性的值。
  3. XAML 绑定:在 XAML 中,使用 {Binding MyProperty, RelativeSource={RelativeSource AncestorType=UserControl}} 来绑定 TextBlockText 属性到 MyProperty
  • <TextBlock Text="{Binding MyProperty}" />:

    这种方式依赖于 DataContext 的设置。如果 MyCustomControlDataContext 被设置为某个视图模型,TextBlock 将会从这个视图模型中查找 MyProperty

    如果 MyCustomControlDataContext 没有被设置,或者没有包含 MyProperty,则会导致绑定失败。

  • <TextBlock Text="{Binding MyProperty, RelativeSource={RelativeSource AncestorType=UserControl}}" />:

    这种方式明确指定了绑定的来源是 UserControl 本身。这意味着无论 DataContext 是什么,TextBlock 都会从 MyCustomControl 中获取 MyProperty 的值。

    这种方式更加可靠,确保了无论外部 DataContext 如何变化,TextBlock 都能够正确显示 MyProperty 的值。

ItemsControl

案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<ItemsControl Grid.Row="1"
ItemsSource="{Binding ShowStrings, RelativeSource={RelativeSource AncestorType=UserControl}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="{Binding ShowStrings.Count, RelativeSource={RelativeSource AncestorType=UserControl}}"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border
Background="White"
BorderBrush="LightGray"
BorderThickness="1">
<TextBlock
Margin="10,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontSize="16"
FontWeight="Bold"
Foreground="DarkRed"
Text="{Binding .}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
  • ItemsSource 绑定到 ShowStrings 集合。
  • UniformGrid 的列数绑定到 ShowStrings.Count。
  • TextBlock 的 Text 属性绑定到当前项的值(Binding .)

ItemsPanelTemplate

  • 定义了 ItemsControl 的面板模板。
  • 使用 UniformGrid 来布局子项。
  • Columns=”{Binding ShowStrings.Count, RelativeSource={RelativeSource AncestorType=UserControl}}”: 设置 UniformGrid 的列数为 ShowStrings 集合的项数。

ItemTemplate

  • 定义了每个子项的模板。
  • 使用 DataTemplate 来定义子项的外观。

实现控件自定义所需知识点盘点

动画

进入动画代码详解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!--  动画  -->
<MultiTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- ThicknessAnimationUsingKeyFrames逐帧动画 -->
<!--
Duration="时:分:秒" 动画间隔多久
AutoReverse 是否自动复原
Storyboard.TargetName 动画针对谁
Storyboard.TargetProperty 动画针对的属性
From,To 将所设置的属性的值从多少(From)设置到多少(To)
EasingFunction 缓动函数:
SineEase 三角函数缓动函数
BackEase 回弹式缓动函数函数
CubicEase 贝塞尔曲线缓动函数
PowerEase 有力量的缓动函数
等等非常多
-->
<ThicknessAnimation
AutoReverse="True"
Storyboard.TargetName="PART_Border"
Storyboard.TargetProperty="Margin"
From="0"
To="5"
Duration="0:0:0.2">
<ThicknessAnimation.EasingFunction>
<PowerEase EasingMode="EaseOut" />
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
</MultiTrigger.EnterActions>

动画案例

下面以一个圆形红点的闪缩动画为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<Window.Resources>
<Storyboard x:Key="FlashStoryboard" RepeatBehavior="Forever">
<!-- 瞬间切换到不透明 -->
<DoubleAnimation
Storyboard.TargetName="RecordEllipse"
Storyboard.TargetProperty="Opacity"
To="1"
Duration="0:0:0" />
<!-- 等待一段时间 -->
<DoubleAnimation
BeginTime="0:0:0.5"
Storyboard.TargetName="RecordEllipse"
Storyboard.TargetProperty="Opacity"
To="1"
Duration="0:0:0" />
<!-- 切回透明 -->
<DoubleAnimation
BeginTime="0:0:1.0"
Storyboard.TargetName="RecordEllipse"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0:0:0" />
<!-- 等待一段时间 -->
<DoubleAnimation
BeginTime="0:0:1.5"
Storyboard.TargetName="RecordEllipse"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0:0:0" />
</Storyboard>
</Window.Resources>

<!--使用该动画-->
<Ellipse
x:Name="RecordEllipse"
Width="8"
Height="8"
Margin="20,20,12,20"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Fill="Red"
ToolTip="录制中"
Visibility="{Binding isSave, Converter={StaticResource Boolean2VisibilityConverter}}">
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Window.Loaded">
<BeginStoryboard Storyboard="{StaticResource FlashStoryboard}" />
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>

Storyboard可以在resource中定义,使用key标识,然后在EventTrigger中的BeginStoryboard中使用key

变换

1
2
TestButton.RenderTransform = new RotateTransform(){ Angle = 30 };//旋转30度
var newPoint = TestButton.RenderTransform.Transform(new Point(0,0));//可以通过变换之前的点(0,0)得到变换后的点的坐标位置

渐变画刷写法记录

1
2
3
4
5
6
7
8
9
10
<TextBlock>
hello world!
<TextBlock.Background>
<!--渐变画刷-->
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0.0" Color="AliceBlue" />
<GradientStop Offset="1.0" Color="Black" />
</LinearGradientBrush>
</TextBlock.Background>
</TextBlock>

Offset

  • Offset 的值通常在 0.0 到 1.0 之间。
  • 0.0 表示渐变的起始位置,而 1.0 表示渐变的结束位置。

StartPoint/EndPoint

  • StartPoint:表示渐变的起始点,通常以坐标的形式给出
  • EndPoint:表示渐变的结束点,通常也是以坐标的形式给出

可选值如下:

  • 0, 0:表示渐变从左上角开始或结束
  • 0, 1:表示渐变从左下角开始或结束
  • 1, 0:表示渐变从右上角开始或结束
  • 1, 1:表示渐变从右下角开始或结束

子线程中操作ui界面

在 WPF 中,UI 控件绑定的属性通常是由 UI 线程(主线程)管理和更新的。在大多数情况下,不建议在子线程中直接操作绑定的属性,因为 WPF 的控件通常不是线程安全的,直接在非 UI 线程中操作可能会导致线程安全问题和界面更新异常。

如果您需要在子线程中更新 UI 控件的绑定属性,可以使用以下方法来确保线程安全:

  1. 使用 Dispatcher: 在子线程中通过控件的 Dispatcher 调度到 UI 线程执行更新操作。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //同步等待ui线程调用
    System.Windows.Application.Current.Dispatcher.Invoke(() =>
    {
    // 在 UI 线程中更新绑定属性
    YourProperty = newValue;
    });
    //异步等待ui线程调用 Dispatcher.InvokeAsync 或 Dispatcher.BeginInvoke
    System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
    {
    // 在 UI 线程中更新绑定属性
    YourProperty = newValue;
    });
    //上面这段代码一般会加await
  2. 使用 DispatcherTimer: 如果需要定时更新 UI 控件的绑定属性,可以使用 DispatcherTimer,它在 UI 线程上运行。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    using System.Windows.Threading;

    DispatcherTimer timer = new DispatcherTimer();
    timer.Interval = TimeSpan.FromSeconds(1);
    timer.Tick += (sender, e) =>
    {
    // 在 UI 线程中更新绑定属性
    YourProperty = newValue;
    };
    timer.Start();

在执行Dispatcher操作之前,最好使用CheckAccess方法检查当前线程是否为UI线程。如果不是,再使用Invoke或BeginInvoke来确保操作在UI线程上执行

1
2
3
4
5
6
7
8
9
10
11
12
13
if (Dispatcher.CheckAccess())
{
// 在UI线程上执行操作
textBox.Text = "在UI线程上更新UI";
}
else
{
// 在非UI线程上使用Invoke确保在UI线程上执行
Dispatcher.Invoke(() =>
{
textBox.Text = "在UI线程上更新UI";
});
}

BindingOperations.EnableCollectionSynchronization

WPF 提供了 BindingOperations.EnableCollectionSynchronization 方法,允许你在多线程环境中安全地更新 ObservableCollection。这个方法会启用集合的线程安全访问,使得你可以在后台线程中直接修改集合,而不需要显式地使用 Dispatcher。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private ObservableCollection<string> _dataCollection = new ObservableCollection<string>();
private readonly object _lock = new object();

public MainWindow()
{
InitializeComponent();

// 启用集合的线程安全访问
BindingOperations.EnableCollectionSynchronization(_dataCollection, _lock);
}

// 在后台线程中接收数据
void OnDataReceived(string data)
{
lock (_lock)
{
_dataCollection.Add(data);
}
}

还有一种方法为自定义ObservableCollection

创建一个自定义的 AsyncObservableCollection<T>,它内部使用 Dispatcher 来确保所有对集合的修改都在 UI 线程上执行。这样可以简化代码,避免在每个地方都显式地使用 Dispatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AsyncObservableCollection<T> : ObservableCollection<T>
{
public override void Add(T item)
{
if (Application.Current.Dispatcher.CheckAccess())
{
base.Add(item);
}
else
{
Application.Current.Dispatcher.Invoke(() => base.Add(item));
}
}

// 类似地,可以重写其他方法(如 Remove、Insert 等)
}

// 使用自定义的 AsyncObservableCollection
private AsyncObservableCollection<string> _dataCollection = new AsyncObservableCollection<string>();

// 在后台线程中接收数据
void OnDataReceived(string data)
{
_dataCollection.Add(data); // 自动调度到 UI 线程
}

// 在 UI 线程中修改集合
void ModifyCollectionInUIThread(string data)
{
_dataCollection.Add(data); // 自动调度到 UI 线程
}

VS默认代码片段记录

prop 快速生成属性代码(property)

propfull 快速生成完整属性代码

propdp 快速生成依赖属性

ctor 快速生成构造函数

cmd 快速生成Command

cmdfull 快速生成完整命令写法

wpf相关库

nuget库查询网址

xctk

HandyControl

开源地址 MIT开源

下载链接 其中有各种控件效果打包而成的demo应用程序

参考链接:

提供的相关属性参考此处

正确性未经验证

HandyControl 是一个开源的 WPF 控件库,提供了一系列丰富的控件和功能,旨在帮助开发者更轻松地构建现代化的 WPF 应用程序。它包含了许多常用的控件和样式,极大地扩展了 WPF 的默认控件集。

主要特点

  1. 丰富的控件:
    • HandyControl 提供了许多额外的控件,比如按钮、文本框、进度条、日期选择器、消息框等,这些控件通常具有更丰富的功能和更现代的外观。
  2. 主题和样式:
    • 它包含多种主题和样式,可以轻松地应用到整个应用程序中,帮助开发者快速实现统一的用户界面设计。
  3. 开源:
    • HandyControl 是开源的,开发者可以自由使用、修改和扩展它的功能。
  4. 易于集成:
    • 它易于集成到现有的 WPF 项目中,并且有良好的文档和示例代码,帮助开发者快速上手。

如何使用 HandyControl

nuget包管理器安装HandyControl:Install-Package HandyControl

App.xaml中引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Application x:Class="YourNamespace.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- 引用 HandyControl 的主题资源字典 -->
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/>
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

应用主题:

1
2
3
4
5
6
7
8
<Window x:Class="YourNamespace.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:hc="https://handyorg.github.io/handycontrol"
Title="MainWindow" Height="350" Width="525">
<Grid>
<hc:Button Content="HandyControl Button" Width="200" Height="50"/>
</Grid>

控件使用方式盘点

滑动条

单值滑动条
1
2
3
4
5
6
7
8
9
10
11
12
<hc:PreviewSlider
Width="220"
Value="{Binding Ch[0].SetTemperature, StringFormat=F1}"
Minimum="0.0"
Maximum="{Binding Ch[0].Uplimit}"
HorizontalAlignment="Stretch">
<hc:PreviewSlider.PreviewContent>
<Label Style="{StaticResource LabelPrimary}"
Content="{Binding Path=(hc:PreviewSlider.PreviewPosition),RelativeSource={RelativeSource Self}}"
ContentStringFormat="#0.0 °C"/>
</hc:PreviewSlider.PreviewContent>
</hc:PreviewSlider>
范围滑动条
1
2
3
4
5
6
7
8
9
10
<hc:RangeSlider
hc:TipElement.Visibility="Visible"
hc:TipElement.Placement="Top"
IsSnapToTickEnabled="True"
Maximum="105.0"
Minimum="-5.0"
ValueStart="{Binding Ch[0].OutputLowlimit}"
ValueEnd="{Binding Ch[0].OutputUplimit}"
VerticalAlignment="Center"
Grid.Column="1"/>

进度按钮

1
2
3
4
5
6
7
8
<ToggleButton Style="{StaticResource ToggleButtonLoading}"
<!--下面控制是否显示进度条-->
IsChecked="{Binding ATStatus[1],Mode=TwoWay}"
Margin="50 0 10 0"
HorizontalAlignment="Left"
Content="启动自动演算"
Command="{Binding StartAT}"
CommandParameter="1"/>

数字输入框

1
2
3
4
5
6
7
8
9
<hc:NumericUpDown  Value="{Binding Ch[1].RatioBand, StringFormat=F1}"
<!--步长 -->
Increment="0.1"
<!--控制精度 -->
ValueFormat="F1"
Tag="{Binding TemperatureController.Ch[1].RatioBand}"
Style="{StaticResource IsChangedDifferenceStyle}"
Margin="5"
Minimum="0"/>

这里介绍一种样式:

当Tag和Value属性不同的时候,应用红色阴影效果,当属性相同的时候,取消阴影效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<Style TargetType="hc:NumericUpDown"
x:Key="IsChangedDifferenceStyle"
BasedOn="{StaticResource {x:Type hc:NumericUpDown}}">
<Style.Triggers>
<DataTrigger Value="True">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource IsValueChangedDifferenceMultiConverter}">
<Binding Path="Value"
RelativeSource="{RelativeSource Self}"/>
<Binding Path="Tag"
RelativeSource="{RelativeSource Self}"/>
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="Red"
BlurRadius="10"
ShadowDepth="0"/>
</Setter.Value>
</Setter>
</DataTrigger>
<DataTrigger Value="False">
<DataTrigger.Binding>
<MultiBinding Converter="{StaticResource IsValueChangedDifferenceMultiConverter}">
<Binding Path="Value"
RelativeSource="{RelativeSource Self}"/>
<Binding Path="Tag"
RelativeSource="{RelativeSource Self}"/>
</MultiBinding>
</DataTrigger.Binding>
<Setter Property="Effect"
Value="{x:Null}"/>
</DataTrigger>
</Style.Triggers>
</Style>

开关按钮

1
2
3
4
5
6
7
<ToggleButton Style="{StaticResource ToggleButtonLoading}"
IsChecked="{Binding ATStatus[2],Mode=TwoWay}"
Margin="50 0 10 0"
HorizontalAlignment="Left"
Content="启动自动演算"
Command="{Binding StartAT}"
CommandParameter="2"/>

按钮组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<hc:ButtonGroup Margin="5">
<Button Content="应用"
Command="{Binding Set}"
CommandParameter="Ch2|比例带"/>
<Button Content="重置"
Command="{Binding Reset}"
CommandParameter="Ch2|比例带"/>
<hc:ButtonGroup.IsEnabled>
<MultiBinding Converter="{StaticResource IsValueChangedDifferenceMultiConverter}">
<Binding Path="Ch[1].RatioBand"/>
<Binding Path="TemperatureController.Ch[1].RatioBand"/>
</MultiBinding>
</hc:ButtonGroup.IsEnabled>
</hc:ButtonGroup>

gif显示控件

1
2
3
4
5
6
7
8
9
<hc:GifImage x:Name="GifImageMain"
Stretch="Fill"
Uri="/UI.Application.Maintain;Component/images/fire.gif"
Width="20"
Height="30"
Margin="0,-10,0,0"
HorizontalAlignment="Left"
Visibility="{Binding TemperatureController.Ch[0].Output, Converter={StaticResource ValueGreaterThan0ToOpacityConverter}}">
</hc:GifImage>

时间获取

1
2
3
4
5
6
7
8
9
10
11
12
13
<hc:TimePicker
Width="160"
Margin="5,0,0,0"
VerticalAlignment="Center"
hc:InfoElement.ShowClearButton="True"
SelectedTime="{Binding SelectStartTime, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TimePickerExtend}">
<!--用于指定如何显示时间选择器的时钟-->
<hc:TimePicker.Clock>
<!--以列表的形式显示可选择的时间-->
<hc:ListClock />
</hc:TimePicker.Clock>
</hc:TimePicker>

对话框

使用现成的

1
HandyControl.Controls.MessageBox.Info("发送内容为空", "提示");
自定义对话框
1
Dialog.Show(new TextDialog(), "发送内容为空");

TextDialog定义如下:

1
2
3
4
5
6
7
8
<!-- TextDialog.xaml -->
<UserControl x:Class="YourNamespace.TextDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<TextBlock Text="发送内容为空" />
</Grid>
</UserControl>

阿里矢量图标库中获取图标

参考链接

hc:IconElement.Geometry="{StaticResource t_import}"

这里的 t_import就是自定义导入的Geometry几何图形

在APP中进行导入

1
2
3
4
5
6
7
8
9
10
11
12
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
<!-- Geometries -->
<ResourceDictionary Source="/Styles/Geometries/Custom.xaml" />

<!-- HandyControl -->
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml" />
<ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

如果获得t_import的内容呢?打开F12开发者工具去找path

img

F12中获取相关图标的代码,然后把这段Path的值复制到我们的 Custom.xaml 文件中,如下所示。

img

上面的图表Path只有一个,有时候 阿里矢量图标库使用Geometry图标有多个Path的组合,我们如果也要采用,那么定义稍微调整一下。

通过GeometryGroup来定义父级,然后添加多个PathGeometry集合即可

img

使用按钮展示几何图形

1
2
3
4
5
6
<Button
hc:IconElement.Geometry="{StaticResource SuccessGeometry}"
BorderBrush="Transparent"
BorderThickness="0"
Foreground="Green"
IsHitTestVisible="False"/>

但这样只适合展示静态几何图形

使用按钮动态切换几何图形

使用 ContentControlDataTemplate 的方法可以更灵活地切换内容。ButtonStyleDataTrigger 有时可能无法直接影响复杂的子元素属性(如几何图形),而 ContentControl 可以通过模板轻松切换整个内容

  • 模板切换:ContentControl 可以根据数据绑定的值动态切换 DataTemplate,这使得在不同状态下显示不同的内容变得非常简单。
  • 复杂内容:可以在 DataTemplate 中定义复杂的 UI 结构,而不仅限于简单的属性设置。

并且使用 Viewbox 可以更好地控制整体大小,并保持内容的比例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<Button
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
IsHitTestVisible="False">
<ContentControl>
<ContentControl.Style>
<Style TargetType="ContentControl">
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<Viewbox Width="18"
Height="18">
<Path Data="{DynamicResource ErrorGeometry}"
Fill="Red">
</Path>
</Viewbox>
</DataTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding PRIOk}"
Value="True">
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<Viewbox Width="18"
Height="18">
<Path Data="{DynamicResource SuccessGeometry}"
Fill="Green">
</Path>
</Viewbox>
</DataTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
<DataTrigger Binding="{Binding SECOk}"
Value="True">
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<Viewbox Width="18"
Height="18">
<Path Data="{DynamicResource SuccessGeometry}"
Fill="Green">
</Path>
</Viewbox>
</DataTemplate>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</ContentControl.Style>
</ContentControl>
</Button>

切换主题色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void ExecuteToggleTheme()
{
if (IsLight)
{
ResourceDictionary darkTheme = new ResourceDictionary
{
Source = new Uri("pack://application:,,,/HandyControl;component/Themes/SkinDark.xaml")
};
ResourceDictionary theme = new ResourceDictionary
{
Source = new Uri("pack://application:,,,/HandyControl;component/Themes/Theme.xaml")
};

Application.Current.Resources.MergedDictionaries.Clear();
Application.Current.Resources.MergedDictionaries.Add(darkTheme);
Application.Current.Resources.MergedDictionaries.Add(theme);
IsLight = !IsLight;
}
else
{
ResourceDictionary lightTheme = new ResourceDictionary
{
Source = new Uri("pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml")
};
ResourceDictionary theme = new ResourceDictionary
{
Source = new Uri("pack://application:,,,/HandyControl;component/Themes/Theme.xaml")
};
Application.Current.Resources.MergedDictionaries.Clear();
Application.Current.Resources.MergedDictionaries.Add(lightTheme);
Application.Current.Resources.MergedDictionaries.Add(theme);
IsLight = !IsLight;
}

}

Growl

Growl用法如下,详情参考此处

1
2
3
4
5
6
7
8
xmlns:hc="https://handyorg.github.io/handycontrol"
<!--Grid.ZIndex="1000"表示设置为置顶-->
<StackPanel Grid.ZIndex="1000"
Margin="0,10,10,10"
VerticalAlignment="Top"
HorizontalAlignment="Right"
hc:Growl.GrowlParent="True"
hc:Growl.Token="MainWindow"/>

然后就可以使用这种语句来控制出现显示信息:HandyControl.Controls.Growl.Error("超时无响应", "MainWindow");.此处的MainWindow对应上面的 hc:Growl.Token

覆盖默认样式

可以使用这种语法:BasedOn="{StaticResource {x:Type Button}}"来覆盖Button的默认样式

wpfui

MIT开源控件库

1
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"

图表控件库

库名称 安装 好看 速度 手册等 专有技术文章
OxyPlot 必要
LiveCharts 必要 ×
ScottPlot 必要 ×
Microsoft Chart 不要 ×
  • 如果以绘制大量数据的速度为准,则Microsoft Chart、ScottPlot最好。
  • 如果为了绘图方便,则ScattPlot是最佳选择。
  • 如果你想专注于设计或制作动画,LiveCharts是个不错的选择。
  • 如果你想实现一个类似EXCEL的3D图表的东西,那就只能用Microsoft Chart。
  • OxyPlot是ScottPlot和LiveCharts的中间存在

ScottPlot

绘图库 性能强悍:千万级数据处理无压力, 媲美 Python Matplotlib。

MIT开源 开源地址

demo程序下载

绘制速度与Visual Stuido下的标准Chart一样。

与OxyPlot、 LiveCharts 相比较,ScottPlot更快。

与Visual Stuido标准Chart相比,图表类型更加丰富,且视觉表现上更加漂亮。

Nuget下搜索 “scottplot”、”ScottPlot.Wpf” 选择安装

  • 【缺点1】:不支持MVVM模式
  • 【缺点2】:在图表上标注每个点的数据,没有其他图表库方便。
  • 【缺点3】:要绘实时折线图表没有其他图表库方便。

ScottPlot Cookbook 官方文档

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xmlns:ScottPlot="clr-namespace:ScottPlot;assembly=ScottPlot.WPF"  
<!--绘图 ScottPlot-->
<ScottPlot:WpfPlot x:Name="WpfPlot1" Panel.ZIndex="-1" ScrollViewer.CanContentScroll="True" VirtualizingPanel.ScrollUnit="Item" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" Width="1670" Margin="-20,0,0,0" Background="Black">
<ScottPlot:WpfPlot.Style>
<Style TargetType="ScottPlot:WpfPlot">
<Setter Property="Visibility" Value="Hidden"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ElementName=ViewChartMI,Path=IsChecked}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ScottPlot:WpfPlot.Style>
</ScottPlot:WpfPlot>
  • **ScottPlot:WpfPlot**:这是一个来自 ScottPlot 库的控件,用于在 WPF 应用程序中绘制图表。
  • **x:Name="WpfPlot1"**:为该控件指定一个名称(WpfPlot1),可以在代码后面中引用这个控件。
  • **Panel.ZIndex="-1"**:设置该控件的 Z 轴索引为 -1,意味着它会被放置在其他控件的下方(如果存在其他控件)。
  • **ScrollViewer.CanContentScroll="True"**:启用内容滚动,这样当内容超出可视区域时可以滚动。
  • **VirtualizingPanel.ScrollUnit="Item"**:设置滚动单位为项目,这意味着滚动将以单个项目为单位进行。
  • **VirtualizingPanel.IsVirtualizing="True"**:启用虚拟化,这意味着只会创建可视区域内的元素,提高性能。
  • **VirtualizingPanel.VirtualizationMode="Recycling"**:设置虚拟化模式为回收模式,这样可以重用已经不再可见的元素,进一步提高性能。
  • **Width="1670"**:设置控件的宽度为 1670 像素。
  • **Margin="-20,0,0,0"**:设置控件的外边距,左边距为 -20 像素(可能会使控件向左移动),上下和右边距为 0。
  • **Background="Black"**:设置控件的背景颜色为黑色。

这款控件强大的根本原因可以参考官方网址,其如此写到:

许多图表库使用 MVVM 和数据绑定模式与图表进行交互。ScottPlot 则不使用。这一有意为之的决定让 ScottPlot 的性能更佳,因为它让用户能够直接访问用于绘图的数组值,并且还让用户能够完全控制何时渲染新帧(这可能是一项成本高昂的操作)。虽然 MVVM 模式和数据绑定在设计交互式 GUI 应用程序时通常很有用,但请考虑几乎所有 ScottPlot 功能都可用于从无头控制台应用程序创建静态图像,而这些模式在这些应用程序中的使用并不广泛。

MVVM 和数据绑定模式可用于创建包装 ScottPlot 图的图形控件。强烈希望使用数据绑定或 MVVM 模式的用户可能正在使用特定于平台的 GUI 开发框架(例如 WPF),并且只想创建一个控件来完成一项任务(例如具有特定样式和布局的交互式散点图)。鼓励这些用户编写自己的用户控件来实现此自定义数据处理和渲染功能。ScottPlot 控件的设计非常简单,鼓励想要将数据管理与图形交互性结合起来的用户使用他们选择的模式编写自己的控件。

ScottPlot常用代码盘点

1
2
3
double[] dataX = { 1, 2, 3, 4, 5 };
double[] dataY = { 1, 4, 9, 16, 25 };
myPlot.Add.Scatter(dataX, dataY);

MSChart

官方详解

Visual Studio 标准Windows Form 平台下使用的图标控件,WPF平台通WindowsFormsHost 组件嵌入使用

Microsoft官方网站有说明,但是并不容易看懂。

与其他图表库比较,这个更加难以理解。

但是,由于WindowsForm平台的信息比较丰富,这就补足了它的不全。

可以创建3D类型的图表,这是其他图表库中没有的功能。并且可以绘制的图标种类繁多。

虽然需要编写一些代码,单支持鼠标放大和缩小,以及像ScottPlot一样的快速绘制图表,设计时比其他图表库更令人着迷。

img

OxyPlot

渲染10万个点量级不卡

官方网站上的信息有点难以理解,许多地方的描述和图表示例不一致。

官方文档

img

livechart2

livechart2

默认情况下 LiveCharts 使用 SkiaSharp 来渲染控件,这意味着您可以使用所有 SkiaSharp API 在画布上绘图,您可以在此处找到有关 SkiaSharp 的更多信息。

十字线参考:https://livecharts.dev/docs/Maui/2.0.0-rc2/samples.axes.crosshairs

多线程参考:https://livecharts.dev/docs/Maui/2.0.0-rc2/samples.general.multiThreading

使用livechart2的多线程用法,不要使用子线程执行(效率大幅降低),应该在ui线程中执行

需要安装

  • LiveChartsCore.SkiaSharpView
  • LiveChartsCore.SkiaSharpView.WPF

需要的引入:

1
2
3
4
5
6
7
8
using LiveChartsCore;
using LiveChartsCore.Defaults;
using LiveChartsCore.Kernel;
using LiveChartsCore.Kernel.Sketches;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.SKCharts;
using LiveChartsCore.SkiaSharpView.WPF;

xaml控制项如下:

1
2
3
4
5
6
7
8
9
10
11
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"


<lvc:CartesianChart
FontFamily="微软雅黑" <!--这个字体设置似乎可可有可无,参考下面的中文支持配置-->
LegendPosition="Right"
LegendTextPaint="{Binding LegendTextPaint}"
Series="{Binding ChartSeries}"
TooltipPosition="Auto"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}" />

中文支持配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 // 设置图例文本画笔支持中文
LegendTextPaint = new SolidColorPaint(SKColors.Black)
{
SKTypeface = SKTypeface.FromFamilyName("微软雅黑")
};

// 设置工具提示文本画笔支持中文
TooltipTextPaint = new SolidColorPaint(SKColors.Black)
{
SKTypeface = SKTypeface.FromFamilyName("微软雅黑")
};

//设置Y轴名支持中文
YAxes.Add(new Axis
{
Name = "数值",
NamePaint = new SolidColorPaint(SKColors.Black) { SKTypeface = SKTypeface.FromFamilyName("微软雅黑")},
}
//x轴同理

设置各项属性参考

第一版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
 private void initChart()
{
//优化效率,此处初始化的是最朴素的折线图
ChartSeries = new List<ISeries>();
for (int i = 0; i < 3; i++)
{
ChartSeries.Add(new LineSeries<ObservablePoint>()
{
//Name = "Parallelism",
Fill = null,//到x轴之间的色块填色
ScalesYAt = 0,//绑定到哪个y轴(通过序号)
AnimationsSpeed = TimeSpan.Zero, // 去除动画效果
EasingFunction = null, // 去除动画效果
Stroke = new SolidColorPaint(SKColors.Blue)
{
StrokeThickness = 1 // 将线变细
},
LineSmoothness = 0, // 平滑
/*DataLabelsPosition =LiveChartsCore.Measure.DataLabelsPosition.Bottom */ //设置位置
GeometrySize = 0,
GeometryStroke = null//去除圆心
});
}
// ChartSeries.Add(new LineSeries<ObservablePoint>());
// ChartSeries.Add(new LineSeries<ObservablePoint>());
// ChartSeries.Add(new LineSeries<ObservablePoint>());
// ChartSeries[0].Name = "Parallelism";
// ChartSeries[1].Name = "HalfWidth";
// ChartSeries[2].Name = "Uniformity";
// ChartSeries[0].Fill = null;
// ChartSeries[1].Fill = null;
// ChartSeries[2].Fill = null;
XAxes = new List<Axis>();
XAxes.Add(new Axis
{
Labels = new List<string>() { "" },
ShowSeparatorLines = false // 禁用X轴网格线
});
//3个Y轴
YAxes = new List<Axis>();
for (int i = 0; i < 3; i++)
{
YAxes.Add(new Axis
{
Labels = new List<string>() { "" },
ShowSeparatorLines = false // 禁用Y轴网格线
});
}
}

_yAxis[i] = new Axis
{
NameTextSize = 14,
NamePaint = new SolidColorPaint{SKTypeface = SKTypeface.FromFamilyName("微软雅黑")},//中文支持
NamePadding = new LiveChartsCore.Drawing.Padding(0, 20),
Padding = new LiveChartsCore.Drawing.Padding(10, 0, 0, 0),
TextSize = 12,
LabelsPaint = new SolidColorPaint(_seriesColor[i]),
TicksPaint = new SolidColorPaint(_seriesColor[i]),
SubticksPaint = new SolidColorPaint(_seriesColor[i]),
IsVisible = false,
DrawTicksPath = true,

Position = LiveChartsCore.Measure.AxisPosition.End
};

第二版

此处记录一个重置缩放的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void ExecuteResetZoom()
{
// 重置X轴
XAxes2[0].MinLimit = null;
XAxes2[0].MaxLimit = null;

// 重置Y轴
YAxes2[0].MinLimit = null;
YAxes2[0].MaxLimit = null;

// 通知图表更新
//RaisePropertyChanged(nameof(XAxes));
//RaisePropertyChanged(nameof(YAxes));
}

自定义类型的映射

1
2
3
4
5
6
7
8
9
10
11
12
13
//静态构造函数中添加LiveCharts2配置
static BeamPosAnalyzeViewModel()
{
// 配置 LiveCharts2的映射方式
LiveChartsCore.LiveCharts.Configure(config =>
{
config
.HasMap<ObservablePoint>((point, mapper) =>
{
return new Coordinate(point.X, point.Y);
});
});
}

WPF Suite

WPF Suite 仓库地址

引入库方式

nuget管理器中搜索WPF Suite,找到EleCho.WpfSuite下载

在xaml中引入库的命名空间

1
xmlns:ws="https://schemas.elecho.dev/wpfsuite"

优化界面布局

WPF原本需要间隔开空间是用Margin控制的,但添加Margin(外边距)之后,其本身与外边距是一个整体

解决这个问题可以使用 ws:WrapPanel内置属性:HorizontalSpacingVerticalSpacing可以设置水平和垂直间隔

内容过渡效果封装

以下面的平滑动画过渡为例:

1
2
3
4
5
6
<ws:TransitioningContentContorl Name="transitioningContentControl"
Maring="0 8 0 0">
<ws:TransitioningContentControl.Transition>
<ws:SlideTransition/>
</ws:TransitioningContentControl.Transition>
</ws:TransitioningContentContorl>
1
transitioningContentControl.Content = ...

XamlAnimatedGif

支持gif显示与控制

Handycontrol中的GifImage控件可以显示gif内容,但是切换tab的话会卡在某一帧停止播放

项目地址

只支持.net5.0,注意不支持.net6.0

.net6.0显示gif,采用如下方法解决

gif显示的手动方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
        /// <summary>
/// 显示GIF动图
/// </summary>
/// <param name="imageControl">要显示GIF的Image控件</param>
/// <param name="filePath">GIF文件的路径</param>
private void ShowGifByAnimate(Image imageControl, string filePath)
{
if (imageControl == null || filePath=="") return;
// 创建一个列表来存储GIF的每一帧
List<BitmapFrame> frameList = new List<BitmapFrame>();

// 使用GifBitmapDecoder解码GIF文件
GifBitmapDecoder decoder = new GifBitmapDecoder(
new Uri(filePath, UriKind.RelativeOrAbsolute),
BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);

// 检查解码器和帧是否有效
if (decoder != null && decoder.Frames != null)
{
// 将所有帧添加到列表中
frameList.AddRange(decoder.Frames);

// 创建一个对象动画,用于逐帧显示GIF
ObjectAnimationUsingKeyFrames objKeyAnimate = new ObjectAnimationUsingKeyFrames
{
Duration = new Duration(TimeSpan.FromSeconds(1)) // 设置动画持续时间
};

// 为每一帧创建一个关键帧,并添加到动画中
foreach (var item in frameList)
{
DiscreteObjectKeyFrame keyFrame = new DiscreteObjectKeyFrame(item);
objKeyAnimate.KeyFrames.Add(keyFrame);
}

// 设置Image控件的初始显示帧
imageControl.Source = frameList[0];

// 创建一个Storyboard来控制动画
board = new Storyboard
{
RepeatBehavior = RepeatBehavior.Forever, // 设置动画循环播放
FillBehavior = FillBehavior.HoldEnd // 设置动画结束后保持最后一帧
};

// 将对象动画添加到Storyboard中
board.Children.Add(objKeyAnimate);

// 设置动画的目标控件和属性
Storyboard.SetTarget(objKeyAnimate, imageControl);
Storyboard.SetTargetProperty(objKeyAnimate, new PropertyPath("(Image.Source)"));

// 开始播放动画
board.Begin();

// 将Storyboard存储在Image控件的Tag属性中,以便后续释放
imageControl.Tag = board;
}
}

/// <summary>
/// 停止并清理GIF动画
/// </summary>
private void StopGifAnimation(Image imageControl)
{
if (imageControl?.Tag is Storyboard board)
{
board.Stop();
imageControl.Tag = null;
}
}

//使用如下:
//开始
var imageControl = FindChild<Image>(System.Windows.Application.Current.MainWindow, "GifImageMain");
ShowGifByAnimate(imageControl, @"pack://application:,,,/UI.Application.Maintain;Component/images/fire.gif");
//停止
StopGifAnimation(imageControl);

CommunityToolkit.Mvvm

这是一个MVVM框架

包含很多功能,其中有一个数据验证的功能,可以参考此处

ValueConverters

好用的值转换器

MIT开源 开源地址

提供了很多现成的转换器,允许串联转换器

盘点一些转换器

  • ValueConverterGroup 转换器组
  • BoolToVisibilityConverter bool到可见性
  • StringIsNotNullOrEmptyConverter 字符串非空或null
  • IsInRangeConverter 对于字符串表示长度是否在范围内,对于数值表示值是否在范围内: MinValue,MaxValue设置范围
  • BoolInvert bool 值反转
  • StringToDecimalConverter 字符串转换为小数
  • DebugConverter 做的事情只是把当前转换器的信息输出到Debug.WriteLine中,不会改变任何值,用于调试转换器组

基本使用方式

1
2
3
4
5
6
7
8
9
10
11
12
xmlns:conv="clr-namespace:ValueConverters;assembly=ValueConverters"
<!--没有url方式引入,直接使用引用程序集方式引入-->

<!--引入需要用到的转换器,并定制-->
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="AgreementToVisibilityConverter" IsInverted="True" FalseValue="Hidden"/>
</Window.Resources>

<!--使用上面定制的转换器,绑定为agree名的控件的IsChecked属性,使用上面的转换器-->
<label Visibility="{Binding ElementName=agree,Path=IsChecked,Converter={StaticResource AgreementToVisibilityConverter}}"/>
<!--如果不定制,只需要默认的,就不需要引入-->
<label Visibility="{Binding ElementName=agree,Path=IsChecked,Converter={x:Static conv:BoolToVisibilityConverter.Instance}}"/>

串联转换器组使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
<!--引入需要用到的转换器,并定制-->
<Window.Resources>
<!--用于组合多个转换器-->
<conv:ValueConverterGroup x:Key="UserNameToVisibilityConverter">
<!--string是否为空或null返回反转bool值-->
<conv:StringIsNotNullOrEmptyConverter isInverted="True"/>
<!--bool值到可见性转换-->
<conv:BoolToVisibilityConverter/>
</conv:ValueConverterGroup>
</Window.Resources>

<!--使用上面定制的转换器-->
<TextBlock Visibility="{Binding ElementName=username,Path=Text,Converter={StaticResource UserNameToVisibilityConverter}}"/>

串联转换器代码原理参考

核心就在于在 List<IValueConverter>中两两一组将转换器串联起来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
namespace ValueConverters
{
/// <summary>
/// Value converters which aggregates the results of a sequence of converters: Converter1 >> Converter2 >> Converter3
/// The output of converter N becomes the input of converter N+1.
/// </summary>
#if (NETFX || NETWPF || XAMARIN || MAUI)
[ContentProperty(nameof(Converters))]
#elif (NETFX_CORE)
[ContentProperty(Name = nameof(Converters))]
#endif
public class ValueConverterGroup : SingletonConverterBase<ValueConverterGroup>
{
public List<IValueConverter> Converters { get; set; } = new List<IValueConverter>();

protected override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (this.Converters is IEnumerable<IValueConverter> converters)
{
#if NETFX_CORE
var language = culture?.ToString();
#else
var language = culture;
#endif
return converters.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, language));
}

return UnsetValue;
}

protected override object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (this.Converters is IEnumerable<IValueConverter> converters)
{
#if NETFX_CORE
var language = culture?.ToString();
#else
var language = culture;
#endif
return converters.Reverse<IValueConverter>().Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, language));
}

return UnsetValue;
}
}
}

CalcBinding

该库.net core只支持到4.0版本

需要支持Avalonia(c#跨平台Mac,Linux,Windows桌面应用程序框架)可以改用CalcBindingAva

CalcBinding 是一种高级绑定标记扩展,它允许您在 xaml 中编写计算绑定表达式,而无需自定义转换器。CalcBinding 可以自动执行 bool 到可见性的转换、不同的代数运算、反转表达式等等。CalcBinding 使绑定表达式更短、更方便用户使用。

这个库相当厉害,很有研究价值

Apache-2.0 license 开源地址

1
xmlns:c="clr-namespace:CalcBinding;assembly=CalcBinding"

使用方式如下

1
2
3
<TextBlock Text="{c:Binding 0.5*A+B}" />
布尔值运算如下:
<TextBlock Text="{c:Binding BoolA or BoolB}" />

可以编写任何代数、逻辑和字符串表达式,其中包含源属性路径、字符串、数字、Math 类的所有成员以及以下运算符:"(", ")", "+", "-", "*", "/", "%", "^", "!", "&&","||", "&", "|", "?", ":", "<", ">", "<=", ">=", "==", "!="};

因为在 xaml不支持在设置属性值时使用 && || <= 所以需要使用 and or ‘less=’ 替换

也可以使用Math中的方法,Math.Sin(),Math.PI这样的

以及形式为’bool_expression ? expression_1 : expression_2’的三元运算符

字符串用法案例: <TextBox Text="{c:Binding 'text+&quot;haha&quot;', UpdateSourceTrigger=PropertyChanged}" />,其中 &quot;代表双引号

颜色用法:

1
2
3
4
5
6
7
8
9
xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore"
<Ellipse
Width="15"
Height="15"
Fill="{c:Binding (HardwareManager.TemperatureController.Ch[2].IsOn and HardwareManager.TemperatureController.IsOn) ? media:Brushes.Green : media:Brushes.Red}">
<Ellipse.Style>
<Style TargetType="Ellipse" />
</Ellipse.Style>
</Ellipse>

解读该开源项目

功能详解:

  • ExpressionParsers 表达式解析 [[CSharp入门#Dynamic Express|本质上上调用了第三方库]]
    • CachedExpressionParser.cs 文件:
      • CachedExpressionParser 类实现了 IExpressionParser 接口,并使用了缓存机制来提高表达式解析的性能。它通过维护一个字典来存储已经解析过的表达式,避免重复解析。当需要解析一个表达式时,它首先检查缓存中是否已经存在该表达式的解析结果。如果存在,则直接返回缓存中的结果;如果不存在,则使用内部的 IExpressionParser 实例进行解析,并将结果缓存起来。
    • ExpressionParser.cs 文件:
      • ExpressionParser 类实现了 IExpressionParser 接口,它使用 DynamicExpresso 库来解析表达式。它通过 Interpreter 类的 Parse 方法来解析表达式,并通过 Reference 方法来设置引用类型。
    • ParserFactory.cs 文件:
      • ParserFactory 类提供了一个创建 IExpressionParser 实例的工厂方法。它可以创建一个 CachedExpressionParser 实例,并可以指定内部的 IExpressionParser 实例。如果没有指定,则默认使用 ExpressionParser 实例。
    • IExpressionParser.cs 文件:
      • IExpressionParser 接口定义了表达式解析器的两个基本操作:ParseSetReferenceParse 方法用于解析表达式文本并返回一个 Lambda 对象,而 SetReference 方法用于设置解析过程中可能需要的引用类型。
  • Inversion 表达式求逆
    • InverseException.cs文件:
      • 定义了一个自定义异常类 InverseException,用于在表达式求逆过程中抛出异常。
      • 继承自 System.Exception,并提供了四个构造函数,分别用于无参数、带消息、带消息和内部异常、以及序列化时使用。
    • TwoKeysDictionary.cs文件:
      • 定义了一个泛型类 Dictionary<TKey1, TKey2, TValue>,它继承自 Dictionary<TKey1, Dictionary<TKey2, TValue>>
      • 这个类的目的是提供一个二维字典的实现,其中 TKey1TKey2是键的类型,TValue是值的类型。
      • 它提供了添加元素和获取元素的方法,以及一个索引器,可以通过两个键来访问值。
    • Inverter.cs文件:
      • 这个文件是求逆操作的核心,它包含了一个 Inverter类,用于对表达式进行求逆。
      • Inverter类依赖于一个表达式解释器 IExpressionParser,用于将字符串表达式解析为 Expression对象。
      • Inverter类的构造函数接受一个 IExpressionParser接口的实现作为参数。
      • InverseExpression方法是求逆操作的入口,它接受一个表达式和一个参数表达式,返回一个Lambda表达式,表示求逆后的表达式。
      • InverseExpressionInternal方法是一个递归方法,用于遍历表达式树,生成求逆后的表达式。
      • NodeTypeToString方法用于将表达式类型转换为字符串,以便在生成求逆表达式时使用。
      • RES是一个常量字符串,用于在生成求逆表达式时占位。
      • _interpreter是一个私有字段,用于存储表达式解释器。
      • Types for recursion func work部分定义了一些用于递归函数工作的类型,包括 NodeTypeConstantPlaceRecursiveInfoExpressionFuncsDictionary<T>
  • PathAnalysis 路径分析 XAML 绑定路径解析器,它的功能是分析 XAML 绑定路径字符串,并将其拆分成不同的路径片段,以便于进一步处理。
    • PathTokenId.cs:定义了一个 PathTokenId 类,用于标识路径片段的类型和值。它包含两个属性:PathTypeValue,分别表示路径片段的类型和值。此外,它还重写了 EqualsGetHashCode 方法,以便于比较和哈希处理。
    • PathTokenType.cs:定义了一个 PathTokenType 枚举,用于标识路径片段的类型。它包含四个成员:MathPropertyStaticPropertyEnum,分别表示数学路径、属性路径、静态属性路径和枚举路径。
    • PathToken.cs:定义了一个 PathToken 抽象类,用于表示路径片段的基类。它包含两个属性:StartEnd,分别表示路径片段的起始位置和结束位置。此外,它还包含一个抽象属性 Id,用于获取路径片段的标识。
    • EnumToken.cs:定义了一个 EnumToken 类,用于表示枚举路径片段。它继承自 PathToken 类,并增加了两个属性:EnumEnumMember,分别表示枚举类型和枚举成员。此外,它还重写了 Id 属性,以便于返回正确的路径标识。
    • PropertyPathToken.cs:定义了一个 PropertyPathToken 类,用于表示属性路径片段。它继承自 PathToken 类,并增加了一个属性 Properties,用于表示属性路径的名称列表。此外,它还重写了 Id 属性,以便于返回正确的路径标识。
    • MathToken.cs:定义了一个 MathToken 类,用于表示数学路径片段。它继承自 PathToken 类,并增加了一个属性 MathMember,用于表示数学路径的名称。此外,它还重写了 Id 属性,以便于返回正确的路径标识。
    • PropertyPathAnalyzer.cs:定义了一个 PropertyPathAnalyzer 类,用于分析 XAML 绑定路径字符串。它包含一个静态构造函数,用于初始化一些静态字段。此外,它还包含一些私有方法,用于获取路径片段、分析路径类型等。最后,它包含一个公共方法 GetPathes,用于解析 XAML 绑定路径字符串,并返回一个路径片段列表。
    • StaticPropertyPathToken.cs:定义了一个 StaticPropertyPathToken 类,用于表示静态属性路径片段。它继承自 PropertyPathToken 类,并增加了两个属性:ClassNamespace,分别表示静态属性所在的类和命名空间。此外,它还重写了 Id 属性,以便于返回正确的路径标识。
  • Trace 追踪系统,用于记录和输出程序运行时的信息,帮助开发者调试和分析程序
    • TraceComponent.cs 文件定义了一个枚举类型 TraceComponent,其中包含两个成员:CalcConverterParser。这个枚举类型用于标识追踪信息的来源组件,例如,如果信息来自于 CalcConverter 组件,那么在追踪信息中就会包含 CalcConverter 这个标识。
    • Tracer.cs 文件定义了一个密封类 Tracer,它负责追踪信息的记录和输出。这个类有一个静态构造函数,用于初始化两个静态字段:_sourceSwitch_traceSource_sourceSwitch 是一个 SourceSwitch 对象,用于控制追踪的级别;_traceSource 是一个 TraceSource 对象,用于输出追踪信息。

DXBinding

类似于CalcBinding

但是是商业产品,要收费

产品介绍

XamlFlair

XamlFlair 是一个用于 UWP、WPF 和 Uno 的动画库,旨在仅使用附加属性来实现 Xaml 动画。

MIT开源 开源地址

MaterialDesignThemes

MaterialDesignInXamlToolkit 是一个开源、易于使用、强大的 WPF UI 控件库,旨在帮助开发人员在 C# 和 VB.Net 中实现 Google 的 Material Design 风格的用户界面。该框架提供了一组丰富的控件、样式和效果,使开发人员能够轻松创建现代化、具有吸引力的应用程序。

nuget需要安装这两个必要的nuget包: MaterialDesignThemes MaterialDesignColors

MIT开源 开源地址

官方指导文档

App.xaml中修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<Application . . .>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Primary/MaterialDesignColor.DeepPurple.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/Recommended/Accent/MaterialDesignColor.Lime.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

要引用的文件中:

xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"

只作用于局部控件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<TabControl TabStripPlacement="Left">
<TabControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<materialDesign:BundledTheme BaseTheme="Light"
PrimaryColor="DeepPurple"
SecondaryColor="Lime"/>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesign2.Defaults.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</TabControl.Resources>
<TabItem Header="TAB 1">
<TextBlock Margin="8"
Text="Left placement"/>
</TabItem>
<TabItem Header="TAB 2">
<TextBlock Margin="8"
Text="Left placement"/>
</TabItem>
</TabControl>

颜色区域

Material Design中的ColorZone是一种设计概念,旨在通过颜色的使用来增强用户界面的可读性和美观性。它通常涉及到不同的颜色区域,以区分界面中的不同元素或功能

1
<materialDesign:ColorZone Height="650" CornerRadius="15 0 0 15" Background="White"/>

按钮控件

button

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Button 
Style="{StaticResource MaterialDesignFlatButton}" <!-- 扁平无边框样式 -->
Foreground="{DynamicResource PrimaryHueLightForegroundBrush}" <!-- 浅色主题下对比文本色 -->
materialDesign:ButtonAssist.CornerRadius="6" <!-- 圆角弧度6 -->
Width="auto" Height="35" <!-- 自适应宽度,固定高度35 -->
materialDesign:RippleAssist.Feedback="White" <!-- 白色涟漪效果 -->
materialDesign:RippleAssist.FeedbackOpacity="0.2" <!-- 白色效果透明度 -->
materialDesign:ElevationAssist.Elevation="Dp0" <!-- 控制阴影深度 -->
>
<WrapPanel Width="120">
<materialDesign:PackIcon Kind="FolderMedia" VerticalAlignment="Center" Margin="0 0 20 0"/>
<TextBlock>xxxxx</TextBlock>
</WrapPanel>
</Button>

PrimaryHueLightForegroundBrush 需要配合 Material Design 的调色板系统使用,通常通过 ColorZone 或全局主题配置主色调

PackIcon 是 Material Design In XAML 库中专门用于显示图标的控件

它基于 Material Design Icons 官方图标集,提供了数千种标准化图标

通过绑定 Kind 属性,可以在不同状态切换图标,通过 Foreground 属性覆盖默认颜色

1
<materialDesign:PackIcon Kind="{Binding IconType}" />
新版 (Elevation) 对应效果
Dp0 无阴影
Dp1 轻度阴影(低海拔)
Dp2 中等阴影
Dp3 明显阴影
Dp4 强烈阴影(高海拔)
Dp5 最高强度阴影
样式名称 类型 特点 应用场景
MaterialDesignFlatButton 扁平按钮 无背景色、无阴影,仅有文本或图标,点击时显示涟漪效果。 次级操作(如对话框取消、工具栏操作)
MaterialDesignFlatMidBgButton 扁平背景按钮 浅灰色背景(MaterialDesign.Brush.Background.Mid),无阴影,视觉轻微突出。 卡片内部操作、需要弱突出的按钮
MaterialDesignRaisedButton 悬浮按钮 带背景色和阴影,悬浮时轻微抬升(Material Design 2 风格)。 主要操作(如表单提交、确认按钮)
MaterialDesignFloatingActionButton 浮动按钮(FAB) 圆形悬浮按钮,默认使用主色,支持图标。 全局主要操作(如“新建”、“发送”)
MaterialDesignFloatingActionMiniButton 迷你浮动按钮 更小尺寸的浮动按钮,保持圆形设计。 紧凑布局中的主要操作
MaterialDesignToolButton 工具按钮 无背景和边框,仅图标或文本,极简设计。 工具栏、菜单栏操作
MaterialDesignCardButton 卡片按钮 带有卡片样式的背景和边框,适合嵌套在卡片内。 卡片内操作、信息展示中的交互
MaterialDesignPaginationButton 分页按钮 圆形设计,用于分页控件。 分页导航、步骤指示器
MaterialDesignRaisedAccentButton 强调色按钮 使用强调色(Accent)作为背景色,其他特性同 RaisedButton 需要高亮显示的操作(如删除、警告)
MaterialDesignDialogButton 对话框按钮 预定义的固定宽度样式,适合对话框底部操作。 对话框中的确认/取消按钮
MaterialDesignOutlinedButton 轮廓按钮 带边框、无填充背景,悬浮时显示涟漪效果。 需要视觉层次但无需填充色的操作
MaterialDesignTextButton 纯文本按钮 仅文本,无背景和边框,极简设计。 超链接式操作、极简界面中的交互

ToggleButton

样式名称 功能描述 视觉特征 适用场景
MaterialDesignActionToggleButton 默认基础样式,采用主色(Primary Mid)作为强调色 按钮选中时背景填充主色,未选中时为透明边框 强调主要操作,需突出选中状态
MaterialDesignActionLightToggleButton 主色浅色变体,适用于浅色背景 选中时背景为浅色主色(Primary Light),文本和边框颜色适配对比度 浅色主题界面中保持视觉一致性
MaterialDesignActionDarkToggleButton 主色深色变体,适用于深色背景 选中时背景为深色主色(Primary Dark),文本和边框高亮显示 深色主题或需要高对比度的场景
MaterialDesignActionSecondaryToggleButton 使用强调色(Accent)替代主色 选中状态背景填充强调色,与主色形成区分 需要突出次重要操作或与主操作组区分时使用
MaterialDesignFlatToggleButton 扁平化设计风格,无边框和阴影 默认状态无背景色,选中时填充灰色背景 极简风格界面,避免视觉干扰
MaterialDesignFlatPrimaryToggleButton 扁平化变体,选中时填充主色背景 未选中时透明背景,选中时主色填充,无边框 需在扁平化设计中突出关键操作
MaterialDesignSwitchToggleButton 模仿开关(Switch)形态的样式 完全隐藏按钮内容,仅显示开关滑块(需配合自定义图标) 需要与开关组件保持视觉统一时使用
MaterialDesignHamburgerToggleButton 专为导航菜单设计的汉堡图标样式 显示三条水平线构成的汉堡图标,选中时可切换为关闭图标(X 形)

TextBlock控件

1
2
3
4
5
6
7
<TextBlock Foreground="DodgerBlue" FontSize="11" FontWeight="SemiBold" TextAlignment="Center" HorizontalAlignment="Center">
Upgrade to
<Bold FontWeight="Heavy">PRO</Bold><!--加粗-->
for
<LineBreak/> <!--换行-->
more resources
</TextBlock>

LayUI.Wpf

LayUI-WPF是一个WPF版的Layui前端UI样式库,该控件库参考了Web版本的LayUI风格,利用该控件库可以完成现代化UI客户端程序,让你的客户端看起来更加简洁丰富又不失美感。

无开源协议 开源地址

Dotnet9WPFControls

新手引导控件

MIT开源 开源地址

注意在nuget包搜索的时候,要勾选包括预发行版才能搜到Dotnet9WPFControls

添加默认主题文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<prism:PrismApplication
x:Class="NewbieGuideDemo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:prism="http://prismlibrary.com/">
<prism:PrismApplication.Resources>

<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/Dotnet9WPFControls;component/Themes/Dotnet9WPFControls.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</prism:PrismApplication.Resources>
</prism:PrismApplication>

具体使用方式参考此链接

相关的实现原理可以参考这个

混淆工具

Obfuscar MIT开源

混淆与反混淆工具大全盘点

Obfuscator实测未找到反混淆工具,不知道是否就是Obfuscar

FastHotKeyForWPF

MIT开源 用于快速注册全局快捷键

开源地址

System.Configuration.ConfigurationManager

System.Configuration.ConfigurationManager 配置库

WPF-ControlBase

WPF控件库

内含各种图表库,看起来不赖

图表所在nuget包: HeBianGu.Control.Chart2D

一些依赖属性盘点

属性名称 类型 作用
xDisplay ObservableCollection<string> 用于存储和绑定 X 轴的显示数据。
yDisplay ObservableCollection<string> 用于存储和绑定 Y 轴的显示数据。
LegendStyle Style 用于设置图例的样式。
VisualMapStyle Style 用于设置视觉映射的样式。
UseRefreshButton bool 控制是否显示刷新按钮。
UseLegend bool 控制是否使用图例。
UseGrid bool 控制是否显示网格。
UseMarkLine bool 控制是否使用标记线。
UseMarkPosition bool 控制是否使用标记位置。
UseDrawOnce bool 控制是否需要调用 DrawOnce 方法进行刷新。true 表示需要手动调用刷新,false 表示属性更改时自动刷新。
xAxis DoubleCollection 用于存储 X 轴的坐标数据。
yAxis DoubleCollection 用于存储 Y 轴的坐标数据。
xDatas DoubleCollection 用于存储 X 轴的数据点。
yDatas DoubleCollection 用于存储 Y 轴的数据点。
yAxisCount int 用于指定 Y 轴的坐标点个数。
xAxisCount int 用于指定 X 轴的坐标点个数。
yAxisAuto bool 控制 Y 轴是否根据数据自动计算。
xAxisAuto bool 控制 X 轴是否根据数据自动计算。
DrawOnce bool 控制是否在数据刷新后只绘制一次。

图表相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 xmlns:h="https://github.com/HeBianGu"
<h:Chart Padding="100,50"
DisplayName="Beijing AQI"
Style="{DynamicResource {x:Static h:Chart.CoordKey}}"
xAxis="0,90,180,270,360"
yAxis="-2,0,2,4,6,8,10,12">
<h:Series>
<h:Series.Foreground>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Offset="0" Color="#7e0023" />
<GradientStop Offset="0.1" Color="#7e0023" />
<GradientStop Offset="0.1" Color="#660099" />
<GradientStop Offset="0.4" Color="#660099" />
<GradientStop Offset="0.4" Color="#cc0033" />
<GradientStop Offset="0.6" Color="#cc0033" />
<GradientStop Offset="0.6" Color="#ff9933" />
<GradientStop Offset="0.8" Color="#ff9933" />
<GradientStop Offset="0.8" Color="#ffde33" />
<GradientStop Offset="1" Color="#ffde33" />
<GradientStop Offset="1" Color="#096" />
<GradientStop Offset="1.2" Color="#096" />
</LinearGradientBrush>
</h:Series.Foreground>

<h:Line Data="5.5,5.99,6.48,6.95,7.4,7.82,8.22,8.59,8.92,9.21,9.46,9.66,9.82,9.93,9.99,10,9.96,9.87,9.73,9.55,9.32,9.04,8.73,8.38,7.99,7.58,7.14,6.67,6.2,5.71,5.21,4.71,4.21,3.72,3.25,2.79,2.35,1.94,1.56,1.22,0.91,0.64,0.42,0.24,0.11,0.03,0,0.02,0.09,0.21,0.37,0.58,0.84,1.14,1.47,1.84,2.25,2.68,3.13,3.6,4.09,4.58,5.08,5.58,6.08,6.56,7.02,7.47,7.89,8.28,8.64,8.97,9.25,9.49,9.69,9.84,9.94,9.99,9.99,9.95,9.85,9.7,9.51,9.27,8.99,8.67,8.31,7.92,7.51,7.06,6.6,6.11,5.62,5.12,4.62,4.13,3.64,3.17,2.71,2.28,1.87,1.5,1.16,0.86,0.6,0.39,0.22,0.1,0.02,0,0.03,0.1,0.23,0.4,0.62,0.89,1.19,1.53,1.91,2.32,2.75,3.21,3.68,4.17,4.67,5.17,5.67,6.16,6.64,7.1,7.54,7.96,8.35,8.7,9.02,9.3,9.53,9.72,9.86,9.95,10,9.99,9.93,9.83,9.67,9.47,9.23,8.94,8.61,8.25,7.86,7.43,6.98,6.52,6.03,5.54,5.04,4.54,4.05,3.56,3.09,2.64,2.21,1.81,1.44,1.11,0.81,0.56,0.35,0.19,0.08,0.02,0,0.04,0.12,0.26,0.44,0.66,0.93,1.25,1.59,1.98,2.39,2.83,3.29,3.77,4.26,4.75,5.25,5.75,6.24,6.72,7.18,7.62,8.03,8.41,8.76,9.07,9.34,9.56,9.75,9.88,9.96,10,9.98,9.92,9.81,9.64,9.44,9.18,8.89,8.56,8.19,7.79,7.36,6.91,6.44,5.95,5.46,4.96,4.46,3.96,3.48,3.01,2.56,2.14,1.75,1.38,1.06,0.77,0.52,0.32,0.17,0.07,0.01,0,0.05,0.14,0.28,0.47,0.71,0.98,1.3,1.66,2.04,2.46,2.9,3.37,3.85,4.34,4.84,5.34,5.83,6.32,6.8,7.25,7.69,8.09,8.47,8.81,9.12,9.38,9.6,9.77,9.9,9.97,10,9.98,9.9,9.78,9.61,9.4,9.14,8.84,8.5,8.12,7.72,7.28,6.83,6.35,5.87,5.37,4.87,4.37,3.88,3.4,2.94,2.49,2.07,1.68,1.33,1,0.72,0.49,0.29,0.15,0.05,0.01,0.01,0.06,0.16,0.31,0.51,0.75,1.03,1.36,1.72,2.11,2.53,2.98,3.45,3.93,4.42,4.92,5.42,5.92,6.4,6.87,7.33,7.76,8.16,8.53,8.87,9.16,9.42,9.63,9.8,9.91,9.98,10,9.97,9.89,9.76,9.58,9.36,9.09,8.78,8.44,8.06,7.65,7.21,6.75,6.27,5.78,5.29,4.79,4.29,3.8,3.32,2.86,2.42,2,1.62,1.27,0.95,0.68,0.45,0.27,0.13,0.04 " xAxis="1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360" />

<h:MarkLine Data="2,4,6,9" MarkBrushes="#ff9933,#cc0033,#660099,#7e0023" />
</h:Series>
</h:Chart>

动态曲线案例

SinPolarControl.xaml 参考官方项目的正弦曲线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<h:Chart
Grid.Row="1"
Padding="100,50"
Style="{DynamicResource {x:Static h:Chart.CoordKey}}"
xAxis="0,600,1200,1800,2400,3000,3600"
yAxis="-50,-25,0,25,50">
<h:Series xDatas="{Binding XDatas}" yDatas="{Binding YDatas}">
<h:Line Style="{DynamicResource {x:Static h:Line.SingleKey}}" TryFreeze="False" />
<!--平均值横轴显示-->
<h:MarkLine
MarkLineType="Average"
Style="{DynamicResource {x:Static h:MarkLine.SingleKey}}"
TryFreeze="False" />
</h:Series>
</h:Chart>

viewmodel如此写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 private DoubleCollection _YDatas;
public DoubleCollection YDatas
{
get { return _YDatas; }
set { SetProperty(ref _YDatas, value); }
}

private DoubleCollection _XDatas;
public DoubleCollection XDatas
{
get { return _XDatas; }
set { SetProperty(ref _XDatas, value); }
}

private List<double> _Xdoubles = new();
public List<double> Xdoubles
{
get { return _Xdoubles; }
set { SetProperty(ref _Xdoubles, value); }
}

private List<double> _Ydoubles = new();
public List<double> Ydoubles
{
get { return _Ydoubles; }
set { SetProperty(ref _Ydoubles, value); }
}

async void startChartRefresh()
{
double i = 0;
while (true)
{
Xdoubles.Add(i++);
Ydoubles.Add(cpuUsage);
XDatas = new DoubleCollection(Xdoubles);
YDatas = new DoubleCollection(Ydoubles);
PointCount = Xdoubles.Count;//更新点数
await Task.Delay(100);
}
}

示波器

OscControl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<h:Chart Padding="100,50"
Style="{DynamicResource {x:Static h:Chart.CoordKey}}"
xAxis="0,600,1200,1800,2400,3000,3600"
yAxis="-150,-100,-50,0,50,100,150">
<h:Chart.LegendStyle>
<Style TargetType="h:Legend">
<Setter Property="Visibility" Value="Collapsed" />
</Style>
</h:Chart.LegendStyle>
<h:Series xDatas="{Binding WaveyAxis}" yDatas="{Binding WaveData2}">
<h:Line Style="{DynamicResource {x:Static h:Line.SingleKey}}" TryFreeze="False" />
<h:MarkLine MarkLineType="Average" Style="{DynamicResource {x:Static h:MarkLine.SingleKey}}" TryFreeze="False" />
<!--最大值-->
<h:MarkPosition MarkValueType="Max" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" TryFreeze="False" />
</h:Series>
</h:Chart>

控制x,y轴,网格显示

1
2
3
4
5
6
7
8
9
<h:Chart Padding="100,50" xAxis="0,1,2,3,4,5,6,7,8,9,10,11,12" yAxis="0,1,2,3,4,5,6,7,8,9,10">
<h:ViewLayerGroup>
<h:yAxis />
<h:xAxis VerticalAlignment="Top" DockAlignment="Top" />
<h:xAxis VerticalAlignment="Bottom" />
</h:ViewLayerGroup>
<h:Grid Style="{DynamicResource {x:Static h:Grid.CrossKey}}" />
</h:Chart>
<!--<h:xAxis Value="3" />-->

曲线抽稀

Thinning 参考的是 ThinningControl.xaml

主要是用于处理静态图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<h:ChartMap Background="{DynamicResource {x:Static h:BrushKeys.Dark0_6}}"
Chart="{Binding ElementName=chart}"
DockPanel.Dock="Bottom"
xAxis="{Binding xAxis}"
xDatas="{Binding xAxis}"
yAxis="-50,-25,0,25,50"
yDatas="{Binding Datas}" />

<h:Chart x:Name="chart"
Padding="100,50"
Background="Transparent"
Style="{DynamicResource {x:Static h:Chart.CoordKey}}"
UseLegend="True"
xAxisAuto="True"
yAxis="-50,-25,0,25,50"
yAxisAuto="True">
<h:Series DisplayName="抽稀前" Foreground="Green">
<h:Line/>

<h:Scatter>
<h:Scatter.MarkStyle>
<Style BasedOn="{StaticResource {x:Static h:EllipseMarker.DefaultKey}}" TargetType="h:EllipseMarker">
<Setter Property="StrokeThickness" Value="3" />
<Setter Property="Fill" Value="{DynamicResource {x:Static h:BrushKeys.BackgroundDefault}}" />
<Setter Property="Rect">
<Setter.Value>
<Rect Width="5" Height="5" />
</Setter.Value>
</Setter>
<Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=Tag}" />
</Style>
</h:Scatter.MarkStyle>
</h:Scatter>
<h:MarkPosition Foreground="Green" MarkValueType="Max" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkPosition Foreground="Green" MarkValueType="Min" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkLine Foreground="Green" MarkLineType="Average" Style="{DynamicResource {x:Static h:MarkLine.SingleKey}}" />
<h:MarkTip Foreground="Green" MarkTipType="Step" />
</h:Series>

<h:Series DisplayName="抽稀后 阈值 3" Foreground="Purple">
<h:Line ThinningType="Douglas" Threshold="1" />

<h:Scatter>
<h:Scatter.MarkStyle>
<Style BasedOn="{StaticResource {x:Static h:EllipseMarker.DefaultKey}}" TargetType="h:EllipseMarker">
<Setter Property="StrokeThickness" Value="3" />
<Setter Property="Fill" Value="{DynamicResource {x:Static h:BrushKeys.BackgroundDefault}}" />
<Setter Property="Rect">
<Setter.Value>
<Rect Width="5" Height="5" />
</Setter.Value>
</Setter>
<Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=Tag}" />
</Style>
</h:Scatter.MarkStyle>
</h:Scatter>
<h:MarkPosition Foreground="Purple" MarkValueType="Max" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkPosition Foreground="Purple" MarkValueType="Min" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkLine Foreground="Purple" MarkLineType="Average" Style="{DynamicResource {x:Static h:MarkLine.SingleKey}}" />
<h:MarkTip Foreground="Purple" MarkTipType="Step" />
</h:Series>

<h:Series DisplayName="抽稀后 阈值 10" Foreground="Red">
<h:Line ThinningType=" " Threshold="10" />

<h:Scatter>
<h:Scatter.MarkStyle>
<Style BasedOn="{StaticResource {x:Static h:EllipseMarker.DefaultKey}}" TargetType="h:EllipseMarker">
<Setter Property="StrokeThickness" Value="3" />
<Setter Property="Fill" Value="{DynamicResource {x:Static h:BrushKeys.BackgroundDefault}}" />
<Setter Property="Rect">
<Setter.Value>
<Rect Width="5" Height="5" />
</Setter.Value>
</Setter>
<Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=Tag}" />
</Style>
</h:Scatter.MarkStyle>
</h:Scatter>
<h:MarkPosition Foreground="Red" MarkValueType="Max" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkPosition Foreground="Red" MarkValueType="Min" Style="{DynamicResource {x:Static h:MarkPosition.SingleKey}}" />
<h:MarkLine Foreground="Red" MarkLineType="Average" Style="{DynamicResource {x:Static h:MarkLine.SingleKey}}" />
<h:MarkTip Foreground="Red" MarkTipType="Step" />
</h:Series>
</h:Chart>

值得参考的成品项目

HeBianGu.App.Track

拖拽节点编辑器框架

nodify MIT开源,支持MVVM

学习参考

官方学习

WPF项目创建向导VS插件

Template Studio for WPF

可以支持直接常见应用程序的框架,支持Prism,MVVM Toolkit以及Code Behind设计模式

包括强制登录,可选登录

包括主题色选择功能,打包功能等

包括各种测试框架

详细用法参考 内含 ClientId is not a GUID错误的解决方式

二进制编辑器控件库

apache2开源协议

二进制编辑器控件 WpfHexEditorControl

nuget包下载

MVVM的支持不是那么好,需要自己进行二次封装

打包参考

MSIX Packaging

MSIX Packaging 是一种用于打包和分发 Windows 应用程序的技术,旨在取代传统的应用程序安装方式(如 MSI 和 EXE)。MSIX 是 Microsoft 提出的一个现代化的打包格式,旨在提供更好的安全性、可靠性和用户体验

动画库

XamlFlair

开源WPF动画库 XamlFlair

1
2
3
xmlns:xf="clr-namespace:XamlFlair;assembly=XamlFlair.WPF"
<!--只需要为任何需要动画的内容设置附加属性即可-->
<Border xf:Animations.Primary="{StaticResource FadeIn}" />

LottieSharp

用于支持Lottie动画

开源地址

对比项 PAG Lottie MP4 视频
渲染性能 ⭐⭐⭐⭐⭐ (GPU 加速) ⭐⭐⭐ (CPU 依赖强) ⭐⭐ (解码开销大)
文件大小 10-100KB 50-500KB 1-10MB
透明度支持 ❌(需特殊编码)
运行时编辑 ✅(文本/颜色替换)
WPF 兼容性 ✅ (官方 SDK) ✅ (需 Lottie-Windows) ✅ (MediaElement)

LibPag.WPF

用于支持pag动画

开源地址

1
Install-Package LibPag.WPF

在 XAML 中加载动画

1
2
3
<Window xmlns:pag="clr-namespace:LibPag.WPF;assembly=LibPag.WPF">
<pag:PAGView FilePath="animation.pag" />
</Window>

动态控制动画(C# 代码)

1
2
3
var pagPlayer = new PAGPlayer();
pagPlayer.SetComposition(PAGFile.Load("animation.pag"));
pagPlayer.Play(); // 播放/暂停/跳转等

行为库

Microsoft.Xaml.Behaviors.Wpf

以拖拽库为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using Microsoft.Xaml.Behaviors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows;

namespace UI.Application.Share.Behavior
{
public class DraggableBehavior : Behavior<FrameworkElement>
{
private bool _isDragging;
private Point _clickPosition;

protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.MouseLeftButtonDown += OnMouseDown;
AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
}

protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.MouseLeftButtonDown -= OnMouseDown;
AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
}

private void OnMouseDown(object sender, MouseButtonEventArgs e)
{
_isDragging = true;
_clickPosition = e.GetPosition(AssociatedObject);
Mouse.Capture(AssociatedObject);
}

private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_isDragging && AssociatedObject.Parent is Canvas canvas)
{
Point currentPosition = e.GetPosition(canvas);
double left = currentPosition.X - _clickPosition.X;
double top = currentPosition.Y - _clickPosition.Y;
Canvas.SetLeft(AssociatedObject, left);
Canvas.SetTop(AssociatedObject, top);
}
}

private void OnMouseUp(object sender, MouseButtonEventArgs e)
{
_isDragging = false;
Mouse.Capture(null);
}
}
}

使用

1
2
3
4
5
6
7
8
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:behavior="clr-namespace:UI.Application.Share.Behavior;assembly=UI.Application.Share"

<hc:Card>
<i:Interaction.Behaviors>
<behavior:DraggableBehavior /> <!--使用拖拽效果-->
</i:Interaction.Behaviors>
</hc:Card>

WPF引入矢量图

参考链接

WPF与HTML交互

交互方案

  • WebBrowser(微软提供) 最老的,不建议使用 支持Flash
  • WebView(微软提供) 微软优化
  • CefSharp(第三方,用户数量大) 兼容浏览器最广泛,最低可以支持到winXP 支持Flash

WebBrowser

基于WebBrowser 控件是基于 Internet Explorer 的。

优点

  • 简单易用,适合基本的网页显示需求。
  • 可以直接在 XAML 中使用。

缺点

  • 依赖于 Internet Explorer,可能不支持现代网页技术(如 HTML5、CSS3 和 JavaScript ES6)。
  • 性能较差,尤其是在处理复杂网页时。
  • 安全性较低,可能会受到 IE 的安全限制。

显示相关

1
2
//直接显示一个外部网页
<WebBrowser Source="http://www.bing.com"/>

显示一个自己编写的网页:

项目下创建一个文件夹为Assets,下面创建文件Monitor.html,里面编写html代码

该html代码文件必须设置属性为资源

1
<WebBrowser Name="browser"/>

在xaml.cs后台文件中引入html文件到WebBrowser控件

1
2
3
4
5
//加载资源到页面
Uri uri = new Uri("pack://application:,,,/wpfWebbrowser;component/Assets/Monitor.html",
UriKind.RelativeOrAbsolute);
Stream src = Application.GetResourceStream(uri).Stream;
browser.NavigateToStream(src);

js和c#交互

JS调用c#方法

html中添加按钮,按钮处理事件

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>数据监视</title>
</head>
<body>
<div style="background-color:orange" id="data">默认数据显示</div>
<button onclick="window.external.JsMessage('Hello 中文')">点击执行c#逻辑(JS调用C#方法)</button>
</body>
</html>

xaml.cs后台文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
//为浏览器控件准备可调用的对象实例
browser.ObjectForScripting = new ScriptContext();
//加载资源到页面
Uri uri = new Uri("pack://application:,,,/wpfWebbrowser;component/Assets/Monitor.html",
UriKind.RelativeOrAbsolute);
Stream src = Application.GetResourceStream(uri).Stream;
browser.NavigateToStream(src);
}
}

//给他更高权限
[PermissionSet(SecurityAction.Demand, Name = "FullTrust")]
//关联技术基于com
[ComVisible(true)]
public class ScriptContext
{
/// <summary>
/// 这个方法将由JS调用,调用时传递一个参数进来
/// </summary>
/// <param name="message"></param>
public void JsMessage(string message)
{
MessageBox.Show(message);
}
}

C#调用JS方法

html中添加js方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>数据监视</title>
<script type="text/javascript">
//准备一个方法给C#调用
function CSharpMsg(msg) {
document.getElementById("data").innerHTML = msg;
}
</script>
</head>
<body>
<div style="background-color:orange" id="data">默认数据显示</div>
<button onclick="window.external.JsMessage('Hello 中文')">点击执行c#逻辑(JS调用C#方法)</button>
</body>
</html>

在viewmodel中演示:

viewmodel中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//绑定Loaded事件 
void ExecuteLoadedCommand()
{
var browser = FindChild<WebBrowser>(System.Windows.Application.Current.MainWindow, "browser");
Task.Run(async () =>
{
while (true)
{
await Task.Delay(500);
var v = new Random().Next(10, 1090);
//定义调用的js方法
var jsCode = $"CSharpMsg('{v}')";
//因为在子线程中,所以要由ui线程调用
Application.Current.Dispatcher.Invoke(() =>
{
try
{
//下面语句与Winform的WebBrowser有区别
browser.InvokeScript("execScript", new object[] { jsCode, "JavaScript" });//调用js方法
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
});


}
});
}

http服务器方式

上面通过资源加载html的方式有个很大的弊端,就是html无法加载单独的js代码

解决方案,就是程序开启一个http服务器,通过http服务器加载http,js,css这一套网页,WebBrowser控件通过本地链接绑定到本地页面上,就不需要通过资源加载的方式进行

这也是为什么WebBroser用的少的原因,要真正做好的话还得使用web服务器,WebView2就是解决这个问题的

WebView2

不存在WebView,上来就是WebView2

基于WebView2 是 Windows 10 及更高版本中引入的控件,基于 Microsoft Edge(Chromium)。

优点

  • 支持现代网页技术,性能较好。
  • 提供了更好的安全性和稳定性。
  • 可以使用 UWP API,适合开发现代应用程序。

缺点

  • 仅适用于 Windows 10 和更高版本。
  • 需要额外的设置和配置,可能比 WebBrowser 更复杂。

使用需要引入库: Microsoft.Web.WebView2

官方文档

xaml中引入命名空间

1
2
3
4
5
//等于号后输入WebView2,自动补全
//wpf中用这个
xmlns:wv ="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
//winform中用这个
xmlns:wv="clr-namespace:Microsoft.Web.WebView2.WinForms;assembly=Microsoft.Web.WebView2.WinForms"

显示相关

显示外部链接

1
<wv:WebView2 Source="http://www.bing.com"/>

显示本地网页

1
<wv:WebView2 Name="webView" Source=""/>

xaml后台文件 (注意 Monitor.html文件要设置为始终复制)

1
2
var src = $"file:///{Environment.CurrentDirectory}/Assets/Htmls/Monitor.html";
this.webView.Source = new Uri(src);

比资源加载好在当更新html的时候,不需要更新程序,直接更新html就可以了

C#到JS数据传递

有两种方式

  • 执行js方法(也能通过参数传递信息)
  • 传递数据

c#方

1
2
3
4
5
//执行js方法
webView.CoreWebView2.ExecuteScriptAsync("CSharpMsg('hello 中文')");

//将123abc发送到webview控件中,除了string信息,也支持json数据
webView.CoreWebView2.PostWebMessageAsString("123abc");

html文件

1
2
3
4
5
6
7
8
9
10
11
12
<script type="text/javascript">
//准备一个方法给C#调用
function CSharpMsg(msg) {
document.getElementById("data").innerHTML = msg;
}

//监听传上来的信息(除了string,也支持json传递)
window.chrome.webview.addEventListener("message", data => {
//传上来的是一个对象,要用.data取其中的值
document.getElementById("data").innerHTML = data.data;
});
</script>

JS调用C#方法

xaml.cs后台文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public MainWindow()
{
InitializeComponent();
//比资源加载好在当更新html的时候,不需要更新程序,直接更新html就可以了
var src = $"file:///{Environment.CurrentDirectory}/Assets/Htmls/Monitor.html";
this.webView.Source = new Uri(src);

//得在初始化完成的事件中才能将要被js调用的C#方法传递给wenView2控件
this.webView.CoreWebView2InitializationCompleted += WebView_CoreWebView2InitializationCompleted;
}

//wenView完成后才会回调的方法
private void WebView_CoreWebView2InitializationCompleted(object sender, EventArgs e)
{
//将要被js调用的C#方法传递给wenView2控件
webView.CoreWebView2.AddHostObjectToScript("csobj", new ScriptContext());
}

html

1
<button onclick="window.chrome.webview.hostObjects.csobj.JsMessage('hello')">点击执行c#逻辑(JS调用C#方法)</button>

注意html中出现的csobj就是xaml.cs后台文件中的csobj

获取返回值

上面的方法js向c#请求数据会比较麻烦,因为需要两个处理传递(请求传过去,结果传回来),能获得返回值的话,也就可以一次处理完成

js

1
2
3
4
5
6
function myFunc() {
//使用then来取返回值
window.chrome.webview.hostObjects.csobj.GetData().then(data => {
document.getElementById("data").innerHTML = data;
});
}

html

1
<button onclick="myFunc()">调用c#方法并接收返回值</button>

c#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
//比资源加载好在当更新html的时候,不需要更新程序,直接更新html就可以了
var src = $"file:///{Environment.CurrentDirectory}/Assets/Htmls/Monitor.html";
this.webView.Source = new Uri(src);

//得在初始化完成的事件中才能将要被js调用的C#方法传递给wenView2控件
this.webView.CoreWebView2InitializationCompleted += WebView_CoreWebView2InitializationCompleted;

}

//wenView完成后才会回调的方法
private void WebView_CoreWebView2InitializationCompleted(object sender, EventArgs e)
{
//将要被js调用的C#方法传递给wenView2控件
webView.CoreWebView2.AddHostObjectToScript("csobj", new ScriptContext());

}

}

//给他更高权限
[PermissionSet(SecurityAction.Demand, Name = "FullTrust")]
//关联技术基于com
[ComVisible(true)]
public class ScriptContext
{
public string GetData()
{
return "c#返回的信息";
}
}

网页加载时 JavaScript 代码

AddScriptToExecuteOnDocumentCreatedAsync 方法非常强大,可以帮助开发者在网页加载时注入自定义的 JavaScript 代码,从而实现各种功能和效果。如

1
2
3
//可以直接创建一个JS语句来在文档创建时执行
string script = "console.log('Document created!');";
webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(script);

也得在WebView初始化完成的事件中才能被正确执行

CefSharp

基于:CefSharp 是一个基于 Chromium Embedded Framework (CEF) 的开源项目。

按照github文档内容: 使用时需要保留版权声明

开源项目地址

中文帮助文档

Electron包一层的方式进行HTML的包装

Chromium Embedded Framework(CEF)是一个开源项目,允许开发者将 Chromium 浏览器的功能嵌入到其他应用程序中。CEF 提供了一个简单的 API,使得在桌面应用程序中使用 Web 技术(如 HTML、JavaScript 和 CSS)变得更加容易。

CEF(C++)在github上开源免费 功能上与chrome是同步的

Chromium Embedded Framework 是一个强大的工具,允许开发者在其应用程序中嵌入现代网页技术,提供丰富的用户体验

优点

  • 支持现代网页技术,性能优越。
  • 可以自定义浏览器的行为,功能强大。
  • 可以处理复杂的网页和应用程序,适合需要高性能和高兼容性的场景。

缺点

  • 相比于 WebBrowserWebView,集成和使用相对复杂。
  • 需要额外的 NuGet 包和依赖项,增加了项目的复杂性。
  • 包体积较大,可能影响应用程序的大小。

使用方式

下面的流程适用于.Net5.0或更高版本,参考的是此链接

  1. 通过Nuget安装,右击项目 -> 管理Nuget程序包 -> 在打开的界面中搜索CefSharp,依次安装 CefSharp.Wpf.NETCore
  2. 因为CefSharp不支持ANYCPU所以要配置x86、x64,点击菜单生成 -> 配置管理器。选择解决方案平台,点击编辑,先将x64和x86删掉,再重新新建,重新配置比较容易些。

添加命名空间如下:

1
xmlns:cs="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"

显示相关

cs:ChromiumWebBrowser: 这个控件放置不会占用空间,只有绘制先后的顺序,因此可以直接在其上绘制控件

显示外部链接

1
<cs:ChromiumWebBrowser Name="browser"/>

xaml.cs后台文件如下:

1
browser.Load("http://www.baidu.com");

显示本地html文件

要显示的html文件一定要设置复制到输出目录始终复制

1
<cs:ChromiumWebBrowser Name="browser"/>

xaml.cs后台文件如下:

1
2
string file = $"{Environment.CurrentDirectory}/Assets/Htmls/Monitor.html";
browser.Load(file);

C#调用JS方法

html

1
2
3
4
5
6
<script type="text/javascript">
//准备一个方法给C#调用
function CSharpMsg(msg) {
document.getElementById("data").innerHTML = msg;
}
</script>

调用js方法代码

1
2
3
 //调用js代码  CSharpMsg是js方法
browser.ExecuteScriptAsync("CSharpMsg('C#执行ExecuteScriptAsync')");
//注意UI线程执行

显示控制台

1
browser.ShowDevTools();

JS调用C#方法

在 .Net 中注册对象有两种选择,第一个是提前注册的,这通常是在创建 ChromiumWebBrowser 实例后立即完成的。第二个选项更灵活,并允许在需要时解决对象。

第二个方案试验不成功

第一个方案

这个方案必须在创建浏览控件之前注册提供给js调用的类

html

1
<button onclick="window.zeroko.JsMessage('你好,世界!')">点击执行c#逻辑(JS调用C#方法)</button>

xaml.cs后台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//必须在创建browser之前注册 ,个人理解:xaml定义的浏览器控件不能使用这种方法
browser.JavascriptObjectRepository.Settings.LegacyBindingEnabled = true;
browser.JavascriptObjectRepository.Register("zeroko", new scriptContext());//这里的zeroko就是在html中的window.zeroko的zeroko

//新建类
//供JS调用的C#方法集合
public class scriptContext
{
//无返回值的
public void JsMessage(string message)
{
Application.Current.Dispatcher.Invoke(() =>
{
MessageBox.Show(message);
});
}

//带返回值的
public string GetData()
{
return "C#返回的信息";
}
}

第二个方案

略,参考该链接

现有程序迁移到WEB端

UNO平台

wpf与winform控件互相使用

  • winform可以通过 ElementHost使用wpf控件
  • wpf可以通过 WindowsFormsHost使用winform控件

捕获全局异常

  • UI线程未捕获异常(DispatcherUnhandledException)

    默认情况下,Windows Presentation Foundation捕获未经处理的异常,从对话框中通知用户异常 (,他们可以从该对话框中报告异常) ,并自动关闭应用程序。

    这是WPF和WinForms等UI框架特有的机制,它会在UI线程上捕获未处理的异常。如果async void方法是由UI线程的事件触发的,异常会被传播到UI线程,最终由DispatcherUnhandledException捕获。

    可以取消应用程序终止:通过设置 DispatcherUnhandledExceptionEventArgs.Handled = true,可以防止应用程序崩溃并处理异常。

  • 非UI线程未捕获异常(AppDomain.UnhandledException)

    AppDomain.UnhandledException 是 .NET 框架中全局异常处理的机制,用于捕获大部分 未处理的异常,无论它们来自 UI 线程还是后台线程。此机制适用于 整个应用程序域(AppDomain),无论是同步代码还是异步代码中的异常,只要没有在代码中捕获处理,它们都会触发该事件。

    应用程序仍会崩溃:虽然该事件能捕获异常并进行日志记录或其他操作,但应用程序通常仍然会崩溃,因为 UnhandledException 事件仅用于通知,而不能阻止应用崩溃。

  • 未观察到的任务异常(TaskScheduler.UnobservedTaskException)

    默认不会终止程序

    TaskScheduler.UnobservedTaskException 是用于处理 异步任务(Task) 中未观察到的异常。如果你使用 async/await 或 Task,当异步操作抛出异常且未被处理时,该异常会触发 UnobservedTaskException 事件

    虽然这个机制对async void没有直接帮助,但它在处理async Task时非常有效。当任务出现异常并未被观察(即没有await或.ContinueWith捕获异常),该异常会被标记为未观察到的任务异常,进而触发TaskScheduler.UnobservedTaskException。这提供了一层额外的保护,确保在特定情况下未捕获的Task异常不会引发崩溃。

    仅适用于异步任务中的异常:用于处理异步代码中未被观察的异常。

    可以避免应用崩溃:通过 e.SetObserved(); // 标记异常为已观察,防止崩溃,可以防止由于未观察到的任务异常导致的应用程序崩溃。

    垃圾回收前触发:UnobservedTaskException 事件会在垃圾回收(GC)过程中触发,如果该异常仍然没有被处理,那么应用程序会终止。

下面是App.xaml.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
 protected override Window CreateShell()
{
LogGlobalException();//wpf框架中注册全局异常捕获
return Container.Resolve<Shell>();
}

private void LogGlobalException()
{
//UI线程未捕获异常处理事件
this.DispatcherUnhandledException += OnDispatcherUnhandledException;
//Task线程内未捕获异常处理事件
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
//多线程异常
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
}

private void OnDispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
LogException("DispatcherUnhandledException", e.Exception);
e.Handled = true; // 防止程序闪退
}

private void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
LogException("UnobservedTaskException", e.Exception);
e.SetObserved(); // 标记异常为已观察
}

private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception ex = (Exception)e.ExceptionObject;
LogException("UnhandledException", ex);
// Generate dump file
// MiniDump.TryDump($"dumps\\Wemail_{DateTime.Now:HH-mm-ss-ms}.dmp");
if (e.IsTerminating) // 判断是否会导致程序终止
{
//可以生成dump文件
}
}

private void LogException(string exceptionType, Exception ex)
{
var exceptionDetails = GetExceptionDetails(ex);
_logger.LogCritical($"[{exceptionType}]{exceptionDetails}");
}

private string GetExceptionDetails(Exception ex)
{
var details = new StringBuilder();
while (ex != null)
{
details.AppendLine($"异常信息:{ex.Message}");
details.AppendLine($"来自:{ex.Source}");
details.AppendLine($"调用堆栈:\n{ex.StackTrace}");
ex = ex.InnerException;
}
return details.ToString();
}

async void方法的异常会直接传播到调用它的线程,而不会像async Task那样生成一个可等待的任务。因此,async void方法中的异常不会触发TaskScheduler.UnobservedTaskException或Application.DispatcherUnhandledException这些传统的全局异常捕获机制

测试案例

未添加任何防闪退代码情况下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ui线程中触发除零异常 导致了  //DispatcherUnhandledException  ->  UnhandledException  -->  VS中断

Task.Run(()=>{触发除零异常}); //UnobservedTaskException

async void test()
{
await Task.Run(()=>{触发除零异常}); //DispatcherUnhandledException -> UnhandledException --> VS中断
}

Thread th1 = new Thread(() =>{触发除零异常});th1.Start(); //UnhandledException --> VS中断

async void test()
{
await Task.Delay(100).ConfigureAwait(false);
触发除零异常
}
test() //DispatcherUnhandledException -> UnhandledException --> VS中断

async Task test()
{
await Task.Delay(100).ConfigureAwait(false);
int b = 0;
int a = 3 / b;
}
Task.Run(async () => { await test(); }); //UnobservedTaskException

prism框架中的事件聚合器回调体中直接除零异常 //DispatcherUnhandledException -> UnhandledException --> VS中断

也许只有Task并且使用了await承接,并且整个调用链上都没有async void才能真正触发UnobservedTaskException

如果DispatcherUnhandledException中使用e.Handled=true 则只会触发DispatcherUnhandledException,后续触发,包括vs中断都不会发生

作为对比,在控制台的全局未捕获异常:

1
2
3
4
5
6
7
8
9
10
// 订阅全局未处理异常事件(在main中)
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;

private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
// 处理未处理的异常
Exception ex = (Exception)e.ExceptionObject;
Console.WriteLine($"捕获到未处理的异常: {ex.Message}");
// 可以在这里添加更多的日志记录或清理操作
}

但要注意:

AppDomain.CurrentDomain.UnhandledException 只能捕获在主线程和其他非 UI 线程中未被捕获的异常。如果在新线程中发生异常而没有适当的捕获机制,这些异常将不会被全局异常处理程序捕获。

在新线程中,你应该使用 try-catch 结构来捕获可能发生的异常

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建并启动新线程
Thread thread = new Thread(() =>
{
try
{
int a = 0;
int b = 3 / a; // 这里将引发除零异常
}
catch (Exception ex)
{
Console.WriteLine($"捕获到线程中的异常: {ex.Message}");
}
});

无法全局捕获的异常

除了栈溢出(StackOverflowException)之外,还有一些其他类型的异常也是不允许捕获和处理的,直接导致进程闪退,无法被全局异常捕获机制捕获。这些异常包括:

  1. OutOfMemoryException:当程序尝试分配的内存超过可用内存时,会抛出OutOfMemoryException。这个异常通常无法被捕获,因为没有足够的内存来执行异常处理代码。
  2. AccessViolationException:当程序尝试访问无效的内存地址时,会抛出AccessViolationException。这个异常通常无法被捕获,因为它会导致进程崩溃。
  3. NullReferenceException(在某些情况下):当程序尝试访问空引用时,会抛出NullReferenceException。在某些情况下, NullReferenceException可能无法被捕获,例如当异常发生在系统调用期间。
  4. SEHException(结构化异常处理异常):SEHException是Windows特有的异常类型,当程序发生结构化异常处理异常时,会抛出SEHException。这个异常通常无法被捕获,因为它会导致进程崩溃。
  5. CorruptedStateException:(损坏状态异常)当程序的状态已经被破坏:当程序的状态已经被破坏时,会抛出CorruptedStateException。这个异常通常无法被捕获,因为程序的状态已经不可恢复。
  6. ExecutionEngineException:当程序的执行引擎发生异常时,会抛出ExecutionEngineException。这个异常通常无法被捕获,因为它会导致进程崩溃。
  7. InvalidProgramException:当程序的代码不正确或被破坏时,会抛出InvalidProgramException。这个异常通常无法被捕获,因为程序的代码已经不可恢复。
  8. FatalExecutionEngineError:当程序的执行引擎发生致命错误时,会抛出FatalExecutionEngineError。这个异常通常无法被捕获,因为它会导致进程崩溃。

请注意,这些异常通常是由于程序的错误或系统级别的问题引起的,而不是由于异常处理机制的缺陷。因此,应该重点关注程序的错误和系统级别的问题,而不是尝试捕获和处理这些异常。

快捷键相关

程序内快捷键

使用KeyDown和KeyUp事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
this.KeyDown += MainWindow_KeyDown;
this.KeyUp += MainWindow_KeyUp;


public bool isHotKeyPressed { get; private set; } = false;

private void MainWindow_KeyDown(object sender, KeyEventArgs e)
{
if ((e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl) && !isHotKeyPressed)
{
isHotKeyPressed = true;
//按下ctrl时执行的操作
}
}

private void MainWindow_KeyUp(object sender, KeyEventArgs e)
{
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
{
isHotKeyPressed = false;
//放开ctrl时执行的操作
}
}

值得记录的代码

TextBox切换输入与绑定模式

下面介绍:通过一系列的绑定、触发器和命令来实现既能显示绑定值的变化,又能让用户编辑的功能

默认显示状态:TextBox 默认显示的是通过 MultiBinding 绑定的值。这个值是只读的,用于显示当前的设置值。

进入编辑模式:

  • 当用户点击 TextBox 时,触发 PreviewMouseLeftButtonDown 事件。
  • 这个事件执行 NewControlSetPointMouseEnterCommand 命令,传入 “引出电源” 作为参数。在 ViewModel 中,这个命令执行以下操作:
    • 当 TBoxExtractPowerControlVisibility 变为 Visible 时,触发 DataTrigger。
    • 这个触发器改变 TextBox 的背景色,并将 Text 属性绑定到 NewExtractPowerControlSetPoint。
    • 新的绑定是双向的(Mode=TwoWay),允许用户编辑值。
  • 编辑值:
    用户可以直接在 TextBox 中编辑值。
    由于绑定设置了 UpdateSourceTrigger=PropertyChanged,每次文本变化都会更新 NewExtractPowerControlSetPoint 属性。
  • 退出编辑模式:
    • 虽然在提供的代码中没有明确显示,但可能存在一个失去焦点或其他事件来触发编辑模式的结束。
    • 这可能涉及将 TBoxExtractPowerControlVisibility 设置回 Collapsed 或 Hidden。
  • 保存编辑的值:
    • PreviewMouseLeftButtonUp 事件触发 PowerTextBoxSelectionChangedCommand。
    • 这个命令可能负责将编辑后的值保存或应用到实际的设置中。

重点是使用一个标志位切换编辑模式和显示模式,编辑模式和显示模式分别绑定一套数据

下面罗列一部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<TextBox x:Name="tb_Control_ExtractPower" Width="87" Height="36" TextAlignment="Center" VerticalAlignment="Center" Foreground="Blue" FontSize="14" Padding="-5,8,8,0">
<i:Interaction.Triggers>
<i:EventTrigger EventName="PreviewMouseLeftButtonDown">
<i:InvokeCommandAction Command="{Binding NewControlSetPointMouseEnterCommand}" CommandParameter="引出电源"/>
</i:EventTrigger>
<i:EventTrigger EventName="PreviewMouseLeftButtonUp">
<prism:InvokeCommandAction Command="{Binding PowerTextBoxSelectionChangedCommand}" TriggerParameterPath="Source"/>
</i:EventTrigger>
</i:Interaction.Triggers>
<TextBox.Style>
<Style TargetType="TextBox" BasedOn="{StaticResource TextBoxBaseBaseStyle}">
<Setter Property="Text">
<!--绑定的第一套数据,只读的,不可编辑的-->
<Setter.Value>
<MultiBinding Converter="{StaticResource DisplayDataCvt}">
<Binding Path="CombineDataProvider.UDPData.Model.ExtractPower.Port.ControlSet"/>
<Binding Path="SystemConfigProvider.SoftwareConfig.ExtractPower.ControlConvertCoefficient"/>
<Binding Path="SystemConfigProvider.SoftwareConfig.ExtractPower.ControlDigits"/>
<Binding Path="SystemConfigProvider.SoftwareConfig.ExtractPower.IsControlScientificNotation"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding TBoxExtractPowerControlVisibility}" Value="Visible">
<Setter Property="Background" Value="#AEEA00"/>
<!--绑定的可读写的数据-->
<Setter Property="Text" Value="{Binding NewExtractPowerControlSetPoint,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>

更好的方式

下面是更好的方法来实现 TextBox 的绑定切换,使代码更清晰、更易维护。以下是一些改进建议:

  1. 使用单一绑定源和转换器
1
2
3
4
5
6
7
8
9
10
11
<TextBox Text="{Binding TextBoxValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Converter={StaticResource TextBoxValueConverter}}">
<TextBox.Style>
<Style TargetType="TextBox" BasedOn="{StaticResource TextBoxBaseStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsEditing}" Value="True">
<Setter Property="Background" Value="#AEEA00"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
  1. 在 ViewModel 中使用单一属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    private string _textBoxValue;
    public string TextBoxValue
    {
    get => _textBoxValue;
    set
    {
    if (SetProperty(ref _textBoxValue, value))
    {
    UpdateUnderlyingValue();
    }
    }
    }

    private bool _isEditing;
    public bool IsEditing
    {
    get => _isEditing;
    set => SetProperty(ref _isEditing, value);
    }

    private void UpdateUnderlyingValue()
    {
    if (IsEditing)
    {
    // 更新编辑值
    NewExtractSuppPowerControlSetPoint = TextBoxValue;
    }
    else
    {
    // 更新显示值
    // 可能需要进行格式转换
    }
    }
  2. 使用 IValueConverter 来处理显示和编辑值的转换

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class TextBoxValueConverter : IValueConverter
    {
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
    // 从底层值转换为显示值
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
    // 从编辑值转换为底层值
    }
    }
  3. 使用命令来处理编辑模式的进入和退出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    private DelegateCommand _enterEditModeCommand;
    public DelegateCommand EnterEditModeCommand =>
    _enterEditModeCommand ?? (_enterEditModeCommand = new DelegateCommand(ExecuteEnterEditMode));

    private void ExecuteEnterEditMode()
    {
    IsEditing = true;
    TextBoxValue = NewExtractSuppPowerControlSetPoint;
    }

    private DelegateCommand _exitEditModeCommand;
    public DelegateCommand ExitEditModeCommand =>
    _exitEditModeCommand ?? (_exitExitEditModeCommand = new DelegateCommand(ExecuteExitEditMode));

    private void ExecuteExitEditMode()
    {
    IsEditing = false;
    UpdateUnderlyingValue();
    }
  4. 在 XAML 中使用这些命令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <TextBox Text="{Binding TextBoxValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
    <i:Interaction.Triggers>
    <i:EventTrigger EventName="GotFocus">
    <i:InvokeCommandAction Command="{Binding EnterEditModeCommand}"/>
    </i:EventTrigger>
    <i:EventTrigger EventName="LostFocus">
    <i:InvokeCommandAction Command="{Binding ExitEditModeCommand}"/>
    </i:EventTrigger>
    </i:Interaction.Triggers>
    </TextBox>

这种方法的优点:

  1. 使用单一的绑定源,简化了 XAML。
  2. 将显示逻辑和编辑逻辑清晰地分开。
  3. 使用命令来处理模式切换,使逻辑更集中、更易于管理。
  4. 通过转换器处理值的转换,使代码更加灵活。
  5. 减少了对多个可见性属性的依赖。

这种实现方式更符合 MVVM 模式,使代码更易于理解和维护,同时保持了原有功能的灵活性。

一些技巧盘点

自定义控件如果不设置background的值,默认的值是null,此时没有东西的位置是不可点击的

这个时候可以通过给Background属性设置为transparent,使自定义控件整个区域都是可命中区

控件的默认 Background 值为 null的情况下,那么只有在将鼠标悬停在图标上的时候才能点击。所以为了在控件的整个区域内启用点击事件,建议是将 Background 设置为透明。这样一来,点击控件的任何部分都能触发事件

混淆工具

obfuscar

参考链接

安装

使用该指令dotnet tool install --global Obfuscar.GlobalTool来安装

使用

在根目录执行obfuscar.console Obfuscar.xml读取配置文件,进行保护

配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?xml version='1.0'?>
<Obfuscator>
<!-- 输入的工作路径,采用如约定的 Windows 下的路径表示法,如以下表示当前工作路径 -->
<!-- 推荐使用当前工作路径,因为 DLL 的混淆过程,需要找到 DLL 的所有依赖。刚好当前工作路径下,基本都能满足条件 -->
<Var name="InPath" value="." />
<!-- 混淆之后的输出路径,如下面代码,设置为当前工作路径下的 Obfuscar 文件夹 -->
<!-- 混淆完成之后的新 DLL 将会存放在此文件夹里 -->
<Var name="OutPath" value=".\Obfuscar" />
<!-- 以下的都是细节的配置,配置如何进行混淆 -->

<!-- 使用 KeepPublicApi 配置是否保持公开的 API 不进行混淆签名,如公开的类型公开的方法等等,就不进行混淆签名了 -->
<!-- 语法的写法就是 name 表示某个开关,而 value 表示值 -->
<!-- 对于大部分的库来说,设置公开的 API 不进行混淆是符合预期的 -->
<Var name="KeepPublicApi" value="true" />
<!-- 设置 HidePrivateApi 为 true 表示,对于私有的 API 进行隐藏,隐藏也就是混淆的意思 -->
<!-- 可以通过后续的配置,设置混淆的方式,例如使用 ABC 字符替换,或者使用不可见的 Unicode 代替 -->
<Var name="HidePrivateApi" value="true" />
<!-- 设置 HideStrings 为 true 可以设置是否将使用的字符串进行二次编码 -->
<!-- 由于进行二次编码,将会稍微伤一点点性能,二次编码需要在运行的时候,调用 Encoding 进行转换为字符串 -->
<Var name="HideStrings" value="false" />
<!-- 设置 UseUnicodeNames 为 true 表示使用不可见的 Unicode 字符代替原有的命名,通过此配置,可以让反编译看到的类和命名空间和成员等内容都是不可见的字符 -->
<Var name="UseUnicodeNames" value="true" />
<!-- 是否复用命名,设置为 true 的时候,将会复用命名,如在不同的类型里面,对字段进行混淆,那么不同的类型的字段可以是重名的 -->
<!-- 设置为 false 的时候,全局将不会有重复的命名 -->
<Var name="ReuseNames" value="true" />
<!-- 配置是否需要重命名字段,默认配置了 HidePrivateApi 为 true 将都会打开重命名字段,因此这个配置的存在只是用来配置为 false 表示不要重命名字段 -->
<Var name="RenameFields" value="true" />
<!-- 是否需要重新生成调试信息,生成 PDB 符号文件 -->
<Var name="RegenerateDebugInfo" value="true" />

<!-- 需要进行混淆的程序集,可以传入很多个,如传入一排排 -->
<!-- <Module file="$(InPath)\Lib1.dll" /> -->
<!-- <Module file="$(InPath)\Lib2.dll" /> -->
<Module file="$(InPath)\HeenerholiCeleehano.dll" />

<!-- 程序集的引用加载路径,对于 dotnet 6 应用,特别是 WPF 或 WinForms 项目,是需要特别指定引用加载路径的 -->
<!-- 这里有一个小的需要敲黑板的知识点,应该让 Microsoft.WindowsDesktop.App 放在 Microsoft.NETCore.App 之前 -->
<!-- 对于部分项目,如果没有找到如下顺序,将会在混淆过程中,将某些程序集解析为旧版本,从而失败 -->
<AssemblySearchPath path="C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App\6.0.1\" />
<AssemblySearchPath path="C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.1\" />
</Obfuscator>

还支持可以自己将混淆过程嵌入到构建过程里面,如此可以实现在开发阶段对混淆的结果进行调试。也就是开发时调试的 DLL 就是混淆过后的

自定义可被观察容器

ObservableDictionary

在 WPF 中,标准的 Dictionary<TKey, TValue> 无法像 ObservableCollection<T> 一样自动通知界面更新,因为它未实现 INotifyCollectionChangedINotifyPropertyChanged 接口

因此下面是一个自定义的ObservableDictionary

实现思路

继承Dictionary<TKey,TValue>或封装一个字典结构

实现INotifyCollectionChanged(集合结构变化时通知,如增删键值对)和INotifyPropertyChanged(字典整体替换时通知)

对字典值的修改(如dict[key] = newValue)需触发属性变更通知

适用场景: 需要高效键值查找且动态更新值的场景

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Core.UI.CustomContainer
{
public class ObservableDictionary<TKey, TValue> :
INotifyCollectionChanged, INotifyPropertyChanged,
IDictionary<TKey, TValue>
{
private readonly Dictionary<TKey, TValue> _innerDictionary = new();

// Keys/Values返回类型改为ICollection<T>
public ICollection<TKey> Keys => _innerDictionary.Keys;
public ICollection<TValue> Values => _innerDictionary.Values;

public TValue this[TKey key]
{
get => _innerDictionary[key];
set
{
bool isUpdate = _innerDictionary.TryGetValue(key, out var oldValue);
_innerDictionary[key] = value;

OnPropertyChanged(nameof(Values));
OnPropertyChanged(nameof(Keys));

var action = isUpdate ?
NotifyCollectionChangedAction.Replace :
NotifyCollectionChangedAction.Add;

// 传递完整的参数
OnCollectionChanged(
action: action,
key: key,
oldValue: isUpdate ? oldValue! : default,
newValue: value);
}
}

public event NotifyCollectionChangedEventHandler? CollectionChanged;
public event PropertyChangedEventHandler? PropertyChanged;

// 重构通知方法
private void OnCollectionChanged(NotifyCollectionChangedAction action) =>
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action));

private void OnCollectionChanged(
NotifyCollectionChangedAction action,
TKey key,
TValue oldValue,
TValue newValue)
{
var args = action switch
{
NotifyCollectionChangedAction.Add =>
new NotifyCollectionChangedEventArgs(
action,
new KeyValuePair<TKey, TValue>(key, newValue)),

NotifyCollectionChangedAction.Replace =>
new NotifyCollectionChangedEventArgs(
action,
new KeyValuePair<TKey, TValue>(key, newValue),
new KeyValuePair<TKey, TValue>(key, oldValue)),

NotifyCollectionChangedAction.Remove =>
new NotifyCollectionChangedEventArgs(
action,
new KeyValuePair<TKey, TValue>(key, oldValue)),

_ => throw new NotSupportedException()
};

CollectionChanged?.Invoke(this, args);
}

private void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

// 实现IDictionary接口
public void Add(TKey key, TValue value)
{
_innerDictionary.Add(key, value);
OnCollectionChanged(NotifyCollectionChangedAction.Add, key, default!, value);
OnPropertyChanged(nameof(Values));
OnPropertyChanged(nameof(Keys));
}

public bool Remove(TKey key)
{
if (!_innerDictionary.TryGetValue(key, out var value)) return false;
bool removed = _innerDictionary.Remove(key);

if (removed)
{
OnPropertyChanged(nameof(Values));
OnPropertyChanged(nameof(Keys));
//整个容器重绘
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}

return removed;
}

public void Clear()
{
_innerDictionary.Clear();
// 使用无参版本触发Reset
OnCollectionChanged(NotifyCollectionChangedAction.Reset);
OnPropertyChanged(nameof(Values));
OnPropertyChanged(nameof(Keys));
}

// 其他接口实现
public bool ContainsKey(TKey key) => _innerDictionary.ContainsKey(key);
public bool TryGetValue(TKey key, out TValue value) => _innerDictionary.TryGetValue(key, out value);
public int Count => _innerDictionary.Count;

/// <summary>
/// 尝试添加键值对,如果键不存在则添加并返回true,否则返回false
/// </summary>
/// <param name="key">要添加的键</param>
/// <param name="value">要添加的值</param>
/// <returns>如果键值对被成功添加则返回true,如果键已存在则返回false</returns>
public bool TryAdd(TKey key, TValue value)
{
if (_innerDictionary.ContainsKey(key))
return false;

Add(key, value);
return true;
}

public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => _innerDictionary.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

// 显式接口实现
void ICollection<KeyValuePair<TKey, TValue>>.Add(KeyValuePair<TKey, TValue> item) => Add(item.Key, item.Value);
bool ICollection<KeyValuePair<TKey, TValue>>.Contains(KeyValuePair<TKey, TValue> item) => ((ICollection<KeyValuePair<TKey, TValue>>)_innerDictionary).Contains(item);
void ICollection<KeyValuePair<TKey, TValue>>.CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex) => ((ICollection<KeyValuePair<TKey, TValue>>)_innerDictionary).CopyTo(array, arrayIndex);
bool ICollection<KeyValuePair<TKey, TValue>>.Remove(KeyValuePair<TKey, TValue> item) => Remove(item.Key);
bool ICollection<KeyValuePair<TKey, TValue>>.IsReadOnly => false;
}
}

3D曲面图绘制方案

需求 推荐方案
WinForm快速集成 [SharpGL定制控件](#SharpGL + WinForm)
WPF项目 & 交互需求 [Helix Toolkit](#Helix Toolkit)
科学计算/大规模数据 ActiViz.NET (VTK)
底层控制/跨平台 OpenTK
WPF原生 Viewport3D(无需依赖库)

SharpGL和Helix Toolkit更适合通用图表,VTK擅长科研级可视化;若需文字标签清晰,优先选SharpGL的文字缓存方案

如果想通过2d图实现,可以使用热力图来实现,效果也不错

参考付费方案的话,有SciChart

SharpGL + WinForm

  • 核心特点:
    • 基于SharpGL库(OpenGL的C#封装),提供高效的GPU加速渲染
    • 支持自定义曲面绘制逻辑(如顶点、光照、纹理),适合复杂3D曲面
    • 开源社区提供了文字缓存技术,优化3D场景中的文字显示清晰度
  • 适用场景:WinForm平台的高性能曲面图(如科学计算、金融分析)

开源地址SharpGL GitHub | WinForm定制控件参考

集成示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 初始化SharpGL渲染环境
private void openGLControl_OpenGLInitialized(object sender, EventArgs e) {
OpenGL gl = openGLControl.OpenGL;
gl.Enable(OpenGL.GL_DEPTH_TEST); // 启用深度测试
}
private void openGLControl_OpenGLDraw(object sender, RenderEventArgs e) {
OpenGL gl = openGLControl.OpenGL;
gl.Clear(OpenGL.GL_COLOR_BUFFER_BIT | OpenGL.GL_DEPTH_BUFFER_BIT);
gl.Begin(OpenGL.GL_TRIANGLES);
// 添加顶点数据(例如曲面点集)
gl.Vertex(x, y, z);
gl.End();
}

Helix Toolkit

  • 核心特点:
    • 专为WPF设计的3D库,支持曲面图、点云、模型交互
    • 内置SurfacePlot3D控件,可直接绘制参数方程曲面(如Z=f(X,Y))
    • 支持鼠标拖拽旋转、缩放,交互体验优秀。
  • 适用场景:WPF平台的快速开发(教育、工业可视化)
  • 集成步骤:
    1. NuGet安装HelixToolkit.Wpf
    2. XAML中添加命名空间:xmlns:hx="http://helix-toolkit.org/wpf"
    3. 使用HelixViewport3D容器和SurfacePlot3D对象。
  • 开源地址Helix Toolkit GitHub

其中包含两个nuget包: HelixToolkit.Wpf 与 HelixToolkit.Wpf.SharpDX + SharpDX(也需要安装这个nuget包)

  • WPF 版适合简单3D、教学、轻量场景,XAML兼容性好,但功能有限
  • SharpDX 版适合高性能、复杂3D、科学可视化、工业应用,支持顶点色和高级特性

对于.net 6.0,SharpDX有问题,实测只能使用WPF版

ActiViz.NET(VTK的C#封装)

  • 核心特点:

    • 基于VTK(Visualization Toolkit),支持大规模数据的高质量渲染(如医学成像)
    • 提供曲面滤波、平滑、着色等高级处理能
  • 适用场景:复杂科学计算曲面(如流体动力学、地质建模)

  • 集成示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    using Kitware.VTK;
    vtkParametricFunctionSource surfaceSource = vtkParametricFunctionSource.New();
    vtkParametricBoy boyFunction = vtkParametricBoy.New();
    surfaceSource.SetParametricFunction(boyFunction);
    vtkPolyDataMapper mapper = vtkPolyDataMapper.New();
    mapper.SetInputConnection(surfaceSource.GetOutputPort());
    vtkActor actor = vtkActor.New();
    actor.SetMapper(mapper);
    // 添加到渲染窗口(完整流程见VTK文档)
  • 安装:通过NuGet安装ActiViz.NET(x64平台)

  • 开源地址VTK GitHub | ActiViz文档

OpenTK(轻量级选择)

  • 核心特点:
    • 跨平台OpenGL/OpenCL封装,适合底层图形控制
    • 可结合OpenTK.GLControl在WinForm中嵌入3D视图。
  • 适用场景:需精细控制渲染管线或跨平台需求(如游戏、仿真)
  • 代码示例
1
2
3
4
5
6
7
8
9
10
using OpenTK.Graphics.OpenGL;
protected override void OnPaint(PaintEventArgs e) {
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
GL.Begin(PrimitiveType.Points);
for (float x = -1; x <= 1; x += 0.1f)
for (float y = -1; y <= 1; y += 0.1f)
GL.Vertex3(x, y, Math.Sin(x * y)); // 曲面函数示例
GL.End();
glControl.SwapBuffers();
}

开源地址OpenTK GitHub

Viewport3D

通过手动构建网格几何体(MeshGeometry3D)实现曲面,适合轻量级需求

适用场景:

  • 简单曲面(顶点数<1000)
  • 需完全控制顶点生成逻辑(如自定义部分算法)

实现步骤:

  1. 创建曲面网格

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var mesh = new MeshGeometry3D();
    for (double u = 0; u <= 1; u += 0.05) {
    for (double v = 0; v <= 1; v += 0.05) {
    double x = u * 10 - 5;
    double y = v * 10 - 5;
    double z = Math.Sin(u * v * 10);
    mesh.Positions.Add(new Point3D(x, y, z));
    }
    }
    // 构建三角形索引(略,需三角剖分)
  2. 材质与着色

    使用渐变画刷按高度着色

    1
    2
    3
    var material = new DiffuseMaterial(
    new LinearGradientBrush(Colors.Blue, Colors.Red, 90)
    );
  3. 场景组装

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <Viewport3D>
    <ModelVisual3D>
    <ModelVisual3D.Content>
    <Model3DGroup>
    <DirectionalLight Direction="-1,-1,-1"/>
    <GeometryModel3D Geometry="{StaticResource SurfaceMesh}"
    Material="{StaticResource HeightMaterial}"/>
    </Model3DGroup>
    </ModelVisual3D.Content>
    </ModelVisual3D>
    </Viewport3D>