dotnet / wpf

WPF is a .NET Core UI framework for building Windows desktop applications.
MIT License
7.1k stars 1.17k forks source link

XAMLWriter.Save throwing exception on generics #9569

Open frankhaugen opened 3 months ago

frankhaugen commented 3 months ago

Description

I have made a WPF dropdown component that is generic, so I can make a Combobox a little less tedious to work with:

public class MyDropDown<T> : UserControl
{
    private readonly ComboBox _comboBox = new();

    public MyDropDown()
    {
        _comboBox.SelectionChanged += ComboBox_SelectionChanged;
        Content = _comboBox;
    }

    public IEnumerable<T> Items
    {
        get => (IEnumerable<T>)_comboBox.ItemsSource;
        set => _comboBox.ItemsSource = value;
    }

    public Func<T, string> DisplayFunc
    {
        init => _comboBox.ItemTemplate = CreateDataTemplate(value);
    }

    public Action<T> SelectionChangedAction { get; init; }

    private void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (_comboBox.SelectedItem is T selectedItem)
        {
            SelectionChangedAction(selectedItem);
        }
    }

    private DataTemplate CreateDataTemplate(Func<T, string> displayFunc)
    {
        var dataTemplate = new DataTemplate(typeof(T));
        var factory = new FrameworkElementFactory(typeof(TextBlock));
        factory.SetBinding(TextBlock.TextProperty, new System.Windows.Data.Binding
        {
            Converter = new FuncValueConverter<T, string>(displayFunc),
            Mode = System.Windows.Data.BindingMode.OneWay
        });
        dataTemplate.VisualTree = factory;
        return dataTemplate;
    }

    // Converter for converting the Func<T, string> to a binding-friendly format
    private class FuncValueConverter<TInput, TOutput> : System.Windows.Data.IValueConverter
    {
        private readonly Func<TInput, TOutput> _func;

        public FuncValueConverter(Func<TInput, TOutput> func)
        {
            _func = func;
        }

        public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
        {
            return value is TInput input ? _func(input) : default;
        }

        public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
        {
            return value is TOutput output ? output : default;
        }
    }
}

Reproduction Steps

I have a basic test using Xunit with XUnit WpfFact nuget, where I would check for elements in the string:

[WpfFact]
public void Test2()
{
    var uiElement = new StackPanel
    {
        Orientation = Orientation.Horizontal,
        Children =
        {
            new MyDropDown<string>()
            {
                Items = new[] { "One", "Two", "Three" },
                DisplayFunc = x => x,
                SelectionChangedAction = x => { }
            }
        }
    };

    var result = XamlWriter.Save(uiElement);
    _outputHelper.WriteLine(result);

    Assert.Contains("One", result);
}

Expected behavior

XAMLWriter.Save(...) to return something like:

<StackPanel Orientation="Horizontal" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<MyDropDown T="Namespace.TypeName">
<ComboBox>
<Items>
// Items
</Items>
</ComboBox>
</MyDropDown>
</StackPanel>

Actual behavior

Throws exception:

System.InvalidOperationException: Cannot serialize a generic type 'Frank.Wpf.Tests.MyDropDown`1[System.String]'.

System.InvalidOperationException
Cannot serialize a generic type 'Frank.Wpf.Tests.MyDropDown`1[System.String]'.
   at System.Windows.Markup.Primitives.MarkupWriter.VerifyTypeIsSerializable(Type type)
   at System.Windows.Markup.Primitives.MarkupWriter.WriteItem(MarkupObject item, Scope scope)
   at System.Windows.Markup.Primitives.MarkupWriter.WriteItem(MarkupObject item, Scope scope)
   at System.Windows.Markup.Primitives.MarkupWriter.WriteItem(MarkupObject item)
   at System.Windows.Markup.Primitives.MarkupWriter.SaveAsXml(XmlWriter writer, MarkupObject item)
   at System.Windows.Markup.XamlWriter.Save(Object obj)
   at Frank.Wpf.Tests.XamlSerializerTests.Test2() in D:\frankrepos\Frank.Wpf\Frank.Wpf.Tests\XamlSerializerTests.cs:line 54
   at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)

