F#奇妙游(14):F#实现WPF的绑定
WPF中的绑定
绑定在UI开发中是一个非常重要的概念,它可以让我们的UI界面和数据模型之间建立起联系,当数据模型发生变化时,UI界面也会随之变化,反之亦然。这样的好处是显而易见的,我们不需要手动去更新UI界面,而是让数据模型自己去更新UI界面,这样的代码更加简洁,更加易于维护。
在传统的UI开发中,我们需要手动去更新UI界面,这样的代码往往是重复的,而且容易出错。具体的方法是通过输入控件的时间来更新数据模型,然后再通过数据模型来更新UI界面。
在.NET平台的WinForm中,在Java的Swing中,以及传统的C++的MFC中,都是通过这种方式来更新UI界面的。
时间上比较近的UI框架,例如JavaFX、WPF、UWP等,都是通过数据绑定来更新UI界面的。
WPF中的绑定
WPF中的绑定是通过Binding类来实现的,Binding类的构造函数接受一个字符串参数,这个字符串参数是一个路径,它指定了数据模型中的一个属性,这个属性的值会被绑定到UI界面上。
所以绑定必然包含几个要素:
- source,提供数据的对象
- target,使用数据的对象
- path,数据的路径
根据不同的绑定需求,Binding类的构造函数还接受一个可选的参数,这个参数是一个BindingMode枚举值,它指定了绑定的模式,BindingMode枚举有以下几个值:
- OneWay:单向绑定,数据模型的属性的值会被绑定到UI界面上,但是UI界面的值不会被绑定到数据模型上。
- TwoWay:双向绑定,数据模型的属性的值会被绑定到UI界面上,UI界面的值也会被绑定到数据模型上。
- OneWayToSource:单向绑定,UI界面的值会被绑定到数据模型上,但是数据模型的属性的值不会被绑定到UI界面上。
- OneTime:单次绑定,数据模型的属性的值会被绑定到UI界面上,但是这个绑定只会发生一次,之后数据模型的属性的值的变化不会被绑定到UI界面上。
- Default:默认绑定,这个值会根据绑定的目标来确定,如果绑定的目标是UI界面,那么就是OneWay,如果绑定的目标是数据模型,那么就是OneWayToSource。
绑定源的实现
在实现绑定的底层中,实际上还是通过事件来完成的。当数据模型的属性的值发生变化时,会触发一个事件,这个事件会被绑定目标监听到,然后绑定目标就会更新UI界面。
在.NET平台中,这个事件是PropertyChanged事件,它是INotifyPropertyChanged接口的一个事件,这个接口定义了一个PropertyChanged事件,这个事件的参数是一个PropertyChangedEventArgs对象,这个对象包含了一个PropertyName属性,这个属性指定了发生变化的属性的名称。
因此,要实现一个源,就需要实现INotifyPropertyChanged接口,然后在属性的setter中触发PropertyChanged事件。
绑定目标的实现
在.NET平台中,绑定目标是一个依赖属性,它是DependencyObject类的一个属性,这个类定义了一个SetValue方法和一个GetValue方法,这两个方法分别用于设置属性的值和获取属性的值。
在WPF中,依赖属性的名称是以“Property”结尾的,例如TextBlock类的Text属性,它的依赖属性的名称是TextProperty。
F#奇妙游
理论的知识就是上面那些,下面我们用F#实现一个WPF应用程序,这个程序就是点击计数(真是无聊啊!)。
创建项目
首先,我们创建一个F#的WPF应用程序,这个应用程序的名称是FSharpWpfApp1。
dotnet new console -lang F# -o FSharpWpfApp1
把对应的工程文件打开,改成大概是下面的样子。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<UseWpf>true</UseWpf>
<ApplicationIcon>fsharp.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
<Content Include="fsharp.ico" />
</ItemGroup>
</Project>
数据模型
然后,我们创建一个数据模型,这个数据模型包含一个整数属性,这个整数属性的值会被绑定到UI界面上。
type Model() =
let mutable count = 0
member this.Count
with get() = count
and set(value) = count <- value
这个模型要改成是为了支持绑定的,所以我们需要实现INotifyPropertyChanged接口。那么我们定义一个ViewModel的基类。这个基类首先定义一个propertyChanged的事件,这个在C#中是一个委托,但是在F#中是一个Event。
接下来是一点点F#魔法,就是把一个表达式转换成一个字符串,这个字符串就是属性的名称。关于表达式的内容,我还在学……
接下来是实现接口的语法,interface XXXX with XXXXX
,这个语法是F#中的接口实现语法,这个语法的意思是,实现XXXX接口,然后XXXXX是接口中函数的实现。这里就把Event的Publish函数设定为对应PropertyChanged事件的触发函数。
type ViewModelBase() =
let propertyChanged = Event<_, _>()
let toPropName (query: Expr) =
match query with
| PropertyGet (_, b, _) -> b.Name
| _ -> ""
interface INotifyPropertyChanged with
[<CLIEvent>]
member this.PropertyChanged = propertyChanged.Publish
abstract member OnPropertyChanged: string -> unit
default this.OnPropertyChanged(propertyName: string) =
propertyChanged.Trigger(this, PropertyChangedEventArgs(propertyName))
member this.OnPropertyChanged(expr: Expr) =
let propName = toPropName expr
this.OnPropertyChanged(propName)
实际的OnPropertyChanged函数有两个重载,一个是接受字符串参数的,一个是接受表达式参数的。这个表达式参数是用来获取属性名称的,这样就不需要输入属性名称,改为输入获取属性的表达式,下面可以看到例子。
实际的ViewModel类就是继承这个ViewModelBase类,然后实现OnPropertyChanged函数。
type MyData() =
inherit ViewModelBase()
let mutable count = 0
member x.Text = $"计数: %4d{count}"
member this.Count
with get () = count
and set value =
count <- value
this.OnPropertyChanged(<@ this.Text @>)
this.OnPropertyChanged("Count")
member this.Increment() = this.Count <- this.Count + 1
可以看到,这里的Count属性的setter中,我们调用了OnPropertyChanged函数。注意,我们这个Model实际上提供了两个可以绑定的属性,一个是Count,一个是Text,但是我们只在Count属性的setter中调用了OnPropertyChanged函数,这是因为Text属性的值是由Count属性的值计算出来的,所以当Count属性的值发生变化时,Text属性的值也会发生变化,所以我们只需要在Count属性的setter中调用OnPropertyChanged函数就可以了。
这里申明属性变化包含了两种方式,一种是直接传递字符串,一种是传递表达式。这两种方式都是可以的,但是传递表达式的方式更加安全,因为它可以在编译时检查属性名称是否正确。
UI界面
UI界面这里还是没有采用XAML的方法实现,而是通过直接编写代码。XAML我还没开始学。
这里的主界面采用Grid布局来实现,首先是行和列的定义,增加两行两列。注意这里对行的高度进行限定,第二行(序号1)的高度是自动,也就是按照空间的需求定义;第一行(序号0)的高度是“*”,也就是占满剩下的全部空间。
let mainContent =
let grid = Grid()
// define rows and columns for grid
grid.RowDefinitions.Add(RowDefinition(Height = GridLength(1.0, GridUnitType.Star)))
grid.RowDefinitions.Add(RowDefinition(Height = GridLength.Auto))
grid.ColumnDefinitions.Add(ColumnDefinition())
grid.ColumnDefinitions.Add(ColumnDefinition())
let textBlock =
TextBlock(
Foreground = Brushes.Lime,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
)
textBlock.SetBinding(
TextBlock.TextProperty,
Binding(Source = HelloText, Mode = BindingMode.OneWay, Path = PropertyPath("Text"))
)
|> ignore
let vb = Viewbox(Margin = Thickness(50.0))
vb.Child <- textBlock
Grid.SetRow(vb, 0)
Grid.SetColumn(vb, 0)
Grid.SetColumnSpan(vb, 2)
grid.Children.Add(vb) |> ignore
// add a button to grid
let button =
Button(Content = "加一", FontSize = 32.0, FontWeight = FontWeights.Bold, Margin = Thickness(10.0))
button.Click.Add(fun _ -> HelloText.Increment())
Grid.SetRow(button, 1)
Grid.SetColumn(button, 0)
grid.Children.Add(button) |> ignore
// add a button to grid
let button2 =
Button(Content = "清零", FontSize = 32.0, FontWeight = FontWeights.Bold, Margin = Thickness(10.0))
button2.Click.Add(fun _ -> HelloText.Count <- 0)
Grid.SetRow(button2, 1)
Grid.SetColumn(button2, 1)
grid.Children.Add(button2) |> ignore
grid
控件的定义很常规,属性可以作为构造函数的参数传递,也可以通过属性赋值的方式传递。唯一比较麻烦的是AttachedProperty的赋值,就是类似于Grid.Row
这几个,没办法通过构造函数的参数传递,只能通过Grid.SetRow
这样的函数来赋值。
这里的TextBlock控件的Text属性是通过绑定的方式传递的,这个绑定的源是HelloText,这个HelloText是我们在后面定义的数据模型。这里的绑定的方式是通过Binding类来实现的,Binding类的构造函数接受一个字符串参数,这个字符串参数是一个路径,它指定了数据模型中的一个属性,这个属性的值会被绑定到UI界面上。
主函数
[<STAThread>]
[<EntryPoint>]
let main _ =
let app = Application()
let window = Window()
window.Content <- mainContent
window.SetBinding(
Window.TitleProperty,
Binding(Source = HelloText, Mode = BindingMode.OneWay, Path = PropertyPath("Count"))
)
|> ignore
// move window to center of screen
window.WindowStartupLocation <- WindowStartupLocation.CenterScreen
// set window size
window.Width <- 400.0
window.Height <- 300.0
app.Run(window) |> ignore
0
主函数中把窗口的标题也定义了一个绑定,到HelloText的Count属性上。这样,当HelloText的Count属性的值发生变化时,窗口的标题也会发生变化。
这里隐式包含了一个转换,就是把整数转换成字符串,这个转换是通过ToString函数实现的,这个函数是Object类的一个方法,它可以把任意类型的对象转换成字符串。
全部代码
open System
open System.ComponentModel
open System.Windows
open System.Windows.Controls
open System.Windows.Data
open System.Windows.Media
open Microsoft.FSharp.Quotations
open Microsoft.FSharp.Quotations.Patterns
type ViewModelBase() =
let propertyChanged = Event<_, _>()
let toPropName (query: Expr) =
match query with
| PropertyGet (_, b, _) -> b.Name
| _ -> ""
interface INotifyPropertyChanged with
[<CLIEvent>]
member this.PropertyChanged = propertyChanged.Publish
abstract member OnPropertyChanged: string -> unit
default this.OnPropertyChanged(propertyName: string) =
propertyChanged.Trigger(this, PropertyChangedEventArgs(propertyName))
member this.OnPropertyChanged(expr: Expr) =
let propName = toPropName expr
this.OnPropertyChanged(propName)
type MyData() =
inherit ViewModelBase()
let mutable count = 0
member x.Text = $"计数: %4d{count}"
member this.Count
with get () = count
and set value =
count <- value
this.OnPropertyChanged(<@ this.Text @>)
this.OnPropertyChanged("Count")
member this.Increment() = this.Count <- this.Count + 1
let HelloText = MyData()
let mainContent =
let grid = Grid()
// define rows and columns for grid
grid.RowDefinitions.Add(RowDefinition(Height = GridLength(1.0, GridUnitType.Star)))
grid.RowDefinitions.Add(RowDefinition(Height = GridLength.Auto))
grid.ColumnDefinitions.Add(ColumnDefinition())
grid.ColumnDefinitions.Add(ColumnDefinition())
let textBlock =
TextBlock(
Foreground = Brushes.Lime,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
)
textBlock.SetBinding(
TextBlock.TextProperty,
Binding(Source = HelloText, Mode = BindingMode.OneWay, Path = PropertyPath("Text"))
)
|> ignore
let vb = Viewbox(Margin = Thickness(50.0))
vb.Child <- textBlock
Grid.SetRow(vb, 0)
Grid.SetColumn(vb, 0)
Grid.SetColumnSpan(vb, 2)
grid.Children.Add(vb) |> ignore
// add a button to grid
let button =
Button(Content = "加一", FontSize = 32.0, FontWeight = FontWeights.Bold, Margin = Thickness(10.0))
button.Click.Add(fun _ -> HelloText.Increment())
Grid.SetRow(button, 1)
Grid.SetColumn(button, 0)
grid.Children.Add(button) |> ignore
// add a button to grid
let button2 =
Button(Content = "清零", FontSize = 32.0, FontWeight = FontWeights.Bold, Margin = Thickness(10.0))
button2.Click.Add(fun _ -> HelloText.Count <- 0)
Grid.SetRow(button2, 1)
Grid.SetColumn(button2, 1)
grid.Children.Add(button2) |> ignore
grid
[<STAThread>]
[<EntryPoint>]
let main _ =
let app = Application()
let window = Window()
window.Content <- mainContent
window.SetBinding(
Window.TitleProperty,
Binding(Source = HelloText, Mode = BindingMode.OneWay, Path = PropertyPath("Count"))
)
|> ignore
// move window to center of screen
window.WindowStartupLocation <- WindowStartupLocation.CenterScreen
// set window size
window.Width <- 400.0
window.Height <- 300.0
app.Run(window) |> ignore
0
总结
- F#的WPF开发还是比较麻烦的,特别是跟XAML比,略显繁琐。
- 因为F#支持面向对象,所以还是能够比较容易就实现数据绑定。