Templated Controls in Xamarin.Forms

Contents

Sometimes you want to create that awesome control, but also want the user to be able to totally customize it… What do you do? Create a templated control!

Xamarin.Forms has a pretty cool view that we can use: TemplatedView (API docs).

More information on how to use templated controls and page can be found in the docs: https://docs.microsoft.com/xamarin/xamarin-forms/app-fundamentals/templates/control-template

So, on this journey to create the best templated control, let us create a cool and exciting “confetti view” like this:

In most cases we would just create a single view, no need to template anything. But, we would also like to try out templated views… and the user may want to place borders, backgrounds or some other fancy thing. If we can support this, then why not?

The Control

So, how does this work? Very simple! All you need is a new type that is going to be the control. Make sure it derives form TemplatedView:

public class SKConfettiView : TemplatedView
{
}

There we go! All done! Thanks for reading.

Just kidding, we need to show how to use it!

This is just like a normal control, so we can add it to our page as we would any other control:

<ContentPage ...
    xmlns:controls="clr-namespace:SkiaSharp.Extended.Controls">

    <controls:SKConfettiView />

</ContentPage>

When we run the app, we will have an invisible control in the app. We can’t see anything, but now we can get started on building up the control.

The Template

Now that we have the files we need, we can add the template. This is straight forward, and we just add a new <ControlTemplate> to the ControlTemplate property:

<ContentPage ...
    xmlns:controls="clr-namespace:SkiaSharp.Extended.Controls"
    xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms">

    <controls:SKConfettiView>
        <controls:SKConfettiView.ControlTemplate>
            <ControlTemplate>
                <skia:SKCanvasView x:Name="PART_DrawingSurface" />
            </ControlTemplate>
        </controls:SKConfettiView.ControlTemplate>
    </controls:SKConfettiView>

</ContentPage>

So, let us have a look at all the moving parts in this XAML.

  1. First, there is the ControlTemplate property. This is just where we place the new template that we want to use for the control.
  2. Next, there is the <ControlTemplate> element. Just like with a data template, the actual views we want to add to the new control must go inside one of these.
  3. Now, there is <skia:SKCanvasView> element. This is just what we are going to use as our control. Not much now, but when a user overrides this template, they may want to wrap this is a frame to get a nice border.
  4. Finally, there is the x:Name="PART_DrawingSurface" attribute. This is what we will use to talk to the actual controls inside that we care about. In our control, we just care about the drawing surface, so we give that a name. If the user adds some cool frame, then we don’t need to worry about that.

As a demonstration of all this and a customization, I take this XAML (the red background is just so that we can see the surface):

<StackLayout>
    <Label Text="Plain control" Margin="20" />

    <controls:SKConfettiView Margin="20">
        <controls:SKConfettiView.ControlTemplate>
            <ControlTemplate>
                <skia:SKCanvasView x:Name="PART_DrawingSurface"
                                   BackgroundColor="Red" />
            </ControlTemplate>
        </controls:SKConfettiView.ControlTemplate>
    </controls:SKConfettiView>

    <Label Text="Custom control" Margin="20" />

    <controls:SKConfettiView Margin="20">
        <controls:SKConfettiView.ControlTemplate>
            <ControlTemplate>
                <Frame Padding="20">
                    <skia:SKCanvasView x:Name="PART_DrawingSurface"
                                       BackgroundColor="Red" />
                </Frame>
            </ControlTemplate>
        </controls:SKConfettiView.ControlTemplate>
    </controls:SKConfettiView>
</StackLayout>

And it renders like this:

Even though we have two different controls with two different view structures, we still have the same essential control because both have the core element in the template:

<skia:SKCanvasView x:Name="PART_DrawingSurface" />

The Code Behind

Because we are making a control that we actually want to do something, we need some code. To do this, we hop on over to our C# code file.

The way templated controls work is very similar to other controls, but with one additional feature – they allow the actual control to use a template. But, this means that anyone can go and put anything in that template. So, how do we get access to those views in the template? By listening for when the template is applied.

When a template is applied to a control, there is a method (OnApplyTemplate) that we can override and start to access the things in the template by name. For example, in our control template, we have a SkiaSharp SKCanvasView that is named PART_DrawingSurface. Now, in our code file, we can use the GetTemplateChild to get that child view:

