Skip to content

Commit

Permalink
Implement OnPlatform/OnIdiom markup extensions (xamarin#2615)
Browse files Browse the repository at this point in the history
* Added OnPlatform markup extension supporting iOS/Android/UWP for xamarin#2608

* Add Default and Other properties to OnPlatformExtension

This allows setting a default value for unknown platforms, as well
as specify values for arbitrary platforms by using a named parameter
like syntax:

`{OnPlatform Android=15, iOS=10, UWP=12, Default=11, Other=Tizen:20}`

The `Other` supports multiple semi-colon separated values. By using
this format, we can make the string more concise than if we used `=`
which would have to be escaped in quotes. For example:

`{OnPlatform Default=10, Other=Tizen:22;Xbox:20;Switch=25;PlayStation=22}`

Added unit tests that verify all the supported combinations.

* Add OnIdiomExtension

The extension allows the following syntax:

`{OnIdiom Phone=23, Tablet=25, Desktop=26, TV=30, Watch=10, Default=20}`

At least one value or `Default` must be specified. `Default` is returned
whenever the specific idiom was not specified.

* Add missing known platforms and return Default if missing

Add all strings that are provided in `Device`.
Convert individual asserts into test cases for better reporting.
Also, whenever a matching platform value isn't specified, return Default
instead of null.

* Turn OnIdiom asserts into test cases

This makes for easier to read, document and report tests.

* Add missing platforms to null check

* Make Default the content property

* Perform type conversion as expected by XamlC

Leverage the conversion that is used elsewhere, to return a
properly typed object that can be assigned directly to the
property. Update tests with typed values since now we get
integers, rather than strings out of the parser.

* Add Converter/ConverterParameter support

* Message should state that the value must be non-null

* Remove Unsupported idiom since it's not useful to set

You can use Default instead.

* Use new GetService<T> extension method for conciseness

* Don't fail if service provider is null

* Remove Other from OnPlatformExtension

As suggested, this might come back in the future in some other form.
  • Loading branch information
kzu authored and StephaneDelcroix committed May 14, 2018
1 parent bf8d706 commit dc62dc1
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 16 deletions.
10 changes: 2 additions & 8 deletions Xamarin.Forms.Core.Design/Xamarin.Forms.Core.Design.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,13 @@
<ItemGroup Condition=" '$(OS)' != 'Unix' ">
<None Include="packages.config" />
</ItemGroup>

<!-- The IDE will look for a top level assembly resource called 'Xamarin.Forms.toolbox.xml' to -->
<!-- load the toolbox metadata from. -->
<ItemGroup>
<EmbeddedResource Include="toolbox\Xamarin.Forms.toolbox.xml">
<LogicalName>Xamarin.Forms.toolbox.xml</LogicalName>
</EmbeddedResource>
</ItemGroup>

<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Xamarin.Forms.Design.1.0.26-pre\build\Xamarin.Forms.Design.targets" Condition="Exists('..\packages\Xamarin.Forms.Design.1.0.26-pre\build\Xamarin.Forms.Design.targets') And '$(OS)' != 'Unix' " />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild" Condition=" '$(OS)' != 'Unix'">
Expand All @@ -83,17 +81,15 @@
</PropertyGroup>
<Error Condition="!Exists('..\packages\Xamarin.Forms.Design.1.0.26-pre\build\Xamarin.Forms.Design.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Xamarin.Forms.Design.1.0.26-pre\build\Xamarin.Forms.Design.targets'))" />
</Target>

<!-- Ensure that all images in the 'mac' and 'win' subdirectories are included as embedded resources -->
<!-- using a defined format. That format is "{platform}.{imagename}". We will look up images using -->
<!-- exact-match logic so there's no guessework to figure out which image we need to load. -->
<PropertyGroup>
<AssignTargetPathsDependsOn>
<AssignTargetPathsDependsOn>
$(AssignTargetPathsDependsOn);
IncludeToolboxImages
</AssignTargetPathsDependsOn>
</PropertyGroup>

<Target Name="IncludeToolboxImages">
<!-- Be explicit about the prefix rather than relying on the directory name being exactly what we need -->
<ItemGroup>
Expand All @@ -104,10 +100,8 @@
<Prefix>win</Prefix>
</Images>
</ItemGroup>

<CreateItem Include="@(Images)" AdditionalMetadata="LogicalName=%(Prefix).%(Filename)%(Extension)">
<Output TaskParameter="Include" ItemName="EmbeddedResource" />
</CreateItem>
</Target>

</Project>
</Project>
12 changes: 10 additions & 2 deletions Xamarin.Forms.Xaml.Design/AttributeTableBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
using Microsoft.Windows.Design;
using System;
using System.ComponentModel;
using Microsoft.Windows.Design;

namespace Xamarin.Forms.Xaml.Design
{
class AttributeTableBuilder : Microsoft.Windows.Design.Metadata.AttributeTableBuilder
{
public AttributeTableBuilder()
{
// Turn off validation of values, which doesn't work for OnPlatform/OnIdiom
AddCustomAttributes(typeof (ArrayExtension).Assembly,
new XmlnsSupportsValidationAttribute("http://xamarin.com/schemas/2014/forms", false));

AddCallback(typeof(OnPlatformExtension), builder => builder.AddCustomAttributes(new Attribute[] {
new System.Windows.Markup.MarkupExtensionReturnTypeAttribute (),
}));
AddCallback(typeof(OnIdiomExtension), builder => builder.AddCustomAttributes(new Attribute[] {
new System.Windows.Markup.MarkupExtensionReturnTypeAttribute (),
}));
}
}
}
68 changes: 63 additions & 5 deletions Xamarin.Forms.Xaml.UnitTests/MarkupExpressionParserTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
using System;
using NUnit.Framework;
using System.Xml;
using System.Collections.Generic;

using Xamarin.Forms.Core.UnitTests;
using System.Reflection;
using System.Xml;
using NUnit.Framework;
using Xamarin.Forms.Core.UnitTests;

namespace Xamarin.Forms.Xaml.UnitTests
{
Expand Down Expand Up @@ -177,7 +176,7 @@ public object TargetObject {
}
}

public object TargetProperty { get; } = null;
public object TargetProperty { get; set; } = null;
}

