VincentH-Net / CSharpForMarkup

Concise, declarative C# UI markup for .NET browser / native UI frameworks

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support ControlTemplates

VincentH-Net opened this issue · comments

As discussed:

@VincentH-Net: ControlTemplates => Basically a reusable piece of C# Markup. Could be done by adding a factory helper that creates a control template from C# Markup content, similar to the layout factory helpers. Then you could use that helper in a local build method or in a derived control so it has a typesafe name

@scottcg: I don't see ControlTemplates

For WinUI this proposal exists to add support for DataTemplate, ItemsPanelTemplate and ControlTemplate.

C# Markup already contains a workaround for DataTemplate using XamlReader

Consider the same approach for other types derived from FrameworkTemplate, including ControlTemplates

Encountered a blocker using the approach suggested above.

Can anyone explain how I can set (or cause to be set) TemplatedParent on child elements that I add from C# to a ControlTemplate instance that was created via XamlReader ?

I dynamically add child controls to a root Grid which is in a ControlTemplate via a custom attached property on that Grid. The controls show up fine where the template is applied.

However ContentPresenter and TemplateBinding added via C# do not work. I noticed thatTemplatedParent is set on the Grid which was in the XAML, however it is not set not on the child controls I add to the Grid from C# and I suspect this may be the cause.

Any explanation / alternatives appreciated!

ControlTemplates are now supported in Uno Platform and WPF, but not in WinUI 3 (Windows Desktop).

  • Uno Platform exposes the needed API's to C#, each templates is instantiated only once.
  • WPF uses a workaround, however a downside to this is that the template content is instantiated once for each templated parent. This is because WPF does not support VisualState in XamlWriter so we cannot use that for a workaround, and WPF also does not expose creating a template from C# like UNO does. Better workarounds may be possible; see workaround implementation for details.
  • In WinUI 3 (Windows Desktop) Control Templates are not supported yet - the API used in the workaround for WPF are not available in WinUI 3 (yet) - WinUI3 Desktop does not have TemplatedParent property read access.

Have you taken a look a x:Bind for templated parents? The underlying APIs used by the WinUI code generator may help.

@jeromelaban I have not; I'd be happy to check that out. Could you point me to an example of the relevant code - for WinUI? I don't really know where to start looking.

I don't, but you can create a template with x:Bind to templated parent, and look at the generated .g.cs files in the obj folder.

@jeromelaban wrote:

you can create a template with x:Bind to templated parent, and look at the generated .g.cs files in the obj folder.

The templated parent is passed into the generated code by the XAML runtime, which is what we need at minimum for a workaround in WinUI 3 Desktop. However I have not found if/how that call could be triggered from C#. Below are my findings.

@jeromelaban looking at below, can you think of any further avenues on how to specify connectionIds from C#? Or alternatively, do you know whether my theory on how connectionIds are supplied when using XAML (see below) is correct?

Findings

I investigated the code that WinUI 3 x:Bind generates for below example, which binds the Text property of a TextBlock to the Content property of the templated parent (a Button):

<Button Content="Click Me!" >
    <Button.Template>
        <ControlTemplate TargetType="Button">
            <Grid>
                <TextBlock Text="{x:Bind Content, Mode=OneWay }"/>
            </Grid>
        </ControlTemplate>
    </Button.Template>
</Button>

The generated page code adds an implementation of IComponentConnector to the page. Here is the relevant part of the implementation:

public IComponentConnector GetBindingConnector(int connectionId, object target)
{
    // ...
    switch(connectionId)
    {
    case 2: // BlankPage1.xaml line 14
        {                    
            var templatedParent2 = target as global::Microsoft.UI.Xaml.Controls.Button;
            // ... 
        }
        break;
    }
    // ...
}

The connectionId parameter is an identifier token to distinguish calls - in this example, 2 represents the binding expression for the Text property of the TextBlock in above XAML. However there are no GetBindingConnector invocations in any generated C# and the number 2 is not passed in from generated C# anywhere.

Problem

The problem is that GetBindingConnector is invoked by the XAML runtime, and that the values for connectionId that are passed into this method by the runtime are not specified in (generated) C# (unless reflection is used by the runtime to derive the number from C# generated code names, but that seems unlikely since the main point of x:Bind is to add a binding mechanism that does not require reflection).

Theory

The connectionId for each binding expression in the XAML is either (re)calculated at runtime from the XAML resource by the XAML runtime, or - more likely - it is stored in another type of XAML-derived metadata resource (XBF?).

Blocker

Current situation: without a way to tell the XAML runtime which connectionId's to call for a templated parent, the API's used in the code generated by x:Bind cannot be leveraged to create working template bindings in control templates from C#.

An alternative workaround (not using the API's that x:Bind uses or even an actual ControlTemplate) would be to provide guidance on how to use standard C# language features to implement the functionality of templates.

E.g. simply define your own factory method for each templated control.

@jeromelaban looking at below, can you think of any further avenues on how to specify connectionIds from C#? Or alternatively, do you know whether my theory on how connectionIds are supplied when using XAML (see below) is correct?

I do not have this kind of details, figuring it out is likely to be a guessing game of wack-a-mole :)

One avenue that I could explore, given all these blockers, is to provide an alternative C# implementation for the C# Markup control template helpers for WinUI desktop (ControlTemplate and BindTemplate methods), that does not use the XAML ControlTemplates at all.

This would allow to use the same C# Markup for WinUI desktop and Uno; the WinUI implementation would work like a factory method - for each templated parent a new set of the same controls and bindings would be built in C#. The performance would be similar to the workaround used in WPF.

Would something like this help?