public class SKConfettiView : TemplatedView
{
    protected override void OnApplyTemplate()
    {
        // get the child
        var templateChild = GetTemplateChild("PART_DrawingSurface");
        if (templateChild is SKCanvasView canvasView)
        {
            // subscribe to the paint even of the child so we can redraw
            canvasView.PaintSurface += OnPaintSurface;
        }
    }

    private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
    {
        e.Surface.Canvas.Clear(SKColors.Green);

        // TODO: draw the real thing
    }
}

When the framework determines it is time to build the actual control we see, it will invoke the OnApplyTemplate method, which we can then override to do things, such as subscribe to events or set properties. In our confetti control we are making, we just want to subscribe to the PaintSurface event and draw the confetti.

The GetTemplateChild method should only be called after the OnApplyTemplate method has been called.

If we run the code now, you can see that in both controls, the canvas has drawn a nice green background – even the one that has some weird frame around it! And this green is drawn from the event that we just subscribed to in the OnApplyTemplate method.

The Library

All done! Even though the confetti is not being drawn, our control is ready to package up. So, how do we do that? Again, very simple! We just create a new Xamarin.Forms class library and move the control there.

One this to remember is that when moving a control out of the main app project, we will have to update the XML namespaces and add the assembly= part:

<!-- from -->
xmlns:controls="clr-namespace:SkiaSharp.Extended.Controls"

<!-- to -->
xmlns:controls="clr-namespace:SkiaSharp.Extended.Controls;assembly=SkiaSharp.Extended.Controls"

Once the control is moved, we add a reference to this new library in our app, and make sure that it all works again.

However, if we package this library up and push to NuGet, every single developer out there will have to create their own control template. This is not how we want anyone to live! So, we ship our own template in the library!

The Resources

We could do a few things and place templates in a few places, but we want the control to use our default theme, but also allow for custom templates. So, in order to do this nicely, we can create a custom resource dictionary in our library. I decided to follow the pattern of UWP and WPF and place our “generic” control template in the “Themes\Generic.xaml” file.

Because there is no “resource dictionary” item template in Visual Studio, I just created a new XAML page and replaced the contents of both files.

In the XAML file, I specify the dictionary:

<ResourceDictionary xmlns="http://xamarin.com/schemas/2014/forms"
                    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                    xmlns:local="clr-namespace:SkiaSharp.Extended.Controls"
                    x:Class="SkiaSharp.Extended.Controls.Themes.Generic">
</ResourceDictionary>

In the C# code-behind, I change the base type:

public partial class Generic : ResourceDictionary
{
    public Generic()
    {
        InitializeComponent();
    }
}

Next, we need to move the template from the first control in our page into the resource dictionary XAML file. I went with three new entries:

  1. The new control template.
  2. The explicit style that applies that control template. This explicit style allows for extensions using the BasedOn property of styles.
  3. The implicit style that will apply our explicit style to all of the instances of our control.
<ResourceDictionary xmlns="http://xamarin.com/schemas/2014/forms"
                    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                    xmlns:local="clr-namespace:SkiaSharp.Extended.Controls"
                    xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
                    x:Class="SkiaSharp.Extended.Controls.Themes.Generic">

    <!-- the control template for SKConfettiView -->
    <ControlTemplate x:Key="SKConfettiViewControlTemplate">
        <skia:SKCanvasView x:Name="PART_DrawingSurface" />
    </ControlTemplate>

    <!-- the explicit style that allows for extension -->
    <Style x:Key="SKConfettiViewStyle" TargetType="local:SKConfettiView">
        <Setter Property="ControlTemplate"
                Value="{StaticResource SKConfettiViewControlTemplate}" />
    </Style>

    <!-- the implicit style that applies to all controls -->
    <Style TargetType="local:SKConfettiView"
           BasedOn="{StaticResource SKConfettiViewDefaultStyle}" />

</ResourceDictionary>

This is a bit more XAML as we could have just created a single implicit style that also has the control template, but the multiple parts allow for multiple extension points and for reuse of parts if need be.

In order to test all this fancy new XAML, we can update our page to only provide a custom template for the first control:

<StackLayout>
    <Label Text="Plain control" Margin="20" />

    <controls:SKConfettiView Margin="20" />

    <Label Text="Custom control" Margin="20" />

    <controls:SKConfettiView Margin="20">
        <controls:SKConfettiView.ControlTemplate>
            <ControlTemplate>
                <Frame Padding="20">
                    <skia:SKCanvasView x:Name="PART_DrawingSurface" BackgroundColor="Red" />
                </Frame>
            </ControlTemplate>
        </controls:SKConfettiView.ControlTemplate>
    </controls:SKConfettiView>
</StackLayout>

When we run the app now, the first control is missing everything! What has happened? Is all lost? No! This is because the app does not know about this fancy new style we just created.

The Registration

There are a couple of ways to solve this. First we could just import the resource dictionary into the page or into the app. Because I want this style to apply to all parts of my app, I went with the app resources:

<Application xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:themes="clr-namespace:SkiaSharp.Extended.Controls.Themes;assembly=SkiaSharp.Extended.Controls"
             x:Class="SkiaSharpDemo.App">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <themes:Generic />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

We are basically done now. No matter what we do, we are importing our styles and templates, and we also allow for total customization.

But.

We had to do work to get the styles to apply! We never want to do work. If we ship this out to NuGet, every single developer will forget to add the styles, we will get a new issue on our repository, we will be sad! No! We want magic!

In UWP and WPF, the path that we used for our XAML resources (Themes\Generic.xaml) is actually pretty special. So special in fact they have a docs page on this:

Theme-level dictionaries are stored in a subfolder named Themes. The files in the Themes folder correspond to themes. For example, you might have Aero.NormalColor.xaml, Luna.NormalColor.xaml, Royale.NormalColor.xaml, and so on. You can also have a file named Generic.xaml. When the system looks for a resource at the themes level, it first looks for it in the theme-specific file and then looks for it in Generic.xaml.

Now, it would be wonderful if Xamarin.Forms also was able to do something like this, wouldn’t it? Well, I agree! So I just opened an issue for this exact reason. I will try get it implemented.

But.

There is always a but! What about today? We can’t wait for some feature to ship! We need that confetti, now! We could throw up our hands and say “oh, well, ’tis what it is”. But we won’t! We come up with solutions!

The Hack

One way that seems to work just fine is to register the theme inside the control. And, we can do this by inserting our theme in the very same MergedDictionaries property that we just used.

Before we start hacking, we can remove that pesky code we just added to the App.xml and we will see our control disappear again. But not to worry, we will use magic to fix it.

This magic is pretty straight forward, in the Generic resource dictionary with out styles, we add a nice method to the code-behind:

public partial class Generic : ResourceDictionary
{
    private static bool registered;

    public Generic()
    {
        InitializeComponent();
    }

    internal static void EnsureRegistered()
    {
        // don't do extra work
        if (registered)
            return;

        // get the dictionary if we can
        var merged = Application.Current?.Resources?.MergedDictionaries;
        if (merged != null)
        {
            // check to see if we are added already
            foreach (var dic in merged)
            {
                if (dic.GetType() == typeof(Generic))
                {
                    registered = true;
                    break;
                }
            }

            // if we are not added, add ourselves
            if (!registered)
            {
                merged.Add(new Generic());
                registered = true;
            }
        }
    }
}

Finally, we can call this method in our control’s constructor:

public SKConfettiView()
{
    Generic.EnsureRegistered();
}

Only now we are done! We now have a very nice library that works automatically and is awesome for customization.

Who cares about the view anyway?

While working on an issue, I discovered a cool way in which to draw using SkiaSharp – without caring about view sizes at all.

In many cases, drawing code does not really care about the actual view sizes or canvas sizes. For example, if you are drawing a button, the drawing code is going to fill the entire view. If you are working on some image, you typically want the image to fill the view.

This is because in most cases, the UI framework will do all the view sizing based on your constraints, available space and any parameters. Then, you want to draw the best image on the size that the UI framework gave you.

Let’s take a practical example: we want to make a badge with a number on it.

Badge

If we assumed that the badge will always be 15×15, then we would think this would be easy. All our sizes would be around that number. But since we are drawing a circle from the center, we actually have a center of 7.5 and a radius of 7.5. This is not a problem in any way for SkiaSharp, but it is a bit for us as we now have more things to do.

If we were to draw this without checking screen density or actual view size, we might get something like this:

