kamulog

xamarin.formsのネタなど

ResourceDictionaryとEffectsを使ってFormsのThemeの切り替えだけで変えられない色も含めて強引に切り替えるサンプル

これは[初心者さん・学生さん大歓迎!] Xamarin その2 Advent Calendar 2016 - Qiitaの14日目の記事です。

初心者向きとは言い難いかも知れませんが、EffectsはさておきResourceDictionaryに関しては序盤から使っていくべき機能だと思いますので、こんなこともできるんだなぁという感じで流し読みしていただけると幸いです。

またサンプルのアプリはちゃんと中身も実装しているので、実際に動かしてみることもできるはずですので良かったら参考にしてみてください。

はじめに

Xamarin Advent Calendar 2016 - Qiitaの6日目の qiita.com

を早速試してみました。

この記事はものすごく丁寧でわかりやすくResourceDictionaryについて解説されているので、もしまだ見てない方は必読だと思います。

さて、本題に戻りまして、テーマ切り替えのサンプルとしてこんなイメージで作成していきました。

f:id:kamusoft:20161213171053p:plain

  • テーマを2つ作って動的に切り替える
  • とりあえずやっつけBMI測定アプリを作ってそれに適用してみる

以上のような感じで実装した結果がこちら

f:id:kamusoft:20161213171307p:plain:h400 f:id:kamusoft:20161213171317p:plain:h400

f:id:kamusoft:20161213171328p:plain:h400 f:id:kamusoft:20161213171338p:plain:h400

いい感じでテーマが切り替わってますが、いくつか気になる箇所がありませんか?
私は気になりまくりでした。色が指定できないパーツがいくつかあるんですよね。

  • iOS / Android共にSliderとSwitchの後ろのバーみたいなところの色
  • AndroidのSliderとSwitchのつまみの色
  • Androidのステータスバーの色

AndroidのFormsで変えられない色は大概AndroidのThemeで変更する必要があるんですが(今回の部分はアクセントカラー)、FormsのThemeで動的に色を変える時にそれに合わせてAndroidのThemeも変更するとかだるすぎるので、今回はResourceDictionaryの切り替えだけでなんとかするために、Effectsを使って無理やり対応していきたいと思います。

完成品

github.com

作成するEffectsの仕様

サンプルなのでやっつけ仕様で以下のとおりとします。

  • 1つでステータスバー・Slider・Switch全部対応する
  • AndroidのMainActivityはFormsAppCompatActivityであるものとする
  • プロパティはColorのみとする
  • ColorプロパティがDefaultからそれ以外に変化した時にEffectを追加する
  • EffectはRemoveしないものとする

これを踏まえて作成したEffectsが以下になります。

今回はEffectについての説明は省略します。

Effectsについて基本的なことはこちらで。
ticktack.hatenablog.jp

それから当ブログのこちらの記事で具体的な使い方を書いていますのでもしよければ参考にしてください。
kamusoft.hatenablog.jp

Androidのステータスバーの色に対応する

ステータスバーの色はNavigationPageで指定するようにします。

AlterColorPlatformEffect.cs

void UpdateColor()
{
    //PCLのColorプロパティを取得してAndroid用に変換
    var color = AlterColor.GetColor(Element).ToAndroid();
    //対象がNavigationPage
    if (Element is NavigationPage) {
        UpdateStatusBar(color);
    }
    //対象がSlider
    else if (Element is Slider) {
        UpdateSlider(color);
    }
    //対象がSwitch
    else if (Element is Xamarin.Forms.Switch) {
        UpdateSwitch(color);
    }
}

void UpdateStatusBar(Android.Graphics.Color color)
{
    //windowを取得してStatusBarColorセット
    var window = (Container.Context as FormsAppCompatActivity).Window;
    window.SetStatusBarColor(color);
}

XamlでEffectを適用する

MyNavigationPage.xaml

<?xml version="1.0" encoding="UTF-8"?>
<NavigationPage xmlns="http://xamarin.com/schemas/2014/forms"
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
        xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms"
        xmlns:ef="clr-namespace:ThemeSample.Effects;assembly=ThemeSample"
        prism:ViewModelLocator.AutowireViewModel="false"
        x:Class="ThemeSample.Views.MyNavigationPage"
        BarBackgroundColor="{DynamicResource AccentColor}"
        BarTextColor="{DynamicResource ObjectTextColor}"
        ef:AlterColor.Color="{DynamicResource AccentColor}"
        >
</NavigationPage>

