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
newredefinitions carry theNewsuffix. - 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.