kamulog

xamarin.formsのネタなど

Xamarin.FormsのTabbedPageでNGraphicsを使ってSVGアイコンを利用したり色を変えたりのサンプル

TabbedPageやNavigationPageのアイコンは子ページのIconプロパティの値にNative側のResourceを指定して使用しますが、このIconプロパティはImageのSourceのようなImageSource型ではなくて、FileImageSource型なのでPCLのResourceを指定することができません。 他のImageはPCLで処理できるのにPageのアイコンだけNativeにそれぞれ用意するのはめんどくさいし、ついでに言うと解像度別の画像を用意するのもめんどくさいということで、個人開発のアプリ(色しらべ・復習帳)ではCustomRendererとNGraphicsを利用してPCLのResourceを使用するようにしています。

この度その部分を少し整理したのでサンプルとしてご紹介します。

作成したもの

github.com

masterブランチはNavigationPageやToolbarItemのCustomRendererが入っているのですが、ちょっとカオスでサンプルにはふさわしくないので、ここでは紹介しません。

処理の流れ

  1. PCLにSVG画像を登録
  2. PCLのTabbedPageExでタブ毎の色とかテキストとかResourceのパスとかを設定
  3. TabbedPageExRendererでBaseの処理が終わった後で
    • PCLのSvgLoaderを使ってSvgのリソースを取得し
    • それをNGraphicsでScale(DPI)を考慮しつつ描画(彩色)して各Native画像へ変換
    • それをTabIconに設定
public partial class MyTabbed : TabbedPageEx
{
    public MyTabbed()
    {
        InitializeComponent();
        SvgLoader.Init(this.GetType());
        
        SelectedTextColor = Color.Gray;
        UnSelectedTextColor = Color.Silver;
        BarBackgroundColor = Color.White;
        StatusBarBackColor = Color.LightBlue;
        IsTextHidden = false;

        //2.3.3あたりからできたやつ(自分以外で何ページキャッシュしとくかの設定)
        this.On<Android>().SetOffscreenPageLimit(2);

        TabAttributes = new List<TabAttribute>{
            new TabAttribute{
                Title = "Abc",
                Resource = "camera.svg",
                SelectedColor = Color.Orange,
                UnSelectedColor = Color.Orange.MultiplyAlpha(0.2),
            },
            new TabAttribute{
                Title = "Def",
                Resource = "colours.svg",
                SelectedColor = Color.Green,
                UnSelectedColor = Color.Green.MultiplyAlpha(0.2),
            },
            new TabAttribute{
                Title = "Xyz",
                Resource = "back-in-time.svg",
                SelectedColor = Color.Blue,
                UnSelectedColor = Color.Blue.MultiplyAlpha(0.2),
            },
        };
    }
}

こんな感じでTabbedPageを設定すると

f:id:kamusoft:20170424192838g:plain f:id:kamusoft:20170424193024g:plain

こんな感じになります。

全然関係ないですがプラットフォーム固有の(抽象化できない)オプションは this.On<Android>().SetOffscreenPageLimit(2); みたいにPlatform Specificsで追加されていってるっぽいです。

iOS

