kamulog

xamarin.formsのネタなど

Xamarin.Forms で ダークモードにほぼ完全に対応し、「端末の設定を使う」にも対応する

Twitterアプリにある、ダークモード対応の

  • ライト
  • ダーク
  • 端末の設定を使う

の3択パターンを Xamarin.Forms で実装してみました。

サンプル

github.com

youtu.be

Forms 側の対応

Xamarin.Formsでのダークモード対応は現時点でいくつかの選択肢があります。

  1. アプリケーションのシステムテーマの変更に応答する Xamarin.FormsRespond to system theme changes in Xamarin.Forms applications - Xamarin | Microsoft Docs
  2. アプリケーションのテーマを Xamarin.Forms 適用するTheme a Xamarin.Forms Application - Xamarin | Microsoft Docs

1は、まだ試験段階ですが、「端末の設定を使う」だけで良いなら一番簡単だと思います。 ただし、端末はダークだけど、アプリはライトとかの要件だと1では対応しきれないので、今回は2のテーマ切り替えの方法を使いました。

やり方はリンク先のドキュメントに詳しく載っているので割愛しますが、ライト・ダークに対応するResourceDictionaryを作って、それを動的に切り替えるというものです。

追記

Xamarin.Forms 4.7 で現在のシステムのテーマを切り替えることができるようになったみたいなので多分もっと簡単に実装できるかも知れません。

Respond to system theme changes in Xamarin.Forms applications - Xamarin | Microsoft Docs

ただ、この記事の方法だとライト2種、ダーク2種とかのテーマを切り替えることも可能です…

Forms側だけの限界

画像の下部分はSwitchとDatePickerですが

iOSではSwitchの背景が黒くて背景に溶け込みすぎて見にくいのと、DatePickerは背景が白いままです。また画像は省略しますがPickerのドラムみたいな部品も明るいままです。 AndroidではSwitchも同じように見にくく、DatePickerは真っ黒で見えず、DatePickerの下線の色もPlatform側のStyleがそのままなので変化せず、ダイアログは完全にライトモードのままです。

f:id:kamusoft:20200618004007p:plainf:id:kamusoft:20200617213953p:plain

このように、Forms側のテーマだけを変えても対応できない部分があります。 このような部分はシステム側が持っている現在のモードを変更してあげる必要があります。

プラットフォーム実装

NETStandard

現在のシステムテーマを返すものと、システムテーマを上書きするものを定義します。

    public interface ISystemTheme
    {
        void SetTheme(Theme theme);
        Theme GetTheme();
    }

iOS

Interfaceを実装します。

[assembly: Dependency(typeof(SystemThemeImplementation))]
namespace DarkSample.iOS.Platforms
{
    public class SystemThemeImplementation:ISystemTheme
    {
        public Theme GetTheme()
        {
            return UITraitCollection.CurrentTraitCollection.UserInterfaceStyle switch
            {
                UIUserInterfaceStyle.Dark => Theme.Dark,
                _ => Theme.Light
            };
        }

        public void SetTheme(Theme theme)
        {
            UIApplication.SharedApplication.KeyWindow.OverrideUserInterfaceStyle = theme switch
            {
                Theme.Dark => UIUserInterfaceStyle.Dark,
                Theme.Light => UIUserInterfaceStyle.Light,
                _ => UITraitCollection.CurrentTraitCollection.UserInterfaceStyle
            };            
        }
    }
}

Android

iOSと同様に実装します。 ただしこちらはSet時に設定値を保存してます。 これは以下の記事にあるように、システムのテーマをForms初期化前に設定する必要があるからです。

【Android】ダークテーマを反映するアプリ起動時の適切なタイミングについて【Kotlin】 - Qiita

[assembly: Dependency(typeof(SystemThemeImplementation))]
namespace DarkSample.Droid.Platforms
{
    public class SystemThemeImplementation:ISystemTheme
    {
        public static Context Context;

        public Theme GetTheme()
        {
            var currentMode = Context.Resources.Configuration.UiMode & UiMode.NightMask;
            return currentMode switch
            {
                UiMode.NightYes => Theme.Dark,
                _ => Theme.Light
            };
        }

