VodiSoft
We build data binding, two-way binding and multibinding from scratch in .NET MAUI through a single shipping calculator screen. Weight, distance and the express option are combined into one price label, and the form is validated without writing a converter class. All code is C# markup — no XAML.

Most of us know data binding: a source changes, the UI follows. But what happens when a target property depends on more than one source? The usual answers are “add a computed property to the view model” or “write an IMultiValueConverter class”. Neither is always right — especially when the sources don't live in the view model at all, but in the controls on screen.

This post walks through a single screen: a shipping cost calculator. The price updates as weight, distance and an express switch change, and the confirm button stays disabled until the form is complete. Everything is C# markup — no XAML.

The library used is FmgLib.MauiMarkup 10.2.0. One package targets both .NET 9 and .NET 10.

What we are building

The screen will have:

  • Two sliders: weight (kg) and distance (km)
  • A switch: express delivery
  • A live price label — the combination of three sources
  • A hint text that reacts to the same sources
  • Full name, address and terms-acceptance fields
  • A confirm button that only enables when all three are valid

Step 1 — Create the project

The fastest path is the template:

dotnet new install FmgLib.MauiMarkup.Template
dotnet new fmglib-mauimarkup-app -o ShippingDemo

For an existing MAUI project:

dotnet add package FmgLib.MauiMarkup

No registration call, no MauiProgram edit. As soon as the package is referenced, fluent methods exist for every MAUI control.

Step 2 — The view model

Binding has one requirement: the source has to announce its changes. That means INotifyPropertyChanged.

public class ShippingViewModel : INotifyPropertyChanged
{
    double _weight = 2;
    double _distance = 150;
    bool _isExpress;
    string _firstName = "";
    string _lastName = "";
    string _address = "";
    bool _acceptedTerms;

    public double Weight { get => _weight; set => Set(ref _weight, value); }
    public double Distance { get => _distance; set => Set(ref _distance, value); }
    public bool IsExpress { get => _isExpress; set => Set(ref _isExpress, value); }
    public string FirstName { get => _firstName; set => Set(ref _firstName, value); }
    public string LastName { get => _lastName; set => Set(ref _lastName, value); }
    public string Address { get => _address; set => Set(ref _address, value); }
    public bool AcceptedTerms { get => _acceptedTerms; set => Set(ref _acceptedTerms, value); }