Tiny badge

This is because we have used numbers like 15 and 7.5. These numbers are actually tiny when compared to the thousands of pixels on modern devices.

If we used bigger numbers, this help, but not fix all the issues. For example, this might happen:

Too big Too small

This is a result of the way SkiaSharp and UI frameworks work. The way SkiaSharp does pixels is a bit different to typical UI frameworks. When designing a UI, the framework knows about screen densities so this typically means that UI frameworks will scale UI elements automatically.

For example, if there is a view with a size of 15×15, in most cases you would expect this view to be the same size across all screens and devices. So, if you were on an iPhone, this might translate to a 30×30 view. On some Android devices, they use a density of 3.5, so this translates to a 52.5. As a result of the screen density, placing the devices side by side results in very similar view sizes to our eyes.

However, SkiaSharp does not care about screen densities (maybe incorrectly, but that is for an open issue). Regardless of the device or platform, it asks the OS for the exact bounds of the view – in raw pixels. So, if we take our previous examples of iOS and Android, we will end up with different canvas sizes: 30×30 and a 52×52 (because there is no such thing as a .5 raster pixel).

Drawing Correctly

If we know that SkiaSharp does not scale automatically, we can use simple arithmetic to work out that we can just scale before we draw.

In all our drawing code examples, we are going to draw a 15×15 circle in the center of the view so that it touches the edges:

  // assume a perfect 15x15 canvas and view
  canvas.DrawCircle(7.5f, 7.5f, 7.5f, paint);
  

Screen Desnsity

To handle screen density, we could use something like Xamarin.Essentials. There is a nice DeviceDisplay API that will give us the main screen’s density:

// get the density
var density = (float)DeviceDisplay.MainDisplayInfo.Density;

// scale to the density
canvas.Scale(density);

// draw
canvas.DrawCircle(7.5f, 7.5f, 7.5f, paint);

This would work.

In that one case.

If we need to support devices where density may change or has multiple displays, then we can use a trick of just dividing the canvas size by the view size:

  var density = e.Info.Width / (float)this.Width;
  

NOTE: The e in the code above is from the event args of the PaintSurface event found on the various SkiaSharp views.

View Size

What happens if you decide that a 15×15 badge is too small and want to use 16×16 or even a big 20×20? Or, in a case where 15×15 is too big? You would think to just make the view bigger and all would be well.

Unfortunately, it won’t.

Because you may have used sizes like radius = 7.5f or a textSize = 8f.

Using exact sizes is often just fine, if you are very sure that the view size will never change. But, as developers, we can not assume that.

A smart developer might think to use the view size instead of hard coordinates. Then using arithmetic, scale things relative to that:

// get the density
var density = e.Info.Width / (float)this.Width;

// scale to the density
canvas.Scale(density);

// draw
var halfWidth = (float)this.Width / 2f;
canvas.DrawCircle(halfWidth, halfWidth, halfWidth, paint);

That would work.

In many cases.

If you have a lot of drawing to do, there may be many, many division and multiplication operations. Modern CPUs are very fast, but why even do that? Also, with every draw, you might end up with many other operations to calculate the size and position.

An example would be if padding was needed. If you were going for a 2px padding, then this would also have to be calculated:

var halfWidth = (float)this.Width / 2f;
var padding = 2f;
canvas.DrawCircle(halfWidth, halfWidth, halfWidth - (padding * 2), paint);

And this gets worse if you want the padding to be based on the circle size, maybe with a 10% padding:

var halfWidth = (float)this.Width / 2f;
var padding = (float)this.Width / 10f;
canvas.DrawCircle(halfWidth, halfWidth, halfWidth - (padding * 2), paint);

This involves lots of arithmetic – who really wants to deal with that when trying to draw a basic circle?

Not me.

Arbitrary Size

What if we could draw things using any base size, and then let the graphics engine do all the work? Like it was designed to do.

Now this is probably not something that I invented or discovered. I may have thought of it now, but I am not always the brightest person so that basically means everyone already knows. But, let’s assume that there was one other person that didn’t know.

So, what if we could take our 15×15 circle and convert that thought into a 100×100 circle and let the engine do the work to make our numbers appear as if it was 15×15?

We can do that with SkiaSharp.