Regression?

No response

Known Workarounds

Writing one's own "serializer", or rewriting to not use a generic

Impact

Edge case maybe, but I often include a secret key-combo in my WPF apps that will display a "raw dump" of the XAML and whatever else is loaded in the current window

Configuration

Windows 11 .net 8

Other information

Documentation don't state that this is an unreasonable expectation, (accepting generic UiElements): https://learn.microsoft.com/en-us/dotnet/desktop/wpf/advanced/serialization-limitations-of-xamlwriter-save?view=netframeworkdesktop-4.8&viewFallbackFrom=netdesktop-8.0

lindexi commented 3 months ago

I think it is the generic type issues...

The exception throw in MarkupWriter.VerifyTypeIsSerializable:

https://github.com/dotnet/wpf/blob/ce8e3041f94469a4fd2aa5948905c2b70852fe55/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Markup/Primitives/MarkupWriter.cs#L143-L158

It means that this exception is as wpf's design. Maybe we should update the document.

frankhaugen commented 3 months ago

... It means that this exception is as wpf's design. Maybe we should update the document.

Yes, as if this is "per design" then I would like something to tell me, with XML-docs, docs about the functionality, analyzer and maybe a more clear exception message

lindexi commented 3 months ago

Yeah, I agree with you. I think we should update the document.

miloush commented 3 months ago

I think you have other issues. How do you expect the DisplayFunc and SelectionChangedAction to be serialized? The private FuncValueConverter?

frankhaugen commented 3 months ago

I think you have other issues. How do you expect the DisplayFunc and SelectionChangedAction to be serialized? The private FuncValueConverter?

Why would I want to serialize it? They are behavioral

miloush commented 3 months ago

Why would I want to serialize it? They are behavioral

How would the serializer know? They are public properties and have values, so they are subject to serialization. You are giving the "final" object to the serializer, so for example the ComboBox has the ItemTemplate set, which should be written out during serialization. Your expected output does not have it.

frankhaugen commented 3 months ago

Why would I want to serialize it? They are behavioral

How would the serializer know? They are public properties and have values, so they are subject to serialization. You are giving the "final" object to the serializer, so for example the ComboBox has the ItemTemplate set, which should be written out during serialization. Your expected output does not have it.

Well, it's not causing an issue as far as I see, anyway it was a simple, and discoverable way to have a "template". I must admit I am not a WPF dev, so I'm skipping the XAML-parts and making everything how I would build components for a backend microservice 😆

miloush commented 3 months ago

Right, so there is two XAML schemas, 2006 and 2009. The latter supports generics. See https://learn.microsoft.com/en-us/dotnet/desktop/xaml-services/generics. The compiler does not support 2009, but that shouldn't be an issue for your use case.

There is also two XAML writers/readers, one in System.Windows.Markup namespace and one in System.Xaml namespace. The reader/loader situation is a bit easier because the Markup one uses the Xaml one internally and supports generics and most of the other 2009 features.

The writer situation is slightly less transparent. It is true that the System.Windows.Markup.XamlWriter does not support generics and I agree it would be nice if it did. The System.Xaml one does support generics. You don't really want to use it to generate XAML from WPF for users, because it serializes way more than is needed, but that might not be an issue for you either. However, I cannot simply suggest you use that one instead, because as I noted, you have bunch of other issues, such as non-public types and delegates that affect your object. I should have included the intro above, sorry. What I am trying to say is even if the XamlWriter supported generics, you won't be able to serialize your object, so maybe you might need to look for a different solution.

Btw for inspecting WPF apps you can use https://github.com/snoopwpf/snoopwpf.

Related: #58