Attached Properties
Many of the attached property implementations are straightforward and documented in the API section. However, a few of them have more involved usage, and they are documented more in this page.
TextAs
Attached Properties
In order to generate "complicated" formatted text in TextBlock
and RichTextBlock
controls, requires some machinery to make it run smoothly.
This documents the
Attached.v2
namespace implementation, which is more flexible and also supportsRichTextBlock
controls.
To facilitate doing this in a "pure" VM fashion, we require a "shadow DOM" that can get traversed and output the requisite UI components, at the appropriate moment (where it's safe).
In the following sections, we go through how one would create some "sophisticated" text layout, for a dictation-taking applicaiton. In the dictation view, there is a RichTextBlock
control displaying the accumulated text, along with the dictation "hypothesis" that updates dynamically as callbacks are received. After each dictation "cycle" the newly-recognized text "moves" from the hypothesis and is appended to the accumulated text.
Wiring up the VM
This is the simplest part, as all you require is a VM property of type TextAs
:
public TextAs CurrentText { get; set; }
Then when triggered, it will hook up with the attached property machinery.
Wiring up the View
Next stop is your XAML to configure the attached property:
<RichTextBlock Name="Text" attached:RichTextBlockReformat.Reformat="{Binding CurrentText}" Margin="6" FontSize="{Binding FontSize}"
RelativePanel.AlignLeftWithPanel="True" RelativePanel.AlignRightWithPanel="True"
RelativePanel.AlignBottomWithPanel="True" RelativePanel.Below="Instructions"/>
Making Text
With this in place, the Reformat
attached property takes the TextAs
shadow DOM and generates the components wanted by XAML.
void PrepareCurrentText(){
CurrentText = new TextAsFormat(new TextFormatBase[] { Existing, Hypothesis });
Changed(nameof(CurrentText));
}
The key player here is TextAsFormat
which holds the shadow DOM elements, which all descend from TextFormatBase
. In our example, there is some "existing" text, and some "hypothesis" text, which each have their own formatting.
protected DictationTextFormat Existing { get; set; }
protected TextFormat Hypothesis{ get; set; }
One of these has a custom implementation, which leads us to the next section. But first, let's look at the initialization:
Existing = new DictationTextFormat() {
OnClick = (sender, ev) => {
var item = CurrentText.FromElement(sender);
_trace.Verbose($"clicked {sender} {ev.OriginalSource} item {item}");
...
IsOpen = true;
Changed(nameof(IsOpen));
}
};
Existing.Initialize(dict);
Hypothesis = new TextWeightFormat() { FontRatio = 1.25 };
Notice the custom implementation has some logic to control a FlyoutBase
in the form of IsOpen
property. The Hypothesis
element is a simple "resize" element that makes the FontSize
25 percent larger than the "base" font size.
Custom Text
If you have special requirements, you can easily implement your own TextFormatBase
to handle it. Let's look at the DictationTextFormat
for some highlights!
public sealed class DictationTextFormat : TextFormatBase {
public float FontRatio { get; set; } = 1f;
public TypedEventHandler<Hyperlink, HyperlinkClickEventArgs> OnClick { get; set; }
List<Paragraph> Composed { get; set; } = new List<Paragraph>();
List<Inline> MustRegister { get; set; } = new List<Inline>();
The Create
method is where the generation takes place. In this implementation, certain runs of text are converted into Hyperlink
elements, otherwise they are conditionally reformatted and yield
ed back.
public override IEnumerable<TextElement> Create(ITextFormatContext itfc) {
if (Composed.Count == 0) yield return null;
else {
foreach(var px in Composed) {
foreach(var inl in px.Inlines) {
if(inl is Run run) {
run.FontSize = FontRatio * itfc.FontSize;
}
var mr = MustRegister.Find(xx => xx == inl);
if(mr != null) {
var hx = new Hyperlink() {
Inlines = { new Run() { Text = "\u00b6", FontSize = 1.25 * itfc.FontSize } },
};
hx.Click += OnClick;
itfc.Register(hx, this);
MustRegister.Remove(mr);
}
}
yield return px;
}
}
}
Initialization is straightforward: split on line breaks and make a "paragraph" out of each line.
public void Initialize(Dictation target) {
if (target == null) throw new ArgumentNullException(nameof(target));
Composed.Clear();
MustRegister.Clear();
if (target.LatestVersion == null) return;
string[] pieces = target.LatestVersion.Text.Split(new[] { "\n", "\r\n" }, StringSplitOptions.RemoveEmptyEntries);
foreach (var piece in pieces) {
AddParagraph(piece);
}
}
These helpers demonstrate how to augment the TextAs
shadow DOM. In this implementation, the "last" element is checked for an ending punctuation character, and if found, some additional bookkeeping is set up for later. A sentence just appends a new Run
to the final Paragraph
whereas a paragraph always adds a new Paragraph
element.
public void AddSentence(string text) {
if(Composed.Count == 0) {
Composed.Add(new Paragraph());
}
var target = Composed[Composed.Count - 1];
var run = new Run() { Text = text };
if (!char.IsPunctuation(text[text.Length - 1])) {
MustRegister.Add(run);
}
target.Inlines.Add(run);
}
public void AddParagraph(string text) {
var para = new Paragraph();
var run = new Run() { Text = text };
if (!char.IsPunctuation(text[text.Length - 1])) {
MustRegister.Add(run);
}
para.Inlines.Add(run);
Composed.Add(para);
}
A convenience method Render
"textifies" the encoded text into lines separated by a "blank" line. This is used to get the "plaintext" version of the formatted data.
public string Render() {
var sb = new StringBuilder();
foreach(var px in Composed) {
foreach(var inl in px.Inlines) {
if(inl is Run run) {
sb.Append(run.Text);
sb.Append(" ");
}
}
sb.Append("\r\n\r\n");
}
return sb.ToString().Trim();
}
FlyoutBase
Attached
Let's follow the same example some more, because it also uses the attached properties for FlyoutBase
to wire up a context menu.
Starting with the XAML in the previous section, let's reveal some more of that:
<RichTextBlock Name="Text" attached:RichTextBlockReformat.Reformat="{Binding CurrentText}" Margin="6" FontSize="{Binding FontSize}"
RelativePanel.AlignLeftWithPanel="True" RelativePanel.AlignRightWithPanel="True"
RelativePanel.AlignBottomWithPanel="True" RelativePanel.Below="Instructions">
<FlyoutBase.AttachedFlyout>
<MenuFlyout attached:FlyoutHelper.IsOpen="{Binding IsOpen, Mode=TwoWay}" attached:FlyoutHelper.Parent="{Binding ElementName=Text}">
<MenuFlyoutItem Text="Period" Command="{Binding PunctuationCommand}" CommandParameter="."/>
<MenuFlyoutItem Text="Comma" Command="{Binding PunctuationCommand}" CommandParameter=","/>
<MenuFlyoutItem Text="Question" Command="{Binding PunctuationCommand}" CommandParameter="?"/>
</MenuFlyout>
</FlyoutBase.AttachedFlyout>
</RichTextBlock>
If you examine the MenuFlyout
element, you can see the binding to the IsOpen
VM property managed by the VM.
Deploy context menus inline instead of using a
StaticResource
because the XAML "wires up" better for yourDataContext
.
The FlyoutHelper.IsOpen
attached property provides a way to trigger a menu from your VM without any code-behind. The FlyoutHelper.Parent
attached property locates the "parent" UI element that locates the FlyoutBase
on the screen.
FindAncestor
Attached
If you find yourself missing the WPF RelativeSource
binding FindAncestor
mode, we have it for you!
<ComboBox ...
attached:FindAncestor.AncestorType="Grid"
ItemsSource="{Binding Path=(attached:FindAncestor.Ancestor).DataContext.MyCollection, RelativeSource={RelativeSource Self}}"
/>
The purpose here is to navigate at runtime up the Visual Tree, usually to locate an element that knows your DataContext
. The Visual Tree is distinct from the XAML tree, which reflects the "data" organization instead of the "screen" organization.
Because of this duality, DataTemplate
can make it hard to "wire up" the DataContext
of the template "client" like a DataGrid
.
You should use
RelativeSource Self
as the source.
Selector
Attached
If you ever implemented the "right-click context menu" pattern in UWP XAML, you need code-behind, or an attached property. Now you can throw away your code-behind ways!
Flyout Opening
An additional complication is getting the clicked/selected item into your ICommand
handlers.
<ListBox Name="list1" Grid.Row="0" Margin="2"
ItemsSource="{Binding XXX}"
attached:SelectorFlyoutOpened.SelectedItemEnabled="True">
<ListBox.ContextFlyout>
<MenuFlyout>
<MenuFlyoutItem Text = "Command 1" Command="{Binding CtxCommand1}"/>
<MenuFlyoutItem Text = "Command 2" Command="{Binding CtxCommand2}"/>
<MenuFlyoutItem Text = "Command 3" Command="{Binding CtxCommand3}"/>
</MenuFlyout>
</ListBox.ContextFlyout>
</ListBox>
This attached property handles the propagation for you in the FlyoutBase.OnOpening
event by setting the CommandParameter
of all the bindings in the menu to the selected item. In addition, you can make it set the SelectedItem
so that right-click also selects the item like a left-click would.
Flyout Tapped
A similar situation exists when you require to execute ICommand
when an item is tapped (left-clicked). Again, code-behind is required without this kind of attached property.
<ListBox Name="list1" Grid.Row="0" Margin="2"
ItemsSource="{Binding XXX}"
attached:SelectorTappedCommand.Command="{Binding TappedCommand}"/>
CalendarView
Attached
Another pain-point that requires code-behind is wiring up to a CalendarView
to get the SelectedDates
into your VM. Once again, you must resort to code-behind (OnSelectedDatesChanged
) without this attached property to help you.
ListViewBase
Attached
Working well with list virtualization is a challenge, especially when there are "expensive" resources involved that are async
in nature, e.g. loading an ImageSource
for a thumbnail or loading ImageProperties
for a StorageFile
.
The ListViewBase
implementation has an event used for view container recycling: ContainerContentChanging
. This event allows you to intelligently participate in the recycling, and only obtain resources that are currently in the viewport, and release resource that are no longer in the viewport. This provides for minimal resource usage, and avoids retaining large amounts of resource for items that are not currently visible.
xmlns:attached="using:eScape.Core.Attached"
...
<GridView ItemsSource="{Binding Source={StaticResource ItemSource}}" attached:ListViewBaseContainerContentChanging.IsEnabled="True">
...
<GridView>
IRequireContentChanging
Interface
The marker interface for your VM to implement. The attached behavior queries for this interface, and conducts the appropriate operation as indicated in the event arguments of ContainerContentChanging
.
Do not use the attached behavior without also implementing
IRequireContentChanging
.
public class ImportWrapper : CoreViewModel, IRequireContentChanging {
private BitmapImage thumbnail;
...
public BitmapImage Thumbnail {
get { return thumbnail; }
protected set { SetProperty(ref thumbnail, value); }
}
...
public virtual Task Load() {
return Task.Run(async () => {
await EnsureItemProperties();
if (Thumbnail == null) {
await LoadThumbnailAsync(128, ThumbnailMode.ListView, ThumbnailOptions.UseCurrentScale);
}
});
}
public virtual Task Unload() {
Thumbnail = null;
return Task.CompletedTask;
}
#endregion
}
The above example VM demonstrates the classic "load an image" functionality.