It is called scaling. Pretty obvious, I know, but it only took a few years.

Instead of doing “advanced” arithmetic to calculate sizes, offsets and percentages, we can use any base size that makes things easier. In our example, we have a full-view circle with a number in the center.

So why not use 100 as the base size and have the circle have a radius of 50? Well, we can.

// pick a cool number for us
const float baseSize = 100f;

var canvas = e.Surface.Canvas;

// scale to our base unit
canvas.Scale(e.Info.Width / baseSize);

var circleFillPaint = new SKPaint {
    IsAntialias = true,
    Style = SKPaintStyle.Fill,
    Color = 0xFFFF5722,
};

// center of 50x50 and a radius of 50
canvas.DrawCircle(50f, 50f, 50f, circleFillPaint);

// we can even use nice numbers for font sizes
var textPaint = new SKPaint {
    IsAntialias = true,
    TextSize = 70f,
    Color = SKColors.White,
};

// measure the text width
var textBounds = SKRect.Empty;
var advance = textPaint.MeasureText(BadgeValue, ref textBounds);

// draw the text at the center (x = 50 - half the width; y = 100 - half text height)
canvas.DrawText(BadgeValue, 50f - (advance / 2f), 100f - (textBounds.Height / 2f), textPaint);

Now, we can use this exact code to render a perfect badge, regardless of the view size, the screen density or any other view-related property. The reason for this is that the first thing we do is convert from drawing units into our perfect unit:

canvas.Scale(e.Info.Width / baseSize);

As long as our numbers are based on baseSize, then we can never go wrong.

Beware of C# 8 Using Statements

Let’s be honest here, C# 8 is crazy cool. It has such a big set of new features to make developers more productive and write even better code. There are some awesome things, like nullable reference types and asynchronous streams (async enumerables). There are also some weird things, like private members on interfaces. But there are many features that just make life better. Using statements is one of them.

If you want to see a list of all the new things, check out the Microsoft docs. It has all the goodness.

But, let’s get back to the using statements. What are they? Where are they used? Well, using statements are just normal using blocks with some syntactic sugar to make the code more readable. Here is an example of a normal, traditional using block:

using (var stream = File.Create("test.txt"))
using (var writer = new StreamWriter(stream))
{
    writer.Write("Hello World!");
}

This is a very simple example. There are two objects that need to be disposed, and we have them in the using blocks. This code creates a file, creates a writer and then writes to the file. At the end, the writer closes the stream and the file is closed. We have all seen this before.

So, what does C# 8 bring to the table? Well, lets convert the using block into a using statement:

using var stream = File.Create("test.txt");
using var writer = new StreamWriter(stream);

writer.Write("Hello World!");

It is very similar, but there are no braces and parenthesis. There are no indents.

The Good

That was a simple example, but just imagine the case where there are loops, conditions and other statements. It could, and does, get a bit messy. Take a look at this example:

var array = new[] { "first", "second", "third" };
foreach (var item in array)
{
    using (var stream = File.Create($"{item}.txt"))
    {
        stream.WriteByte(1);
        if (item != null)
        {
            using (var writer = new StreamWriter(stream))
            {
                writer.Write("Hello World!");
            }
        }
    }
}

Lots of nesting, lots of indents, lots of braces. With using statements we can remove 4 lines and 2 levels of indents. Much better on the eyes:

var array = new[] { "first", "second", "third" };
foreach (var item in array)
{
    using var stream = File.Create($"{item}.txt");
    stream.WriteByte(1);
    if (item != null)
    {
        using var writer = new StreamWriter(stream);
        writer.Write("Hello World!");
    }
}

The Bad

We saw a nice saving of everything, so what is this “beware” in the title? Well, it has to do with scopes. The using statements are nice as they dispose/close things when they go out of scope.

If we look at the example above, we can see the stream variable goes out of scope at the end of each iteration of the foreach loop. This means that after the code in the foreach block runs, it is auto disposed. That is nice. But, this is also the cause of the “beware”. Take a look at this example:

using (var stream = File.Create("test.txt"))
using (var writer = new StreamWriter(stream))
{
    writer.Write("Hello World!");
}

return File.ReadAllText("test.txt");

Here, we create a file, write to it, dispose it and then return the results. We probably wouldn’t write this exact code in real life, but hey, examples! If we were to convert this into using statements, as one would be tempted, we end up with this code:

