Flutter 深色模式分析與實踐

oldbirds發表於2020-05-31

深色模式(Dark Mode),也被稱為暗黑模式,是一種高對比度,或者反色模式的顯示模式,開啟之後在夜間可以緩解疲勞,更易於閱讀,同時也能在一定程度上達到省電的效果。iOS和安卓分別從 iOS 13 和 Android 10(不同廠商不盡相同,部分 Android 9 也支援) 開始加入深色模式的支援,各大瀏覽器紛紛開始支援深色模式,強如微信也終於在 iOS 客戶端 7.0.12、Android 客戶端 7.0.13 支援了深色模式,等網頁端適配深色模式後將更進一步提高使用者體驗的一致性。

最近在業餘時間開發自己的 App,起初並開始考慮深色模式的適配,到晚上的時候,介面慘不忍睹。雖然可以手動在系統設定裡配置外觀,但是全域性修改也會影響其他 App(很討厭修改了自己而影響了別人,比較傾向自完備性)。

對我來說,適配深色模式是勢在必行的:

  • 個人很喜歡深色模式, 獨立做一款符合自己品味的 App 也是一大幸事。
  • 也不知道哪天 Apple 會硬性要求適配深色模式。如今硬體的效能越來越強大,記憶體也越來越大,人們對色彩的感知也越來越強烈。 App 除了能解決使用者的痛點之外,互動、色彩也變得越來越重要。
  • 寫過很多 App,但對主題這塊都沒涉及過,可以借這個契機學習一波。

需求

使用者可以主動設定深色模式、淺色模式、跟隨系統

要實現這個需求,可以先問幾個問題:

  • 如何設定主題
  • 如何去切換主題
  • 如何儲存切換的狀態

分析

我們一起逐個攻破上面的問題。

如何設定主題

Flutter 提供了 Theme 元件,它可以設定 Widget 的主題,Theme 元件可以為 Material App 定義主題資料(ThemeData)。Material 元件庫裡很多元件都使用了主題資料,如導航欄顏色、標題字型、Icon樣式等。Theme 內會使用 InheritedWidget 來為其子樹共享樣式資料。它有兩種:

  • 全域性 Theme
  • 區域性 Theme

全域性 Theme 是由應用程式根 MaterialAppTheme

/// 全域性主題在MaterialApp的theme屬性
/// 全域性生效
MaterialApp(
  title: 'demo',
  theme: ThemeData( // 這裡就是引數
    brightness: Brightness.dark,
    primaryColor: Colors.lightBlue[800],
    accentColor: Colors.cyan[600],
  ),
);
複製程式碼

區域性 Theme

/// 假如我們要給 FloatingActionButton 設定主題樣式
/// 直接寫個 Theme 包裹 FloatingActionButton 元件
/// 然後設定 data,接收型別依然是 ThemeData,裡面填寫我們的引數
/// (如果沒有設定區域性主題則預設使用全域性主題)
Theme(
  data: ThemeData(
    accentColor: Colors.red,
  ),
  child: FloatingActionButton(
    onPressed: () {},
    child: Icon(Icons.add),
  ),
);
複製程式碼

Theme 使用舉例

擴充套件父主題:

擴充套件父主題時無需覆蓋所有的主題屬性,可以通過使用 copyWith 方法來實現。

Theme(
  data: Theme.of(context).copyWith(accentColor: Colors.yellow),
  child: FloatingActionButton(
    onPressed: (){},
    child: new Icon(Icons.add),
  ),
);
複製程式碼

Theme.of(context) 將查詢 Widget 樹並返回樹中最近的 Theme。如果 Widget 之上有一個單獨的 Theme 定義,則返回該值。如果沒有,則返回 App 主題。

區分平臺顯示指定主題

我們也可以使用 io 包裡的 Platform 來進行判斷。

MaterialApp(
  theme: defaultTargetPlatform == TargetPlatform.iOS
      ? iOSTheme
      : AndroidTheme,
  title: 'Flutter Theme',
  home: new MyHomePage(),
)
複製程式碼

根據當前展示的模式指定顏色

