WPF but F#

上一篇,写了一个F#的WPF,所有的东西都是随手写出来的,没有经过详细考虑和设计。就是吧,一点也不优雅……咋们虽然头发不多,但是优雅永不过时!

实际上能用的开源UI库(F#,WPF)有两个:

F#奇妙游(15):优雅的WPF程序-LMLPHP

F#奇妙游(15):优雅的WPF程序-LMLPHP

本文旨在学习F#,因此不使用任何第三方库,只用F#和WPF,来实现一个优雅的WPF程序。

优雅的F#

前面写了一大堆F#的帖子,记录了我慢慢学习的过程。到底F#哪里优雅?目前我学到的地方有以下几个,排序完全是个人爱好,不涉及任何学术和技术因素。

  1. 模式识别,简直是太优雅了,配合类型系统、可分解联合类型、匿名函数、管道操作符,简直是太优雅了!
  2. 管道操作,就是那一串集合类型的操作,大概跟LINQ类似的,但是F#提供的方式更加顺眼。
  3. 面向对象的一堆东西,搞来搞去都是type,包括扩散、重载、属性,语法和语义都挺好的,真是excited!

其他我还没感觉到。

那么按照F#的技术特征和上一篇帖子中的大量重复代码,我也来优雅一下。

目标

这一次,我们要把上一篇的计算玩具升级一下。增加两个部分:

  1. 右下角增加一个Slider,同步计算器的数值;
  2. 左下角增加一个TextBox,用来输入前缀,同步窗口标题。

当然,核心的数据还是计算的值。

F#奇妙游(15):优雅的WPF程序-LMLPHP

优雅一下

从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")

这里面有几个技术支持我们这样做:

  1. F#(或者说.NET)支持在创建对象是直接设置对象的属性,只能是属性,所以看起来没有重载那么复杂的构造函数,但是用出了一种非常优雅的构造方式。
  2. 对这个属性,TtitleBinding不是WPF提供的,是利用F#的特殊语法进行的类扩展属性。
  3. 属性的设定采用了元组来实现,也特别的优雅。

那我们就先来搞一下这个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
    )

这些控件的属性定义全部写在一起,看起来就像是调用了一个很复杂的构造函数。其实实现的方式跟上面一样,都是属性。其中有两个属性是特别优雅的:

  1. 绑定的属性,比如TextBinding,这个属性的类型是一个元组,第一个元素是一个对象,第二个元素是一个字符串,这个字符串是对象的属性名。这样就可以在构造控件的时候,直接把数据源和属性名传递进去,然后在构造函数中进行绑定。
  2. 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

总结

  1. F#可以用来做很多事情,但是UI总是一个让初学者激动的主题,实际上目前F#也有几个能用的UI库,比如Avalonia.FuncUI, Elmish.WPF,但是这里为了更好地学习F#和WPF,用编码的方式则更加有价值。
  2. F#提供的语言工具,类扩展、match、管道操作符、元组、属性等等,都可以用来实现一些非常优雅的代码,这些代码可以让我们的程序更加简洁、易读、易维护。
  3. 我自己也是现学现卖,感觉还挺开心的!
07-17 20:05