VodiSoft
The question every real project asks about C# markup: do you lose the fluent API for Syncfusion, UraniumUI, SkiaSharp and friends? You don't — the same source generator works for them too. We cover the one-line opt-in, automatic scanning, attached properties, the redefined-property rules, and finally how to write your own design-system methods.

In the earlier posts of this series we set up bindings, lists, theming and the hot reload loop. All of them used MAUI's own controls. But in a real app half the screens are not built from what ships in the box: a Syncfusion chart, a UraniumUI form field, a SkiaSharp Lottie animation, a ZXing barcode scanner.

So the question is fair: "Do I lose the fluent syntax for those controls? Will half my code be new X().Y(...) and the other half object initializers?"

The answer is no. But to see why, you first need to know where the fluent methods come from.

Where the fluent methods come from

The thousands of methods in FmgLib.MauiMarkup — .Text(...), .FontSize(...), .OnClicked(...) — were not written by hand. A Roslyn source generator scans MAUI's controls and emits them at compile time, one set per BindableProperty and per event.

Which leads to the useful consequence: the generator is not MAUI-specific. Its input is “a BindableObject with public bindable properties”, and it does not care which library that type came from.

There are three routes: a one-line opt-in, automatic scanning, and a separate attribute for attached properties.

1. [MauiMarkup] — pick the controls you want

The attribute goes on any class; the class itself is irrelevant and serves only as an anchor.

using FmgLib.MauiMarkup;
using Acme.Controls;

[MauiMarkup(typeof(FancyRating))]
public static class Markup { }

That's it. After a build the control behaves like any other:

new FancyRating()
    .Value(4.5)
    .IsReadOnly(true)
    .StarColor(e => e.OnLight(Colors.Goldenrod).OnDark(Colors.Yellow))
    .OnValueChanged((s, v) => Analyze(v))

Note what StarColor accepts: not just a value, but the property builder lambda as well. So bindings, OnLight/OnDark, DynamicResource and multi-bindings all work on third-party controls too. The generated methods are first-class citizens — indistinguishable from the ones for MAUI's own controls.

The attribute takes several types, and a class can carry several attributes. Keeping them in one place is cleanest; many projects use MauiProgram as the anchor:

[MauiMarkup(typeof(CameraView))]
[MauiMarkup(typeof(SKLottieView), typeof(SKConfettiView))]
[MauiMarkup(typeof(TextField), typeof(EditorField), typeof(DataGrid))]
public static class MauiProgram { /* ... */ }

Base classes come along automatically

Most of a control's bindable surface usually lives in its base class. The generator knows this: annotating the leaf type also produces extensions for its eligible third-party base classes.

In our example FancyRating inherits Maximum from RatingBase. One attribute produced two extension classes, and this line compiled without any extra annotation:

new FancyRating()
    .Value(4.5)      // FancyRating's own property
    .Maximum(10)     // from RatingBase — no separate attribute needed

Syncfusion's SfButton is a good example of the same thing: Text, Command and FontSize all live on ButtonBase, and [MauiMarkup(typeof(SfButton))] brings them along. (MAUI's own base classes are never re-generated — their extensions already ship inside the library.)

How much is generated?

For the small control above — three properties and one event — the generator emitted 20 methods. Each property maps to four overloads:

Overload What it gives you
.StarColor(Color) direct value
.StarColor(e => ...) property builder: bindings, theming, dynamic resources, multi-binding
the SettersContext version use inside Style<FancyRating>
SettersContext + builder bindings/theming inside a style

On top of that come animation extensions such as AnimateStarColorTo(...) for colour and numeric properties. There is no “third-party controls are second-class” tier here — you can write styles for them as well:

public static Style<FancyRating> ReadOnly { get; } = new(e => e
    .IsReadOnly(true)
    .StarColor(t => t.OnLight(Colors.Gray).OnDark(Colors.DimGray)));

2. Attached properties

Attached properties need their own attribute, because the generator needs four pieces of information: the type declaring the property, the name of the BindableProperty field, the value type, and the type the extension will be applied to.

[MauiMarkupAttachedProp(typeof(FancyRating),
                        nameof(FancyRating.HighlightProperty),
                        typeof(bool),
                        typeof(Label))]
public static class Markup { }

The generated method is named owner class + property name:

new Label()
    .Text("Featured")
    .FancyRatingHighlight(true)

Applying InputKit's FormView.IsSubmitButton to a Button follows exactly the same shape.

3. Automatic mode — no attributes at all

If you'd rather not enumerate controls, one line in your app project's .csproj:

<PropertyGroup>
  <MauiMarkupSourceGenerator>true</MauiMarkupSourceGenerator>
</PropertyGroup>

The generator scans referenced third-party assemblies and emits extensions for every eligible public BindableObject. The remaining MSBuild wiring ships with the package.

The trade-off: automatic mode is convenient but generates code for everything it finds, which can lengthen build times in large solutions. The attribute approach keeps generation scoped to what you actually use. Automatic for small and mid-sized projects, attributes for big ones, is a reasonable default.

The rule that saves you an hour: redefined properties