通過 Theme.of(context).brightness 的來判斷現在是深色還是淺色模式。

var isDarkTheme = Theme.of(context).brightness == Brightness.dark;

Text("APP", 
    color : isDarkTheme ? AppColors.darkPink : AppColors.textBlack,
)
複製程式碼

ThemeData 解讀

上面說了這麼多主題的使用,但是當我們真正要進行適配的時候,還是無從下手,因為我們不知道設定主題後到底起了哪些樣式變化,那麼 ThemeData 就是我們的答案。

ThemeData({
  Brightness brightness, // 應用程式整體主題的亮度。 由按鈕等 Widget 使用,以確定在不使用主色或強調色時要選擇的顏色
  MaterialColor primarySwatch, // 主題顏色樣本
  Color primaryColor,  // 前景色(文字、按鈕等)
  Brightness primaryColorBrightness, // primaryColor 的亮度
  Color primaryColorLight, // primaryColor 的較亮版本
  Color primaryColorDark, // primaryColor 的較暗版本
  Color accentColor, // 前景色(文字、按鈕等)
  Brightness accentColorBrightness, // accentColor的亮度。 用於確定放置在突出顏色頂部的文字和圖示的顏色(例如FloatingButton上的圖示)
  Color canvasColor, // MaterialType.canvas Material 的預設顏色
  Color scaffoldBackgroundColor, // 作為Scaffold基礎的Material預設顏色,典型Material應用或應用內頁面的背景顏色。
  Color bottomAppBarColor, // BottomAppBar 的預設顏色
  Color cardColor, // Material被用作Card時的顏色
  Color dividerColor, // Dividers 和 PopupMenuDividers的顏色,也用於ListTiles中間,和DataTables 的每行中間
  Color focusColor, // 焦點獲取時的顏色,例如,一些按鈕焦點、輸入框焦點。
  Color hoverColor, // 點選之後徘徊中的顏色,例如,按鈕長按,按住之後的顏色
  Color highlightColor, // 用於類似墨水噴濺動畫或指示選單被選中的高亮顏色。
  Color splashColor, // 墨水噴濺的顏色。
  InteractiveInkFeatureFactory splashFactory, // 定義InkWall和InkResponse生成的墨水噴濺的外觀。
  Color selectedRowColor, // 選中行時的高亮顏色
  Color unselectedWidgetColor, // 用於 Widget 處於非活動(但已啟用)狀態的顏色。 例如,未選中的核取方塊。 通常與 accentColor 形成對比。
  Color disabledColor, // 用於 Widget 無效的顏色,無論任何狀態。例如禁用核取方塊
  Color buttonColor, // Material 中 RaisedButtons 使用的預設填充色
  ButtonThemeData buttonTheme, // 定義了按鈕等控制元件的預設配置
  ToggleButtonsThemeData toggleButtonsTheme, // Flutter 1.9 全新元件 ToggleButtons 的主題
  Color secondaryHeaderColor, // 有選定行時 PaginatedDataTable 標題的顏色
  Color textSelectionColor, // 文字欄位中選中文字的顏色,例如 TextField
  Color cursorColor, // 輸入框游標顏色
  Color textSelectionHandleColor, // 用於調整當前文字的哪個部分的控制程式碼顏色
  Color backgroundColor, // 與 primaryColor 對比的顏色(例如 用作進度條的剩餘部分)
  Color dialogBackgroundColor, // Dialog 元素的背景色
  Color indicatorColor, // TabBar 中選項選中的指示器顏色。
  Color hintColor, // 用於提示文字或佔位符文字的顏色,例如在 TextField 中。
  Color errorColor, // 用於輸入驗證錯誤的顏色,例如在 TextField 中
  Color toggleableActiveColor, // 用於突出顯示切換Widget(如Switch,Radio和Checkbox)的活動狀態的顏色。
  String fontFamily, // 字型樣式
  TextTheme textTheme, // 與卡片和畫布對比的文字顏色
  TextTheme primaryTextTheme, // 一個與主色對比的文字主題
  TextTheme accentTextTheme, // 與突出顏色對照的文字主題
  InputDecorationTheme inputDecorationTheme, // InputDecorator,TextField 和 TextFormField 的預設 InputDecoration 值基於此主題
  IconThemeData iconTheme, // 與卡片和畫布顏色形成對比的圖示主題
  IconThemeData primaryIconTheme, // 一個與主色對比的圖片主題
  IconThemeData accentIconTheme, // 與突出顏色對照的圖片主題
  SliderThemeData sliderTheme, // 用於渲染 Slider 的顏色和形狀
  TabBarTheme tabBarTheme, // TabBar 的主題樣式
  TooltipThemeData tooltipTheme, // tooltip 提示的主題樣式
  CardTheme cardTheme, // 卡片的主題樣式
  ChipThemeData chipTheme, // 用於渲染Chip的顏色和樣式
  TargetPlatform platform, // Widget 需要適配的目標型別
  MaterialTapTargetSize materialTapTargetSize, // Chip 等元件的尺寸主題設定
  bool applyElevationOverlayColor, // 是否應用 elevation 覆蓋顏色
  PageTransitionsTheme pageTransitionsTheme, // 頁面轉場主題樣式
  AppBarTheme appBarTheme, // AppBar 主題樣式
  BottomAppBarTheme bottomAppBarTheme, // 底部導航主題樣式
  ColorScheme colorScheme, // scheme組顏色,一組13種顏色,可用於配置大多陣列件的顏色屬性
  DialogTheme dialogTheme, // 對話方塊主題樣式
  FloatingActionButtonThemeData floatingActionButtonTheme, // FloatingActionButton 的主題樣式,也就是 Scaffold 屬性的那個
  Typography typography, // 用於配置 TextTheme、primaryTextTheme 和 accentTextTheme的顏色和幾何文字主題值
  CupertinoThemeData cupertinoOverrideTheme, // cupertino 覆蓋的主題樣式
  SnackBarThemeData snackBarTheme, // 彈出的 snackBar 的主題樣式
  BottomSheetThemeData bottomSheetTheme, // 底部滑出對話方塊的主題樣式
  PopupMenuThemeData popupMenuTheme, // 彈出選單對話方塊的主題樣式
  MaterialBannerThemeData bannerTheme, // Material 材質的 Banner 主題樣式
  DividerThemeData dividerTheme, // Divider 元件的主題樣式,也就是那個橫向線條元件
  ButtonBarThemeData buttonBarTheme,
})
複製程式碼

