User Controls & Custom Controls
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Sooner or later a desktop app needs a piece of UI that is used in more than one place, or that other teams must be
able to restyle. WPF offers three ways to package it: compose existing controls in a UserControl, derive from
Control and describe the look in a replaceable template, or derive from FrameworkElement and draw it yourself.
This page explains how to choose, then builds one of each of the first two for Bookshelf Desktop: a BookCard
UserControl and a templated StarRating custom control with a routed event, template parts, an automation peer and
a default style in Themes/Generic.xaml. It ends with packaging both in a control library.
The page was written against these versions, current in October 2026:
-
.NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2 (general availability is expected 10 to 13 November 2026). The WPF control authoring model is unchanged between .NET Framework 4.8 and .NET 8, 9 and 10; the APIs below exist in all of them.
-
Visual Studio 2026 18.11.1, whose WPF item templates include User Control and Custom Control, and whose project templates include WPF Custom Control Library and WPF Class Library.
-
Windows App SDK 2.5.1 for the WinUI 3 comparison.
-
CommunityToolkit.Mvvm 8.4.2 for the view model in the usage examples.
The earlier pages of this section are assumed: dependency properties, routed events and resources are summarised only where a control needs them. See Dependency Properties & Attached Properties, Routed Events & Input and Resources & Resource Dictionaries. Styles, templates and triggers, themes and accessibility have their own pages in this section; this page covers only what is specific to authoring a control, and mentions those pages in prose.
Before Writing a Control
The official control authoring overview begins with a warning that many needs do not call for a new control at all. WPF’s content model, styles, data templates, control templates and triggers can change the look of an existing control so far that a new class is often unnecessary.
| Need | Try first | Page of this section |
|---|---|---|
Put an icon and text on a button |
|
|
Make every |
A |
|
Show an item differently in a list |
A |
|
Restructure a control (a |
Replace its |
|
Change appearance when a property changes |
A |
A new control is justified when you need new behaviour (new state, new events, new keyboard handling), when you want a
packaged component with a stable contract that many screens reuse, or when the same visual group repeats across
screens and a DataTemplate would be copied each time.
The Three Authoring Models
The documentation names three base classes for a new WPF control, each trading simplicity for flexibility.
| Model | You write | Consumers can retemplate it? | Typical use |
|---|---|---|---|
Derive from |
A XAML file with a root |
No: a |
A reusable screen fragment used like part of an application |
Derive from |
A class with dependency properties and events, plus a default |
Yes: the look is a template, the logic is the class |
A library control that must be themeable, such as |
Derive from |
A class that overrides |
Not by a template; you draw everything |
Special rendering such as |
Source: Control Authoring Overview
UserControl derives from ContentControl, so it still has a Template property, but the class is designed so that
its content is fixed by your XAML. The documented guidance for choosing it is that all of the following hold: you
want to build the control the way you build an application, it consists only of existing components, and you do not
need complex customization. The documented reasons to derive from Control are that the appearance must be
customizable through the ControlTemplate and that the control must support different themes.
UserControl or Custom Control?
The right-hand branches are a judgement, not a rule. A UserControl is quick to build and to review because its
structure is visible in one XAML file. A custom control costs more up front (a class, a generic dictionary, template
parts, an automation peer) and pays that back in every screen that restyles it. A practical test: if you expect to
write <Style TargetType="local:YourControl"> with a different Template in a consuming project, derive from
Control.
Side by Side
| Aspect | UserControl |
Templated Control |
|---|---|---|
Look defined in |
The control’s own XAML file, compiled with its code-behind |
A |
Logic can reference visuals |
Freely, by |
Only through template parts found in |
Consumers change the look |
By styling exposed properties only |
By replacing |
Theming |
Inherits whatever its parts inherit |
Own default style per theme or in the generic dictionary |
Design-time experience |
Preview in the XAML designer |
Preview needs the generic dictionary to load |
Test and automate |
Add an automation peer if it is more than a container |
Always add an automation peer when it accepts input |
Cost |
Low |
Moderate to high |
A UserControl: BookCard
BookCard shows a book as a small card: a title, an author and a favourite toggle. It is a fragment of the main
window that appears in a list item, a search result and a recently opened strip. It is a good UserControl candidate:
it is made of existing controls, it is used inside this app, and nobody needs to retemplate it.
<UserControl x:Class="Bookshelf.Desktop.Controls.BookCard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="Root"
MinWidth="220">
<Border Padding="12" CornerRadius="6" BorderThickness="1"
BorderBrush="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}"
Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Text="{Binding Title, ElementName=Root}" />
<TextBlock Grid.Row="1" Grid.Column="0" Opacity="0.75" TextTrimming="CharacterEllipsis"
Text="{Binding Author, ElementName=Root}" />
<ToggleButton Grid.RowSpan="2" Grid.Column="1" VerticalAlignment="Center"
AutomationProperties.Name="Favourite"
IsChecked="{Binding IsFavorite, ElementName=Root, Mode=TwoWay}"
Content="☆" />
</Grid>
</Border>
</UserControl>
Source: Control Authoring Overview
The two inline-form ColumnDefinitions and RowDefinitions attributes are the short Grid syntax added in
.NET 10; see Layout & Panels for the older element form. On earlier
runtimes write <Grid.ColumnDefinitions> explicitly.
The code-behind declares the public surface as dependency properties, so a consumer can bind, style and animate them:
using System.Windows;
using System.Windows.Controls;
namespace Bookshelf.Desktop.Controls;
public partial class BookCard : UserControl
{
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
nameof(Title), typeof(string), typeof(BookCard), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty AuthorProperty = DependencyProperty.Register(
nameof(Author), typeof(string), typeof(BookCard), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty IsFavoriteProperty = DependencyProperty.Register(
nameof(IsFavorite), typeof(bool), typeof(BookCard),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public BookCard()
{
InitializeComponent();
}
public string Title
{
get => (string)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public string Author
{
get => (string)GetValue(AuthorProperty);
set => SetValue(AuthorProperty, value);
}
public bool IsFavorite
{
get => (bool)GetValue(IsFavoriteProperty);
set => SetValue(IsFavoriteProperty, value);
}
}
Source: Control Authoring Overview
The CLR property wrappers only call GetValue and SetValue. The overview is explicit that clients, the binding
engine and the designer can bypass the accessors, so logic placed in them runs sometimes and not others. Put logic
in the PropertyChangedCallback, CoerceValueCallback or ValidateValueCallback instead.
Used in the main window, the card is bound exactly like any control:
<ItemsControl ItemsSource="{Binding Books}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<controls:BookCard Margin="0,0,0,8"
Title="{Binding Title}"
Author="{Binding Author}"
IsFavorite="{Binding IsFavorite, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
Source: Data templating overview
Here Books is the ObservableCollection<BookViewModel> of BooksViewModel, and IsFavorite is the property of
BookViewModel from the Commands page. The controls prefix maps to the control’s namespace and assembly with
xmlns:controls="clr-namespace:Bookshelf.Desktop.Controls;assembly=Bookshelf.Controls" when the control lives in a
separate library, or without the assembly part when it lives in the app.
Why the Card Binds with ElementName
The card’s XAML binds to Root, its own root element, rather than setting DataContext = this in the constructor.
That choice is the single most common UserControl mistake to avoid. A control’s DataContext is inherited by its
children and is set by its consumer: <controls:BookCard Title="{Binding Title}" /> evaluates Title against the
DataContext of the BookCard, which the parent supplies (the BookViewModel). If the card’s constructor replaced that
DataContext with the card itself, the consumer’s {Binding Title} would then look for Title on the card and
fail silently. Binding inside the control with ElementName (or RelativeSource={RelativeSource AncestorType=UserControl})
leaves DataContext to whoever uses the control. See Data Binding for DataContext inheritance.
Limits of a UserControl
-
No template. Consumers cannot restyle the card’s inner layout. They can only set properties the card exposes (colours, fonts,
Padding, and so on), which is why a card meant for reuse across themes should take brushes from system or application resources withDynamicResource, as above. -
Names are private.
x:Nameelements inside the card belong to its namescope, so a consumer cannot reach them withElementName. This is deliberate encapsulation; see Markup Extensions & Namescopes. -
Content is the card. Setting
Contenton aUserControlin the consumer replaces the card’s XAML content. It is not a slot, so do not put content in an instance ofBookCardunless you intend to replace it. -
Code-behind couples the layout. Event handlers in the card’s code-behind refer to named elements, which is fine for a fragment, but becomes a liability once a team wants to change the layout.
A Custom Control: StarRating
StarRating lets a reader set a book’s rating from 0 to a configurable maximum with the mouse or the arrow keys. It
is a library control: other apps will reuse it and want to replace the stars with, for example, circles. That makes it
a templated Control. The pieces are:
| Piece | Role |
|---|---|
|
Dependency properties ( |
|
The default |
assembly-level |
Tells WPF where the generic dictionary lives |
|
Exposes the control to UI Automation (Narrator, test tools) |
The Class
using System.Windows;
using System.Windows.Automation.Peers;
using System.Windows.Controls;
using System.Windows.Input;
namespace Bookshelf.Controls;
[TemplatePart(Name = PartSurface, Type = typeof(Border))]
[TemplatePart(Name = PartGlyphs, Type = typeof(TextBlock))]
public class StarRating : Control
{
public const string PartSurface = "PART_Surface";
public const string PartGlyphs = "PART_Glyphs";
static StarRating()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(StarRating), new FrameworkPropertyMetadata(typeof(StarRating)));
}
public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register(
nameof(Maximum), typeof(int), typeof(StarRating),
new FrameworkPropertyMetadata(5, OnMaximumChanged), IsValidMaximum);
public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
nameof(Value), typeof(int), typeof(StarRating),
new FrameworkPropertyMetadata(
0,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue));
public static readonly RoutedEvent ValueChangedEvent = EventManager.RegisterRoutedEvent(
nameof(ValueChanged), RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<int>), typeof(StarRating));
private Border? _surface;
private TextBlock? _glyphs;
public int Maximum
{
get => (int)GetValue(MaximumProperty);
set => SetValue(MaximumProperty, value);
}
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public event RoutedPropertyChangedEventHandler<int> ValueChanged
{
add => AddHandler(ValueChangedEvent, value);
remove => RemoveHandler(ValueChangedEvent, value);
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (_surface is not null)
{
_surface.MouseLeftButtonDown -= OnSurfaceMouseDown;
}
_surface = GetTemplateChild(PartSurface) as Border;
_glyphs = GetTemplateChild(PartGlyphs) as TextBlock;
if (_surface is not null)
{
_surface.MouseLeftButtonDown += OnSurfaceMouseDown;
}
UpdateGlyphs();
}
protected override void OnKeyDown(KeyEventArgs e)
{
switch (e.Key)
{
case Key.Right or Key.Up:
Value++;
e.Handled = true;
break;
case Key.Left or Key.Down:
Value--;
e.Handled = true;
break;
case Key.Home:
Value = 0;
e.Handled = true;
break;
case Key.End:
Value = Maximum;
e.Handled = true;
break;
default:
base.OnKeyDown(e);
break;
}
}
protected virtual void OnValueChanged(RoutedPropertyChangedEventArgs<int> args)
{
RaiseEvent(args);
}
protected override AutomationPeer OnCreateAutomationPeer() => new StarRatingAutomationPeer(this);
private static bool IsValidMaximum(object value) => (int)value > 0;
private static object CoerceValue(DependencyObject d, object baseValue)
{
var control = (StarRating)d;
return Math.Clamp((int)baseValue, 0, control.Maximum);
}
private static void OnMaximumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (StarRating)d;
control.CoerceValue(ValueProperty);
control.UpdateGlyphs();
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (StarRating)d;
control.UpdateGlyphs();
var args = new RoutedPropertyChangedEventArgs<int>((int)e.OldValue, (int)e.NewValue, ValueChangedEvent);
control.OnValueChanged(args);
if (UIElementAutomationPeer.FromElement(control) is StarRatingAutomationPeer peer)
{
peer.RaiseValueChanged((int)e.OldValue, (int)e.NewValue);
}
}
private void OnSurfaceMouseDown(object sender, MouseButtonEventArgs e)
{
if (_surface is null || _surface.ActualWidth <= 0)
{
return;
}
Focus();
var fraction = e.GetPosition(_surface).X / _surface.ActualWidth;
Value = (int)Math.Ceiling(fraction * Maximum);
e.Handled = true;
}
private void UpdateGlyphs()
{
if (_glyphs is null)
{
return;
}
_glyphs.Text = new string('★', Value) + new string('☆', Maximum - Value);
}
}
Source: Control Authoring Overview
The parts that make this a well-behaved WPF control, not just a class that works:
-
Static constructor and default style key.
DefaultStyleKeyProperty.OverrideMetadatawith the control’s own type tells WPF to look up the implicit style whoseTargetTypeisStarRating. The overview requires this whenever the template is in a theme dictionary. Without it the control inherits its base class’s key and renders as a plainControl, which has no visual. -
Dependency properties with callbacks, not logic in accessors.
Maximumhas a validation callback (IsValidMaximumrejects zero and negatives; WPF throwsArgumentExceptionfor an invalid value) and a changed callback that re-coercesValue.Valuehas a coerce callback that clamps to0..Maximum. See Dependency Properties & Attached Properties for the callback order. -
BindsTwoWayByDefaultfor the editable property. Consumers can writeValue="{Binding Rating}"without aMode, the way they do forTextBox.Text. -
Routed event, not a CLR event.
ValueChangedis registered withEventManager.RegisterRoutedEventand aBubblestrategy. The overview lists the benefits: a parent such as aListBoxcan handle it for every item, it works in anEventSetterand in anEventTrigger. Theaddandremoveaccessors callAddHandlerandRemoveHandler; a protected virtualOnValueChangedraises it withRaiseEvent. -
Template parts declared with
TemplatePart. The attribute is applied to the class and tells template authors which named elements the code expects and of what type. It cannot be used in XAML. A template that omits a part must still work, which is why the code checks fornull. -
Detach before re-attach.
OnApplyTemplatecan run more than once (when the template changes). The code removes its handler from the old surface before attaching to the new one.
Naming GetTemplateChild and OnApplyTemplate here is intentional: GetTemplateChild is a protected method of
FrameworkElement meant for a control to look up named template elements, as described on the
Markup Extensions & Namescopes page.
The Default Template
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Bookshelf.Controls">
<Style TargetType="{x:Type local:StarRating}">
<Setter Property="Focusable" Value="True" />
<Setter Property="IsTabStop" Value="True" />
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:StarRating}">
<Border x:Name="PART_Surface"
Background="Transparent"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<TextBlock x:Name="PART_Glyphs"
Foreground="{TemplateBinding Foreground}"
FontSize="{TemplateBinding FontSize}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="PART_Surface" Property="BorderBrush"
Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
<Setter TargetName="PART_Surface" Property="BorderThickness" Value="1" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
Source: Control Authoring Overview
Points about this dictionary:
-
The file is named
Generic.xamland sits in aThemesfolder at the root of the project that contains the control. The folder and file names are fixed by convention, and the overview describesgeneric.xamlas the theme-level fallback used when no theme-specific file supplies the resource. -
The
Stylehas nox:Key. AStylewith aTargetTypeand no key is implicit for that type; combined with theDefaultStyleKey, this is how WPF finds the control’s default style. -
TemplateBindinglinks template properties to the control’s own properties (Foreground,FontSize,Padding). EveryTemplateBindingis a property a consumer can set onStarRatingwithout touching the template. -
Trigger-driven looks (focus border, disabled opacity) live in the template, so a consumer who retemplates replaces them as well. That is the contract: the class owns behaviour, the template owns every pixel.
Declaring Where the Dictionary Lives
WPF needs to be told that the assembly carries control resources. This is the job of the ThemeInfoAttribute, an
assembly-level attribute with two properties:
| Property | Meaning |
|---|---|
|
Where the theme-specific dictionaries ( |
|
Where the generic, not theme-specific, dictionary ( |
The constructor takes the theme location first and the generic location second, and each is a
ResourceDictionaryLocation: None, SourceAssembly (the same assembly as the control) or ExternalAssembly
(a separate assembly, whose name must follow a naming scheme and be built with the same key information). A library
that has only a generic dictionary in the same assembly declares:
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None,
ResourceDictionaryLocation.SourceAssembly)]
Source: ThemeInfoAttribute class
The "WPF Custom Control Library" project template generates this attribute, normally in AssemblyInfo.cs. In an
SDK-style project, which generates its own assembly attributes, a file such as AssemblyInfo.cs can still be added
to carry it. If the attribute is missing, the control builds and the default style is silently not found at run time.
How WPF Finds a Control’s Default Style
When a StarRating is created and no explicit Style is set, WPF resolves its default style in this order:
-
The
DefaultStyleKeyis read (the control’s type, from the static constructor). -
The theme dictionary for the current Windows theme is searched, according to
ThemeDictionaryLocation. -
If it has no entry, the generic dictionary is searched, according to
GenericDictionaryLocation. -
The style found is applied beneath any
Stylethe consumer sets and any local property values.
An application’s Application.Resources implicit style for StarRating overrides the default style because it is
the control’s Style; a Style with BasedOn="{StaticResource {x:Type local:StarRating}}" extends the default one.
Value precedence is explained on the dependency properties page. The theme files for Windows XP, Vista and later
are still the documented names, but a current app normally ships only Generic.xaml; the Fluent theme that ships
with .NET 9 and later is covered on the Themes & Fluent Design page of this section.
Template Parts and Visual States
The control above reads two parts by name. The wider convention has three elements:
| Element | Convention |
|---|---|
Template part |
A named element in the template, prefixed |
Template binding |
|
Visual state |
A named state in a named group, declared with |
Source: TemplatePartAttribute class
The part contract is soft: a template author may omit a part, so the code must tolerate null. Visual states keep
animation out of code. To add them to StarRating, apply [TemplateVisualState(Name = "Normal", GroupName = "CommonStates")]
and MouseOver the same way, then call VisualStateManager.GoToState(this, "MouseOver", true) from OnMouseEnter and
GoToState(this, "Normal", true) from OnMouseLeave. The template then supplies a VisualStateManager.VisualStateGroups
block. Styles, Templates & Triggers goes through that machinery in detail.
Using the Control
<StackPanel Margin="16" xmlns:ctl="clr-namespace:Bookshelf.Controls;assembly=Bookshelf.Controls">
<TextBlock Text="Your rating" />
<ctl:StarRating Maximum="5"
Value="{Binding SelectedBook.Rating, Mode=TwoWay}"
ValueChanged="OnRatingChanged"
AutomationProperties.Name="Book rating" />
</StackPanel>
Source: Control Authoring Overview
Because ValueChanged is a routed event, a parent can handle it for every StarRating inside it without wiring each
one:
<ListBox ItemsSource="{Binding Books}"
ctl:StarRating.ValueChanged="OnAnyRatingChanged" />
Source: Routed events overview
The type-prefixed attribute form works because routed events can be attached in XAML to any element in the route; see
Routed Events & Input. The handler’s signature is
void OnAnyRatingChanged(object sender, RoutedPropertyChangedEventArgs<int> e).
Retemplating It
A consuming app that wants circles instead of stars supplies its own template; the class keeps working because the two parts are present with the declared types:
<Style x:Key="BookRatingCircles" TargetType="ctl:StarRating">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ctl:StarRating">
<Border x:Name="PART_Surface" Background="Transparent" Padding="2">
<TextBlock x:Name="PART_Glyphs" FontFamily="Segoe UI Symbol"
Foreground="{TemplateBinding Foreground}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Source: Create a template for a control
The glyphs the code writes (★ and ☆) are still stars, because the glyph choice lives in the class.
Making the symbols themselves replaceable is the next design step: expose FilledGlyph and EmptyGlyph
dependency properties, or replace the single TextBlock with an ItemsControl of items, one per star, driven by a
template. The more decisions are in the template, the less code the class owns.
Automation Peers
A control that accepts input must be usable from UI Automation, which is how Narrator, Windows Voice Access and test
tools drive WPF. WPF supports it through a tree of peer objects that parallels the element tree. The peer classes
derive from AutomationPeer; by convention their names are the control name followed by AutomationPeer.
The built-in peers for Button, TextBox and Label exist; classes derived from Decorator (such as Border) and
Panel (such as Grid and Canvas) have none. The base Control class has no corresponding peer, so a custom control
that derives from Control should derive its peer from FrameworkElementAutomationPeer. Without an override, the
control appears to automation clients only as a generic element.
| Member | Responsibility |
|---|---|
|
Override on the control; return your peer. WPF calls it to obtain the peer. |
|
Returns the class name. A minimum requirement. |
|
Returns an |
|
Returns the object that implements a pattern, such as the |
|
Say whether the element carries content or is interactive; both return |
|
Report state changes to subscribed clients. |
A rating with a minimum, a maximum and a current value is a natural fit for the range value pattern. The peer
implements IRangeValueProvider itself and answers GetPattern with itself:
using System.Windows.Automation;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
namespace Bookshelf.Controls;
public sealed class StarRatingAutomationPeer(StarRating owner)
: FrameworkElementAutomationPeer(owner), IRangeValueProvider
{
private StarRating Rating => (StarRating)Owner;
public double Value => Rating.Value;
public double Minimum => 0;
public double Maximum => Rating.Maximum;
public double SmallChange => 1;
public double LargeChange => 1;
public bool IsReadOnly => !Rating.IsEnabled;
public void SetValue(double value)
{
if (!Rating.IsEnabled)
{
throw new ElementNotEnabledException();
}
Rating.Value = (int)Math.Round(value);
}
public override object? GetPattern(PatternInterface patternInterface) =>
patternInterface == PatternInterface.RangeValue ? this : base.GetPattern(patternInterface);
protected override string GetClassNameCore() => nameof(StarRating);
protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.Slider;
public void RaiseValueChanged(int oldValue, int newValue)
{
if (ListenerExists(AutomationEvents.PropertyChanged))
{
RaisePropertyChangedEvent(
RangeValuePatternIdentifiers.ValueProperty, (double)oldValue, (double)newValue);
}
}
}
Notes on the peer:
-
The listener check before
RaisePropertyChangedEventis the optimisation the documentation shows: raise the event only when a client has subscribed to it, so an unobserved control pays no cost. The control’sOnValueChangedcallback above gets the peer withUIElementAutomationPeer.FromElement, which returnsnullwhen no client has created one, so no peer is created just to be notified. -
A consuming page can override the peer’s defaults in XAML with
AutomationProperties.NameandHelpText, which is why the usage example above sets a name. Provide good defaults and let the consumer refine them. -
ElementNotEnabledExceptionis the exception automation clients expect when a disabled control is operated; the provider interfaces are inSystem.Windows.Automation.Provider. The details of Narrator testing and Accessibility Insights belong to the Accessibility & UI Automation page of this section. -
Peers are for controls, not for every element: a pure container needs none.
WPF, WinUI 3 and WinForms
| Aspect | WPF | WinUI 3 | Windows Forms |
|---|---|---|---|
Composed fragment |
|
|
|
Library control |
Derive from |
Templated Control item template; |
Derive from |
Properties |
|
|
Plain CLR properties with |
Events |
Routed events ( |
Routed events for input; ordinary CLR events for your own |
Ordinary CLR events |
Template parts |
|
|
Not applicable |
Automation |
|
|
|
The WinUI 3 documentation builds its walkthrough around a BgLabelControl: the constructor sets
DefaultStyleKey = typeof(BgLabelControl), the dependency property is registered with a PropertyMetadata, and the
default Style in Themes\Generic.xaml uses TemplateBinding. Two details differ from WPF: the default style key is
assigned in the constructor, not in a static constructor, and the namespace in the XAML uses the using: form. The
WinUI version of this page’s control would start like this:
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Bookshelf.Controls;
public sealed class StarRating : Control
{
public static readonly DependencyProperty ValueProperty = DependencyProperty.Register(
nameof(Value), typeof(int), typeof(StarRating),
new PropertyMetadata(0, OnValueChanged));
public StarRating()
{
DefaultStyleKey = typeof(StarRating);
}
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
// Update the template parts here.
}
}
Source: Build XAML templated controls
In WinUI the default template XAML is <ResourceDictionary xmlns:local="using:Bookshelf.Controls" ...> with
<Style TargetType="local:StarRating">. WPF requires clr-namespace: and {x:Type}, and a coerce callback exists
only in WPF’s FrameworkPropertyMetadata; WinUI’s PropertyMetadata has a changed callback only, so clamping has
to be done in the changed callback or the setter logic.
Packaging a Control Library
Controls that two or more apps share belong in a class library, so they version and ship independently.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Bookshelf.Controls</RootNamespace>
<AssemblyName>Bookshelf.Controls</AssemblyName>
<!-- NuGet metadata -->
<PackageId>Bookshelf.Controls</PackageId>
<Version>1.0.0</Version>
<Description>Reusable WPF controls for Bookshelf Desktop.</Description>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
Source: Control Authoring Overview
The library project holds BookCard.xaml(.cs), StarRating.cs, StarRatingAutomationPeer.cs, Themes/Generic.xaml
and the ThemeInfo attribute. Packaging notes:
-
UseWPFand the-windowstarget. TheMicrosoft.NET.SdkwithUseWPF=trueand a-windowstarget framework moniker is the SDK-style form of a WPF library; see Project Structure & Build for the project file anatomy. XAML files in the project compile asPageitems by default, includingThemes/Generic.xaml. -
No
App.xamlin a library. The overview points out that an assembly containing only controls has noApplicationobject, so there is noApp.xamlfor global resources. Put shared resources at the element level (a dictionary merged in the control) or at the theme level (Generic.xaml). -
Theme-level resources use
ComponentResourceKey. A resource defined in the generic dictionary and meant to be reused (a brush shared by several controls) is keyed withComponentResourceKey, naming the type in the target assembly and a resource id. The consumer then writes{StaticResource {ComponentResourceKey TypeInTargetAssembly={x:Type local:StarRating}, ResourceId=StarBrush}}. Element-level resources may use simple string keys. -
Avoid merging the same dictionary repeatedly. Merging a shared dictionary from XAML in every control creates one
ResourceDictionaryinstance per use. The overview shows a small static class that loads it once withApplication.LoadComponentand returns the cached instance, and merging it in the constructor beforeInitializeComponent. -
Namespaces and
XmlnsDefinition. Consumers can map a library withclr-namespaceandassembly. To give a library a single stable XML namespace, addXmlnsDefinitionattributes in the library. This page does not use them. -
Version and sign. Use semantic versioning for the package, and keep the public surface (dependency property names, part names and types, event names) stable: they are the contract with every consumer’s custom template.
The consuming app refers to the library with a project or package reference and uses the control with the
clr-namespace:...;assembly=... prefix shown above.
Choosing Where Logic Lives
| Logic | Put it in | Reason |
|---|---|---|
What the control is (state, constraints) |
Dependency properties with coerce and validate callbacks |
Works with binding, styling and animation |
What the control does |
Overridden |
Consumers handle events without replacing the template |
What the control looks like |
|
Replaceable by consumers |
What must be found by name |
|
Explicit, optional contract |
What assistive technology sees |
Automation peer |
Same logic serves Narrator and test tools |
Pitfalls
-
DataContext = thisin aUserControl. It replaces the inheritedDataContext, so the consumer’s bindings on the control break without an error you can see. Bind inside the control withElementNameorRelativeSource. -
Missing
DefaultStyleKeyoverride. A custom control with a template inGeneric.xamlbut no static-constructor override shows nothing. Check this first when a new control renders empty. -
Missing or wrong
ThemeInfo. The generic dictionary is not found when the attribute is absent or points to the wrong location. In a control library, verify the file path (Themes/Generic.xaml, exact folder name) and the attribute. -
Logic in property wrappers. Callbacks run for every path that sets the value; wrapper code runs only when somebody uses the CLR property.
-
Hard-coding template details in code. Looking up parts and restyling them in code makes retemplating hard. Treat the part contract as optional and check for
null. -
Forgetting to detach handlers on a re-applied template.
OnApplyTemplatecan run again; the old elements must stop raising into your control. -
Forgetting automation. A mouse-only custom control is invisible to screen readers and unreachable from keyboards. Support keys, focus, and an automation peer.
-
Calling
GetValue-based properties from the constructor with expensive work. Property metadata defaults and inherited values are not final at that point; defer work to the changed callbacks orOnApplyTemplate. -
Colours baked into the template. Hard-coded colours ignore high contrast and dark themes; use
SystemColorskeys or application brush resources withDynamicResource. -
A
UserControlused as a templated control. SettingTemplateon aUserControlinstance to restyle it replaces theContentPresenterthat shows its content, and the card disappears. If you need that, the control should have been aControl. -
Binding a
Valuecoerced in aTwoWaybinding. Coercion can change the value after the source set it. The binding does not push the coerced value back by itself unless the source is updated; test with out-of-range inputs.