Articles MVVM Starter Kit (Part 3 of 3) by Erik van Bilsen

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
MVVM Starter Kit (Part 3 of 3)
January 26, 2018 Erik van Bilsen
[SHOWTOGROUPS=4,20]
In this 3rd and final part of the MVVM series we tie everything together by looking at the Views and how to bind controls and actions to their ViewModels. We show that by putting most UI logic in the ViewModel, we can easily create both an FMX and VCL version of the same application with almost no code duplication.
You may want to review Для просмотра ссылки Войди или Зарегистрируйся (where we introduce MVVM, data binding and Models) and Для просмотра ссылки Войди или Зарегистрируйся (where we focus on ViewModels and unit testing).
Views
When you start a new project in Delphi, you often start by designing a form. We take the opposite approach here and save the Views for last. And for a reason: thinking more in terms of Models and ViewModels makes it easier to avoid the pitfalls of putting (too much) code in the Views. Of course, in practice you will probably build out the Views and ViewModels at the same time, but it is good to take a step back and decide what code belongs in the View and what code should be part of the ViewModel.
Remember that one of the main reasons for using a pattern like MVVM is that it makes it easier to unit test UI and business logic. So ideally, you should have as little code in the Views as possible. In Delphi terms, this means your Views should only have code that manipulates VCL or FMX controls, or calls VCL or FMX APIs.
Mvvm-V

As a quick reminder: the View part is the only part of your application that can access VCL or FMX functionality. It uses the ViewModel through data binding and actions. The ViewModel contains the logic to operate the UI and uses the Model for its data and business logic.
We already talked extensively about data binding in Для просмотра ссылки Войди или Зарегистрируйся. However, we focused mostly about binding individual values. In the next section, we look at how you can bind collections of objects, and how you can bind TAction components to execute methods in the ViewModel.
Binding Collections
You have seen the following image before. It shows the data bindings of the tracks View (TViewTracks) and its ViewModel (TViewModelTracks):
Mvvm-TViewModelTracks

The Views in the sample applications won’t win any design awards (although they may qualify for an equivalent of the Golden Raspberry), but that is not the point here. They are purely for demonstrating a couple of ways to create Views and ViewModels. The designs are especially bad for mobile platforms. But the beauty of MVVM is that you can design completely different forms for a mobile experience while reusing your existing Models and ViewModels.
We briefly discussed that you can bind certain types of collections to list-like controls such as TListView.
For this to work, the collection must implement IgoNotifyCollectionChanged. You can implement this interface yourself, or you can use the TgoObservableCollection<T> class that implements it for you. This class is very similar to a generic list, but it sends notifications whenever an item is added, deleted or modified, or when the collection is cleared or rearranged somehow. The list-like controls use these notifications to update their view state.
In the MyTunes app, the tracks are stored in a TgoObservableCollection<TAlbumTrack>, and thus can be bound to a list-like control:
1
2
3
4
5
6
7
procedure TViewTracks.SetupView;
begin
...
{ Bind collections }
Binder.BindCollection<TAlbumTrack>(ViewModel.Tracks,
ListViewTracks, TTemplateTrack);
end;
It uses the TgoDataBinder.BindCollection<T> method to create the binding. It expects 3 arguments:
  • The source collection to bind. The source must derive from TEnumerable<T>, which many collections such as TList<T> do. You generally want to provide a collection that also implements the IgoNotifyCollectionChanged interface (such as TgoObservableCollection<T> described above).
  • The target View to bind to. This is a class that implements the IgoCollectionViewProvider interface. We haven’t discussed this interface since you generally don’t need to implement it yourself. It is implemented in controls like TListBox and TListView.
  • A template class that defines the mapping between properties of each item in the collection and the corresponding item in the View. We’ll introduce this class next.
Data Templates
When populating a list view, you must decide what data you want to show for each item in the list. In both VCL and FMX list views, each item usually has a caption and image index (in case an image list is used). In FMX list views, you often also provide a string with details. In the screenshot above, you can see that the caption of each list view item is set to the title of the track, and the details are set to the duration of the track. We don’t use image indices in this example.
To create this mapping, you derive a class from TgoDataTemplate, and pass that class as the last argument to the TgoDataBinder.BindCollection<T> method. Like the value converters discussed in part 1, this class only contains virtual class methods that you can override. You don’t have to instantiate the class.
You must always override the GetTitle method and can optionally override the GetDetail and GetImageIndex methods. All methods have a plain TObject parameter that you must typecast to the type of objects in your collection.
This is easier shown with an example. The TTemplateTrack class overrides the GetTitle and GetDetail methods to map the track name to the title and the track duration to the details:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type
TTemplateTrack = class(TgoDataTemplate)
public
class function GetTitle(const AItem: TObject): String; override;
class function GetDetail(const AItem: TObject): String; override;
end;