    public static decimal Price(double weightKg, double distanceKm, bool isExpress)
    {
        var basePrice = 29.90m;
        var weightCost = (decimal)Math.Max(0, weightKg - 1) * 8.50m;
        var distanceCost = (decimal)distanceKm * 0.35m;
        var total = basePrice + weightCost + distanceCost;
        return isExpress ? total * 1.4m : total;
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (Equals(field, value))
            return;

        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

Note that the price is a plain static method, not a property. The reason becomes clear shortly: the binding layer will drive the calculation, so we never have to maintain a PropertyChanged chain by hand.

Step 3 — The first binding

The skeleton of the page:

public class ShippingPage : ContentPage, IFmgLibHotReload
{
    readonly ShippingViewModel vm = new();

    public ShippingPage() => this.InitializeHotReload();

    public void Build() => this
        .BindingContext(vm)
        .Content(
            new VerticalStackLayout()
            .Spacing(16)
            .Padding(24)
            .Children(
                new Label()
                    .Text(e => e
                        .Getter(static (ShippingViewModel v) => v.Weight)
                        .StringFormat("Weight: {0:F1} kg"))
            )
        );
}

That e => e... lambda is the property builder: every fluent property method accepts one in place of a value.

Path or Getter?

Two ways to say the same thing:

// string path — resolved at runtime through reflection
.Text(e => e.Path("Weight").StringFormat("Weight: {0:F1} kg"))

// compiled binding — checked by the compiler, no reflection
.Text(e => e.Getter(static (ShippingViewModel v) => v.Weight).StringFormat("Weight: {0:F1} kg"))

Prefer Getter: misspell the property and the code fails to build instead of silently rendering an empty label, renaming the property carries the binding along, and it is faster. Make static a habit too — it prevents accidental closure captures.

Path still has its place: when the shape isn't known at compile time (dictionaries, chains like Text.Length) or when the source is another control.

Step 4 — Two-way binding

Reading the slider is not enough; dragging it has to write back. In a compiled binding, Setter supplies the reverse direction:

new Slider()
    .Minimum(0.5)
    .Maximum(30)
    .Value(e => e
        .Getter(static (ShippingViewModel v) => v.Weight)
        .Setter(static (ShippingViewModel v, double x) => v.Weight = x)
        .BindingMode(BindingMode.TwoWay)),

The same shape works for Entry.Text, Switch.IsToggled and CheckBox.IsChecked:

new Switch()
    .IsToggled(e => e
        .Getter(static (ShippingViewModel v) => v.IsExpress)
        .Setter(static (ShippingViewModel v, bool x) => v.IsExpress = x)
        .BindingMode(BindingMode.TwoWay)),

Three rules: Getter reads, Setter writes, BindingMode.TwoWay opens both directions. Forget the Setter and the UI changes while the view model never hears about it — no error, just the most annoying class of bug.

Step 5 — MultiBinding: one price, three sources

Here is the real subject. The price depends on three things: weight, distance, express. The classic approach adds a TotalPrice property and calls OnPropertyChanged(nameof(TotalPrice)) inside each of the three setters. Three extra lines for three sources, and forgetting one is a silent bug.

MultiBinding inverts this: you describe the dependency next to the target, not next to the sources.

new Label()
    .FontSize(28)
    .Text(e => e
        .Getter(static (ShippingViewModel v) => v.Weight)
        .Getter(static (ShippingViewModel v) => v.Distance)
        .Getter(static (ShippingViewModel v) => v.IsExpress)
        .MultiConvert((double w, double d, bool express) =>
            ShippingViewModel.Price(w, d, express).ToString("C", CultureInfo.CurrentCulture))),

It reads as: "this label's text comes from these three sources; whenever any of them changes, run this function."

Three details:

  1. Order matters. MultiConvert parameters line up with the Getter/Path calls. If the counts disagree you get an error while the binding is built — “3 sub bindings were declared, but the delegate takes 2 parameters” — rather than silently wrong behaviour at runtime.
  2. The types are real types. No object[], no casting: you write double, double, bool.
  3. Sources can come from anywhere. They don't all have to be view model properties; .Source(control) binds straight to another control:
new Slider().Assign(out var widthSlider).Minimum(1).Maximum(300),
new Slider().Assign(out var heightSlider).Minimum(1).Maximum(300),

new Label()
    .Text(e => e
        .Path(nameof(Slider.Value)).Source(widthSlider)
        .Path(nameof(Slider.Value)).Source(heightSlider)
        .MultiConvert((double w, double h) => $"{w:F0} × {h:F0} = {w * h:F0} px²")),

This second case is exactly where a computed view model property doesn't help at all: the values never pass through the view model.

When it is only formatting

If all you do is put two values side by side, skip the lambda:

new Label()
    .Text(e => e
        .Path(nameof(ShippingViewModel.FirstName))
        .Path(nameof(ShippingViewModel.Address))
        .MultiStringFormat("{0} — {1}")),

Step 6 — Converting one source first

Sometimes a source needs reshaping before it is combined. Convert belongs to the source it follows:

new Label()
    .Text(e => e
        .Getter(static (ShippingViewModel v) => v.Weight).Convert((double w) => w > 10)
        .Getter(static (ShippingViewModel v) => v.IsExpress)
        .MultiConvert((bool heavy, bool express) => (heavy, express) switch
        {
            (true, true)  => "Heavy parcel + express: a surcharge applies.",
            (true, false) => "Heavy parcel: delivery may take one extra day.",
            (false, true) => "Express: at your door tomorrow.",
            _             => "Standard delivery: 2-3 business days."
        })),

Weight is a double, but thanks to its own Convert it reaches MultiConvert as a bool. The second source arrives untouched.

One sentence to remember: Convert belongs to a source, MultiConvert closes the chain.

Step 7 — Validating the form with MultiAll

“Enable the button when all three conditions hold” is so common that it needs no lambda at all:

new Button()
    .Text("Confirm order")
    .IsEnabled(e => e
        .Getter(static (ShippingViewModel v) => v.FirstName)
            .Convert((string s) => !string.IsNullOrWhiteSpace(s))
        .Getter(static (ShippingViewModel v) => v.Address)
            .Convert((string s) => !string.IsNullOrWhiteSpace(s) && s.Length >= 10)
        .Getter(static (ShippingViewModel v) => v.AcceptedTerms)
            .MultiAll())
    .OnClicked(async b => await DisplayAlert("Done", "Your order has been placed.", "Close")),

Every source produces a bool — the first two through their own Convert, the third natively. The same family gives you MultiAny, MultiNone, MultiAtLeast(n) and MultiExactly(n).

The traditional version of this snippet is an AllTrueConverter class, its registration, and three separate IsValid properties.

Step 8 — One Entry, two properties

MultiBinding is not read-only. Suppose the user types “Ada Lovelace” into a single field and you want it split into FirstName and LastName:

new Entry()
    .Placeholder("Full name")
    .Text(e => e
        .Path(nameof(ShippingViewModel.FirstName))
        .Path(nameof(ShippingViewModel.LastName))
        .MultiMode(BindingMode.TwoWay)
        .MultiConvert((string first, string last) => $"{first} {last}".Trim())
        .MultiConvertBack((string full) =>
        {
            var parts = (full ?? string.Empty).Trim().Split(' ', 2);
            return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
        })),

MultiConvertBack returns a tuple whose elements are written back in declaration order. MultiMode sets the mode of the whole multi-binding — to keep one source read-only, give that one its own .BindingMode(BindingMode.OneWay).

The complete page

using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using FmgLib.MauiMarkup;

namespace ShippingDemo;

public class ShippingPage : ContentPage, IFmgLibHotReload
{
    readonly ShippingViewModel vm = new();

    public ShippingPage() => this.InitializeHotReload();

    public void Build() => this
        .BindingContext(vm)
        .Content(
            new ScrollView()
            .Content(
                new VerticalStackLayout()
                .Spacing(16)
                .Padding(24)
                .Children(
                    new Label()
                        .Text("Shipping cost")
                        .FontSize(24)
                        .FontAttributes(FontAttributes.Bold),

                    new Label()
                        .Text(e => e
                            .Getter(static (ShippingViewModel v) => v.Weight)
                            .StringFormat("Weight: {0:F1} kg")),

                    new Slider()
                        .Minimum(0.5)
                        .Maximum(30)
                        .Value(e => e
                            .Getter(static (ShippingViewModel v) => v.Weight)
                            .Setter(static (ShippingViewModel v, double x) => v.Weight = x)
                            .BindingMode(BindingMode.TwoWay)),

                    new Label()
                        .Text(e => e
                            .Getter(static (ShippingViewModel v) => v.Distance)
                            .StringFormat("Distance: {0:F0} km")),

                    new Slider()
                        .Minimum(5)
                        .Maximum(1500)
                        .Value(e => e
                            .Getter(static (ShippingViewModel v) => v.Distance)
                            .Setter(static (ShippingViewModel v, double x) => v.Distance = x)
                            .BindingMode(BindingMode.TwoWay)),

                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        new Switch()
                            .IsToggled(e => e
                                .Getter(static (ShippingViewModel v) => v.IsExpress)
                                .Setter(static (ShippingViewModel v, bool x) => v.IsExpress = x)
                                .BindingMode(BindingMode.TwoWay)),
                        new Label().Text("Express delivery")
                    ),

                    new Label()
                        .FontSize(28)
                        .Text(e => e
                            .Getter(static (ShippingViewModel v) => v.Weight)
                            .Getter(static (ShippingViewModel v) => v.Distance)
                            .Getter(static (ShippingViewModel v) => v.IsExpress)
                            .MultiConvert((double w, double d, bool express) =>
                                ShippingViewModel.Price(w, d, express)
                                    .ToString("C", CultureInfo.CurrentCulture))),

                    new Label()
                        .Text(e => e
                            .Getter(static (ShippingViewModel v) => v.Weight).Convert((double w) => w > 10)
                            .Getter(static (ShippingViewModel v) => v.IsExpress)
                            .MultiConvert((bool heavy, bool express) => (heavy, express) switch
                            {
                                (true, true)  => "Heavy parcel + express: a surcharge applies.",
                                (true, false) => "Heavy parcel: delivery may take one extra day.",
                                (false, true) => "Express: at your door tomorrow.",
                                _             => "Standard delivery: 2-3 business days."
                            })),

                    new Entry()
                        .Placeholder("Full name")
                        .Text(e => e
                            .Path(nameof(ShippingViewModel.FirstName))
                            .Path(nameof(ShippingViewModel.LastName))
                            .MultiMode(BindingMode.TwoWay)
                            .MultiConvert((string first, string last) => $"{first} {last}".Trim())
                            .MultiConvertBack((string full) =>
                            {
                                var parts = (full ?? string.Empty).Trim().Split(' ', 2);
                                return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
                            })),

                    new Entry()
                        .Placeholder("Delivery address")
                        .Text(e => e
                            .Getter(static (ShippingViewModel v) => v.Address)
                            .Setter(static (ShippingViewModel v, string x) => v.Address = x)
                            .BindingMode(BindingMode.TwoWay)),

                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        new CheckBox()
                            .IsChecked(e => e
                                .Getter(static (ShippingViewModel v) => v.AcceptedTerms)
                                .Setter(static (ShippingViewModel v, bool x) => v.AcceptedTerms = x)
                                .BindingMode(BindingMode.TwoWay)),
                        new Label().Text("I accept the terms of sale")
                    ),

                    new Button()
                        .Text("Confirm order")
                        .IsEnabled(e => e
                            .Getter(static (ShippingViewModel v) => v.FirstName)
                                .Convert((string s) => !string.IsNullOrWhiteSpace(s))
                            .Getter(static (ShippingViewModel v) => v.Address)
                                .Convert((string s) => !string.IsNullOrWhiteSpace(s) && s.Length >= 10)
                            .Getter(static (ShippingViewModel v) => v.AcceptedTerms)
                            .MultiAll())
                        .OnClicked(async b => await DisplayAlertAsync("Done", "Your order has been placed.", "Close"))
                )
            )
        );
}