using var stream = File.Create("test.txt");
using var writer = new StreamWriter(stream);

writer.Write("Hello World!");

return File.ReadAllText("test.txt");

That looks nice, but has a “hidden” bug. If you think back to the using statements and their scope… Where does the stream go out of scope? At the end of the method, after the return. So what does this mean? Well, it is that the stream is still open and the file handle is being held. That means we will get an exception:

Unhandled exception. System.IO.IOException:
The process cannot access the file ‘test.txt’ because it is being used by another process.

Not so nice.

We can have a look at what the compiler writes to help us understand what just happened:

using (var stream = File.Create("test.txt"))
using (var writer = new StreamWriter(stream))
{
    writer.Write("Hello World!");

    return File.ReadAllText("test.txt");
}

As you can see, the return is inside the using block.

Not what we wanted.

The Ugly

Just when we thought we had the bad news, there is more. An exception is not nice, but at least we know something went wrong. We can fix that. So what is worse than an unhandled exception? No exception!

If our operating system was cool with opening a file that was already opened. We cannot be sure that the contents had actually been flushed! Some streams only flush when the stream is either explicitly flushed or when it is closed.

So, if we are running on this system, we would not get an exception, but we would return an empty string. Even though we just wrote to the file! This is most certainly an unexpected result!

Now, streams are one thing and we can handle that. But it gets tricky when we have other disposable objects.

Maybe they are very large. If we have 1GB of RAM and we open a 1GB file, we are good. But if we try an open another 1GB file, we will run out of memory. If we were using blocks, then we could control the fact that it had to first close the first file before opening the second.

Maybe they are critical. We could potentially lock some resource for longer than necessary if we aren’t careful. This is very dangerous. We could potentially cause other systems of functions to crash because things took too long to respond.

At the end of the day. Use the new using statements. Your code will be better. But… Use them were they should be used. Don’t just auto-convert all blocks to statements with ReSharper.

JavaScript Ajax & ASP.NET WebAPI

Recently I was working on a HTML/JavaScript web application and I was having one of those moments where everything was working fine except for one small thing: the server always seemed to receive NULL.

I tried the usual check-for-null-before-sending, but this was not the problem. Maybe it was breaking in the jQuery Ajax call? Is that even possible? 🙂 Everything was perfect, including when checking the request data with Internet Explorer’s Network Traffic Capturing Developer Tool. It was sending the data across. The JSON was valid and everything.

I decided it was the server. It was a basic ASP.NET WebAPI. All the GETs were working so why was the POST failing? I checked the ApiController’s http context request content. That was correct. The only thing that was wrong was the method’s object parameter value being NULL.

So what was it? The client was sending the right data and the server was receiving it, but the object was NULL.

Here is the JavaScript code:

$.ajax({
    url: '/api/ingredient',
    type: 'POST',
    data: JSON.stringify(ingredient),
    contentType: 'json',
    success: function() { ... },
    error: function() { ... }
});

That was perfect. Now on the server:

public class IngredientController : ApiController
{
    public void Post(IngredientDto ingredient)
    {
        // it failed here as &quot;ingredient&quot; was NULL
    }
}

After searching for some time and trying all sorts of things, I finally found where I went wrong. Now, we all know Microsoft for not being very standards compliant, I mean look at Internet Explorer before version 9, it was pretty glum times. But the problem lay with Microsoft being too standards compliant. The problem lay in the small string, “json”: it is not the right string. Of course if this was a strongly typed language, an enum based action, this would have never have happened. (Look out for my upcoming post on Type Safety)

The informal standard according to Internet Assigned Numbers Authority‘s (IANA) media types and The Internet Engineering Task Force‘s (IETF) RFC4627:

The official MIME type for JSON text is “application/json”.

Wow. What a waste of time. And of course, as soon as I changed the Ajax type from “json” to “application/json” everything JustWorked™.
So the new code is:

$.ajax({
    url: '/api/ingredient',
    type: 'POST',
    data: JSON.stringify(ingredient),
    contentType: 'application/json',
    success: function() { ... },
    error: function() { ... }
});

I hope this helps someone to avoid what I was doing: wasting time. But I did learn a few other things along the way, all was not lost.