How to create a XAML markup extension that returns a collection

Deadly 提交于 2019-12-20 10:34:42

问题


I am using XAML serialization for an object graph (outside of WPF / Silverlight) and I am trying to create a custom markup extension that will allow a collection property to be populated using references to selected members of a collection defined elsewhere in XAML.

Here's a simplified XAML snippet that demonstrates what I aim to achieve:

<myClass.Languages>
    <LanguagesCollection>
        <Language x:Name="English" />
        <Language x:Name="French" />
        <Language x:Name="Italian" />
    </LanguagesCollection>
</myClass.Languages>

<myClass.Countries>
    <CountryCollection>
        <Country x:Name="UK" Languages="{LanguageSelector 'English'}" />
        <Country x:Name="France" Languages="{LanguageSelector 'French'}" />
        <Country x:Name="Italy" Languages="{LanguageSelector 'Italian'}" />
        <Country x:Name="Switzerland" Languages="{LanguageSelector 'English, French, Italian'}" />
    </CountryCollection>
</myClass.Countries>

The Languages property of each Country object is to be populated with an IEnumerable<Language> containing references to the Language objects specified in the LanguageSelector, which is a custom markup extension.

Here is my attempt at creating the custom markup extension that will serve in this role:

[ContentProperty("Items")]
[MarkupExtensionReturnType(typeof(IEnumerable<Language>))]
public class LanguageSelector : MarkupExtension
{
    public LanguageSelector(string items)
    {
        Items = items;
    }

    [ConstructorArgument("items")]
    public string Items { get; set; }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        var service = serviceProvider.GetService(typeof(IXamlNameResolver)) as IXamlNameResolver;
        var result = new Collection<Language>();

        foreach (var item in Items.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(item => item.Trim()))
        {
            var token = service.Resolve(item);

            if (token == null)
            {
                var names = new[] { item };
                token = service.GetFixupToken(names, true);
            }

            if (token is Language)
            {
                result.Add(token as Language);
            }
        }

        return result;
    }
}

In fact, this code almost works. As long as the referenced objects are declared in XAML before the objects that are referencing them, the ProvideValue method correctly returns an IEnumerable<Language> populated with the referenced items. This works because the backward references to the Language instances are resolved by the following code line:

var token = service.Resolve(item);

But, if the XAML contains forward references (because the Language objects are declared after the Country objects), it breaks because this requires fixup tokens which (obviously) cannot be cast to Language.

if (token == null)
{
    var names = new[] { item };
    token = service.GetFixupToken(names, true);
}

As an experiment I tried converting the returned collection to Collection<object> in the hope that XAML would somehow resolve the tokens later, but it throws invalid cast exceptions during deserialization.

Can anyone suggest how best to get this working?

Many thanks, Tim


回答1:


You can't use the GetFixupToken methods because they return an internal type that can only be processed by the existing XAML writers that work under the default XAML schema context.

But you can use the following approach instead:

[ContentProperty("Items")]
[MarkupExtensionReturnType(typeof(IEnumerable<Language>))]
public class LanguageSelector : MarkupExtension {
    public LanguageSelector(string items) {
        Items = items;
    }
    [ConstructorArgument("items")]
    public string Items { get; set; }
    public override object ProvideValue(IServiceProvider serviceProvider) {
        string[] items = Items.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
        return new IEnumerableWrapper(items, serviceProvider);
    }
    class IEnumerableWrapper : IEnumerable<Language>, IEnumerator<Language> {
        string[] items;
        IServiceProvider serviceProvider;
        public IEnumerableWrapper(string[] items, IServiceProvider serviceProvider) {
            this.items = items;
            this.serviceProvider = serviceProvider;
        }
        public IEnumerator<Language> GetEnumerator() {
            return this;
        }
        int position = -1;
        public Language Current {
            get {
                string name = items[position];
                // TODO use any possible methods to resolve object by name
                var rootProvider = serviceProvider.GetService(typeof(IRootObjectProvider)) as IRootObjectProvider
                var nameScope = NameScope.GetNameScope(rootProvider.RootObject as DependencyObject);
                return nameScope.FindName(name) as Language;
            }
        }
        public void Dispose() {
            Reset();
        }
        public bool MoveNext() { 
            return ++position < items.Length; 
        }
        public void Reset() { 
            position = -1; 
        }
        object IEnumerator.Current { get { return Current; } }
        IEnumerator IEnumerable.GetEnumerator() { return this; }
    }
}



回答2:


Here is a complete and working project that solves your issue. At first I was going to suggest using the [XamlSetMarkupExtension] attribute on your Country class, but actually all you need is the XamlSchemaContext's forward name resolution.

Although the documentation for that feature is very thin on the ground, you can in fact tell Xaml Services to defer your target element, and the following code shows how. Note that all of your language names get properly resolved even though the sections from your example are reversed.

Basically, if you need a name that couldn't be resolved, you request deferral by returning a fixup token. Yes, as Dmitry mentions it's opaque to us, but that doesn't matter. When you call GetFixupToken(...), you will specify a list of names that you need. Your markup extension—ProvideValue, that is—will be called again later when those names have become available. At that point, it's basically a do-over.

Not shown here is that you should also check the Boolean property IsFixupTokenAvailable on the IXamlNameResolver. If the names are truly to be found later, then this should return true. If the value is false and you still have unresolved names, then you should hard-fail the operation, presumably because the names given in the Xaml ultimately couldn't be resolved.