Controls sometimes re-declare a base class property. The generator treats two cases differently, and knowing this prevents a “my method disappeared” panic in IntelliSense.

Redefined with the same type (the common case). SfButton.TextColor re-exposes ButtonBase.TextColor with the same type. Here no separate method is generated for the derived class: the base class's generic extension already applies to SfButton. Emitting both would make every call ambiguous (CS0121).

So if a method seems “missing” on the leaf type, a base-class extension is almost certainly already serving it under the same name.

Redefined with a different type (a genuine new). SfAvatarView.Background changes the property from Brush to Color. The derived method then carries the New suffix, because two same-named overloads differing only in a generic argument would break lambda call sites and could silently target the wrong BindableProperty:

new SfAvatarView()
    .BackgroundNew(Colors.LightBlue)   // SfAvatarView's own Color property
    .Background(someBrush)             // the inherited Brush property from VisualElement

Writing your own vocabulary

Third-party controls are handled. One thing remains: your project's own words. If your design system says “heading”, “card”, “primary button”, your code should say the same.

Level 1 — composition shorthands

The simplest and most useful kind. Since every fluent method returns T, no library machinery is involved:

public static class MarkupHelpers
{
    public static T PrimaryText<T>(this T self) where T : Label
        => self
            .FontSize(16)
            .TextColor(e => e.OnLight(AppColors.Text).OnDark(AppColors.TextDark));

    public static T Card<T>(this T self) where T : Border
        => self
            .BackgroundColor(e => e.OnLight(Colors.White).OnDark(AppColors.SurfaceDark))
            .StrokeThickness(0)
            .Padding(16);
}
new Border().Card().Content(
    new Label().Text("Total").PrimaryText())

Keep the <T> generic and the where T : ... constraint so the concrete type keeps flowing through the chain — otherwise you lose access to Border-specific methods after .Card().

Level 2 — a full property method

When a concept should genuinely behave like a property — accept bindings, respond to OnLight/OnDark, work inside a style — you write the four overloads. They are exactly what the generator emits:

public static class SpacingExtensions
{
    public static readonly BindableProperty DensityProperty =
        BindableProperty.CreateAttached("Density", typeof(double), typeof(SpacingExtensions), 1d);

    // 1. Direct value
    public static T Density<T>(this T self, double density) where T : VisualElement
    {
        self.SetValue(DensityProperty, density);
        return self;
    }

    // 2. Property builder — bindings, theming, dynamic resources
    public static T Density<T>(this T self, Func<PropertyContext<double>, IPropertyBuilder<double>> configure)
        where T : VisualElement
    {
        var context = new PropertyContext<double>(self, DensityProperty);
        configure(context).Build();
        return self;
    }

    // 3. Style setter
    public static SettersContext<T> Density<T>(this SettersContext<T> self, double density)
        where T : VisualElement
    {
        self.XamlSetters.Add(new Setter { Property = DensityProperty, Value = density });
        return self;
    }

    // 4. Style setter with builder
    public static SettersContext<T> Density<T>(this SettersContext<T> self,
        Func<PropertySettersContext<double>, IPropertySettersBuilder<double>> configure)
        where T : VisualElement
    {
        var context = new PropertySettersContext<double>(self.XamlSetters, DensityProperty);
        configure(context).Build();
        return self;
    }
}

With all four in place every usage pattern lights up:

new Label().Density(1.25)                                  // value
new Label().Density(e => e.OnPhone(1.0).OnDesktop(1.25))   // idiom
new Style<Label>(e => e.Density(0.8).FontSize(13))         // style

Two rules: always write through the BindableProperty with SetValue (never the CLR property), or styles, bindings and triggers stop working. And don't mix up PropertyContext<TValue> with SettersContext<T>: the first writes to a live control, the second to the setter list inside a Style<T>.

Which level when?

Situation Approach
Using a third-party control fluently [MauiMarkup(typeof(...))]
A third-party attached property [MauiMarkupAttachedProp(...)]
Many third-party controls, small solution MauiMarkupSourceGenerator automatic mode
A repeating style chain (card, heading, badge) Level 1 shorthand
A new “property” concept (density, brand typography) Level 2 four-overload template
The control already has that bindable property None — the generator has it covered

That last row is a warning: look for the generated method before writing your own. A second extension with the same name leads to ambiguity errors that are annoying to track down.

Summary

  • The fluent API is the output of a source generator, and the generator is not MAUI-specific — it works for any BindableObject.
  • [MauiMarkup(typeof(X))] is one line: properties, events, style setters and animation extensions follow, base classes included.
  • Attached properties use [MauiMarkupAttachedProp] with its four parameters.
  • To automate everything, MauiMarkupSourceGenerator, with a build-time trade-off.
  • Properties redefined with the same type are served by the base-class extension; genuine type-changing new redefinitions carry the New suffix.
  • For your own words: a composition shorthand for simple chains, the four-overload template when you need real property behaviour.

The result is that your whole codebase — MAUI controls, third-party controls and your own design system — speaks one language. That coherence, more than any individual method, is what C# markup actually buys you.

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