Flutter - 暗模式

Flutter是基于主題構(gòu)建的,因此處理OS級暗模式非常簡單。但這不是那么簡單。您還必須:


  • 使自定義窗口小部件(或漸變或陰影之類的裝飾)和非平臺顫動窗口小部件(例如來自Google Maps的程序包)在所有主題中都可以接受。
  • 確保自定義窗口小部件(或裝飾)在用戶更改設(shè)備上的主題時(shí)正確更新。
  • 對于在較舊版本的操作系統(tǒng)上運(yùn)行的用戶以及更喜歡在單個(gè)應(yīng)用程序上設(shè)置主題的用戶,請進(jìn)行適當(dāng)?shù)慕导墶?/li>

本文將討論如何處理這些問題。我將提供示例代碼。

本文中的信息來自許多來源,包括本文馬特-卡羅爾,這也解釋了顫如何支持深色模式本身在Android上。我希望在iOS 13發(fā)布后不久,即可在iOS上獲得本機(jī)支持。它還包括Matthias Schuyten的這篇文章中的信息,其中詳細(xì)介紹了如何設(shè)置Flutter Google Maps Plugin地圖的樣式。

黑暗模式就在這里;您的應(yīng)用必須調(diào)整

iOS和Android的最新版本均具有暗模式。在測試為家人編寫的Android / iOS NYC公交應(yīng)用程序時(shí),我不得不處理黑暗模式。當(dāng)我第一次編寫它時(shí),系統(tǒng)范圍內(nèi)的暗模式并不是什么問題,而該應(yīng)用程序是海洋或明亮的色彩。雖然該應(yīng)用程序在iPhone和Android上均可正常運(yùn)行,但在暗模式下的手機(jī)上使用該應(yīng)用程序卻令人震驚,尤其是在夜間戶外。盡管Flutter進(jìn)行了自動更新以支持暗模式的應(yīng)用程序,但外部包裝和自定義組件并不容易。在這種情況下,撲朔迷離的Google Maps包不會(也不應(yīng))通過平臺級主題更改自動切換主題。應(yīng)用程序中的彩色小部件需要針對不同的模式進(jìn)行一些其他的重新設(shè)計(jì)。

除非您的應(yīng)用是單色的,否則處理淺色或深色主題會很棘手,尤其是在彩色背景下的文本。請記住,當(dāng)用戶選擇深色主題時(shí),他們希望所有主要元素都變暗,而所有對比元素(例如文本和圖標(biāo))都變亮。添加顏色使其變得棘手。我懷疑這就是Google移除應(yīng)用程序所有品牌著色的原因(例如,中等深紅色,#D44638和深紅色輔助口音#B23121表示gmail),這是支持深色模式的第一步。將文本保持黑色以切換為淺色主題會產(chǎn)生難以閱讀的標(biāo)題:

讓我們從確定暗模式對我們的應(yīng)用程序的工作開始。我認(rèn)為最好從一個(gè)角度進(jìn)行研究:哪種方法最適合我們的用戶?

應(yīng)該如何運(yùn)作?

除非用戶特別關(guān)心,否則這不是您的用戶應(yīng)該考慮的事情;它應(yīng)該易于理解和以他們期望的方式工作;并且應(yīng)該隱藏任何復(fù)雜的邏輯。我們有一個(gè)適用于Google日歷等生產(chǎn)應(yīng)用程序的模型:主題首選項(xiàng)交叉的最簡單版本很清楚:LIGHT,DARK和SYSTEM,默認(rèn)為system。

我建議默認(rèn)為系統(tǒng)有以下三個(gè)原因。首先,大多數(shù)關(guān)心明暗模式的用戶已經(jīng)使用該系統(tǒng)選擇了首選模式。用戶的默認(rèn)首選項(xiàng)已定義。其次,對于有選擇權(quán)而又不在乎的用戶,系統(tǒng)的其他每個(gè)部分都使用系統(tǒng)值(通常為燈光模式)。第三,在不支持亮/暗主題的較舊版本的OS上運(yùn)行時(shí),它的作用與其他應(yīng)用一樣。

我們?nèi)绾螌?shí)現(xiàn)呢?讓我們看一些Flutter代碼。

(在Android上)暗模式已經(jīng)可以在Flutter中工作

但是,您必須做一些工作才能啟用它。如果在應(yīng)用程序中使用MaterialApp類,則允許您自定義明暗主題作為應(yīng)用程序定義的一部分。這些主題將鏈接到系統(tǒng)的明/暗模式設(shè)置。

@override
Widget build(BuildContext context) {
 return MaterialApp(
  title: 'App title',
  theme: ThemeData(),
  darkTheme: ThemeData.dark(),
  home: MyHomePage(),
 );
}

simple_theme.dart

您可以自定義每個(gè)主題的顏色和字體:

Widget build(BuildContext context) {
return MaterialApp(
 title: 'BusWatch',
 theme: ThemeData(
  brightness: Brightness.light,
  primarySwatch: Colors.orange,
 ),
 darkTheme: ThemeData(
  brightness: Brightness.dark,
  primarySwatch: Colors.orange
 ),
 home: MyHomePage(),
);
}