public sealed partial class MainPage : Page
{
    public MainPage()
    {
        this.InitializeComponent();

        var template = XamlReader.Load(@"
            <ControlTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:helper=""using:Net6WinUI01"" TargetType=""ContentControl"">
                <Grid>
                    <helper:TemplateBindingHelper TemplatedParent=""{Binding RelativeSource={RelativeSource TemplatedParent}}"" Content=""{TemplateBinding Content}"" />  
                </Grid>
            </ControlTemplate>
            ") as ControlTemplate;

        var content = new ContentControl() { 
            Template = template,
            Content = new Border() // user written visual code
        };

        content.Loaded += delegate
        {
            var templatedParent = TemplateBindingHelper.FindTemplatedParent(content.ContentTemplateRoot);
            // Here, template == content
        };

        Content = content;
    }
}

[Bindable]
public class TemplateBindingHelper : ContentPresenter
{
    public TemplateBindingHelper() { }

    public UIElement TemplatedParent
    {
        get => (UIElement)GetValue(TemplatedParentProperty);
        set => SetValue(TemplatedParentProperty, value);
    }

    public static readonly DependencyProperty TemplatedParentProperty =
        DependencyProperty.Register("TemplatedParent", typeof(UIElement), typeof(TemplateBindingHelper), new PropertyMetadata(null));

    internal static UIElement FindTemplatedParent(DependencyObject element)
    {
        while(VisualTreeHelper.GetParent(element) is { } parent)
        {
            if(parent is TemplateBindingHelper helper)
            {
                return helper.TemplatedParent;
            }

            element = parent;
        }

        return null;
    }
}

@jeromelaban wrote:

Would something like this help?

That works as-is to access the templated parent - thanks!

I tried to simplify that by making the TemplatedParentProperty an attached property.
However when I load below Xaml, and apply the resulting template to a control, the template renders OK and a breakpoint in BuildChild.SetId is hit but in BuildChild.SetTemplatedParent it is not hit.

-> Any idea why this binding expression would not work as an attached property in a control template?

If I change the type of TemplatedParentProperty to string and set the value to a string in the XAML the breakpoint is hit.
I would expect it to work as in your example the same binding is used in the XAML.

<ControlTemplate TargetType="Button"
					xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
					xmlns:x= "http://schemas.microsoft.com/winfx/2006/xaml"
					xmlns:root="using:Microsoft.UI.Xaml.Controls"
                    xmlns:delegators="using:CSharpMarkup.WinUI.Delegators">
                    <root:Grid delegators:BuildChild.TemplatedParent="{Binding RelativeSource={RelativeSource TemplatedParent}}" delegators:BuildChild.Id="WinUICsMarkupExamples.FlutterPage+&lt;&gt;c.&lt;.ctor&gt;b__2_0" />
				</ControlTemplate>

This is the BuildChild implementation:

    [Bindable]
    public class BuildChild
    {
        static Dictionary<string, Func<CSharpMarkup.WinUI.UIElement>> delegates = null;

        static string Id(Delegate build) => $"{build.Method.DeclaringType.FullName}.{build.Method.Name}";

        [Conditional("DEBUG")]
        internal static void AssertStateless(Delegate build)
        {
            if (build.Target != null)
            {
                string fields = string.Join(',', build.Method.DeclaringType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly).Select(f => f.Name));
                string properties = string.Join(',', build.Method.DeclaringType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly).Select(p => p.Name));
                string state = string.Join(',', fields, properties);
                if (state != ",")
                    throw new ArgumentException($"State in template methods is not supported: {state}. Either avoid state in the lambda expression or use a static method, e.g. DataTemplate(AStaticMethod)", nameof(build));
            }
        }

        public static string CreateIdFor(Func<CSharpMarkup.WinUI.UIElement> build)
        {
            AssertStateless(build);

            if (delegates == null) delegates = new Dictionary<string, Func<CSharpMarkup.WinUI.UIElement>>();

            string id = Id(build);
            delegates[id] = build;
            return id;
        }

        public static Xaml.DependencyProperty IdProperty = Xaml.DependencyProperty.RegisterAttached("Id", typeof(string), typeof(BuildChild), new Xaml.PropertyMetadata(null));

        public static string GetId(Microsoft.UI.Xaml.Controls.Panel panel) => (string)panel.GetValue(IdProperty);

        public static void SetId(Microsoft.UI.Xaml.Controls.Panel panel, string id)
        {
            panel.SetValue(IdProperty, id);
            panel.Children.Clear();
            if (!string.IsNullOrEmpty(id)) panel.Children.Add(delegates[id]().UI);
        }

        public static Xaml.DependencyProperty TemplatedParentProperty = Xaml.DependencyProperty.RegisterAttached("TemplatedParent", typeof(Microsoft.UI.Xaml.UIElement), typeof(BuildChild), new Xaml.PropertyMetadata(null));

        public static Xaml.UIElement GetTemplatedParent(Microsoft.UI.Xaml.Controls.Panel panel)
            => (Xaml.UIElement)panel.GetValue(TemplatedParentProperty);

        public static void SetTemplatedParent(Microsoft.UI.Xaml.Controls.Panel panel, Microsoft.UI.Xaml.UIElement parent)
            => panel.SetValue(TemplatedParentProperty, parent);
    }

The property is changed, but not through the SetTemplatedParent method, but rather the DP system underneath.

If you do this, you'll get the new value:

        public static DependencyProperty TemplatedParentProperty = DependencyProperty.RegisterAttached(
            "TemplatedParent", typeof(Microsoft.UI.Xaml.UIElement), typeof(BuildChild), new PropertyMetadata(null, (s, e) => { }));

In general, it's best not to rely on the methods being invoked, as the value can be changed by other means (animations being one).

@jeromelaban wrote:

In general, it's best not to rely on the methods being invoked, as the value can be changed by other means (animations being one).

This works, thanks! I will integrate it for the next release.