public class TabbedPageExRenderer : TabbedRenderer
{
    //いろいろ試した結果、このタイミングがベスト
    public override void ViewWillAppear(bool animated)
    {
        base.ViewWillAppear(animated);

        var tabbedEx = Element as TabbedPageEx;

        UITabBarController tabctrl = Platform.GetRenderer(Tabbed.Children[0]).ViewController.TabBarController;

        for (var i = 0; i < Tabbed.Children.Count; i++) {
            Page child = Tabbed.Children[i];
            var attr = tabbedEx.TabAttributes[i];

            if (string.IsNullOrEmpty(attr.Resource)) continue;
            var vc = Platform.GetRenderer(child).ViewController;

            if (tabbedEx.IsDefaultColor && attr.IsDefaultColor) {
                //色指定なしはそのままアイコンに設定
                var icon = SvgToUIImage.GetUIImage(attr.Resource, 30, 30);
                vc.TabBarItem.Image = icon;
            }
            else {
                vc.TabBarItem.Title = attr.Title;
                vc.TabBarItem.SetTitleTextAttributes(
                    new UITextAttributes { TextColor = tabbedEx.SelectedTextColor.ToUIColor() },
                    UIControlState.Selected
                );
                vc.TabBarItem.SetTitleTextAttributes(
                    new UITextAttributes { TextColor = tabbedEx.UnSelectedTextColor.ToUIColor() },
                    UIControlState.Normal
                );
                //色を変える場合は選択用、非選択用の画像を生成してそれを設定
                var selColor = attr.SelectedColor == Xamarin.Forms.Color.Default ? tabbedEx.SelectedColor : attr.SelectedColor;
                var unColor = attr.UnSelectedColor == Xamarin.Forms.Color.Default ? tabbedEx.UnSelectedColor : attr.UnSelectedColor;
                var icon1 = SvgToUIImage.GetUIImage(attr.Resource, 30, 30, selColor);
                var icon2 = SvgToUIImage.GetUIImage(attr.Resource, 30, 30, unColor);
                vc.TabBarItem.SetFinishedImages(icon1, icon2);
            }

            if (tabbedEx.IsTextHidden) {
                vc.TabBarItem.Title = null;
                //タブテキスト非表示の場合のレイアウト調整
                vc.TabBarItem.ImageInsets = new UIEdgeInsets(6, 0, -6, 0);
            }
        }

        if (!tabbedEx.IsDefaultColor) {
            tabctrl.TabBar.TintColor = (Element as TabbedPageEx).SelectedTextColor.ToUIColor();
        }
    }
}