modified_theme.dart

如您所見,您可以根據(jù)需要修改默認(rèn)主題,F(xiàn)lutter窗口小部件將進(jìn)行自我更新以匹配系統(tǒng)。但這對自定義和自定義窗口小部件沒有幫助。如果我們有一個(gè)帶有深紅色背景的小部件,并且在其頂部有一個(gè)文本小部件,該怎么辦?當(dāng)用戶選擇燈光模式時(shí),默認(rèn)的黑色文本將變得不可讀。我們?nèi)绾沃烙脩暨x擇了什么亮度?

查詢系統(tǒng)亮度

如果用戶使用的是最新版本的Android,iOS或Fuschia,則我們想知道設(shè)備在平臺級別上顯示的主題。最安全的方法是使用上下文的媒體查詢數(shù)據(jù)。如果返回Brightness.dark,則表示選擇了暗模式。

final Brightness brightnessValue = MediaQuery.of(context).platformBrightness;
bool isDark = brightnessValue == Brightness.dark;

brightness_query.dart

如果要查詢該級別的值,也可以直接從系統(tǒng)窗口獲取平臺亮度。您可以使用類似于以下代碼

Window window = WidgetsBinding.instance.window;

(您永遠(yuǎn)不必真正執(zhí)行此操作。如果您發(fā)現(xiàn)自己直接使用Window,則可能是做錯(cuò)了,還有一種更好的方法可以簡化模擬和無頭測試。在這種情況下,您可能應(yīng)該使用MediaQuery。)

您可以在小部件初始化上檢查并適當(dāng)設(shè)置小部件值。但是,當(dāng)用戶更改為亮或暗模式(或者它在預(yù)定的時(shí)間自動發(fā)生,例如日落)時(shí)該怎么辦?大多數(shù)小部件都會自動處理這些問題,但是您的自定義組件又如何,例如嵌入式地圖或?qū)⒉粫碌淖远x裝飾呢?他們怎么知道什么時(shí)候更新?

監(jiān)聽黑暗模式的變化

我們通常必須在應(yīng)用程序的主要部分中,在平臺級別上親自聽取主題更改。您可以在有狀態(tài)的小部件中使用WidgetsBinding,該小部件將抽象的WidgetsBindingObserver子類化(作為混合)。

WidgetsBinding使您可以鉤住通常的窗口小部件生命周期方法之外的更改,例如旋轉(zhuǎn)電話或用戶更改設(shè)備字體大?。ɡ纾瑢⑹謾C(jī)交給需要較大字體的親戚)。對于這種情況,它為平臺亮度的變化提供了一個(gè)契機(jī)。您將小部件添加為initState()中的偵聽器。當(dāng)通過覆蓋dispose()從窗口小部件樹中永久刪除窗口小部件的狀態(tài)對象時(shí),這確實(shí)需要釋放引用。例如:

class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
     WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangePlatformBrightness() {
    final Brightness brightness = 
    WidgetsBinding.instance.window.platformBrightness;
    //inform listeners and rebuild widget tree
  }

theme_listener.dart

Flutter的小部件會自動運(yùn)行,我們可以安全地設(shè)置自定義小部件的值。但是外部小部件呢?我們?nèi)绾味ㄖ扑鼈??我將使用一個(gè)常見的軟件包:Google Maps for Flutter。

在嵌入式Google地圖中實(shí)現(xiàn)暗模式

Flutter最大的優(yōu)勢之一就是其開放的,受支持的軟件包系統(tǒng)。通常,每種需求都有編寫良好且維護(hù)良好的軟件包。最好的之一(我已經(jīng)在包括該項(xiàng)目在內(nèi)的多個(gè)項(xiàng)目中使用過)是google maps軟件包。默認(rèn)的地圖視圖非常明亮,不會自動更新為主題更改。幸運(yùn)的是,地圖允許主題化??梢栽?a target="_blank">這里找到關(guān)于這方面的優(yōu)秀文章。

Google提供了一個(gè)主題化網(wǎng)絡(luò)工具,可在https://mapstyle.withgoogle.com/上生成地圖主題。

我使用該工具生成默認(rèn)的明暗地圖主題。地圖使用json格式保存地圖樣式。您可以使用浮動資產(chǎn)加載實(shí)時(shí)加載地圖定義。

首先,使用網(wǎng)絡(luò)工具生成淺色和深色json樣式的文件。將它們保存在資產(chǎn)文件夾中。您可以使用rootBundle將文件作為字符串加載:

@override
void initState() {
  super.initState();
  rootBundle.loadString('assets/dark_map_style.json').then((string) {
     _darkMapStyle = string;
  });
  rootBundle.loadString('assets/normal_map_style.json').then((string) {
     _normalMapStyle = string;
  });

load_styles.dart

加載地圖并設(shè)置地圖控制器后(通過onMapCreated設(shè)置),您可以在構(gòu)建地圖小部件時(shí)安全地使用樣式:

@override
Widget build(BuildContext context) {
  bool isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
  if (mapController != null ) {
     if (isDark) {
        mapController.setMapStyle(_darkMapStyle);
     }
     else {
        mapController.setMapStyle(_normalMapStyle);
     }
  }
  //...

map_styles.dart

現(xiàn)在,地圖會動態(tài)更新其樣式以匹配系統(tǒng)。

因此,我們已負(fù)責(zé)更新應(yīng)用程序以匹配設(shè)備的主題。但是,想要獨(dú)立于系統(tǒng)設(shè)置應(yīng)用程序主題的用戶或使用舊版本操作系統(tǒng)的用戶呢?我們?nèi)绾为?dú)立設(shè)置主題?

獨(dú)立于系統(tǒng)更改主題

獨(dú)立設(shè)置主題實(shí)際上是兩個(gè)選擇(暗/亮),是我上面提到的三個(gè)選擇的子集。在這種情況下,簡單的UI效果很好。

但是我們要保存用戶在會話之間的選擇。有幾種方法可以從屬性文件,sqlite或遠(yuǎn)程執(zhí)行此操作。在設(shè)計(jì)時(shí)就考慮到了這種用例。我建議使用“ 共享首選項(xiàng)”插件。(我不會遠(yuǎn)程存儲亮/暗模式首選項(xiàng);它是每個(gè)設(shè)備的首選項(xiàng)。)

保存主題首選項(xiàng)

要使用共享首選項(xiàng),請導(dǎo)入“首選項(xiàng)”插件并創(chuàng)建一種安全獲取引用的方法,您可以在應(yīng)用啟動時(shí)實(shí)例化該引用(可能在主窗口小部件狀態(tài)的initState()中)。為主題值使用一個(gè)枚舉。

其次,如果您想讓系統(tǒng)主題更新的監(jiān)聽器,請定義一種方法來接收這些更新。在這種情況下,我使用了typedef PrefsListener。

在這種情況下,我關(guān)心的首選項(xiàng)是應(yīng)用程序的主題:

static const String THEME_PREF = “AppTheme”;

然后,為其他類創(chuàng)建一個(gè)鉤子,以偵聽主題更新并注銷(addListener()removeListener())。

示例首選項(xiàng)實(shí)現(xiàn)如下:

import 'package:shared_preferences/shared_preferences.dart';

enum Themes {
    DARK, LIGHT, SYSTEM
}

class Prefs {

    static const Map<Themes, String> themes = {
        Themes.DARK: "Dark", Themes.LIGHT : "Light", Themes.SYSTEM : "System"
    };

    Map<String, List<PrefsListener>> _listeners;

    factory Prefs.singleton() {
        return _instance;
    }

    static final Prefs _instance = Prefs._internal();

    SharedPreferences _prefs;
    bool _initialized = false;

    static const String THEME_PREF = "AppTheme";

    Prefs._internal() {
        _listeners = Map<String, List<PrefsListener>>();
        _getPrefs().then((prefs) {
            _initialized = true;
            for (String key in _listeners.keys) {
                List<PrefsListener> listeners = _listeners[key];
                if(listeners != null && listeners.isNotEmpty) {
                    Object value = prefs.get(key);
                    for (PrefsListener listener in listeners) {
                        listener(key, value);
                    }
                }
            }
        });
    }

    void addListenerForPref(String key, PrefsListener listener) {
        List<PrefsListener> list = _listeners[key];
        if (list == null) {
            list = List<PrefsListener>();
            _listeners[key] = list;
        }
        list.add(listener);
    }

    Future<SharedPreferences> _getPrefs() async {
        if (_prefs == null) {
            _prefs = await SharedPreferences.getInstance();
        }
        return _prefs;
    }

    String getTheme() {
        if (_initialized) {
            String theme = _prefs.getString(THEME_PREF);
            if (theme != null) {
                return theme;
            }
            else {
                _prefs.setString(THEME_PREF, themes[Themes.SYSTEM]); //ok not to wait
                return themes[Themes.SYSTEM];
            }
        }
        else {
            return themes[Themes.SYSTEM];
        }
    }

    //called when the user updates the operating system theme
    //(by choosing light or dark mode)
    void systemThemeUpdated(Brightness brightness) {
        if (isSystemTheme()) {
            String theme = getTheme();

            List<PrefsListener> listenerList = _listeners[THEME_PREF];
            if (listenerList != null) {
                for (PrefsListener listener in listenerList) {
                    listener(THEME_PREF, theme);
                }
            }
        }
    }

    ///set the app's theme preference from the 
    ///app's own UI
    void setTheme(String theme) {
        _getPrefs().then((prefs) {
            prefs.setString(THEME_PREF, theme);
        });
        List<PrefsListener> listenerList = _listeners[THEME_PREF];
        if (listenerList != null) {
            for (PrefsListener listener in listenerList) {
                listener(THEME_PREF, theme);
            }
        }
    }
}

typedef PrefsListener(String key, Object value);

prefs.dart

翻譯自:https://medium.com/@pmutisya/dark-mode-in-flutter-3742062f9f59

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容