Thanks to IFmgLibHotReload, Build() re-runs on every code edit — you can tune the pricing formula while dragging the sliders.

MultiBinding or a view model property?

MultiBinding is not the answer everywhere. A simple split:

Situation Choose
Sources live in the view model and the result is business logic (tax, discount, permissions) Computed view model property
Sources are controls on screen (slider, entry, switch) MultiBinding
The result is purely presentational (formatting, colour, visibility) MultiBinding
The result needs unit tests View model — far easier to test
The same combination repeats across three screens View model, or your own IMultiValueConverter

Rule of thumb: business logic belongs in the view model, presentation belongs in the binding.

Common mistakes

The source doesn't implement INotifyPropertyChanged. The binding reads the initial value and freezes there, with no error message. When a binding “doesn't work”, check this first.

A missing Setter. The UI changes and the view model stays empty. BindingMode.TwoWay alone is not enough; in a compiled binding you describe the reverse direction yourself.

A parameter type that doesn't match the source. If Weight is a double and you write MultiConvert((int w, ...) => ...), the binding throws an exception naming the property, the path and the index of the value that didn't match. Read the message — line the parameter type up with the real source type.

Panicking about missing values. A multi-binding is evaluated as soon as the first source resolves, while the others may still be empty. In that case the target keeps its current value instead of being overwritten with null, so you don't see labels flashing empty on the first frame.