public static class SvgToUIImage
{
    public static UIImage GetUIImage(string resource, double width, double height)
    {
        var g = SvgLoader.GetResourceAndLoadSvg(resource);
        var transform = Transform.AspectFillRect(g.ViewBox, new Rect(0, 0, width, height));
        var transGraphic = g.TransformGeometry(transform);
        //スケールを指定して描画 DPIを考慮した解像度にしてくれるっぽい多分
        var canvas = Platforms.Current.CreateImageCanvas(
            new NGraphics.Size(width, height),
            UIScreen.MainScreen.Scale);

        transGraphic.Draw(canvas);

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

    public static UIImage GetUIImage(string resource, double width, double height, Xamarin.Forms.Color color)
    {
        if (color == Xamarin.Forms.Color.Default) {
            return GetUIImage(resource, width, height);
        }

        var g = SvgLoader.GetResourceAndLoadSvg(resource);

        var transform = Transform.AspectFillRect(g.ViewBox, new Rect(0, 0, width, height));
        var transGraphic = g.TransformGeometry(transform);

        var canvas = Platforms.Current.CreateImageCanvas(
            new NGraphics.Size(width, height),
            UIScreen.MainScreen.Scale);


        var nColor = new NGraphics.Color(color.R, color.G, color.B, color.A);

        foreach (var element in transGraphic.Children) {

            ApplyColor(element, nColor);
            element.Draw(canvas);
        }

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

    private static void ApplyColor(NGraphics.Element element, NGraphics.Color color)
    {
        var children = (element as Group)?.Children;
        if (children != null) {
            foreach (var child in children) {
                ApplyColor(child, color);
            }
        }

        if (element?.Pen != null) {
            element.Pen = new Pen(color, element.Pen.Width);
        }

        if (element?.Brush != null) {
            element.Brush = new SolidBrush(color);
        }
    }
}

色変更を含むNGraphics処理はぴーさんログの ticktack.hatenablog.jp を使わせていただきました。

タブテキストの個別の色変えはややこしそうだったので実装は断念しました。

Android

public class TabbedPageExRenderer : TabbedPageRenderer, TabLayout.IOnTabSelectedListener
{

    private TabbedPageEx tabbedEx;
    private TabLayout tabs;
    private Window window;

    protected override void OnElementChanged(ElementChangedEventArgs<TabbedPage> e)
    {
        base.OnElementChanged(e);
        
        //リフレクションで_tabLayoutフィールド取得
        //TabSelectedListenerに必要
        var fieldInfo = typeof(TabbedPageRenderer).GetField("_tabLayout", BindingFlags.Instance | BindingFlags.NonPublic);
        tabs = (TabLayout)fieldInfo.GetValue(this);
        
        //後述で必要になるメソッドをリフレクションで取得
        var teardownPage = typeof(TabbedPageRenderer).GetMethod("TeardownPage", BindingFlags.Instance | BindingFlags.NonPublic);

        window = (Context as FormsAppCompatActivity).Window;

        if (e.OldElement != null) {

        }

        if (e.NewElement != null) {

            tabbedEx = Element as TabbedPageEx;
            if (!tabbedEx.IsDefaultColor) {
                //OnTabSelectedListenerを上書きする
                tabs.SetOnTabSelectedListener(this);
            }

            // https://github.com/xamarin/Xamarin.Forms/blob/master/Xamarin.Forms.Platform.Android/AppCompat/TabbedPageRenderer.cs#L297
            // OnPagePropertyChangedでいらんことしてるので即TeardownPageを呼び出して解除する
            // (TabのTextを子ページのTitleと連動させているが、TabのTextはTabAttributeで設定するようにしているので不要)
            foreach (var page in Element.Children) {
                teardownPage.Invoke(this, new object[] { page });
            }

            for (var i = 0; i < tabbedEx.TabAttributes.Count; i++) {
                var attr = tabbedEx.TabAttributes[i];

                if (string.IsNullOrEmpty(attr.Resource)) continue;
                //SvgからBitmap変換(色はTintで変えるのでここでは不要)
                var bitmap = SvgToBitmap.GetBitmap(attr.Resource, 24, 24);
                var icon = new BitmapDrawable(Context.Resources, bitmap);
                var tab = tabs.GetTabAt(i);
                //それをTabIconにする
                tab.SetIcon(icon);

                if (!tabbedEx.IsDefaultColor || !attr.IsDefaultColor) {
                    var color = tabbedEx.SelectedColor.ToAndroid();

                    if (i == 0) {
                        if (attr.SelectedColor != Xamarin.Forms.Color.Default) {
                            color = attr.SelectedColor.ToAndroid();
                        }
                        //TabIndicator(下線)の色も変えとく
                        tabs.SetSelectedTabIndicatorColor(color);
                        if (tabbedEx.StatusBarBackColor != Xamarin.Forms.Color.Default) {
                            window.SetStatusBarColor(tabbedEx.StatusBarBackColor.ToAndroid());
                        }
                        else if (attr.StatusBarBackColor != Xamarin.Forms.Color.Default) {
                            window.SetStatusBarColor(attr.StatusBarBackColor.ToAndroid());
                        }
                    }
                    else {
                        color = tabbedEx.UnSelectedColor.ToAndroid();
                        if (attr.UnSelectedColor != Xamarin.Forms.Color.Default) {
                            color = attr.UnSelectedColor.ToAndroid();
                        }
                    }
                    //アイコンの色
                    tab.Icon.SetTint(color);
                    //タブテキストの色
                    tabs.SetTabTextColors(tabbedEx.UnSelectedTextColor.ToAndroid(), tabbedEx.SelectedTextColor.ToAndroid());
                }

                if (tabbedEx.IsTextHidden) {
                    //タブテキスト非表示
                    tab.SetText("");
                }
            }
        }
    }

    void TabLayout.IOnTabSelectedListener.OnTabReselected(TabLayout.Tab tab)
    {

    }
    //タブ選択
    void TabLayout.IOnTabSelectedListener.OnTabSelected(TabLayout.Tab tab)
    {
        if (tabbedEx == null)
            return;

        int selectedIndex = tab.Position;


        var attr = tabbedEx.TabAttributes[selectedIndex];
        if (attr == null) return;

        var color = tabbedEx.SelectedColor.ToAndroid();
        if (attr.SelectedColor != Xamarin.Forms.Color.Default) {
            color = attr.SelectedColor.ToAndroid();
        }
        //選択色にする
        tab.Icon.SetTint(color);
        tabs.SetSelectedTabIndicatorColor(color);

        if (tabbedEx.StatusBarBackColor != Xamarin.Forms.Color.Default) {
            window.SetStatusBarColor(tabbedEx.StatusBarBackColor.ToAndroid());
        }
        else if (attr.StatusBarBackColor != Xamarin.Forms.Color.Default) {
            window.SetStatusBarColor(attr.StatusBarBackColor.ToAndroid());
        }
        
        //TabbedPageのタイトルを子ページのタイトルにする
        tabbedEx.Title = tabbedEx.Children[selectedIndex].Title;
        tabbedEx.Children[selectedIndex].PropertyChanged += CurrentPage_PropertyChanged;

        if (Element.Children.Count > selectedIndex && selectedIndex >= 0) {
            //カレントページ変更
            Element.CurrentPage = Element.Children[selectedIndex];
        }

    }
    //タブ非選択
    void TabLayout.IOnTabSelectedListener.OnTabUnselected(TabLayout.Tab tab)
    {
        if (tabbedEx == null) return;

        int selectedIndex = tab.Position;

        var attr = tabbedEx.TabAttributes[selectedIndex];
        if (attr == null) return;

        var color = tabbedEx.UnSelectedColor.ToAndroid();
        if (attr.UnSelectedColor != Xamarin.Forms.Color.Default) {
            color = attr.UnSelectedColor.ToAndroid();
        }
        //非選択色にする
        tab.Icon.SetTint(color);

        tabbedEx.Children[selectedIndex].PropertyChanged -= CurrentPage_PropertyChanged;
    }

    void CurrentPage_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (e.PropertyName == Page.TitleProperty.PropertyName) {
            //TabbedPageのCurrentPageが変わったらそのタイトルも同期させる
            tabbedEx.Title = (sender as Page).Title;
        }
    }
}
public static class SvgToBitmap
{
    public static Bitmap GetBitmap(string resource, double width, double height)
    {
        var g = SvgLoader.GetResourceAndLoadSvg(resource);
        
        //DPIのスケール計算
        double scale = (int)Android.App.Application.Context.Resources.DisplayMetrics.DensityDpi / 160d;

        var transform = Transform.AspectFillRect(g.ViewBox, new NGraphics.Rect(0, 0, width, height));
        var transGraphic = g.TransformGeometry(transform);
        //スケールを指定して描画 DPIを考慮した解像度にしてくれるっぽい多分
        var canvas = Platforms.Current.CreateImageCanvas(new NGraphics.Size(width, height), scale);

        transGraphic.Draw(canvas);

        return (canvas.GetImage() as NGraphics.BitmapImage)?.Bitmap;
    }

}

Androidはリフレクションを使ってしまいました。 Tab切り替えのリスナーを設定するのにどうしても必要でしたので。

TeardownPageに関しては標準では子ページのタイトルが変わった時に連動してTabTextも変わってしまう動きなんですが、個人的にTabTextとPageTitleは連動していらないので、それを殺すのに必要でした。 もちろん本家更新には極めて弱いです。

TabbedPageのTitleと子PageのTitleを連動させているのは、NavigationPageが絡んだ場合の対応で、このサンプルではあまり関係ありません。

終わりに

正直これはnuget公開無理でしたねw
それっぽい名前つけましたけどプライベイト用にとどめておくことにしました。 本家XFの更新に弱すぎるのと、この辺の挙動の好みは人それぞれでしょうしね。 ただNGraphicsの使いどころとしてのサンプルにはなったかなと思います。

使用アイコンフォント

Entypo
Artwork by Daniel Bruce
http://www.entypo.com