Some might be curious to note that this project is not a WPF app, i.e., it references no WPF libraries; the only reference you must add to this standalone ConsoleApplication is System.Xaml. This is true even though there is a using statement for System.Windows.Markup (a historical artifact). It was in .NET 4.0 that the XAML Services support was moved from WPF (and elsewhere) and into the core BCL libraries.

IMHO, this change made XAML Services the greatest BCL feature that nobody's heard of. There's no better foundation for developing a large systems-level application that has radical reconfiguration capability as a primary requirement. An example of such an 'app' is WPF.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows.Markup;
using System.Xaml;

namespace test
{
    public class Language { }

    public class Country { public IEnumerable<Language> Languages { get; set; } }

    public class LanguageSelector : MarkupExtension
    {
        public LanguageSelector(String items) { this.items = items; }
        String items;

        public override Object ProvideValue(IServiceProvider ctx)
        {
            var xnr = ctx.GetService(typeof(IXamlNameResolver)) as IXamlNameResolver;

            var tmp = items.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)
                           .Select(s_lang => new
                            {
                                s_lang,
                                lang = xnr.Resolve(s_lang) as Language
                            });

            var err = tmp.Where(a => a.lang == null).Select(a => a.s_lang);
            return err.Any() ? 
                    xnr.GetFixupToken(err) : 
                    tmp.Select(a => a.lang).ToList();
        }
    };

    public class myClass
    {
        Collection<Language> _l = new Collection<Language>();
        public Collection<Language> Languages { get { return _l; } }

        Collection<Country> _c = new Collection<Country>();
        public Collection<Country> Countries { get { return _c; } }

        // you must set the name of your assembly here ---v
        const string s_xaml = @"
<myClass xmlns=""clr-namespace:test;assembly=ConsoleApplication2""
         xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml"">

    <myClass.Countries> 
        <Country x:Name=""UK"" Languages=""{LanguageSelector 'English'}"" /> 
        <Country x:Name=""France"" Languages=""{LanguageSelector 'French'}"" /> 
        <Country x:Name=""Italy"" Languages=""{LanguageSelector 'Italian'}"" /> 
        <Country x:Name=""Switzerland"" Languages=""{LanguageSelector 'English, French, Italian'}"" /> 
    </myClass.Countries> 

    <myClass.Languages>
        <Language x:Name=""English"" /> 
        <Language x:Name=""French"" /> 
        <Language x:Name=""Italian"" /> 
    </myClass.Languages> 

</myClass>
";
        static void Main(string[] args)
        {
            var xxr = new XamlXmlReader(new StringReader(s_xaml));
            var xow = new XamlObjectWriter(new XamlSchemaContext());
            XamlServices.Transform(xxr, xow);
            myClass mc = (myClass)xow.Result;   /// works with forward references in Xaml
        }
    };
}

[edit...]

As I'm just learning XAML Services, I may have been overthinking it. Below is a simple solution which allows you to establish whatever references you desire--entirely in XAML--using just the built-in markup extensions x:Array and x:Reference.

Somehow I hadn't realized that not only can x:Reference populate an attribute (as it's commonly seen: {x:Reference some_name}), but it can also stand as a XAML tag on its own (<Reference Name="some_name" />). In either case it functions as a proxy reference to an object elsewhere in the document. This allows you to populate an x:Array with references to other XAML objects and then simply set the array as the value for your property. The XAML parser(s) automatically resolve forward references as required.

<myClass xmlns="clr-namespace:test;assembly=ConsoleApplication2"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <myClass.Countries>
        <Country x:Name="UK">
            <Country.Languages>
                <x:Array Type="Language">
                    <x:Reference Name="English" />
                </x:Array>
            </Country.Languages>
        </Country>
        <Country x:Name="France">
            <Country.Languages>
                <x:Array Type="Language">
                    <x:Reference Name="French" />
                </x:Array>
            </Country.Languages>
        </Country>
        <Country x:Name="Italy">
            <Country.Languages>
                <x:Array Type="Language">
                    <x:Reference Name="Italian" />
                </x:Array>
            </Country.Languages>
        </Country>
        <Country x:Name="Switzerland">
            <Country.Languages>
                <x:Array Type="Language">
                    <x:Reference Name="English" />
                    <x:Reference Name="French" />
                    <x:Reference Name="Italian" />
                </x:Array>
            </Country.Languages>
        </Country>
    </myClass.Countries>
    <myClass.Languages>
        <Language x:Name="English" />
        <Language x:Name="French" />
        <Language x:Name="Italian" />
    </myClass.Languages>
</myClass>

To try it out, here's a complete console app that instantiates the myClass object from the preceding XAML file. As before, add a reference to System.Xaml.dll and change the first line of the XAML above to match your assembly name.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Xaml;

namespace test
{
    public class Language { }

    public class Country { public IEnumerable<Language> Languages { get; set; } }

    public class myClass
    {
        Collection<Language> _l = new Collection<Language>();
        public Collection<Language> Languages { get { return _l; } }

        Collection<Country> _c = new Collection<Country>();
        public Collection<Country> Countries { get { return _c; } }

        static void Main()
        {
            var xxr = new XamlXmlReader(new StreamReader("XMLFile1.xml"));
            var xow = new XamlObjectWriter(new XamlSchemaContext());
            XamlServices.Transform(xxr, xow);
            myClass mc = (myClass)xow.Result;
        }
    };
}


来源:https://stackoverflow.com/questions/8302408/how-to-create-a-xaml-markup-extension-that-returns-a-collection

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!