A button that looks enabled for a moment. The boolean aggregates don't validate how many sources there are: MultiAtLeast(3) over two sources is always false. Make sure you declared as many Getter/Path calls as you think.

Summary

  • Getter + Setter + BindingMode.TwoWay → a compiled, refactor-safe two-way binding.
  • Several Getter/Path calls + MultiConvert → multiple sources with typed parameters and no converter class.
  • Convert belongs to a source, MultiConvert closes the chain.
  • MultiAll and its siblings reduce form validation to a single line.
  • Keep business logic in the view model; keep presentational combinations in the binding.

The whole screen is around 100 lines, with no converter class and no OnPropertyChanged(nameof(Total)) chain anywhere. That is the real win of multi-binding: the dependency is described where it actually matters — right next to the target.

Related Articles

FmgLib.MauiMarkup or CommunityToolkit.Maui.Markup? An Honest Comparison

There are two ways to build a .NET MAUI UI in C# instead of XAML, and both use fluent methods. Nearly every difference between them follows from a single architectural choice: hand-curated extensions or a source generator? This post walks through the differences, when each library is the better pick, and what actually happens — measured, not guessed — when you put both in one project.

Read More

VodiSoft

Turn your software idea into a revenue-generating product

For web, mobile, .NET, SaaS and integration projects, we can map the risks, timeline and fastest path to commercial value.

Get a Quote Meeting