xmlns:ef="clr-namespace:ThemeSample.Effects;assembly=ThemeSample"
ef:AlterColor.Color="{DynamicResource AccentColor}"
がEffect適用に必要な箇所です。

Sliderの色に対応する

iOS AlterColorPlatformEffect.cs

void UpdateColor()
{
    //PCLのColorプロパティを取得してiOS用に変換
    var color = AlterColor.GetColor(Element).ToUIColor();
    //iOSのStatusBarColorはNavigationPageのBarBackgroundColorで一緒に変わるので不要
    //対象がSlider
    if (Element is Slider) {
        UpdateSlider(color);
    }
    //対象がSwitch
    else if (Element is Switch) {
        UpdateSwitch(color);
    }
}

void UpdateSlider(UIColor color)
{
    var slider = Control as UISlider;
    //UISliderは専用のプロパティがあるのでそこにセットするだけ(素敵)
    //つまみの左側に設定色を
    slider.MinimumTrackTintColor = color;
    //つまみの右側は透過30%でそれっぽく
    slider.MaximumTrackTintColor = color.ColorWithAlpha(0.3f);

    //つまみの色も変えられるけど影が消えたりして微妙だし、そもそもデフォルトのままが美しいので変更しない
    //slider.ThumbTintColor = color;
}

コードのコメントの通りです。 iOSはつまみの色はデフォルトのままが自然なので変更しません。

Android AlterColorPlatformEffect.cs

void UpdateSlider(Android.Graphics.Color color)
{
    var seekBar = Control as SeekBar;
    //SeekBarのProgressDrawable(Bar部分)をLayerDrawableにする
    var progress = (LayerDrawable)(seekBar.ProgressDrawable.Current);

    //つまみの左側のBarの色を指定色に
    progress.GetDrawable(2).SetTint(color);
    //つまみの右側のBarの色を指定色(透過30%くらい)に
    var altColor = Android.Graphics.Color.Argb(76,color.R, color.G, color.B);
    progress.GetDrawable(0).SetTint(altColor);

    //つまみの色を指定色にする
    seekBar.Thumb.SetTint(color);
}

ProgressDrawableのどれがどの色かは総当たりで調べましたw
決め打ちなのでバージョンが変わったりした時に使えなくなる可能性大なので、ちゃんと変換できるかのチェックやindexのチェック等をして落ちないようにしておくのが良いと思います。

それからこんな風にdrawableの色を変えると他のEffectを適用していないパーツの色も変わってしまいます。多分共有してるんでしょうね。今回の要件は別に同時に変わっても問題ないんですが、もし個別に変更したい場合は以下のようにコピーを作成して再設定する必要があります。

というか、そもそも正規の方法が知りたいので、もしご存知の方がおられましたら教えていただけると幸いです。

void UpdateSlider(Android.Graphics.Color color)
 {
     var seekBar = Control as SeekBar;

     var progress = (LayerDrawable)(seekBar.ProgressDrawable.Current.GetConstantState().NewDrawable());

     progress.GetDrawable(2).SetTint(color);
     var altColor = Android.Graphics.Color.Argb(76,color.R, color.G, color.B);
     progress.GetDrawable(0).SetTint(altColor);

     seekBar.ProgressDrawable = progress;

     var thumb = seekBar.Thumb.GetConstantState().NewDrawable();
     thumb.SetTint(color);
     seekBar.SetThumb(thumb);
 }

ただこれだけだと動的に色を変えた時におかしなことになるので、もう一工夫必要です。

Xaml指定

EffectsはStyleでも指定可能です。

<ContentPage.Resources>
    <ResourceDictionary>
        <Style TargetType="Slider">
            <Setter Property="ef:AlterColor.Color" Value="{DynamicResource AccentColor}" />
        </Style>
    </ResourceDictionary>
</ContentPage.Resources>

Switchの色に対応する

iOS AlterColorPlatformEffect.cs

void UpdateSwitch(UIColor color)
{
    var uiSwitch = Control as UISwitch;
    //Onの時の背景色専用のプロパティがあるのでそれを設定する
    uiSwitch.OnTintColor = color;
    ////Offの時の背景を変えるならこれ
    //uiSwitch.BackgroundColor = UIColor.FromRGB(244,244,244);
    ////背景がはみ出るので角を丸める
    //uiSwitch.Layer.CornerRadius = uiSwitch.Frame.Size.Height / 2;
}

Offの時の背景も設定できますが、今回は無しにしました。 Off背景を設定する場合はコメントアウト部分を参考にしてください。