        public void SetTheme(Theme theme)
        {
            AppCompatDelegate.DefaultNightMode = theme switch
            {
                Theme.Dark => AppCompatDelegate.ModeNightYes,
                Theme.Light => AppCompatDelegate.ModeNightNo,
                _ => AppCompatDelegate.ModeNightFollowSystem,
            };            

            Context.GetSharedPreferences("AppSettings", FileCreationMode.Private)
                .Edit()
                .PutInt("NightKey", AppCompatDelegate.DefaultNightMode)
                .Apply();
        }
    }
}

またAndroidはダイアログや、AndroidのStyleが出てしまう部分を対応するためにStyleもダークテーマに対応させます。 以下の記事を参考にして設定しました。

Androidでダークテーマを試す - URL Memo - Medium

Xamarin.FormsのテンプレートのStyleに修正を加えたものです。 Theme.AppCompat.DayNight を使うのがポイントで、これにするとシステムのテーマに応じて色を動的に変更できます。 色の定義はダーク用の values-night/colors.xml で定義します。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="MainTheme" parent="MainTheme.Base">
    </style>
    <!-- Base theme applied no matter what API -->
    <style name="MainTheme.Base" parent="Theme.AppCompat.DayNight">
        <!--If you are using revision 22.1 please use just windowNoTitle. Without android:-->
        <item name="windowNoTitle">true</item>
        <!--We will be using the toolbar so no need to show ActionBar-->
        <item name="windowActionBar">false</item>
        <!-- Set theme colors from https://aka.ms/material-colors -->
        <!-- colorPrimary is used for the default action bar background -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <!-- colorPrimaryDark is used for the status bar -->
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <!-- colorAccent is used as the default value for colorControlActivated
         which is used to tint widgets -->
        <item name="colorAccent">@color/colorAccent</item>
        <!-- You can also set colorControlNormal, colorControlActivated
         colorControlHighlight and colorSwitchThumbNormal. -->
        <item name="windowActionModeOverlay">true</item>
        <!-- 下の3つを設定すれば大体のダイアログに対応できるはず -->
        <item name="android:datePickerDialogTheme">@style/AppCompatDialogStyle</item>
        <item name="android:alertDialogTheme">@style/AppCompatDialogStyle</item>
        <item name="android:timePickerDialogTheme">@style/AppCompatDialogStyle</item>
    </style>
    <style name="AppCompatDialogStyle" parent="Theme.AppCompat.DayNight.Dialog">
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:textColorPrimary">@color/normal_text</item>
        <item name="android:windowBackground">@color/dialog_back</item>
    </style>
</resources>

起動時に設定値を反映させます。あとステータスバーのアイコンの色が見えなくならないようにモードに対応して変更させています。 それについては中の人ブログ参照

Setting Android Status Bar Background & Icon Colors - James Montemagno

public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            var theme = GetSharedPreferences("AppSettings", FileCreationMode.Private)
                .GetInt("NightKey", AppCompatDelegate.ModeNightNo);
            AppCompatDelegate.DefaultNightMode = theme;

            TabLayoutResource = Resource.Layout.Tabbar;
            ToolbarResource = Resource.Layout.Toolbar;            

            base.OnCreate(savedInstanceState);

           ...
            if (Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.M)
            {
                // モードによってステータスバーのアイコンのモードを変える。
                var flag = (Android.Views.StatusBarVisibility)Android.Views.SystemUiFlags.LightStatusBar;
                var uimode = Resources.Configuration.UiMode & UiMode.NightMask;
                Window.DecorView.SystemUiVisibility = uimode == UiMode.NightNo ? flag : 0;
            }

変更処理実装

適当なstaticメソッドなどでテーマの変更を処理する。 似たようなメソッドが二つありますが、ApplyThemeの方は起動時用で、 起動時はAndroidiOSで微妙に処理が異なるので専用のメソッドにしました。 ChangeThemeの方は起動中に切り替える場合に使い、設定を保存しておくようにします。

