In articles in Russian, the topic of using F#
conjunction with WPF
paying little attention.
Today I will try to introduce you to one of the F#
libraries, which greatly simplifies this development.
As a demo, let's take one of the WPF
test tasks, which give applicants for the position of Junior-developer to test their knowledge.
The task itself sounds like this
It is necessary to develop an application using the data presented in the Students.xml file.
The specified file contains the following information about students: last name, first name, age, gender.
Of course, there are additional recommendations and restrictions on implementation, but we will not copy them entirely. The main parts will be given in the text if necessary, and the full version is available here.
To begin, create an empty console project for the .NET Framework in Visual Studio (or any other preferred IDE). If you do not want to see the debugging console, then you will need to change the type of output data in the project settings.
We start the work on our simple application from the main step - defining the main data types (those that are not dependent on the user interface).
Let's use F # - types which (for the time being) have no analogues in C # - records ( Record
) and marked associations ( Discriminated Union
).
type Gender = |Male |Female type Student = {FirstName : string; LastName : string; Age : int; Gender : Gender}
Here, perhaps, it is worth staying. There is one more thing on which the emphasis in the task was not made - the uniqueness of the record. In theory, there may be students who will have all the listed fields coincide.
But, if we look at the sample xml file
<?xml version="1.0" encoding="utf-8"?> <Students> <Student Id="0"> <FirstName>Robert</FirstName> <Last>Jarman</Last> <Age>21</Age> <Gender>0</Gender> </Student> ... </Students>
then we note that the ID is specified as an attribute, so we can simply add another field:
type Student = {ID:int; FirstName : string; LastName : string; Age : int; Gender : Gender; }
Such an announcement has one major drawback - having an ID does not guarantee the uniqueness of the record.
That is, in theory, you can add any number of records with the same identifier.
F # does not allow assignment of access modifiers for individual fields, but allows for types.
If we wanted to protect ourselves, we could put an explicit indication that we want to see the Student
type private:
type Student = private {ID:int; FirstName : string; LastName : string; Age : int; Gender : Gender; }
and write a function to create an object
let create firstname lastname age gender = let id = getNextId() { ID = id FirstName = firstname ... }
Consider the requirements for limiting the allowed field values:
fields with first name, last name and gender are required;
The age cannot be negative and must be in the range [16, 100].
A natural question immediately arises - where to check the correctness of the input parameters?
If the type Student
was protected, then it would be possible to write a function tryCreate
, which would returnNone
/ Error<string>
or Some<Student>
/ Ok<Student>
depending on the test result.
Result
useful if you need not only to signal that an attempt to issue a student was unsuccessful, but also to specifically indicate where the problem occurred.
Beyond the obvious implementation, we will not add the code of this function to the article.
Let's remember the approach described above, but we will take the responsibility of controlling the data to the link between View and Model.
Before moving on to the part responsible for the presentation, close the topic of the main features of the application.
- creating a new item and adding to the list;
- editing any entry in the list;
- delete one or more entries from the list.
With the creation have already figured out, add a few more features
// ; let add xs student = student :: xs // let remove students = List.filter (fun student -> Seq.contains student students |> not) // ; let editFirstName firstname student = { student with FirstName = firstname } let editLastName lastname student = { student with LastName = lastname } let editAge age student = { student with Age = age} let editGender gender student = { student with Gender = gender } let editId student id = {student with ID = id} let edit student = List.map (fun st -> if st.ID = student.ID then student else st)
and move on to the next step.
To work with data in F # there is an excellent mechanism called the type provider (sometimes it is also called the data provider (for) data, but due to the greater prevalence, we will use the first option in the future).
There are many implementations for convenient work with this or that format.
In this part we need only XmlProvider (from the library FSharp.Data
).
Add this package to the project:
Install-Package FSharp.Data
Note that the XmlProvider
type is used inside the XmlProvider
, so we still need a reference toSystem.Xml.Linq
.
open FSharp.Data let [<Literal>] Sample = """ <Students> <Student Id="0"> <FirstName>Robert</FirstName> <Last>Jarman</Last> <Age>21</Age> <Gender>0</Gender> </Student> <Student Id="2"> <FirstName>Leona</FirstName> <Last>Menders</Last> <Age>20</Age> <Gender>1</Gender> </Student> </Students>""" type Students = XmlProvider<Sample>
In the sample used, Id
specified as {0, 2}
and not {0, 1}
as in the file, so that the type is defined as an int
, not a bool
.
In general, complex logic may be required in order to convert types from the data source format to the types adopted in the application. However, since we have these data structures almost completely coincide, we will need only one additional function to establish the correspondence between the value of type bool
and the marked union.
let fromBool = function | true -> Female | false -> Male
Record function | true -> Female | false -> Male
function | true -> Female | false -> Male
function | true -> Female | false -> Male
means exactly the same asmatch x with
but only in a shorter form. This form is convenient to use for trivial comparison with the sample.
The next part also does not cause problems - everything is simple and clear.
let toCoreStudent (student:Students.Student) = student.Gender |> fromBool |> create student.Id student.FirstName student.Last student.Age let readFromFile (path : string) = Students.Load path |> fun x -> x.Students |> Seq.map toCoreStudent
But that's not all, you need to consider that the user can add data to the list, therefore you need to be able not only to extract data from a file, but also to write.
The code will be exactly the same, except that the conversion will go in a different direction.
let toBool = function | Male -> false | Female -> true let fromCoreStudent (student:Student) = Students.Student(student.ID, student.FirstName, student.LastName, student.Age, toBool student.Gender) let toXmlStudents data = data |> Seq.map fromCoreStudent |> Seq.toArray |> Students.Students let writeToFile (path : string) data = let students = data |> toXmlStudents students.XElement.Save path
We emphasize that everything that has so far been considered does not have a dependency on WPF and, with the possible transfer (for example, to a different type of interface), there will be no changes in this part.
Normally, it makes sense to bring such code into the class library, but since the functional part not related to a specific format (.xml) is too small, a separate project was not used to create a completely separate module.
Our goal is to write a project entirely in F #, so we’ll resort to the help of FsXAML
in the interface issue.
There is nothing wrong with writing a part in C #, but agree that this would not be so interesting.
FsXAML
is a type provider that allows us to conveniently use xaml
files. You can add it to the project via NuGet .
Install-Package FsXaml.Wpf
XamlReader
can read about the benefits of XamlReader
in a separate response to StackOverFlow (in English)
One of its drawbacks is the lack of documentation, so it’s not at once possible to find out that there are converters there, as well as a convenient wrapper for writing your own.
Here we just need a converter to correctly display the age and errors of validation.
type AgeToStringConverter() = inherit ConverterBase (fun value _ _ _ -> match value with | :? int -> value |> unbox |> AgeToStringConverter.ageToStr |> box | _ -> null ) static member ageToStr age = ...
where ConverterBase
is the base class from FsXAML
for creating converters.
Let's repeat the main requirements for the application, but now we will look at them from the point of view of appearance.
- display a list of existing elements;
- creating a new item and adding to the list;
- editing any entry in the list;
- delete one or more entries from the list.
In order to display a list of items it will be convenient to use ListView
.
In addition to the student table, the control buttons will also be located in the main window.
All together forms a UserControl
which represents the main “page” of the application.
No other pages are foreseen, so using navigation may seem like a redundant solution.
But for demonstration, simple examples are the best fit.
We will edit and add information about the student in the dialog box.
After creating xaml
files, you need to create types for them.
type App = XAML<"App.xaml"> type MainWin = XAML<"MainWindow.xaml"> type StudentsControl = XAML<"StudentsControl.xaml"> type StudentDialogBase = XAML<"StudentDialog.xaml"> type StudentDialog() = inherit StudentDialogBase() override this.CloseClick (_sender, _e) = this.Close()
Beginning with version FsXAML
, basic support for event handling has been added to FsXAML
. In the example above, the window is closed after clicking on the confirmation button.
To connect our model with the presentation we will use the new, but very promising, library Gjallarhorn.Bindable
Install-Package Gjallarhorn.Bindable.Wpf -Version 1.0.0-beta5
The latest available release , which is still in beta.
The basic concept is a peculiar transposition of the Elm
architecture with wpf
specificity over the main Gjallarhorn library. In addition to the wpf
version, there is also a package for XamarinForms
.
To create an application, it is convenient to use the application
function from the Framework
module:
Framework.application model update appComponent nav
which connects the individual parts (model, function to update it, component to communicate with the view and navigator)
model
- application model (top-level model) - basic data with which further work is to be done.update:('message -> 'model -> 'model)
function that processes the model ( model
) depending on the message received ( message
) and returns a new value.Based on the task, our application should be able to add, edit and delete items from the list.
For each action, it is convenient to create a separate message, represented as a named variant of the marked association.
type AppMessages = |Add of Student |Edit of Student |Remove of Student seq |Save
To the already listed features added another function to overwrite the file.
When adding a new entry to the list, you need to take into account the increment ( ID
) for the unique identifier.
To do this, you can write a helper function getId
, which returns the next sequence number after the maximum one in the list.
There are no other pitfalls in the update function, so as a result it takes the following form
let update message model = match message with |Add student -> model |> getId |> editId student |> add model |Edit newValue -> model |> edit newValue |Remove students -> model |> remove students |Save -> XmlWorker.writeToFile path model model
To determine the navigation states, we also use the tagged union.
type CollectionNav = | ViewStudents | AddStudent | EditStudent of Student
All, the navigation framework is ready and you can proceed to linking navigation messages with messages for the application.
Similar to updating the model, the status update is also implemented in the update
function.
let updateNavigation (_ : ApplicationCore<Student list,_,_>) request : UIFactory<Student list,_,_> = match request with |ViewStudents -> Navigation.Page.fromComponent StudentsControl id appComponent id |AddStudent -> Navigation.Page.dialog StudentDialog (fun _ -> defaultStudent) studentComponent Add |EditStudent x -> Navigation.Page.dialog StudentDialog (fun _ -> x) studentComponent Edit
It uses two functions provided by the library.
Navigation.Page.fromComponent
fromComponent : (makeElement : unit -> 'UIElement) -> (modelMapper : 'Model -> 'Submodel) -> (comp : IComponent<'Submodel, 'Nav, 'Submsg>) -> (msgMapper : 'Submsg -> 'Message) -> UIFactory<_,_,_>
and
Navigation.Page.dialog
dialog : (makeElement : unit -> 'Win) -> (modelMapper : 'Model -> 'Submodel) -> (comp : IComponent<'Submodel, 'Nav, 'Submsg>) -> (msgMapper : 'Submsg -> 'Message) = -> UIFactory<_,_,_>
Between themselves, they are very similar, so we will not give their descriptions separately.
The first argument is the function ( makeElement
), which sets the displayed element (window ( Window
) or control ( UIElement
)).
A constructor is essentially the same function, so in most cases it is enough for us to pass the desired type.
The second argument ( modelMapper
) is the conversion function from the top level model to the model level below.
In our case, with editing, we get the object of interest ( Student
) as a parameter, so we can simply pass it on. For adding, we pass the default value.
For the main state of ViewStudents
the main component model will be an application model, so no changes need be made and you can apply
standard F # id
function
Next comes the component ( comp
), which contains all the necessary bindings for interacting with the interface.
The appComponent
component is IComponent<Student list, CollectionNav, AppMessages>
, and the studentComponent
type is IComponent<Student, CollectionNav, Student>
respectively.
The last argument ( msgMapper
) is the inverse transform function for messages. The studentComponent
component will return the student, so here we can only send the correct message to the top.
You can proceed to the final part - the consideration of the components themselves.
The data binding in Gjallarhorn.Bindable.WPF
is responsible for the Bind
module, which in turn is divided into several submodules.
The main (root) API (comment has been added since the first version) is more secure, but sometimes more cumbersome and the second is explicit (functions from the Explicit
module).
Here, to show both approaches, Explicit
used to obtain information about the student and Implicit
for the main one.
Note that both components are independent of each other.
Let's start with the main appComponent
- appComponent
To use the new API, you need to declare an intermediate type, which should contain all the properties and commands that are set.
type AppViewModel = { Students : Student list Add : VmCmd<CollectionNav> Edit : VmCmd<CollectionNav> Remove : VmCmd<AppMessages> RemoveAll : VmCmd<AppMessages> Save : VmCmd<AppMessages> }
Commands are set using a special type of VmCmd
that simply stores the message.
This leads to the fact that the names for teams are obtained using quoting (sometimes called quoting).
Thus, we avoid the "magic lines", which reduces the risk of errors due to mismatched names caused by typos.
Before creating the component, we need to create a basic instance (the default value) of the VM
type
let appvd = { Students = [] Edit = Vm.cmd (CollectionNav.EditStudent defaultStudent) Add = Vm.cmd CollectionNav.AddStudent Remove = Vm.cmd (AppMessages.Remove [defaultStudent]) RemoveAll = Vm.cmd (AppMessages.Remove [defaultStudent]) Save = Vm.cmd AppMessages.Save }
First you need to take into account the blocking of some buttons, if the list is empty, therefore we define a function that contains information about the presence of elements:
let hasStudents = List.isEmpty >> not
(in principle, it was possible to use a data trigger ( DataTrigger
) as it was done to change the ListView
template).
Then create a component by passing the list of all bindings to the Component.create
function as shown below.
let appComponent = let hasStudents = List.isEmpty >> not Component.create<Student list, CollectionNav, AppMessages> [ <@ appvd.Students @> |> Bind.oneWay id <@ appvd.Edit @> |> Bind.cmdParam EditStudent |> Bind.toNav <@ appvd.Add @> |> Bind.cmd |> Bind.toNav <@ appvd.Save @> |> Bind.cmd <@ appvd.Remove @> |> Bind.cmdParamIf hasStudents (Seq.singleton >> Remove) <@ appvd.RemoveAll @> |> Bind.cmdParamIf hasStudents (Seq.cast >> Remove) ]
Bind.oneWay
designed to create a unidirectional binding.
Bind.cmd
, Bind.cmdParam
and Bind.cmdParamIf
create respectively a command, commands with a parameter and a command with an additional check for execution.
Let's pay attention to some moments - in order not to start two separate messages (for deleting one and several elements) the transferred object forms a sequence of unit length.
<@ appvd.Remove @> |> Bind.cmdParamIf hasStudents (Seq.singleton >> Remove)
Since SelectedItems
, unfortunately, is not a generalized collection, an additional transformation has to be applied here.
<@ appvd.RemoveAll @> |> Bind.cmdParamIf hasStudents (Seq.cast >> Remove)
Navigation messages are sent using Bind.toNav
.
It is worth noting here that instead you can use another approach that leaves the components "clean" (without the side effects of navigation).
Its essence is to make not only all changes in the update
functions, but also the change requests themselves.
In our case, they are requests to add and edit information about the student.
In other words, it is necessary to remove calls to Bind.toNav
in the component or direct dispatches through the dispatcher (if an explicit API is used).
Let's look at this method by example.
Add the AddRequest
and EditRequest
to the AppMessages
type which reflect the necessary requests:
type AppMessages = |Add of Student |Edit of Student |Remove of Student seq |Save |AddRequest |EditRequest of Student
then rewrite the AppViewModel
type so that the next part is responsible for adding and editing in the component
<@ appvd.Edit @> |> Bind.cmdParam EditRequest <@ appvd.Add @> |> Bind.cmd
Next, before the update
function, we create a dispatcher.
let disp = Dispatcher<CollectionNav>()
and using it in the function we send requests when receiving messages
|AddRequest -> AddStudent |> disp.Dispatch model |EditRequest st -> EditStudent st |> disp.Dispatch model
To connect the dispatcher (in this case responsible for managing the navigation), we use the Framework.withNavigation
function
let app = Framework.application model update appComponent nav.Navigate |> Framework.withNavigation disp
Yes, in this case the code takes up more space, but the component turns out to be “clean”.
Now we turn to studentComponent
, which we will not give here in full, leaving only the main parts
type StudentUpdate = |FirstName of string |LastName of string |Age of int |Gender of Gender let studentBind _ source model = let mstudent = model |> Signal.get |> Mutable.create [Female; Male] |> Signal.constant |> Bind.Explicit.oneWay source "Genders" let first = mstudent |> Signal.map (fun student -> student.FirstName) |> Bind.Explicit.twoWayValidated source "FirstName" (Validators.notNullOrWhitespace >> Validators.noSpaces) |> Observable.toMessage FirstName // let upd msg = match msg with | FirstName name -> Mutable.step (editFirstName name) mstudent | LastName name -> Mutable.step (editLastName name) mstudent | Age age -> Mutable.step (editAge age) mstudent | Gender gender -> Mutable.step (editGender gender) mstudent [last; age; gender] |> List.fold Observable.merge first |> Observable.subscribe upd |> source.AddDisposable [ Bind.Explicit.createCommandChecked "SaveCommand" source.Valid source |> Observable.map(fun _ -> mstudent.Value) ] let studentComponent : IComponent<_,CollectionNav,_> = Component.fromExplicit studentBind
Here, when linking data, validation is also carried out (validation) using the features of the Gjallarhorn
library.
To track the state of validity of parameters, the source.Valid
signal source.Valid
.
The Validators
module contains several support functions that can be easily combined with each other.
For example, we want the name field not to be an empty string or not to contain whitespace.
For this, both functions are simply compatible.
Validators.notNullOrWhitespace >> Validators.noSpaces
If standard functions are not enough, you can always write your own and add it to the chain of checks.
How to do this, as well as other details about validating data with Gjallarhorn
can be found in the documentation .
The function Validators.noValidation
useful in cases where no checks are needed.
As a result, the dialog box for adding a student will look like this:
Shown approach
mstudent |> Signal.map (fun student -> student.FirstName) |> Bind.Explicit.twoWayValidated source "FirstName" (Validators.notNullOrWhitespace >> Validators.noSpaces)
probably someone seems too verbose. — Bind.Explicit.memberToFromView
, .
, :
, . F# ;)
F# , ( ). F# Slack ( F# Software Foundation)
Reed Copsey ( F#), API .
, , F# .
See you again!
Source: https://habr.com/ru/post/350538/
All Articles