Android AlterColorPlatformEffect.cs

void UpdateSwitch(Android.Graphics.Color color)
{

    var aSwitch = Control as SwitchCompat;

    //track(Barのとこ)のColorStateList
    var trackColors = new ColorStateList(new int[][]
         {
            new int[]{global::Android.Resource.Attribute.StateChecked}, //ONの時
            new int[]{-global::Android.Resource.Attribute.StateChecked},//OFFの時
         },
        new int[] {
            Android.Graphics.Color.Argb(76,color.R,color.G,color.B), //30%透過
            Android.Graphics.Color.Argb(76, 117, 117, 117) //それっぽい濃いめのグレーに
         });


    aSwitch.TrackDrawable.SetTintList(trackColors);

    //thumb(つまみ)のColorStateList
    var thumbColors = new ColorStateList(new int[][]
         {
                new int[]{global::Android.Resource.Attribute.StateChecked},
                new int[]{-global::Android.Resource.Attribute.StateChecked},
         },
        new int[] {
                color,  //ONの時のつまみ指定色に
                Android.Graphics.Color.Argb(255, 244, 244, 244) //Offの時はそれっぽい薄いグレー
         });

    aSwitch.ThumbDrawable.SetTintList(thumbColors);

    //押した時のRippleエフェクトの色をtrackと同じにする
    //aSwitch.BackgroundTintList = trackColors じゃダメだった
    var ripple = aSwitch.Background as RippleDrawable;
    ripple.SetColor(trackColors);

}

TrackDrawableとThumbDrawableのSetTintListに色リストを設定すればいけました。 これの前はSliderの時みたいな強引な方法でやってしまってましたw この方法に気づいて良かったです。 Stateのtrueかfalseかはマイナスつけたらfalseになるみたいですね。

それからSwitchをタップしたときにRippleエフェクトっぽいのがでるんですが、ここにもうっすら色がついてます。気にならないレベルではあるんですが、できれば変えときたいので変えました。 BackgroundをRippleDrawableに変換して色を貼り直してます。

ただAPI21だけON・OFFの時の効果色が逆になってしまうんですよね。22〜24は問題無しなんですが。まぁこれはもう無視して良いでしょうw

Xaml指定

<ContentPage.Resources>
    <ResourceDictionary>
        <Style x:Key="EffectBase" TargetType="View">
            <Setter Property="ef:AlterColor.Color" Value="{DynamicResource AccentColor}" />
        </Style>
        <Style TargetType="Slider" BasedOn="{StaticResource EffectBase}" />
        <Style TargetType="Switch" BasedOn="{StaticResource EffectBase}" />
    </ResourceDictionary>
</ContentPage.Resources>

SliderにもSwitchにも同じEffectを適用する場合は共通の基底クラス(この場合View)にStyleでEffect適用の定義をして、これを各部品にBaseOnで継承してあげると良い感じです。

完成!

f:id:kamusoft:20161213174205g:plain

f:id:kamusoft:20161213174501g:plain

気になる部分の色も一緒に変わるようになって良い感じになりました!
(iOS9.3、iOS10.1、Android API21〜24で動作確認できました)

おまけ Setterの中でOnPlatform

例えばボタンの高さがAndroidでは実際に背景が表示されている領域の高さと異なるのでiOSと同じ値だと見た目が揃いません。そういう時はOnPlatformを使うんですが以下のようにStyleのSetterでも指定できます。

<Style TargetType="Button">
    <Setter Property="HeightRequest">
        <Setter.Value>
            <OnPlatform x:TypeArguments="x:Double" iOS="50" Android="70" />
        </Setter.Value>
    </Setter>
</Style>

その他のViewに関して

変えられない色は今回のSliderやSwitch以外にももちろんあります。
ですが大体はぐぐってEffectsなりCustomRendererなりで解決できると思います。 ただ、Androidの部品はコードだけで色を変更する情報が少ないので、そこは毎回苦労するところです。少しずつこの辺も情報が集まってくれば良いなと思います。

まとめ

  • ResourceDictionaryは便利すぎるので初心者のうちから使ってみよう!
  • ResourceDictionaryでの色定義やサイズ定義、MargeWithでの継承、Style、それらを組み合わせることでCSS程じゃないけど割とそれに近いことができるようになった!
  • 変えられない色はEffects機能を使って変えてしまおう!
  • Effects等で変更してしまえばAndroidのThemeはほぼ無視しても良し…かもしれない。
  • しかしAndroidの部品の動的色変更は難しい…

参考