📜 ⬆️ ⬇️

Introduction to Gjallarhorn.Bindable.WPF (F #) on the example of the test

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 return
None / 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.


Reading and Writing XML


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 to
System.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 as
match 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.


User interface


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.


Gjallarhorn.Bindable


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)



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