-
Notifications
You must be signed in to change notification settings - Fork 20
Data Binding. Growing Micro DSL
Creating data bindings out of F# assignment statement quotation as introduced in Data Binding chapter still stands. It just needs to be extended to handle various real-world scenarios.
Binding.FromExpression <@ ... @>
is a nice facade method to our data binding micro DSL. It has support for batch statements. Mapping from single assignment statement to a single binding happens in Expr.ToBindingExpr
extension method. In this post we'll mostly focus on internals of this method.
Let's have a look at MainView
from previous External Event Sources chapter.
type MainView() as this =
inherit View<MainEvents, MainModel, MainWindow>()
let pause = this.Control.PauseWatch
let fail = this.Control.Fail
override this.EventStreams =
[
...
yield pause.Checked |> Observable.mapTo StopWatch
yield pause.Unchecked |> Observable.mapTo StartWatch
yield fail.Checked |> Observable.mapTo StartFailingWatch
yield fail.Unchecked |> Observable.mapTo StopFailingWatch
]
override this.SetBindings model =
let titleBinding = MultiBinding(StringFormat = "{0} - {1}")
titleBinding.Bindings.Add <| Binding("ProcessName")
titleBinding.Bindings.Add <| Binding("ActiveTab")
this.Control.SetBinding(Window.TitleProperty, titleBinding) |> ignore
Binding.FromExpression
<@
this.Control.PauseWatch.IsChecked <- model.Paused
this.Control.Fail.IsChecked <- model.Fail
@>
this.Control.RunningTime.SetBinding(TextBlock.TextProperty, Binding(path = "RunningTime", StringFormat = "Running time: {0:hh\:mm\:ss}")) |> ignore
It has places where we could not use our Binding DSL:
Window Title property binding requires MultiBinding and StringFormat support.
When some control is referred many times inside a view (like this.Control.PauseWatch
in our example), it is useful to define a shortcut like let pause = this.Control.PauseWatch
. Unfortunately, if we try to use it in Binding.FromExpression
we’ll get an exception.
To find the right approach for solving the issues above let's look at correspondence between key components of binding, parts of single assignment statement, and items available after decomposition using PropertySet active pattern:
We'll skip problem #1 for now. To solve #2 we clearly need to handle more scenarios for parsing target. (|Target|_|)
active pattern would do just that:
...
module BindingPatterns =
let (|Target|_|) expr =
let rec loop = function
| Some( Value(obj, viewType) ) -> obj
| Some( FieldGet(tail, field) ) -> field.GetValue(loop tail)
| Some( PropertyGet(tail, prop, []) ) -> prop.GetValue(loop tail, [||])
| _ -> null
match loop expr with
| :? FrameworkElement as target -> Some target
| _ -> None
...
Type signature:
As a result ToBindingExpr
is much simpler and more readable:
type Expr with
member this.ToBindingExpr() =
match this with
| PropertySet(Target target, targetProperty, [], PropertyPath path) ->
let binding = Binding(path, ValidatesOnDataErrors = true, ValidatesOnExceptions = true)
target.SetBinding(targetProperty.DependencyProperty, binding)
| _ -> invalidArg "expr" (string this)
Moving on to problem #3. Contrary to the binding target object, the source object is never set explicitly by Binding DSL. It was a conscious architectural choice - binding property path is always relative to root (Window) data context which in our case is a model instance. Hence, right-hand side id mostly mapped to Path property of Binding
object. There are different language contracts that can be a part of the assignment statement and at the same time can be nicely mapped to Binding class properties. Let's look at the variations that make sense to support:
---|---|----|---------
1| <@ textBox.Text <-
model.StringProperty @>
| Binding(path = "StringProperty")
| straightforward mapping
2| <@ textBox.Text <-
string model.NonStringProperty @>
| Binding(path = "NonStringProperty")
| straightforward mapping, except that string "shim" happily discards type system. See chapter Data Binding for details.
3 | <@ checkBox.IsChecked <-
Nullable model.BoolProperty @>
| Binding(path = "BoolProperty")
| same as #2 but for Nullable.
4 | <@ comboBox.SelectedItem <-
model.StringProperty @>
| Binding(path = "StringProperty")
| straightforward mapping, except that the compiler
generated coercion is discarded. See chapter Data Binding for details.
5 | <@ textBlock.Text <-
String.Format(format, model.Property) @>
| Binding(path = "Property",
StringFormat = format)
| #1 + StringFormat
6 | <@ control.Property <-
func model.Property @>
| Binding(path = "Property",
Converter = IValueConverter.OneWay func,
Mode = BindingMode.OneWay)
| #1 + Converter
6a | <@ button.IsEnable <-
not model.BoolProperty @>
| same as #6 with func = not | |
7 | <@ this.Control.AddToChart.Visibility <-
converter.Apply model.AddToChartEnabled @>
| Binding(path = "Property",
Converter = converter)
| Using IValueConverter
implemenations
8 | Selector-based controls binding | |
9 | DataGrid binding| |
10 | <@ control.Property <-
model.Collection.CurrentItem @>
| Binding(path = "Collection/")
| See MSDN for details.
11 | <@ textBox.Text <-
model.Property1.StringProperty2 @>
| Binding(path = "Property1.StringProperty2")
| See MSDN
11a | <@ control.Property <-
model.Collection.CurrentItem.Property1 @>
| Binding(path = "Collection/Property1")
| Same as 11, just includes collection in path
To handle all combinations we factor-out parsing right-hand side of assignment statement into a separate BindingExpression
active pattern:
...
member this.ToBindingExpr() =
match this with
| PropertySet(Target target, targetProperty, [], BindingExpression binding) ->
...
target.SetBinding(targetProperty.DependencyProperty, binding)
...
We've seen #1, #2 and #4 in chapter Data Binding and through the series.
#3. In [previous chapter](External Event Sources) it was a bit inconvenient to work with Paused
property of type Nullable<bool>
, because it had to match the type of ToggleButton.IsChecked
. Data binding magic knows how to map Nullable<'T>
to 'T
on model. To take advantage of #3 we change type of Paused
to bool and insert call to Nullable
constructor in binding expression to keep compiler happy. Similar to string "shim" and coercion to obj it's discarded during binding setup.
module BindingPatterns =
...
let (|Nullable|_|) = function
| NewObject( ctorInfo, [ propertyPath ] ) when ctorInfo.DeclaringType.GetGenericTypeDefinition() = typedefof<Nullable<_>> ->
Some propertyPath
| _ -> None
...
let rec (|BindingExpression|) = function
| PropertyPath path ->
Binding path
| Coerce( BindingExpression binding, _)
| SpecificCall <@ string @> (None, _, [ BindingExpression binding ])
| Nullable( BindingExpression binding) ->
binding
...
| expr -> invalidArg "binding property path quotation" (string expr)
...
type MainModel() =
inherit Model()
...
abstract Paused : bool with get, set
abstract Fail : bool with get, set
type MainView() as this =
...
override this.SetBindings model =
Binding.FromExpression
<@
pause.IsChecked <- Nullable model.Paused
fail.IsChecked <- Nullable model.Fail
...
@>
#5. All we need is another active pattern and to handle the case:
module BindingPatterns =
...
let (|StringFormat|_|) = function
| SpecificCall <@ String.Format : string * obj -> string @> (None, [], [ Value(:? string as format, _); Coerce( propertyPath, _) ]) ->
Some(format, propertyPath)
| _ -> None
...
let rec (|BindingExpression|) = function
...
| StringFormat(format, BindingExpression binding) ->
binding.StringFormat <- format
binding
...
type MainView() as this =
...
override this.SetBindings model =
Binding.FromExpression
<@
...
this.Control.RunningTime.Text <- String.Format("Running time: {0:hh\:mm\:ss}", model.RunningTime)
...
@>
To demostrate #6 let's look at our StockPicker
MVC-triple. If user clicks "Retrieve" button before entering anything into "Symbol" TextBox
, the error pops up.
It is handled by validation logic in the controller:
...
model |> Validation.textRequired <@ fun m -> m.Symbol @>
...
Let's make user interface more strict by keeping the "Retrieve" button disabled unless a text is entered into the "Symbol" field. This can be done in declarative fashion through the data binding:
[<AutoOpen>]
module Mvc.Wpf.Sample.Extensions
let isNotNull x = x <> null
...
type StockPickerView() as this =
...
override this.SetBindings model =
Binding.FromExpression
<@
...
this.Control.Retrieve.IsEnabled <- isNotNull model.Symbol
@>
...
The corresponding pattern matching case and helper methods on IValueConverter
are:
...
type IValueConverter with
static member Create(convert : 'a -> 'b, convertBack : 'b -> 'a) = {
new IValueConverter with
member this.Convert(value, _, _, _) = try value |> unbox |> convert |> box with _ -> DependencyProperty.UnsetValue
member this.ConvertBack(value, _, _, _) = try value |> unbox |> convertBack |> box with _ -> DependencyProperty.UnsetValue
}
static member OneWay convert = IValueConverter.Create(convert, fun _ -> DependencyProperty.UnsetValue)
...
module BindingPatterns =
...
let (|Converter|_|) = function
| Call(instance, method', [ propertyPath ]) ->
let instance = match instance with | Some( Value( value, _)) -> value | _ -> null
Some((fun(value : obj) -> method'.Invoke(instance, [| value |])), propertyPath )
| _ -> None
...
let rec (|BindingExpression|) = function
...
| Converter(convert, BindingExpression binding) ->
binding.Mode <- BindingMode.OneWay
binding.Converter <- IValueConverter.OneWay convert
binding
...
It gets mapped to one-way binding/converter, which is reasonable considering the semantics. To have it two-way, two functions are required: convert and convert back, and there is no way the second can be inferred from the first.
Unfortunately, it won't exactly work because TextBox.Text has a default UpdateSourceTrigger
value of LostFocus
. It means that if an application has a TextBox
with a data-bound TextBox.Text
property, the text you type into the TextBox
does not update the source until the TextBox
loses focus (for instance, when you click away from the TextBox
). If you want the source to get updated as you are typing, set the UpdateSourceTrigger
of the binding to PropertyChanged
. For our Binding micro DSL it means accepting override as a parameter to Binding.FromExpression
methods and piping it down to Expr.ToBindingExpr
. We'll make it optional for convenience:
type Binding with
static member FromExpression(expr, ?updateSourceTrigger) =
...
type Expr with
member this.ToBindingExpr(?updateSourceTrigger) =
match this with
| PropertySet(Target target, targetProperty, [], BindingExpression binding) ->
...
if updateSourceTrigger.IsSome then binding.UpdateSourceTrigger <- updateSourceTrigger.Value
BindingOperations.SetBinding(target, targetProperty.DependencyProperty, binding)
Notice, that the call with updateSourceTrigger
parameter set to UpdateSourceTrigger.PropertyChanged
is used often enough to make a shortcut:
type Binding with
...
static member UpdateSourceOnChange expr = Binding.FromExpression(expr, updateSourceTrigger = UpdateSourceTrigger.PropertyChanged)
Going back to StockPickerView
:
type StockPickerView() ...
override this.SetBindings model =
Binding.FromExpression
<@
...
this.Control.Retrieve.IsEnabled <- isNotNull model.Symbol
@>
Binding.UpdateSourceOnChange <@ this.Control.Symbol.Text <- model.Symbol @>
In practice, allowing to fine tune any binding property, not only updateSourceTrigger
, would be nice. Following is the final version of API:
type Binding with
static member FromExpression(expr, ?mode, ?updateSourceTrigger, ?fallbackValue, ?targetNullValue, ?validatesOnDataErrors, ?validatesOnExceptions) =
...
for e in split expr do
let be = e.ToBindingExpr(?mode = mode, ?updateSourceTrigger = updateSourceTrigger, ?fallbackValue = fallbackValue,
?targetNullValue = targetNullValue, ?validatesOnDataErrors = validatesOnDataErrors, ?validatesOnExceptions = validatesOnExceptions)
...
static member UpdateSourceOnChange expr = Binding.FromExpression(expr, updateSourceTrigger = UpdateSourceTrigger.PropertyChanged)
static member TwoWay expr = Binding.FromExpression(expr, BindingMode.TwoWay)
static member OneWay expr = Binding.FromExpression(expr, BindingMode.OneWay)
...
type Expr with
member this.ToBindingExpr(?mode, ?updateSourceTrigger, ?fallbackValue, ?targetNullValue, ?validatesOnDataErrors, ?validatesOnExceptions) =
match this with
| PropertySet(Target target, targetProperty, [], BindingExpression binding) ->
if mode.IsSome then binding.Mode <- mode.Value
if updateSourceTrigger.IsSome then binding.UpdateSourceTrigger <- updateSourceTrigger.Value
if fallbackValue.IsSome then binding.FallbackValue <- fallbackValue.Value
if targetNullValue.IsSome then binding.TargetNullValue <- targetNullValue.Value
binding.ValidatesOnDataErrors <- defaultArg validatesOnDataErrors true
binding.ValidatesOnExceptions <- defaultArg validatesOnExceptions true
target.SetBinding(targetProperty.DependencyProperty, binding)
| _ -> invalidArg "expr" (string this)
It's worth noting that contrary to WPF ValidatesOnDataErrors
and ValidatesOnExceptions
are set on by default, because the framework relies on validation.
To demonstrate application of #6a let's make Restart button enabled only when "Fail" CheckBox
is unchecked:
Implementation in MainView
:
type MainView() as this =
...
override this.SetBindings model =
...
Binding.FromExpression
<@
...
this.Control.RestartWatch.IsEnabled <- not model.Fail
@>
This declarative and simple approach saves a lot of manual coding. A reasonable question to be asked is why not to make it two-way data binding? After all, not is a function easy to reverse. Answer to this simple question reveals key design decisions. Remember, the model is an in-memory image of the view and has to be its close reflection. All complex and especially imperative logic goes into the controller. Therefore, converters are less important than in other architectures. In the snippet above we see that fail.IsChecked
binds directly to the model.Fail. Whatever required conversion logic (including negation) can be applied inside event handler, i.e if not model.Fail then .... These kind of solution is easy to understand and test. On the other hand Binding.FromExpression <@ this.Control.RestartWatch.IsEnabled <- not model.Fail @>
lets button enable state to be calculated off the model.Fail
property (which should participate in INotifyPropertyChanged
for this to work) and two-way binding doesn't make sense either. To emphasize the point let's look at #7.
StockPickerController
controller sets model.AddToChartEnabled
to true once data for requested symbol are retrieved successfully, which in turn enables "Add To Chart" button. Imagine that we want to take a more radical approach and have "Add To Chart" button being hidden unless model.AddToChartEnabled
is true.
The sensible approach would be to bind Button.Visibility
of type [Visibility] (http://msdn.microsoft.com/en-us/library/system.windows.visibility.aspx) property to model.AddToChartEnabled
. Unfortunately, it cannot be done directly because Visibility type is 3-value enum, thus it cannot be mapped to a bool value. WPF has built-in BooleanToVisibilityConverter type to mitigate the issue. An assignment statement, the cornerstone of our binding DSL, gives a natural one-way mapping (source -> target), but not the other way around. To have it two-way and keep it sound we need some creative idea. We'll use "pseudo" methods. Intriguing, right? Let's add the following extensions method:
type IValueConverter with
...
member this.Apply _ = undefined
of type signature:
Usage:
type StockPickerView() as this =
...
override this.SetBindings model =
...
let converter = BooleanToVisibilityConverter()
Binding.FromExpression
<@
this.Control.AddToChart.Visibility <- converter.Apply model.AddToChartEnabled
@>
"Pseudo" methods are never meant to be called (undefined ensures that it will throw an exception if an attempt is made), but serve as hints to Binding DSL interpreter. The funny thing is that this actually works. But how? Just another pattern matching case:
let rec (|BindingExpression|) = function
...
| Call(None, method', [ Value(:? IValueConverter as converter, _); BindingExpression binding ] ) when method'.Name = "IValueConverter.Apply" ->
binding.Converter <- converter
binding
...
To be accurate, this won't guarantee two-way binding/conversion - it goes with default. To override the default, provide parameters to Binding.FromExpression
method. Make sure to use converter instance created outside of quotation to avoid evaluation inside active patterns. Equipped with IValueConverter.Create
, one can easily create local instances out of local lambdas.
Let's go back to the discussion of the role of converters in the framework architecture. It has all the drawbacks mentioned for 6a. Unless there is a handy built-in converter, this is not a good approach. Even for built-in ones, for example, there is no harm to declare property of type Visibility
on the model and let the controller to work off it. Converters are less testable and reusable. Error handling gets tricky for two way converters too. To sum it up, use this feature judiciously.
We'll look at 8-11a in one step, because this way it's easier to demonstrate the approach. In the sample application we'll change stock prices chart MVC-triple to display additional stock properties.
The drop-down list just below the chart contains all stocks we've added so far. Detailed information grid on the right side shows properties for the selected stock. Note that this is a classic example of master-detail view.
StockInfoModel
returned by auxiliary StockPickerController
controller now has detailed properties as a dictionary in addition to some major ones:
...
type StockInfoModel() =
inherit Model()
...
abstract Details : IDictionary<string, string> with get, set
StockPricesChartModel
has been extended to have full information for all stocks and separate property to denote selected item:
type StockPricesChartModel() =
inherit Model()
abstract StocksInfo : ObservableCollection<StockInfoModel> with get, set
abstract SelectedStock : StockInfoModel with get, set
Here is how we do Symbol ComboBox
binding:
type StockPricesChartView(control) as this =
...
override this.SetBindings model =
...
this.Control.Symbol.SetBindings(
itemsSource = <@ model.StocksInfo @>,
selectedItem = <@ model.SelectedStock @>,
displayMember = <@ fun m -> m.Symbol @>
)
...
It is a nice alternative to less cohesive and more error-prone (especially for string-based DisplayMemberPath
)
...
Binding.FromExpression
<@
this.Control.Symbol.ItemsSource <- model.StocksInfo
this.Control.Symbol.SelectedItem <- model.SelectedStock
@>
this.Control.Symbol.DisplayMemberPath <- "Symbol"
...
Implementation and type signature are:
type Selector with
member this.SetBindings(itemsSource : Expr<#seq<'Item>>, ?selectedItem : Expr<'Item>, ?displayMember : PropertySelector<'Item, _>) =
let e = this.SetBinding(ItemsControl.ItemsSourceProperty, match itemsSource with BindingExpression binding -> binding)
assert not e.HasError
selectedItem |> Option.iter(fun(BindingExpression binding) ->
let e = this.SetBinding(DataGrid.SelectedItemProperty, binding)
assert not e.HasError
this.IsSynchronizedWithCurrentItem <- Nullable true
)
displayMember |> Option.iter(fun(SingleStepPropertySelector(propertyName, _)) ->
this.DisplayMemberPath <- propertyName
)
Compared to independent bindings for Selector
, this one forces right types. Type of selectedItem
is the same as the element type of itemsSource
collection and displayMember
is picked off that type too. Note how implementation sets IsSynchronizedWithCurrentItem
when selectedItem
is provided.
Shown below is a DataGird
bind to stock details dictionary as well as implementation details (in additon to #9, itemsSource
binding is an example of #11)
type StockPricesChartView(control) as this =
...
override this.SetBindings model =
...
this.Control.Details.SetBindings(
itemsSource = <@ model.SelectedStock.Details @>,
columnBindings = fun stockProperty ->
[
this.Control.DetailsName, <@@ stockProperty.Key @@>
this.Control.DetailsValue, <@@ stockProperty.Value @@>
]
)
...
type DataGrid with
member this.SetBindings(itemsSource : Expr<#seq<'Item>>, columnBindings : 'Item -> (#DataGridBoundColumn * Expr) list, ?selectedItem) =
this.SetBindings(itemsSource, ?selectedItem = selectedItem)
for column, BindingExpression binding in columnBindings Unchecked.defaultof<'Item> do
column.Binding <- binding
Again, this produces better cohesion, than unrelated bindings. SetBindings
for DataGrid
reuses Selector.SetBindings
to set ItemsSource
and SelectedItem
(displayMember
doesn't make much sense for a grid). columnBindings
function that returns list of pairs: column + expression that is translated to data binding.
Speaking of master-detail, in our example, this is StockPricesChartModel.SelectedStock
-> StockPricesChartModel.SelectedStock.Details
relation. It was possible in part, because we explicitly mapped selected item to SelectedStock
. It is often the case, because usually there is logic in controller based on the value of the currently selected item. But sometimes it's unnecessary; especially when details are read-only, or controller has no dependency on it. To make declarative binding to details possible without SelectedItem
we'll introduce yet another pseudo method:
type IEnumerable<'T> with
member this.CurrentItem : 'T = undefined
Semantics of this are the same as IEnumerator<T>.Current
.
We can rewrite details grid binding like this:
type StockPricesChartView(control) as this =
override this.SetBindings model =
this.Control.Details.SetBindings(
itemsSource = <@ model.StocksInfo.CurrentItem.Details @>,
...
)
This is a demo for #10 and #11a. In order for this to work, PropertyPath
active pattern has support for this pseudo method.
let (|PropertyPath|_|) expr =
let rec loop e acc =
match e with
| PropertyGet( Some tail, property, []) ->
loop tail (property.Name :: acc)
| SpecificCall <@ Seq.empty.CurrentItem @> (None, _, [ tail ]) ->
loop tail ("/" :: acc)
| Value _ | Var _ -> acc
| _ -> []
match loop expr [] with
| [] -> None
| x::_ as xs ->
xs
|> Seq.pairwise
|> Seq.map (function
| "/", x -> x
| _, "/" -> "/"
| x, y -> "." + y)
|> String.concat ""
|> ((+) x)
|> fun propetyPath -> Some propetyPath
This CurrentItem
functionality is nicely mapped to WPF PropertyPath XAML Syntax. It's not critical to have, but the technique is nice. Other mappings, like numeric and symbolic indexers or even multi-indexers, can be done too, but are not particularly useful.
Binding DSL tried to cover a lot, but not everything. The backdoor to use standard API calls like FrameworkElement.SetBinding or BindingOperations.SetBinding is always available. Some additional extensions are definitely possible. For example:
-
DataGridComboBoxColumn doesn't inherit from
DataGridBoundColumn
and therefore requires special support. Ironically,DataGridComboBoxColumn
has the same binding API asSelector/ComboBox
, but because of "brilliant" single inheritance idea (thanks again, Java), or lack of mixins they do not share same base class or interface. - Quotation like <@ model.Property <- control.Property @> can be mapped to a binding with the mode set to OneWayToSource.
Whatever is added has to make sense in current framework and not introduce needless complexity.
Let's look again at most important active patterns of our Binding DSL: (|Target|_|)
(at beginning of article), (|PropertyPath|_|)
(full code just above) and here is full code for (|BindingExpression|)
:
let rec (|BindingExpression|) = function
| PropertyPath path ->
Binding path
| Coerce( BindingExpression binding, _)
| SpecificCall <@ string @> (None, _, [ BindingExpression binding ])
| Nullable( BindingExpression binding) ->
binding
| StringFormat(format, BindingExpression binding) ->
binding.StringFormat <- format
binding
| Converter(convert, BindingExpression binding) ->
binding.Mode <- BindingMode.OneWay
binding.Converter <- IValueConverter.OneWay convert
binding
| Call(None, method', [ Value(:? IValueConverter as converter, _); BindingExpression binding ] ) when method'.Name = "IValueConverter.Apply" ->
binding.Converter <- converter
binding
| expr -> invalidArg "binding property path quotation" (string expr)
All of them use one very basic but key tool of functional programming. That’s right, recursion. Without it (in absence of fold for Quotations), it would be impossible to avoid code duplication. Recursion is praised in classic books, but seems to be rarely used in imperative languages like C#. On the other hand, F# promotes quite different coding style - there are only 2 for loops in our whole codebase (the framework and the sample combined).