[Test]
Expand Down Expand Up @@ -324,5 +323,64 @@ public void BindingWithStaticConverter ()
Assert.AreEqual(".", binding.Path);
Assert.That (binding.Converter, Is.TypeOf<ReverseConverter> ());
}

public int FontSize { get; set; }

[TestCase("{OnPlatform 20, Android=23}", Device.Android, 23)]
[TestCase("{OnPlatform Android=20, iOS=25}", Device.iOS, 25)]
[TestCase("{OnPlatform Android=20, GTK=25}", Device.GTK, 25)]
[TestCase("{OnPlatform Android=20, macOS=25}", Device.macOS, 25)]
[TestCase("{OnPlatform Android=20, Tizen=25}", Device.Tizen, 25)]
[TestCase("{OnPlatform Android=20, UWP=25}", Device.UWP, 25)]
[TestCase("{OnPlatform Android=20, WPF=25}", Device.WPF, 25)]
[TestCase("{OnPlatform 20}", Device.iOS, 20)]
[TestCase("{OnPlatform 20}", Device.GTK, 20)]
[TestCase("{OnPlatform 20}", Device.macOS, 20)]
[TestCase("{OnPlatform 20}", Device.Tizen, 20)]
[TestCase("{OnPlatform 20}", Device.UWP, 20)]
[TestCase("{OnPlatform 20}", Device.WPF, 20)]
[TestCase("{OnPlatform 20}", "Foo", 20)]
[TestCase("{OnPlatform Android=23, Default=20}", "Foo", 20)]
public void OnPlatformExtension(string markup, string platform, int expected)
{
var services = new MockPlatformServices
{
RuntimePlatform = platform
};
Device.PlatformServices = services;

var actual = (new MarkupExtensionParser()).ParseExpression(ref markup, new Internals.XamlServiceProvider(null, null)
{
IXamlTypeResolver = typeResolver,
IProvideValueTarget = new MockValueProvider("foo", new object())
{
TargetProperty = GetType().GetProperty(nameof(FontSize))
}
});

Assert.AreEqual(expected, actual);
}

