kamulog

xamarin.formsのネタなど

Xamarin.Formsに独自のImageSourceを追加して標準のImageコントロールでSvg画像を表示させる(2.3.5〜)

ある部品をSVG対応させる場合は基本的にはNgraphicsなどの外部ライブラリを追加すると思います。 ただそうすると、SVGを使わない人にまで余分なdllを付加させてしまうことになります。

なんとかSvg対応だけを独立したパッケージにしてプラグインっぽい対応にできないかなと、Xamarin.Formsのソースを見ながら探っていたらImageSource自体を追加したら良いんじゃないかという案が浮かんだので作ってみました。(Internalsの恩恵なので2.3.5あたりからしかできません)

リポジトリ

github.com

Nuget

www.nuget.org

Install-Package Xamarin.Forms.Svg -Pre

Xamarin.Forms 2.4.0 以降が必要です。

概要

これをインストールすると、標準のImageなどのSourceにSvgImageSourceを指定できるようになり、特にImage自体はいじらずにSvg画像を表示できるようになります。

f:id:kamusoft:20170825191833p:plain:w200 f:id:kamusoft:20170825191837p:plain:w200

トラは https://commons.wikimedia.org/wiki/File:Ghostscript_Tiger.svg こちらの画像を使っています。

使用前の準備

iOS AppDelegate.cs

public override bool FinishedLaunching(UIApplication app, NSDictionary options) 
{    
    global::Xamarin.Forms.Forms.Init();

    Xamarin.Forms.Svg.iOS.SvgImage.Init();  //これを書く
    
    LoadApplication(new App(new iOSInitializer()));

    return base.FinishedLaunching(app, options);
}

Android MainActivity.cs

protected override void OnCreate(Bundle bundle)
{
    ...
    global::Xamarin.Forms.Forms.Init(this, bundle);

    Xamarin.Forms.Svg.Droid.SvgImage.Init(); //これを書く

    LoadApplication(new App(new AndroidInitializer()));
}

PCL

もしBindingを使用せずにXamlだけで記述する場合は、以下のようにApp.xaml.csあたりに記述が必要です。Resourceを持つアセンブリがAppと同一なら引数なし、別アセンブリならそのアセンブリの何かしらのTypeを指定します。

public App()
{
    InitializeComponent();

    SvgImageSource.RegisterAssembly();
    //SvgImageSource.RegisterAssembly(typeof(typehavingresource));
}

使い方

public ImageSource Image { get; set; } 

public SomeViewModel(){
    //リソースパスのみ指定
    Image = SvgImageSource.FromSvg("Resource.some.svg");
    //色を指定したり
    Image = SvgImageSource.FromSvg("Resource.some.svg", Color.Red);
    //サイズを指定したり
    Image = SvgImageSource.FromSvg("Resourece.some.svg", 150, 50)
}
<Image Source="{Binding Image}" />

リソースパスは後方一致で検索されるので完全フルパスじゃなくても大丈夫です。

Xamlだけの場合

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:svg="clr-namespace:Xamarin.Forms.Svg;assembly=SvgImageSource"
    x:Class="Sample.Views.MainPage" Title="MainPage">
    <StackLayout>
        <Image>
            <Image.Source>
                <svg:SvgImageSource Resource="some.svg" Width=150 Height=50 Color="Red" />
            </Image.Source>
        </Image>
    </StackLayout> 
</ContentPage>

独自のImageSourceの追加方法

大まかに以下の通りです。

  1. PCLにImageSource(またはその派生クラス)を継承したHogeImageSourceを作る
  2. 各プラットフォームにIImageSourceHandlerを実装したHogeImageSourceHandlerを作る
    • HogeImageSourceから実際に画像を読み出すために必要。ViewのRendererみたいなもの。
  3. HogeImageSourceHandlerにLoadImageAsyncメソッドを実装する
    • ここに実際に画像を読み出す処理を書く
  4. 独自ImageSourceを有効にするためFormsにHogeImageSourceとHogeImageSourceHandlerの型を登録する

独自ImageSourceを定義

SvgのリソースのStreamをとるのはStreamImageSourceと同じなのでそれを継承します。 インターフェースは別に必要ないですがなんとなくつけてみてます。 ここでは共通のBindablePropertyや生成用のstaticメソッドを登録したりします。 Assembly取得するところはXamarin.Formsのソースを流用しました。これで呼び出し元からAssemblyを取れるみたいですね。