class function TTemplateTrack.GetTitle(const AItem: TObject): String;
begin
Assert(AItem is TAlbumTrack);
Result := TAlbumTrack(AItem).Name;
end;

class function TTemplateTrack.GetDetail(const AItem: TObject): String;
var
Track: TAlbumTrack absolute AItem;
begin
Assert(AItem is TAlbumTrack);
Result := Format('%d:%.2d', [Track.Duration.Minutes,
Track.Duration.Seconds]);
end;
Binding Actions
As you know by now, most UI logic is contained in the ViewModel. In the Для просмотра ссылки Войди или Зарегистрируйся, we talked about action methods and predicates. The View calls these methods to perform the UI logic. It can do this directly, for example in response to a button press. However, you probably use a TActionList list and TAction‘s to organize the actions a user can take.
If that is the case, then you can use the data binder to bind these actions to the action methods and predicates in the ViewModel. You can either use TgoDataBinder.BindAction or TAction.Bind. Both do the same thing.
In the tracks View, we bind two actions:
1
2
3
4
5
6
7
procedure TViewTracks.SetupView;
begin
...
{ Bind actions }
ActionAddTrack.Bind(ViewModel.AddTrack);
ActionDeleteTrack.Bind(Self.DeleteTrack, ViewModel.HasSelectedTrack);
end;
The ActionAddTrack action is bound to the AddTrack method of the ViewModel. Similarly, the ActionDeleteTrack action is bound to a DeleteTrack method. However, this method is implemented in the View instead of the ViewModel (which I will explain in a bit). You can also see that there is an optional second parameter to the Bind method. This is a predicate that is used to determine if the action should be enabled or disabled. In the MyTunes app, it makes sense to only enable the Delete action if there is a track selected. The HasSelectedTrack predicate is implemented in the ViewModel.
UI Logic in the View
As a small side track: the reason that the DeleteTrack method is implemented in the View is that we want to ask the user for a confirmation before deleting the track. This confirmation requires a VCL or FMX dialog box, so it cannot be part of the ViewModel. The following code is FMX specific:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
procedure TViewTracks.DeleteTrack;
begin
Assert(Assigned(ViewModel.SelectedTrack));
TDialogService.MessageDialog(
Format('Are you sure you want to delete track "%s"?',
[ViewModel.SelectedTrack.Name]),
TMsgDlgType.mtConfirmation, [TMsgDlgBtn.mbYes, TMsgDlgBtn.mbNo],
TMsgDlgBtn.mbNo, 0,
procedure(const AResult: TModalResult)
begin
if (AResult = mrYes) then
ViewModel.DeleteTrack;
end);
end;
In the end, it calls the DeleteTrack method of the ViewModel.
However, with MVVM we want to move as much code as possible from the View to the ViewModel. That means that ideally you would want a version of MessageDialog that does not depend on the VCL or FMX. We could achieve this with the use of a procedural type (as we did for TgoBitmap in part 1). A better solution is probably to have something like a framework-independent TDialogService-like class that you can override for specific frameworks or for mocking and unit testing. I have not yet added something like this to the MVVM Starter Kit, but it would be a nice addition.