「端末の設定を使う」に対応するために、システムテーマを取得して、それをFormsテーマに反映させる処理を入れています。 アプリを起動したまま、ホームに戻り、システムの変更を変更した場合に対応できるようにOnResumeのタイミングで前回テーマと変化があれば切り替えるようにしています。

(AndroidAndroid 10以降のみ機能します。)

public static class ThemeResource
{
    static Theme LastSystemTheme = Theme.Light;

    public static void ApplyTheme()
    {
        // 保存設定読み出し
        var theme = (Theme)Xamarin.Essentials.Preferences.Get("mode", (int)Theme.Light);
        var sysTheme = DependencyService.Get<ISystemTheme>();
        if (Device.RuntimePlatform == Device.iOS)
        {
            // iOSのみシステムテーマ変更
            sysTheme.SetTheme(theme);
            // AndroidはPlatform側の起動時に設定
        }

        if (theme == Theme.Auto)
        {
            theme = sysTheme.GetTheme();
            // Autoであれば端末のモード切り替えを待ち受ける
            App.OnForeground += App_OnForeground;
            LastSystemTheme = theme;
        }

        SetDictionary(theme);
    }

    public static void ChangeTheme(Theme theme)
    {
        Xamarin.Essentials.Preferences.Set("mode", (int)theme);

        var sysTheme = DependencyService.Get<ISystemTheme>();

        // PlatformのThemeを変更
        sysTheme.SetTheme(theme);

        App.OnForeground -= App_OnForeground;
        if (theme == Theme.Auto)
        {
            theme = sysTheme.GetTheme();
            // Autoであれば端末のモード切り替えを待ち受ける
            App.OnForeground += App_OnForeground;
            LastSystemTheme = theme;
        }

        SetDictionary(theme);
    }

    static void SetDictionary(Theme theme)
    {
        var mergedDict = Application.Current.Resources.MergedDictionaries;
        mergedDict.Clear();

        switch (theme)
        {
            case Theme.Light:
                mergedDict.Add(new LightTheme());
                break;
            case Theme.Dark:
                mergedDict.Add(new DarkTheme());
                break;
        }
    }

    private static void App_OnForeground(object sender, EventArgs e)
    {
        var sysTheme = DependencyService.Get<ISystemTheme>();
        var curTheme = sysTheme.GetTheme();
        // 前回とテーマが変わらなければ何もしない
        if (curTheme == LastSystemTheme)
        {
            return;
        }

        // PlatformのThemeを変更
        sysTheme.SetTheme(curTheme);

        SetDictionary(curTheme);

        LastSystemTheme = curTheme;
    }

}

起動時にテーマを適用するように。

public partial class App : Application
{
    public static event EventHandler OnForeground;

    public App()
    {
        InitializeComponent();

        MainPage = new MyNavigationPage(new MainPage());
    }

    protected override void OnStart()
    {
        // 起動時テーマ適用
        ThemeResource.ApplyTheme();
    }

    protected override void OnResume()
    {
        OnForeground?.Invoke(this, EventArgs.Empty);
    }
}

結果

完全にダークになりました。分かりにくいですがiOSのSwitchも見やすくなっています。Androidのダイアログにもダークが反映されています。

f:id:kamusoft:20200618003635p:plainf:id:kamusoft:20200618003651p:plain

終わりに

iOSの方は比較的分かりやすかったです。AndroidはStyleを多少いじらないといけなかったり少し面倒でした。 あとAndroidでActivity属性に

ConfigurationChanges = ConfigChanges.UiMode

みたいに UiModeを入れてるとモードを切り替えてもActivityの再生成が起こらず、テーマが反映されないので注意が必要です。 これでかなりハマってしまいました。これにすると OnConfigurationChanged で変更を検知できるようになりますが、Activityの処理は自分でやれよってことみたいです。 そしてよく見たらそうドキュメントにも書いてあったという…

ダークテーマ  |  Android デベロッパー  |  Android Developers

あとはCollectionViewなどのリスト系のUIは即時反映されない場合があるので、その場合は切り替えのタイミングでリロードする必要があるかも知れません。