Porting a Flutter widget to C#, line by line
Two real Flutter widgets — Spacer and Placeholder — ported from Dart to C# with Plumix, and the translation rules that cover most other widgets.
Plumix's central promise is that porting a Flutter widget to C# should feel like translation, not a
rewrite. Here is what that looks like on two real widgets from Flutter's
packages/flutter/lib/src/widgets, shown next to the C# files Plumix ships. Only doc comments,
using lines and the debugFillProperties diagnostics are trimmed. The rest is the code as it is.
Warm-up: Spacer
Spacer takes up the free space in a Row or Column. In Dart, the whole widget is a few lines:
class Spacer extends StatelessWidget {
const Spacer({super.key, this.flex = 1}) : assert(flex > 0);
final int flex;
@override
Widget build(BuildContext context) {
return Expanded(flex: flex, child: const SizedBox.shrink());
}
}And this is src/Plumix/Widgets/Spacer.cs:
// Dart parity source: flutter/packages/flutter/lib/src/widgets/spacer.dart
namespace Plumix.Widgets;
public sealed class Spacer : StatelessWidget
{
public Spacer(int flex = 1, Key? key = null) : base(key)
{
if (Constants.KDebugMode && !(flex > 0))
{
throw new AssertionError("flex must be greater than zero.");
}
Flex = flex;
}
public int Flex { get; }
public override Widget Build(BuildContext context)
{
return new Expanded(flex: Flex, child: SizedBox.Shrink());
}
}The build method carries over almost word for word. What changes is the C# around it:
const Spacer({super.key, this.flex = 1})becomes an ordinary constructor with optional parameters. The key goes last and is passed up with: base(key).final int flexbecomes a get-only property, in PascalCase.assert(...)becomes a check that only runs in debug builds and throwsAssertionError, so a release build pays nothing for it, as in Flutter.- The named constructor
SizedBox.shrink()becomes a static factory,SizedBox.Shrink(). - The first line names the Dart file. Every framework file carries such a marker, and the repository generates a Dart-to-C# map from them.
Using it is just as familiar:
new Row(
children:
[
new Text("Plumix"),
new Spacer(),
new Text("v0.4"),
]
);A real one: Placeholder
Placeholder draws a box with a cross through it, the "something goes here" widget. It is small,
but it touches the parts that make ports interesting: a private painter class, default values that
are objects, an infinite size, and diagnostics. The Dart source:
class _PlaceholderPainter extends CustomPainter {
const _PlaceholderPainter({required this.color, required this.strokeWidth});
final Color color;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final Rect rect = Offset.zero & size;
final path = Path()
..addRect(rect)
..addPolygon(<Offset>[rect.topRight, rect.bottomLeft], false)
..addPolygon(<Offset>[rect.topLeft, rect.bottomRight], false);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(_PlaceholderPainter oldPainter) {
return oldPainter.color != color || oldPainter.strokeWidth != strokeWidth;
}
@override
bool hitTest(Offset position) => false;
}
class Placeholder extends StatelessWidget {
const Placeholder({
super.key,
this.color = const Color(0xFF455A64), // Blue Grey 700
this.strokeWidth = 2.0,
this.fallbackWidth = 400.0,
this.fallbackHeight = 400.0,
this.child,
});
final Color color;
final double strokeWidth;
final double fallbackWidth;
final double fallbackHeight;
final Widget? child;
@override
Widget build(BuildContext context) {
return LimitedBox(
maxWidth: fallbackWidth,
maxHeight: fallbackHeight,
child: CustomPaint(
size: Size.infinite,
painter: _PlaceholderPainter(color: color, strokeWidth: strokeWidth),
child: child,
),
);
}
}The C# port, src/Plumix/Widgets/Placeholder.cs:
// Dart parity source: flutter/packages/flutter/lib/src/widgets/placeholder.dart
internal sealed class PlaceholderPainter : CustomPainter
{
public PlaceholderPainter(Color color, double strokeWidth)
{
Color = color;
StrokeWidth = strokeWidth;
}
internal Color Color { get; }
internal double StrokeWidth { get; }
public override void Paint(PaintingContext context, Size size)
{
var pen = new Pen(new SolidColorBrush(Color), StrokeWidth);
var rect = new Rect(size);
context.Canvas.DrawRectangle(Brushes.Transparent, pen, rect);
context.Canvas.DrawLine(pen, rect.TopRight, rect.BottomLeft);
context.Canvas.DrawLine(pen, rect.TopLeft, rect.BottomRight);
}
public override bool ShouldRepaint(CustomPainter oldDelegate)
{
return oldDelegate is not PlaceholderPainter oldPainter
|| oldPainter.Color != Color
|| oldPainter.StrokeWidth != StrokeWidth;
}
public override bool? HitTest(Point position) => false;
}
public sealed class Placeholder : StatelessWidget
{
public static Color DefaultColor { get; } = Color.FromARGB(0xFF, 0x45, 0x5A, 0x64);
public Placeholder(
Color? color = null,
double strokeWidth = 2.0,
double fallbackWidth = 400.0,
double fallbackHeight = 400.0,
Widget? child = null,
Key? key = null) : base(key)
{
Color = color ?? DefaultColor;
StrokeWidth = strokeWidth;
FallbackWidth = fallbackWidth;
FallbackHeight = fallbackHeight;
Child = child;
}
public Color Color { get; }
public double StrokeWidth { get; }
public double FallbackWidth { get; }
public double FallbackHeight { get; }
public Widget? Child { get; }
public override Widget Build(BuildContext context)
{
return new LimitedBox(
maxWidth: FallbackWidth,
maxHeight: FallbackHeight,
child: new CustomPaint(
size: new Size(double.PositiveInfinity, double.PositiveInfinity),
painter: new PlaceholderPainter(Color, StrokeWidth),
child: Child));
}
}Again, build is a straight translation: LimitedBox wraps CustomPaint, with the same
parameters in the same places. The differences come from C#, plus one deliberate divergence.
- Object defaults. C# optional parameters must be compile-time constants, so
this.color = const Color(0xFF455A64)becomesColor? color = nullplus a staticDefaultColor. The caller sees the same behaviour: leave it out, and you get Blue Grey 700. - Private classes. Dart's library-private
_PlaceholderPainterbecomes aninternalclass. C# names can't start with an underscore by convention, andinternalis the nearest visibility. - Overrides can't narrow parameter types. Dart lets
shouldRepainttake a_PlaceholderPainter. C# requires the base signature, so the port takes aCustomPainterand pattern-matches. The result is the same comparison of colour and stroke width. - Named constants become expressions.
Size.infiniteis spelled out as an infiniteSize. - Painting. Here the port diverges on purpose. In Plumix,
CustomPainter.Paintreceives thePaintingContextrather than the bareCanvas, and this painter draws with a pen instead of aPaintand aPath. The rectangle and two diagonals are the same either way. The divergence is recorded in the repository's divergence table rather than hidden.
The rules that cover most widgets
After a few hundred files, the translation settles into a short table:
| Dart | C# in Plumix |
|---|---|
class X extends StatelessWidget | public sealed class X : StatelessWidget |
const X({super.key, this.a = 1}) | public X(int a = 1, Key? key = null) : base(key) |
final int a; | public int A { get; } |
@override Widget build(BuildContext context) | public override Widget Build(BuildContext context) |
assert(cond) | debug-only check that throws AssertionError |
const Color(0xFF455A64) as a default | nullable parameter plus a static default |
SizedBox.shrink() (named constructor) | SizedBox.Shrink() (static factory) |
_Private class | internal class |
State<MyWidget> and setState(() {...}) | State<MyWidget> and SetState(() => ...) |
..cascade calls | object initializers or sequential calls |
Stateful widgets follow the same pattern. createState, initState, didUpdateWidget and
dispose keep their names in PascalCase, as the stateful widgets guide shows.
How Plumix keeps ports honest
Translating the code is half the job. The other half is proving it behaves the same. Plumix treats
the Dart source as the specification: a control is ported end to end, with defaults, states, layout,
painting and tests, in one go. Flutter's own widget tests are ported too, through
FrameworkDartTester, a C# version of flutter_test's finders and matchers. A reference app written
in real Flutter is kept in lockstep with the C# sample gallery, so the two can be compared screen by
screen.
Try it on your own widget
Pick a small StatelessWidget from your Flutter app and translate it with the table above. If it
only composes standard widgets, it will usually compile on the first or second try. Every widget it
uses is listed in the controls catalog under the same name. To set up a project, start
with the getting-started guide.
More from the blog
- Plumix 0.4 beta: the Cupertino port, Android and iOS hosts
Plumix 0.4 is the first beta: strict ports of Flutter 3.47's framework, Material and Cupertino libraries, plus Android, iOS and browser hosts.
- Flutter for C# developers in 2026: what actually exists
Can you write Flutter in C#? A survey of every route in 2026 — Flet bridges, transpilers, MVU libraries and Plumix — and what each one really gives you.