[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
MVVM Starter Kit (Part 3 of 3)
January 26, 2018 Erik van Bilsen
[SHOWTOGROUPS=4,20]
View Factory
As you may recall from the Для просмотра ссылки Войди или Зарегистрируйся, a ViewModel uses a View factory to create Views for a specific task. We showed the following TViewModelAlbum.EditTracks method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
procedure TViewModelAlbum.EditTracks;
var
Clone: TAlbumTracks;
ViewModel: TViewModelTracks;
View: IgoView;
begin
Clone := TAlbumTracks.Create;
try
Clone.Assign(Album.Tracks);
ViewModel := TViewModelTracks.Create(Clone);

{ The view becomes owner of the view model }
View := TgoViewFactory.CreateView('Tracks', nil, ViewModel);
View.ExecuteModal(
procedure (AModalResult: TModalResult)
begin
if (AModalResult = mrOk) then
Album.SetTracks(Clone);
Clone.DisposeOf;
end);
except
Clone.DisposeOf;
raise;
end;
end;
Here, the ViewModel uses TgoViewFactory to create a View with the identifier ‘Tracks’. For this to work, there must be a View that is registered with the factory using this identifier.
A View here is any class that implements the IgoView interface. As we saw in the previous part, this does not have to be an actual VCL or FMX form. It can also be a mock view for the purpose of unit testing.
The Views in the MyTunes app register themselves with the factory in the initialization sections of their units. For example, the ‘Tracks’ View is registered at the end of the View.Tracks unit:
1
2
3
4
5
6
7
unit View.Tracks;
...

initialization
TgoViewFactory.Register(TViewTracks, 'Tracks');

end.
IgoView and TgoFormView
As said, all Views must implement the IgoView interface, and TViewTracks is no exception. Again, you could implement this interface yourself. But it is probably easier to derive your forms from the generic TgoFormView<TVM> class, which implements this interface for you. The type parameter TVM is the type of the ViewModel used by the View. For example, TViewTracks is declared as follows:
1
2
3
4
type
TViewTracks = class(TgoFormView<TViewModelTracks>)
...
end;
The usual steps for creating a new View are:
  • Create a new (FMX or VCL) form.
  • Add the unit Grijjy.Mvvm.Views.Fmx (or Grijjy.Mvvm.Views.Vcl) to the uses clause.
  • Change the parent class of the form from TForm to TgoFormView<X>, where X is the type of the corresponding ViewModel.
Using TgoFormView as a base class has a couple of advantages:
  • It implements the IgoView interface for you.
  • It declares a property called ViewModel for you that contains the ViewModel for the View.
  • It creates a TgoDataBinder object for you, which is available through the Binder property.
  • The FireMonkey version has a GrayOutPreviousForm property. When set to True (the default), then when a form is shown modally on desktop platforms, it will gray out the previously active form to make it clear its it not accessible. The following screen shot shows an example.
Mvvm-GrayOut

Setup Data Bindings
The TgoFormView<TVM> class has a virtual method called SetupView that you should override to setup your data bindings. This method is called automatically when the form is created and its ViewModel is assigned. The complete implementation for TViewTracks looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
procedure TViewTracks.SetupView;
begin
{ Bind properties }
Binder.Bind(ViewModel, 'SelectedTrack', ListViewTracks, 'SelectedItem');
Binder.Bind(ViewModel, 'SelectedTrack.Name', EditName, 'Text',
TgoBindDirection.TwoWay, [TgoBindFlag.TargetTracking]);
Binder.Bind(ViewModel, 'SelectedTrack.TrackNumber',
SpinBoxTrackNumber, 'Value');
Binder.Bind(ViewModel, 'SelectedTrackDurationMinutes',
SpinBoxDurationMinutes, 'Value');
Binder.Bind(ViewModel, 'SelectedTrackDurationSeconds',
SpinBoxDurationSeconds, 'Value');
Binder.Bind(ViewModel, 'SelectedTrack.Genres', MemoGenres, 'Text');
Binder.Bind(ViewModel, 'SelectedTrack', ListBoxDetails, 'Enabled',
TgoBindDirection.OneWay);

{ Bind collections }
Binder.BindCollection<TAlbumTrack>(ViewModel.Tracks,
ListViewTracks, TTemplateTrack);

{ Bind actions }
ActionAddTrack.Bind(ViewModel.AddTrack);
ActionDeleteTrack.Bind(Self.DeleteTrack, ViewModel.HasSelectedTrack);
end;
You should bind any properties, collections and actions in this method.
The process for the main form is a little bit different. Usually, when a form is created using a view factory, you create the ViewModel first and then create the View using the factory (refer back to the TViewModelAlbum.EditTracks code above). However, the main form is not created with a view factory. Instead, Delphi creates it for you at application startup. In that case, the form creates its own ViewModel. This is how the main form of the MyTunes app does this:
1
2
3
4
5
6
constructor TViewAlbums.Create(AOwner: TComponent);
begin
inherited;
...
InitView(TViewModelAlbums.Create, True);
end;
It creates a ViewModel inline and passes it to InitView, with the True argument indicating that the View becomes owner of the ViewModel. InitView will in turn call SetupView, which you override to setup the data bindings.
As you can see, you setup your data bindings in code. You could also create a data binding component that you can drop onto a form to create the bindings visually, as you would with Live Bindings or the Для просмотра ссылки Войди или Зарегистрируйся framework. Personally, I prefer creating the bindings in code since imho it gives you a better insight into what is going on.
Data Binding Aware Controls
There is still one big elephant in the room. As mentioned in the Для просмотра ссылки Войди или Зарегистрируйся, any object can be the target of a data binding. But to be the source of a data binding, it needs to implement the IgoNotifyPropertyChanged interface. This means that the visual controls also need to implement this interface, so that property changes can be propagated to any bound objects.
Of course, the FMX and VCL controls don’t implement this interface, so we need to create custom versions of these controls that do implement the interface. The usual way to do this is to create your own controls by deriving them from FMX or VCL controls, and implementing the IgoNotifyPropertyChanged interface. You would then put those controls into a design-time package and install that package into the IDE.
Unfortunately, it is not that simple for FMX controls. FireMonkey uses the actual name of the control classes as identifiers for the style system. This means that when you derive your own class and give it a different name, then the visual style of the control gets messed up at run-time. Although this can often be remedied by overriding the GetDefaultStyleLookupName method, there are other cases where the class name is used. For example, implementations of the IFMXDefaultPropertyValueService.GetDefaultPropertyValue method use the class name of a control to customize behavior (see TCocoaTouchMetricsServices.GetDefaultPropertyValue in the unit FMX.Platform.Metrics.iOS for an example).
Interposer Controls
So instead, we use a (dirty) trick by giving the derived control classes the exact same name as the original FMX or VCL classes. These are also called interposer classes. For example, our modified TSpinbox has the following declaration:
1
2
3
4
5
6
7
type
TSpinBox = class(FMX.SpinBox.TSpinBox, IgoNotifyPropertyChanged)
protected
{ IgoNotifyPropertyChanged }
function GetPropertyChangedEvent: IgoPropertyChangedEvent;
...
end;
We use the fully qualified name of the base class to avoid a name collision.
While being dirty, this trick has a couple of advantages:
  • You don’t have to create and install a separate package for the Delphi IDE.
  • You can use Delphi’s standard controls to layout your forms.
  • It is easier to add new controls without having to rebuild the package.
  • It is also easier to create your own versions of 3rd party controls this way.
However, this trick also has a disadvantage (besides being dirty): you must manually manage the uses clauses of your Views, as I’ll explain below.
In our MVVM Starter Kit framework, we have put all our interposer FMX controls inside the unit Grijjy.Mvvm.Controls.Fmx. We did the same for the VCL controls in the unit Grijjy.Mvvm.Controls.Vcl.
At Grijjy we mostly develop FMX apps, so I didn’t put much effort in the custom VCL controls.
However, since our interposer controls have the same names as the original controls, we must make sure that Delphi uses the correct version when running the application. You do this by ensuring that the Grijjy.Mvvm.Controls.Fmx (or Vcl) unit is listed after all other FMX (or VCL) units in the uses clause. For example, the uses clause of the Album View of the MyTunes app looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
unit View.Album;

interface

uses
System.SysUtils,
..
FMX.Types,
FMX.Controls,
...
FMX.Colors,
Grijjy.Mvvm.Controls.Fmx, // MUST be listed AFTER all other FMX.* units!
Grijjy.Mvvm.Views.Fmx,
ViewModel.Album;
If you don’t do this, then you will get an exception such as:
“Class X must implement IgoNotifyPropertyChanged to support data binding”
when the form is created.
You may have to revisit the uses clause as you design your form. When you put a new control onto the form, Delphi may add a new unit to the uses clauses, which breaks your custom order. I know this is not ideal, but it is the price to pay for this dirty hack. You may prefer to create a package with your custom controls and deal with the “class name” issue in a different way.

[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
MVVM Starter Kit (Part 3 of 3) Erik van Bilsen
January 26, 2018 Erik van Bilsen
[SHOWTOGROUPS=4,20]
If you don’t do this, then you will get an exception such as:
“Class X must implement IgoNotifyPropertyChanged to support data binding”
when the form is created.
You may have to revisit the uses clause as you design your form. When you put a new control onto the form, Delphi may add a new unit to the uses clauses, which breaks your custom order. I know this is not ideal, but it is the price to pay for this dirty hack. You may prefer to create a package with your custom controls and deal with the “class name” issue in a different way.
Sample Interposer Control
We show a simple example of how to implement the IgoNotifyPropertyChanged interface in an interposer control. You can use this as a template for implementing this interface in your own (custom or 3rd party) controls.
The Grijjy.Mvvm.Controls.Fmx unit already implements many interposer controls for you. You only need to read this section if you plan to create your own interposer controls.
The TSwitch interposer control sends a notification whenever the IsChecked property changes. It does so by overriding the DoSwitch method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type
TSwitch = class(FMX.StdCtrls.TSwitch, IgoNotifyPropertyChanged)
private
FOnPropertyChanged: IgoPropertyChangedEvent;
protected
procedure DoSwitch; override;
protected
{ IgoNotifyPropertyChanged }
function GetPropertyChangedEvent: IgoPropertyChangedEvent;
end;

procedure TSwitch.DoSwitch;
begin
if Assigned(FOnPropertyChanged) then
FOnPropertyChanged.Invoke(Self, 'IsChecked');
inherited;
end;

function TSwitch.GetPropertyChangedEvent: IgoPropertyChangedEvent;
begin
if (FOnPropertyChanged = nil) then
FOnPropertyChanged := TgoPropertyChangedEvent.Create;

Result := FOnPropertyChanged;
end;
In general, making a control data-binding-aware requires these steps:
  • Implement the IgoNotifyPropertyChanged interface and its GetPropertyChangedEvent method.
  • The implementation of GetPropertyChangedEvent is boilerplate. You almost always use the same code as in the listing above.
  • Decide for which properties you want to send a change notification. Usually, most controls have just 1 or 2 properties that represent its main data, such as the Text of an edit control or the IsChecked property of a switch.
  • Usually, the base class has a virtual method or event that is responsible for updating these main properties, such as DoSwitch in this example. Override that method (or assign the event) and fire a notification in the implementation.
For many FireMonkey controls, the situation is a little more complicated since the main data is not stored inside the control it self, but inside a “data model class”. That data model class then has methods you need to override to send the notification. This is not too complicated. Take a look at the TEdit interposer control in Grijjy.Mvvm.Controls.Fmx for an example.
For list-like controls, you have to do a bit more work. In addition to implementing IgoNotifyPropertyChanged, you also need to implement IgoCollectionViewProvider. I will not go into the details here since this article is long enough as it is already. Look at TListBox or TListView for implementation details.
Same App, Different Views
We conclude this series by looking at how you can use the MVVM pattern to more quickly and easily create different versions of the same application.
In the Для просмотра ссылки Войди или Зарегистрируйся, you will find a FMX and VCL version of the same application. Both share the same Models and ViewModels, and only have different Views. The Views have only a minimum amount of code however (mostly just setting up the data bindings), making it more cost effective to create different versions of the same app:
Mvvm-DifferentViews

In practice, it may not make much sense to create both a VCL and FMX version of the same app. But it often does make sense to create both a desktop and mobile version of the same app. You can use Delphi’s “View Selector” to customize the look and feel of a single form for multiple platforms, but you can only take this so far. You can have different property values for different platforms, or hide certain controls on certain platforms. You can even move controls to different places for different platforms. However, you cannot use different parents for a control for different platforms. In addition, not all platform-specific changes you make are persisted.
But more importantly, a mobile app usually demands a completely different experience than a desktop app, which is difficult to achieve by just changing some properties or hiding some controls. For example, the MyTunes app has a terrible mobile user experience (although its desktop user experience isn’t great either). In those cases, you are better off creating completely different forms for the mobile and desktop user interfaces. However, if you design your ViewModels well, then those different forms can use the same ViewModel, dramatically cutting down development and maintenance time.
Roll Your Own Pattern
As said at the very beginning of this series, our MVVM Starter Kit is just that: a Starter Kit. It is not a complete MVVM solution and you will probably need to augment or modify it to suit your needs.
If you are looking for a more complete solution (for VCL), then I suggest you take a look at Для просмотра ссылки Войди или Зарегистрируйся.
Or maybe you prefer to create an MVVM framework using Delphi’s LiveBindings instead. In that case, I hope these articles provide some background to get started.
Or you may decide that MVVM is not for you. You may prefer MVC or MVP or a custom decoupling pattern, which is fine too.
In the end, I do hope you’ll see the value of decoupling UI from code. Whatever method you use to accomplish this is up to you.


[/SHOWTOGROUPS]