更多完成資訊,大家可參閱它的原始碼註釋。

屬性很是比較多的,通常我們用到的 5 ~ 10 個左右,如果要高度定製可能會更多點。

primarySwatch 它是主題顏色的一個 樣本色, 通過這個樣本色可以在一些條件下生成一些其它的屬性,例如,如果沒有指定 primaryColor,並且當前主題不是深色主題,那麼 primaryColor 就會預設為primarySwatch 指定的顏色,還有一些相似的屬性如 accentColor 、indicatorColor 等也會受primarySwatch 影響。

切換 & 儲存

我們可以通過 shared_preferences 儲存使用者設定,通過 Provider 實現狀態管理。

新增依賴

provider: ^4.0.5
flustars: ^0.2.6+1
複製程式碼

實踐

定義淺色主題

// light_color.dart
import 'package:flutter/material.dart';

const MaterialColor lightColor =
    MaterialColor(_lightColorPrimaryValue, <int, Color>{
  50: Color(0xFFFDEAE7),
  100: Color(0xFFFACBC3),
  200: Color(0xFFF7A89C),
  300: Color(0xFFF48574),
  400: Color(0xFFF16B56),
  500: Color(_lightColorPrimaryValue),
  600: Color(0xFFED4A32),
  700: Color(0xFFEB402B),
  800: Color(0xFFE83724),
  900: Color(0xFFE42717),
});

const int _lightColorPrimaryValue = 0xFFEF5138;

