An app I hadn’t touched since its last store build stopped opening in my IDE. Not a compiler error, not a failing test. I opened the solution and two of the projects were simply dead in the tree. Greyed out. Unloadable. I hadn’t changed a single line. The ground underneath the code had been quietly removed while I wasn’t looking.
This is the story of dragging that app, a mobile inspection tool I maintain for a client in the lifting-equipment trade, from Xamarin.Forms into .NET 10 MAUI over a long afternoon (well a couple!). Field techs use it to inspect the gear that stops heavy things falling on people: round slings, shackles, chain blocks, harnesses, eyebolts, sixty-odd categories of it. They log in, pull the asset list for their site, work a question set per item, and pass or fail each one. A failure means a photo, a written-up defect, and the item tagged out of service.
The last build shipped as v2.2410.1100 in October 2024. Two commits in the whole repo. The app was done, if I’m honest, and I was happy to leave it that way. What I didn’t expect was that reopening it would end with a better app than I started with, on its way back to both stores.
The diagnosis
I opened the solution in Rider expecting nothing, and got exactly that. Radify.Android and Radify.iOS wouldn’t load. No stack trace to chase. The shared netstandard2.0 project, where all the actual app lives, loaded perfectly fine. It was only the two platform heads that had died.
That’s the tell. Nothing I wrote was wrong. Two things had been removed from the world beneath me.
Xamarin reached end of life on 1 May 2024. The Xamarin.Android and Xamarin.iOS workloads aren’t shipped or installable anymore, and the .NET 10 SDK on my machine (10.0.100) has no concept of Xamarin at all. As far as it’s concerned, that framework never existed.
Rider wound its Xamarin support down through 2024. My head projects were the old, pre-SDK MSBuild format: ToolsVersion="4.0", the 2003 XML namespace, ProjectTypeGuids, and imports of Xamarin.Android.CSharp.targets and Xamarin.iOS.CSharp.targets. Those .targets files no longer exist on disk, so Rider can’t even evaluate the projects, let alone build them. It isn’t that the build fails; there’s nothing left to build against. JetBrains announced the wind-down back in March 2024 (support through 2024.1, maintenance mode by year end), and by the time I came back, getting on for two years later, it had well and truly run out.
This is software rotting in place. Zero commits, zero changes, and it went from shipping-in-the-store to won’t-open-in-the-IDE while doing absolutely nothing. The half-life of a mobile toolchain is shorter than the half-life of the app it builds, and nobody sends you the memo.
The decision: don’t upgrade in place
I did not try to rescue the .csproj files. They’re too far gone: non-SDK format, dead target imports, dead project-type GUIDs. Fighting a corpse into standing upright is a losing afternoon.
Instead, dotnet new maui, scaffold a clean .NET 10 single project, move the code across, delete the old heads entirely.
This was cheap for one reason, and it’s the whole reason it went the way it did (and someone else’s won’t): all the value was in the shared project, and it was already SDK-style. The two platform heads were maybe 150 lines of boilerplate between them, a MainActivity, an AppDelegate, a Main.cs, some plists and a manifest, and MAUI’s single-project model regenerates all of that for free. Nothing in there to salvage, so I didn’t.
The honest scope before I started: nine XAML pages plus code-behind to port, around 260 lines of models and a WebAPI class that’s plain .NET and Newtonsoft (near-verbatim), and platform heads to throw away. The app logic came across almost unchanged. The UI was where all the work lived.
The rebuild: leaning harder on DevExpress, not lighter
A forced migration is a chance to level up your stack, not retreat from it. The DevExpress MAUI controls aren’t simply a one-for-one port of the Xamarin controls; they’re a bigger, better-built set. So every time an old Xamarin control didn’t survive the jump, I had a choice: drop to a stock MAUI control, or push the UI further onto DevExpress. I went the second way almost every time.
Here’s what the migrated app ended up using:
| DevExpress control | Uses |
|---|---|
DXButton | 53 |
DXCollectionView | 28 |
DXPopup | 18 |
TextEdit | 13 |
ComboBoxEdit | 8 |
PasswordEdit | 2 |
MultilineEdit | 2 |
CheckEdit | 2 |
Zero stock MAUI Buttons. Zero stock CheckBoxes. Three changes stood out, because each ended up better than what I had before.
SimpleButton became DXButton, and it’s a straight upgrade. The DevExpress Xamarin suite’s dxe:SimpleButton doesn’t exist in MAUI. The replacement isn’t a plain button; it’s DXButton from the DevExpress.Maui.Core, a much richer control with ButtonType, Icon and IconColor, and a full set of state colours (PressedBackgroundColor, PressedTextColor, PressedBorderColor, DisabledTextColor, PressedScale, and the rest) that SimpleButton never had.
<!-- Xamarin --><dxe:SimpleButton Text="Login" BackgroundColor="#f58220" CornerMode="Round" CornerRadius="7" /><!-- MAUI --><dx:DXButton Content="Login" BackgroundColor="#f58220" TextColor="White" CornerRadius="8" />
Note Text became Content: DXButton is a content presenter, so you can put anything inside it, not just a string.
The FAB menu got rebuilt on DXPopup, and the interaction got better. Every page had a floating action menu built from the Xamarin CommunityToolkit’s Expander with Direction="Left". In MAUI that pattern is dead twice over: the toolkit’s Expander only opens Down or Up, and its header auto-toggles, which would have double-fired against my existing Clicked handler and quietly cancelled itself out. A nasty little bug I’d have chased for an hour if I’d ported it faithfully.
So I rebuilt it as an anchored DXPopup, which is DevExpress’s own documented pattern for exactly this:
<dx:DXButton x:Name="MainFAB" Content="..." Clicked="MainFAB_Clicked" /><dx:DXPopup x:Name="fabMenu" PlacementTarget="{x:Reference MainFAB}" Placement="Bottom" CornerRadius="12"> <HorizontalStackLayout Padding="8" Spacing="8"> <dx:DXButton Content="+" Clicked="btnAddClicked" IsVisible="{Binding CanAddAsset}" /> <dx:DXButton Content="[->" Clicked="btnExitClicked" BackgroundColor="Red" /> </HorizontalStackLayout></dx:DXPopup>
private void MainFAB_Clicked(object sender, EventArgs e) => fabMenu.IsOpen = !fabMenu.IsOpen;
PlacementTarget anchors it to the button, Placement sets the direction, and it handles the shadow, the animation and dismiss-on-tap-outside for free. I’d swapped a third-party toolkit dependency for a first-class DevExpress control, and it behaved better for it.
CheckEdit ported one-to-one. This is the “it just worked” note. The Xamarin CheckEdit markup moved across unchanged: Label, LabelColor, CheckBoxColor, CheckedCheckBoxColor and CheckBoxIndent are all still there in the MAUI CheckEdit. The only code change is that MAUI’s IsChecked is a nullable bool? (it supports a third, indeterminate state), so if (chk.IsChecked) becomes if (chk.IsChecked == true). That’s the whole diff.
Two smaller things earned a nod each. The three separate Xamarin namespaces (dxe: editors, dxcv: collectionview, dxp: popup) collapsed into one unified xmlns:dx="http://schemas.devexpress.com/maui", and every DevExpress control now lives under it, which makes the XAML noticeably tidier. And registration went declarative: where Xamarin had me sprinkle Initializer.Init() calls in both the App constructor and the AppDelegate, MAUI does it once, in the host builder, the same way every other MAUI library does:
builder .UseMauiApp<App>() .UseDevExpress(useLocalization: false) .UseDevExpressControls() .UseDevExpressCollectionView() .UseDevExpressEditors();
And a free upgrade I never asked for: the MAUI PasswordEdit includes a built-in password reveal button. The login screen picked up a feature just by moving across.
The other gotchas
Not everything was a DevExpress win. A few things simply broke, and one was the best find of the day.
The code was smuggling data through an accessibility property. The inspection page had buttons like this:
<dxe:SimpleButton Text="NO" TabIndex="{Binding QuestionNo}" Clicked="btnFailPressed" />
var btn = sender as SimpleButton;FailItem(btn.TabIndex - 1);
TabIndex, a keyboard-navigation property, was being used as a general-purpose slot to carry the question number into the click handler. It worked in Xamarin. It shipped, for years. MAUI doesn’t expose TabIndex on buttons, so it blew up at compile time, and the fix is what it should always have been. DXButton inherits Command and CommandParameter from DXButtonBase:
<dx:DXButton Content="NO" CommandParameter="{Binding QuestionNo}" Clicked="btnFailPressed" />
private static int QuestionNoOf(object sender) => Convert.ToInt32(((DXButton)sender).CommandParameter);
The compiler caught a design smell that had been quietly in production the whole time. Migrations are free code review. You can’t read your way to a find like this; you have to move the code.
Toasts are a different, much smaller API. The Xamarin CommunityToolkit had a fluent DisplayToastAsync with a ToastOptions object: custom colours, fonts, view anchoring. CommunityToolkit.Maui gives you:
await Toast.Make(text, ToastDuration.Short).Show();
Short or Long. No colours, no anchoring. I kept my old showToast(text, ms, withImage) signatures so the call sites didn’t change and mapped the duration behind them, but toast styling is significantly reduced compared to the Xamarin CommunityToolkit, and that’s a CommunityToolkit limitation, not a DevExpress one. Worth saying so rather than pretending the migration was lossless.
Xamarin.Essentials is gone, in the good way. It’s baked into MAUI now behind a .Default / .Current instance pattern: Preferences.Default.Get(...), MediaPicker.Default.CapturePhotoAsync(), Connectivity.Current.NetworkAccess, MainThread.BeginInvokeOnMainThread. Because those APIs are now part of MAUI itself, shipped behind a big pile of implicit global usings, most of my using Xamarin.Essentials; lines became straight deletions rather than rewrites. Satisfying.
And the odds and ends. Color.Green became Colors.Green (named colours moved onto Colors; Color is just the type now). The XAML root namespace went from http://xamarin.com/schemas/2014/forms to http://schemas.microsoft.com/dotnet/2021/maui. And my two platforms have different bundle IDs, which MAUI’s single project doesn’t love (it wants one ApplicationId), so I set it with a per-TFM MSBuild condition.
The NuGet pin that bit me. I added CommunityToolkit.Maui 14.2.0 and got:
error NU1605: Detected package downgrade: Microsoft.Maui.Controls from 10.0.60 to 10.0.20
CommunityToolkit.Maui versions are tightly coupled to the installed MAUI workload version. My installed workload was MAUI 10.0.20; toolkit 14.2.0 demanded 10.0.60 or higher. I dropped to CommunityToolkit.Maui 13.0.0 (happy with 10.0.10 or higher) and it resolved instantly. For contrast, the DevExpress MAUI packages (v25.2.7) also want 10.0.10 or higher and slotted in with zero fuss. Lesson worth a callout: check a package’s MAUI dependency against your installed workload version, not the latest one. dotnet workload list tells you what you’ve actually got.
The archaeology
Two things I dug up that had nothing to do with the migration and everything to do with the fact that I’d never really looked.
A NotImplementedException landmine, wired straight to the training carousel’s CurrentItemChanged:
private void TheCarousel_OnCurrentItemChanged(object sender, CurrentItemChangedEventArgs e){ throw new NotImplementedException();}
An auto-generated stub I never filled in. It would have thrown the instant a user swiped between training slides. Shipped. Fixed to a no-op, because nothing actually needs to happen there.
And then the icon, which was never actually the app’s icon. On Android, the adaptive icon’s foreground was still the stock Xamarin blue hexagon from the project template. It had been shipping like that, in the stores, on every field tech’s phone. The real brand mark (a white logo on orange, #f58220) existed only inside the iOS icon-1024.png. I pulled it out and made it the real icon on both platforms in MAUI.
So here’s the confession: I shipped a Xamarin logo to production Android users and never noticed. Nobody complained. Make of that what you will.
Where it landed
Clean builds for net10.0-android and net10.0-ios (Android and iOS only; no Windows, no Mac Catalyst). It runs on the iOS simulator, and the login screen now renders entirely in DevExpress controls: TextEdit and PasswordEdit with the orange floating labels, a DXButton for Login, a CheckEdit for “Remember me”, and the built-in password reveal. Real brand icon on the home screen, both platforms, for the first time. The old Xamarin projects are gone: one Radify.sln, one Radify.App/. Total elapsed: a couple of days.
And here’s the part that actually matters. It’s on its way back to the App Store and Google Play. An app that a couple of days earlier wouldn’t even open in my IDE is heading back out to the techs who rely on it, on a current, supported, actively-developed stack, with a real icon at last. The Xamarin end-of-life notice felt like a death sentence for this app. It turned out to be the thing that got it a fresh coat of paint and a future.
Now the honest caveats, because I don’t want to oversell it. It’s submitted, not yet approved and live; review is review, and building and running is not shipping. The minimum iOS floor moved from 14 to 15 (that’s .NET 10 MAUI’s floor, so a handful of very old devices drop off, small but real). Toast styling is significantly reduced compared to the Xamarin CommunityToolkit. And the code is still the code: .Result blocking calls scattered through async methods, BindingContext = this where a view model belongs, and a GetSectionIndex that is a sixty-branch if chain of .Contains("round sling"), .Contains("shackle"), and on it goes. I migrated it. I did not redeem it. That was deliberate. One migration at a time, and that’s the next post.
One more honest note on how this got done. I ran the migration paired with Claude. The mechanical grind, renaming namespaces, chasing down every SimpleButton, mapping the Essentials calls onto their .Default and .Current forms, is exactly the kind of work an assistant is good at, and it kept pace while I made the calls that actually needed a human: rebuilding the FAB on DXPopup, killing the TabIndex hack, deciding what to leave well alone. The engineering decisions stayed mine. The typing got a second pair of hands.
The lesson
Two things to take away. First, when you’re estimating a framework migration, don’t count your lines of code. Count your controls. My app logic (models, HTTP, business rules) ported near-verbatim in minutes; essentially all the real work was in the UI layer. Second, when the platform forces your hand, use the move to level up rather than to retreat. I could have limped across on stock MAUI controls. Instead I put the whole interface onto DevExpress’s MAUI suite, which is richer than what I left behind, and the app came out the far side better than it went in.
It went well partly because the app was small and the shared project was already SDK-style. If you’re sitting on a three-hundred-page Xamarin app full of custom renderers, your week is going to be a lot worse than my couple of days were, and you deserve to hear that rather than be made to feel slow. But the clock is real either way. The toolchain has already expired. The only question left is whether you migrate on your schedule, or on the day your IDE stops opening the file.