[TestCase("{OnIdiom Phone=23, Tablet=25, Default=20}", TargetIdiom.Phone, 23)]
[TestCase("{OnIdiom Phone=23, Tablet=25, Default=20}", TargetIdiom.Tablet, 25)]
[TestCase("{OnIdiom 20, Phone=23, Tablet=25}", TargetIdiom.Desktop, 20)]
[TestCase("{OnIdiom Phone=23, Tablet=25, Desktop=26, TV=30, Watch=10}", TargetIdiom.Desktop, 26)]
[TestCase("{OnIdiom Phone=23, Tablet=25, Desktop=26, TV=30, Watch=10}", TargetIdiom.TV, 30)]
[TestCase("{OnIdiom Phone=23, Tablet=25, Desktop=26, TV=30, Watch=10}", TargetIdiom.Watch, 10)]
[TestCase("{OnIdiom Phone=23}", TargetIdiom.Desktop, default(int))]
public void OnIdiomExtension(string markup, TargetIdiom idiom, int expected)
{
Device.SetIdiom(idiom);
var actual = (new MarkupExtensionParser()).ParseExpression(ref markup, new Internals.XamlServiceProvider(null, null)
{
IXamlTypeResolver = typeResolver,
IProvideValueTarget = new MockValueProvider("foo", new object())
{
TargetProperty = GetType().GetProperty(nameof(FontSize))
}
});

Assert.AreEqual(expected, actual);
}
}
}
6 changes: 5 additions & 1 deletion Xamarin.Forms.Xaml/MarkupExtensionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public object Parse(string match, ref string remaining, IServiceProvider service
markupExtension = new TemplateBindingExtension();
else if (match == "StaticResource")
markupExtension = new StaticResourceExtension();
else if (match == "OnPlatform")
markupExtension = new OnPlatformExtension();
else if (match == "OnIdiom")
markupExtension = new OnIdiomExtension();
else
{
if (typeResolver == null)
Expand All @@ -27,7 +31,7 @@ public object Parse(string match, ref string remaining, IServiceProvider service
//The order of lookup is to look for the Extension-suffixed class name first and then look for the class name without the Extension suffix.
if (!typeResolver.TryResolve(match + "Extension", out type) && !typeResolver.TryResolve(match, out type))
{
var lineInfoProvider = serviceProvider.GetService(typeof (IXmlLineInfoProvider)) as IXmlLineInfoProvider;
var lineInfoProvider = serviceProvider.GetService(typeof(IXmlLineInfoProvider)) as IXmlLineInfoProvider;
var lineInfo = (lineInfoProvider != null) ? lineInfoProvider.XmlLineInfo : new XmlLineInfo();
throw new XamlParseException(String.Format("MarkupExtension not found for {0}", match), lineInfo);
}
Expand Down
75 changes: 75 additions & 0 deletions Xamarin.Forms.Xaml/MarkupExtensions/OnIdiomExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System;
using System.Globalization;
using System.Reflection;

namespace Xamarin.Forms.Xaml
{
[ContentProperty("Default")]
public class OnIdiomExtension : IMarkupExtension
{
// See Device.Idiom

public object Default { get; set; }
public object Phone { get; set; }
public object Tablet { get; set; }
public object Desktop { get; set; }
public object TV { get; set; }
public object Watch { get; set; }

public IValueConverter Converter { get; set; }

public object ConverterParameter { get; set; }

public object ProvideValue(IServiceProvider serviceProvider)
{
var lineInfo = serviceProvider?.GetService<IXmlLineInfoProvider>()?.XmlLineInfo;
if (Default == null && Phone == null &&
Tablet == null && Desktop == null && TV == null && Watch == null)
{
throw new XamlParseException("OnIdiomExtension requires a non-null value to be specified for at least one idiom or Default.", lineInfo ?? new XmlLineInfo());
}

var valueProvider = serviceProvider?.GetService<IProvideValueTarget>() ?? throw new ArgumentException();

var bp = valueProvider.TargetProperty as BindableProperty;
var pi = valueProvider.TargetProperty as PropertyInfo;
var propertyType = bp?.ReturnType
?? pi?.PropertyType
?? throw new InvalidOperationException("Cannot determine property to provide the value for.");

var value = GetValue();
var info = propertyType.GetTypeInfo();
if (value == null && info.IsValueType)
return Activator.CreateInstance(propertyType);

if (Converter != null)
return Converter.Convert(value, propertyType, ConverterParameter, CultureInfo.CurrentUICulture);

var converterProvider = serviceProvider?.GetService<IValueConverterProvider>();

if (converterProvider != null)
return converterProvider.Convert(value, propertyType, () => pi, serviceProvider);
else
return value.ConvertTo(propertyType, () => pi, serviceProvider);
}

object GetValue()
{
switch (Device.Idiom)
{
case TargetIdiom.Phone:
return Phone ?? Default;
case TargetIdiom.Tablet:
return Tablet ?? Default;
case TargetIdiom.Desktop:
return Desktop ?? Default;
case TargetIdiom.TV:
return TV ?? Default;
case TargetIdiom.Watch:
return Watch ?? Default;
default:
return Default;
}
}
}
}
80 changes: 80 additions & 0 deletions Xamarin.Forms.Xaml/MarkupExtensions/OnPlatformExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Globalization;
using System.Reflection;

namespace Xamarin.Forms.Xaml
{
[ContentProperty("Default")]
public class OnPlatformExtension : IMarkupExtension
{
public object Default { get; set; }
public object Android { get; set; }
public object GTK { get; set; }
public object iOS { get; set; }
public object macOS { get; set; }
public object Tizen { get; set; }
public object UWP { get; set; }
public object WPF { get; set; }

public IValueConverter Converter { get; set; }

public object ConverterParameter { get; set; }

public object ProvideValue(IServiceProvider serviceProvider)
{
var lineInfo = serviceProvider?.GetService<IXmlLineInfoProvider>()?.XmlLineInfo;
if (Android == null && GTK == null && iOS == null &&
macOS == null && Tizen == null && UWP == null &&
WPF == null && Default == null)
{
throw new XamlParseException("OnPlatformExtension requires a non-null value to be specified for at least one platform or Default.", lineInfo ?? new XmlLineInfo());
}

var valueProvider = serviceProvider?.GetService<IProvideValueTarget>() ?? throw new ArgumentException();

var bp = valueProvider.TargetProperty as BindableProperty;
var pi = valueProvider.TargetProperty as PropertyInfo;
var propertyType = bp?.ReturnType
?? pi?.PropertyType
?? throw new InvalidOperationException("Cannot determine property to provide the value for.");

var value = GetValue();
var info = propertyType.GetTypeInfo();
if (value == null && info.IsValueType)
return Activator.CreateInstance(propertyType);

if (Converter != null)
return Converter.Convert(value, propertyType, ConverterParameter, CultureInfo.CurrentUICulture);

var converterProvider = serviceProvider?.GetService<IValueConverterProvider>();

if (converterProvider != null)
return converterProvider.Convert(value, propertyType, () => pi, serviceProvider);
else
return value.ConvertTo(propertyType, () => pi, serviceProvider);
}

object GetValue()
{
switch (Device.RuntimePlatform)
{
case Device.Android:
return Android ?? Default;
case Device.GTK:
return GTK ?? Default;
case Device.iOS:
return iOS ?? Default;
case Device.macOS:
return macOS ?? Default;
case Device.Tizen:
return Tizen ?? Default;
case Device.UWP:
return UWP ?? Default;
case Device.WPF:
return WPF ?? Default;
default:
return Default;
}
}
}
}

0 comments on commit dc62dc1

Please sign in to comment.