const MaterialColor lightColorAccent =
    MaterialColor(_lightColorAccentValue, <int, Color>{
  100: Color(0xFFFFFFFF),
  200: Color(_lightColorAccentValue),
  400: Color(0xFFFFB4AF),
  700: Color(0xFFFF9C96),
});
const int _lightColorAccentValue = 0xFFFFE4E2;
複製程式碼

定義好自己的主題色0xFFEF5138, 然後通過工具生成。工具地址: mbitson/mcg

通用深色模式 Provider Model 類

// theme_state.dart

class ThemeState with ChangeNotifier {
  /// 0:淺色模式  1:深色模式  2:跟隨系統
  int _darkMode;
  int get darkMode => _darkMode;

  static const Map<int, String> darkModeMap = {0: '淺色模式', 1: '深色模式', 2: '跟隨系統'};
  
  ThemeData get lightTheme =>
      ThemeData(brightness: Brightness.light, primarySwatch: lightColor);
  ThemeData get darkTheme => ThemeData.dark();

  ThemeState() {
    _init();
  }

  void _init() async {
    await SpUtil.getInstance();
    int localModel = SpUtil.getInt('kDarkMode', defValue: 2);
    changeMode(localModel);
  }

  void changeMode(int darkMode) async {
    _darkMode = darkMode;
    notifyListeners();
    SpUtil.putInt("kDarkMode", darkMode);
  }
}
複製程式碼

主題選擇頁面

// theme_page.dart
class ThemePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          elevation: 0,
          title: Text('主題選擇'),
          leading: GestureDetector(
            onTap: () {
              Navigator.of(context).pop();
            },
            child: Icon(Icons.arrow_back_ios),
          ),
        ),
        body: Consumer<ThemeState>(
          builder: (context, themeState, child) {
            Map items = ThemeState.darkModeMap;
            return ListView.builder(
              itemBuilder: (context, index) {
                return ListTile(
                  onTap: () {
                    themeState.changeMode(items.keys.toList()[index]);
                  },
                  title: Text(
                    items.values.toList()[index],
                    style: TextStyle(
                        color: index == themeState.darkMode
                            ? Colors.red
                            : Color(0xff333333)),
                  ),
                );
              },
              itemCount: items.length,
            );
          },
        ));
  }
}
複製程式碼

在 main.dart 整合呼叫


void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
        providers: [
            ChangeNotifierProvider(create: (ctx) => ThemeState())
        ],
        child: Consumer<ThemeState>(
              builder: (context, themeState, child) {
                if (themeState.darkMode == 2) { // 跟隨系統
                  return MaterialApp(
                    title: 'Oldbirds',
                    theme: themeState.lightTheme,
                    darkTheme: themeState.darkTheme,
                    onGenerateRoute: generateRoute,
                    initialRoute: SplashRoute,
                    debugShowCheckedModeBanner: false,
                  );
                } else {
                  return MaterialApp(
                    title: 'Oldbirds',
                    theme: themeState.darkMode == 1 // 深色模式
                        ? themeState.darkTheme
                        : themeState.lightTheme,
                    onGenerateRoute: generateRoute,
                    initialRoute: SplashRoute,
                    debugShowCheckedModeBanner: false,
                  );
                }
              },
            ));
  }
}
複製程式碼

心得

上面的配置完成後,深色適配的功能完成 80% 左右,還有殘餘的,需要區域性按需設定,有些當然還需按設計的色彩進行改動。

全域性配置儘量通用,需要規範專業級別的 ui 設計(因為一般會有設計規範)。

如果不得不改,那麼就是 去同存異

  • 比如指定的文字樣式與全域性配置相同時,就刪除它

  • 如果文字顏色相同,但是字號不同。那就刪除顏色配置資訊,保留字號設定

    Text(
        "僅保留不同",
        style: Theme.of(context).textTheme.body1.copyWith(fontSize: 14.0)
    )
    複製程式碼
  • 顏色不同,因為深色模式主要就是顏色變化:

    Text(
        "僅保留不同",
        style: Theme.of(context).textTheme.body1.copyWith(color: Colors.red, fontSize: 14.0)
    )
    複製程式碼

參考

更多文章閱讀,請搜尋微信公眾號: OldBirds

相關文章