public class SvgImageSource : StreamImageSource, ISvgImageSource
{
    //略
    static Assembly AssemblyCache;
    
    public static ImageSource FromSvg(string resource, Color color = default(Color))
    {
        if (AssemblyCache == null)
        {
            MethodInfo callingAssemblyMethod = typeof(Assembly).GetTypeInfo().GetDeclaredMethod("GetCallingAssembly");
            if (callingAssemblyMethod != null)
            {
                AssemblyCache = (Assembly)callingAssemblyMethod.Invoke(null, new object[0]);
            }
            else
            {
                return null;
            }
        }

        var realResource = GetRealResource(resource);
        if (realResource == null)
        {
            return null;
        }

        Func<Stream> streamFunc = () => AssemblyCache.GetManifestResourceStream(realResource);

        return new SvgImageSource { Stream = token => Task.Run(streamFunc, token), Color = color };

    }

    static string GetRealResource(string resource)
    {
        return AssemblyCache.GetManifestResourceNames()
                          .FirstOrDefault(x => x.EndsWith(resource, StringComparison.CurrentCultureIgnoreCase));
    }
}

IImageSourceHandlerの実装 (iOSの例)

これもStreamImageSourceHandlerのソースを流用しただけのもので、UIImageをとる所をNgraphicsでSvgからUIImageを取得する処理に変えました。

 public class SvgImageSourceHandler : IImageSourceHandler
 {
     internal static float ScreenScale;

     public async Task<UIImage> LoadImageAsync(ImageSource imagesource, CancellationToken cancelationToken = default(CancellationToken), float scale = 1)
     {
         UIImage image = null;
         var svgsource = imagesource as SvgImageSource;
         if (svgsource?.Stream != null)
         {
             using (var streamImage = await ((IStreamImageSource)svgsource).GetStreamAsync(cancelationToken).ConfigureAwait(false))
             {
                 if (streamImage != null)
                     image = GetUIImage(streamImage, svgsource.Width, svgsource.Height, svgsource.Color, scale);
             }
         }

         if (image == null)
         {
             Log.Warning(nameof(SvgImageSourceHandler), "Could not load image: {0}", svgsource);
         }

         return image;
     }

     UIImage GetUIImage(Stream stream, double width, double height, Color color, float scale)
     {
         Graphic g = null;
         using (var sr = new StreamReader(stream))
         {
             g = Graphic.LoadSvg(sr);
         }

         var newSize = SvgUtility.CalcAspect(g.Size, width, height);

         if (width > 0 || height > 0)
         {
             g = SvgUtility.Resize(g, newSize);
         }

         if (scale <= 1)
         {
             scale = ScreenScale;
         }

         var canvas = Platforms.Current.CreateImageCanvas(newSize, scale);

         if (color != Xamarin.Forms.Color.Default)
         {
             var nColor = new NGraphics.Color(color.R, color.G, color.B, color.A);

             foreach (var element in g.Children)
             {
                 SvgUtility.ApplyColor(element, nColor);
                 element.Draw(canvas);
             }
         }
         else
         {
             g.Draw(canvas);
         }

         return canvas.GetImage().GetUIImage();
     }
 }

型の登録

仕上げに「SvgImageSourceに対してはSvgImageSourceHandlerで処理してくださいね」ということをFormsに登録します。この例ではライブラリの初期化の時に一緒に登録するようにしています。

ここでInternalsが必要なので今の所preでしかできないんですよね。例によってEditorBrowsable属性が指定されているのでインテリセンスには出てきませんので注意です。もしかしたらCustomRendererのExport属性みたいに属性で指定できるのかもしれませんが、そこは調べきれてません。

public static class SvgImage
{
    public static void Init()
    {
        Internals.Registrar.Registered.Register(typeof(SvgImageSource), typeof(SvgImageSourceHandler));
    }
}

Internals.Registrar.Registeredに登録することで、Imageなどの内部でImageSourceの型に対応するHandlerを呼び出してLoadImageAsyncで取得するようになっているので、Image自体はいじらずにSvgの表示ができるようになります。

終わりに

Internalsの公開はかなり良い感じですね。今回のように標準のコントロールを拡張することなく、独自ImageSourceを追加することで動きを変えることができたのは感動しました。早くStableになって欲しいです。

同じ仕組みでGestureRecognizerも独自のものを追加できそうな気がするので、そちらもそのうち試してみたいです。