WPF but F#
上一篇,写了一个F#的WPF,所有的东西都是随手写出来的,没有经过详细考虑和设计。就是吧,一点也不优雅……咋们虽然头发不多,但是优雅永不过时!
实际上能用的开源UI库(F#,WPF)有两个:
- Avalonia.FuncUI
- Elmish.WPF
本文旨在学习F#,因此不使用任何第三方库,只用F#和WPF,来实现一个优雅的WPF程序。
优雅的F#
前面写了一大堆F#的帖子,记录了我慢慢学习的过程。到底F#哪里优雅?目前我学到的地方有以下几个,排序完全是个人爱好,不涉及任何学术和技术因素。
- 模式识别,简直是太优雅了,配合类型系统、可分解联合类型、匿名函数、管道操作符,简直是太优雅了!
- 管道操作,就是那一串集合类型的操作,大概跟LINQ类似的,但是F#提供的方式更加顺眼。
- 面向对象的一堆东西,搞来搞去都是type,包括扩散、重载、属性,语法和语义都挺好的,真是excited!
其他我还没感觉到。
那么按照F#的技术特征和上一篇帖子中的大量重复代码,我也来优雅一下。
目标
这一次,我们要把上一篇的计算玩具升级一下。增加两个部分:
- 右下角增加一个Slider,同步计算器的数值;
- 左下角增加一个TextBox,用来输入前缀,同步窗口标题。
当然,核心的数据还是计算的值。
优雅一下
从main函数开始
接下来,我要开始装……优雅啦!到底多优雅呢?我希望入口函数只有如下的代码。初始化Application,用属性初始化ViewModel,用属性初始化Window,用属性初始化Window的Title。
[<STAThread>]
[<EntryPoint>]
let main _ =
let app = Application()
let FakeViewModel = MyDataViewModel(Count = 20, Prefix = "你好")
let window =
Window(
Width = 480,
Height = 300,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = mainContent FakeViewModel,
TitleBinding = (FakeViewModel, "Title")
)
app.Run(window) |> ignore
0
这里面有一个需要注意的就是View直接放在Window的Content属性里面。然后ViewModel作为参数传递给mainContent函数。
这里一个特别优雅的就是那行代码:
TitleBinding = (FakeViewModel, "Title")
这里面有几个技术支持我们这样做:
- F#(或者说.NET)支持在创建对象是直接设置对象的属性,只能是属性,所以看起来没有重载那么复杂的构造函数,但是用出了一种非常优雅的构造方式。
- 对这个属性,
TtitleBinding
不是WPF提供的,是利用F#的特殊语法进行的类扩展属性。 - 属性的设定采用了元组来实现,也特别的优雅。
那我们就先来搞一下这个TitleBinding
属性。
优雅的属性扩展
直接先看扩展属性的代码:
type Window with
member this.TitleBinding
with set (value: obj * string) =
match value with
| obj, prop ->
this.SetBinding(
Window.TitleProperty,
Binding(Source = obj, Path = PropertyPath(prop), Mode = BindingMode.OneWay)
)
|> ignore
首先是语法,F#中type ... with
就是扩展类。代码中定义了一个属性TitleBinding
,这个属性是只写的,其类型是obj * string
,也就是一个元组,第一个元素是一个对象,第二个元素是一个字符串。
只写属性采用with set
定义,这里set就是方法名,从set开始就是一个普通函数的样子,参数列表,=,函数体。
这个函数中,通过match xxx with
把元组的元素提取出来,其实可以采用let obj, prop = value
,不过我match
上瘾了……客观地说,这里用let更好,不过我就是喜欢match
。
最后就是把这个元组的第一个元素作为对象,第二个元素作为属性名,然后调用SetBinding
方法,把这个属性绑定到Window.TitleProperty
上。
ViewModel
这里不再讲细节,这个部分上一个帖子作为核心内容讲了。
module viewmodel =
type MyDataViewModel() =
inherit ViewModelBase()
let mutable prefix = "Hello"
let mutable count = 0
member this.Text = $"计数: %4d{this.Count}"
member this.Title = $"{this.Prefix} - {this.Text}"
member this.Prefix
with get () = prefix
and set value =
prefix <- value
this.OnPropertyChanged(<@ this.Prefix @>)
this.OnPropertyChanged(<@ this.Title @>)
member this.Count
with get () = count
and set value =
count <- value
this.OnPropertyChanged(<@ this.Text @>)
this.OnPropertyChanged(<@ this.Title @>)
this.OnPropertyChanged(<@ this.Count @>)
member this.Increment() = this.Count <- this.Count + 1
只是优雅地定义几个可以用来绑的东西,在优雅地定义了属性之间的依赖关系。
优雅地布局
主函数就这么点内容,下面就是View。这里通过一个函数来定义View,这个函数的参数就是ViewModel,这样就可以在View中使用ViewModel的属性做各种绑定(单向/双向)和操作。
我们先从最优雅的地方来说起,我们希望用Grid来布局。但是Grid布局的调用方式是这样的:
// define grid layout
Grid()
|> Grid.rows [ 1.0; 0.0; 0.0 ]
|> Grid.columns [ 1.0; 1.0 ]
|> Grid.place 0 0 1 2 vb
|> Grid.place 1 0 1 1 button
|> Grid.place 1 1 1 1 button2
|> Grid.place 2 0 1 1 input
|> Grid.place 2 1 1 1 count
这算是优雅了吧。借鉴了集合类的接口设计方法,把Grid对象作为各个函数的最后一个参数,把设置行、列、添加控件三个功能都定义为Grid的扩展静态函数。
调用过程可以用管道操作符来连接,这样就可以把Grid的各个功能调用串联起来,看起来就像是在定义Grid的布局。
同样的,如果需要设置Grid的其他属性,可以Grid构造函数中增加属性设置。
Grid(
ShowGridLines = true,
)
|> Grid.rows 2
|> Grid.columns 2
这个几个扩展函数的实现也很直观,依然是type Grid with
语法。
type Grid with
/// <summary>
/// Extension methods for the <see cref="Gird"/> class.
/// To place a child on row,col with rowspan and colspan, use:
///
/// <code>Grid.place row col rowspan colspan child g</code>
///
/// Also pipes are supported for add multiple children:
///
/// <code>
/// g
/// |> Grid.place row col rowspan colspan child1
/// |> Grid.place row col rowspan colspan child2
/// </code>
///
/// No exception is handled in this function.
/// </summary>
static member place row col rowspan colspan (child: UIElement) (g: Grid) =
Grid.SetRow(child, row)
Grid.SetColumn(child, col)
Grid.SetRowSpan(child, rowspan)
Grid.SetColumnSpan(child, colspan)
g.Children.Add(child) |> ignore
g
static member rows (rowDefinitions: obj) (g: Grid) =
match rowDefinitions with
| :? int as n ->
[ 1..n ]
|> List.iter (fun _ -> g.RowDefinitions.Add(RowDefinition(Height = GridLength.Auto)))
| :? (float list) as heights ->
heights
|> List.map (fun h ->
match h with
| x when x > 0.0 -> GridLength(x, GridUnitType.Star)
| _ -> GridLength.Auto)
|> List.iter (fun h -> g.RowDefinitions.Add(RowDefinition(Height = h)))
| :? (GridLength list) as heights ->
heights
|> List.iter (fun h -> g.RowDefinitions.Add(RowDefinition(Height = h)))
| _ -> ()
g
static member columns (colDefinitions: obj) (g: Grid) =
match colDefinitions with
| :? int as n ->
[ 1..n ]
|> List.iter (fun _ -> g.ColumnDefinitions.Add(ColumnDefinition(Width = GridLength.Auto)))
| :? (float list) as widths ->
widths
|> List.map (fun w ->
match w with
| x when x > 0.0 -> GridLength(w, GridUnitType.Star)
| _ -> GridLength.Auto)
|> List.iter (fun w -> g.ColumnDefinitions.Add(ColumnDefinition(Width = w)))
| :? (GridLength list) as widths ->
widths
|> List.iter (fun h -> g.ColumnDefinitions.Add(ColumnDefinition(Width = h)))
| _ -> ()
g
唯一值得注意的是rows和columns的定义可以用多种不同的方式,直接用整数表明数量,用浮点数表明比例,用GridLength表明具体的长度。实现的方式是在match obj with
中用:?
进行类型判断,然后分别处理。
我用match
我骄傲!
优雅的控件
那些放在Grid中的控件呢?在F#是不是也有一些很优雅的方式来定义呢?答案是肯定的。这个程序中,仅仅中了TextBlock、Viewbox、Button、TextBox、Slider这几个控件,我们来看看这几个控件的定义。
let textBlock =
TextBlock(
Foreground = Brushes.Lime,
Background = Brushes.Bisque,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextBinding = (datasource, "Text")
)
let vb =
Viewbox(
Margin = Thickness(5.0),
Stretch = Stretch.Fill,
StretchDirection = StretchDirection.Both,
Child = textBlock
)
// add a button to grid
let button =
Button(
Content = "加一",
FontSize = 32.0,
FontWeight = FontWeights.Bold,
Margin = Thickness(5.0),
OnClick = (fun _ -> datasource.Increment())
)
// add a button to grid
let button2 =
Button(
Content = "清零",
FontSize = 32.0,
FontWeight = FontWeights.Bold,
Margin = Thickness(5.0),
OnClick = (fun _ -> datasource.Count <- 0)
)
let input =
TextBox(FontSize = 32.0, TextBinding = (datasource, "Prefix"), Margin = Thickness(5.0))
let count =
Slider(
Minimum = 0,
Maximum = 100,
Value = 0,
ValueBinding = (datasource, "Count"),
Margin = Thickness(5.0),
TickPlacement = TickPlacement.BottomRight,
TickFrequency = 10
)
这些控件的属性定义全部写在一起,看起来就像是调用了一个很复杂的构造函数。其实实现的方式跟上面一样,都是属性。其中有两个属性是特别优雅的:
- 绑定的属性,比如
TextBinding
,这个属性的类型是一个元组,第一个元素是一个对象,第二个元素是一个字符串,这个字符串是对象的属性名。这样就可以在构造控件的时候,直接把数据源和属性名传递进去,然后在构造函数中进行绑定。 - Button的Action,这里定义了OnClick属性。
这个也很简单,OnClick需要注册一个函数,非常简单的实现。
type Button with
member this.OnClick
with set func = this.Click.Add(fun _ -> func ())
另外一个需要注意的就是TextBox和Slider这两个可以作为输入的控件,那么这里的绑定就需要实现双向数据传递。
type TextBox with
member this.TextBinding
with set (value: obj * string) =
match value with
| obj, prop ->
this.SetBinding(
TextBox.TextProperty,
Binding(
Source = obj,
Path = PropertyPath(prop),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
)
)
|> ignore
type Slider with
member this.ValueBinding
with set (value: obj * string) =
match value with
| obj, number ->
this.SetBinding(
Slider.ValueProperty,
Binding(
Source = obj,
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
Path = PropertyPath(number)
)
)
|> ignore
唯一需要注意的就是必须定义UpdateSourceTigger
属性,否则默认是在控件失去焦点的时候才会更新数据源,这样就会导致数据源的值和控件的值不一致。
完整代码
整个程序的源代码比较长,ico文件也不好传,就做了一个git的仓库,有兴趣的可以看看。
fsharp.wpf代码仓库
用命令就可以下载。
git clone https://gitcode.net/withstand/fsharp.wpf.git
然后用VS打开,或者 dotnet run
。
总结
- F#可以用来做很多事情,但是UI总是一个让初学者激动的主题,实际上目前F#也有几个能用的UI库,比如Avalonia.FuncUI, Elmish.WPF,但是这里为了更好地学习F#和WPF,用编码的方式则更加有价值。
- F#提供的语言工具,类扩展、match、管道操作符、元组、属性等等,都可以用来实现一些非常优雅的代码,这些代码可以让我们的程序更加简洁、易读、易维护。
- 我自己也是现学现卖,感觉还挺开心的!