Flutter 设计百科全书 - 主题、样式、颜色篇

1,104 阅读13分钟

Flutter 设计百科全书

主题、样式、颜色篇

前言

这篇文章的部分内容我从来没有在互联网上看到过
(至少中文互联网没有 > 😂)
(至少百度搜不到 > 😂)

点名批评
”谁会在意这个,这个东西(颜色)没人注意到,就这样吧“
”设计师(实习生)怎么设计,我就怎么做,最后不好看跟我前端没关系“

本章内容

  • 颜色 Color 颜色的设计

    阴影的设计 阴影的设计

  • 主题 Theme 主题设计

    Brightness ColorScheme ThemeExtension

  • 样式 Style

    ButtonStyle

    其他组件的Style

    TextStyle

    TextSize 、 Color 、 TextStyle 冲突的问题

    GetX 中如何使用Theme Style?

    一个支持黑白主题的程序完整源码

Color 颜色的设计

虽然这篇文章主要讲的是主题样式,但是颜色却是最先要注意的地方。

  • 不要使用 饱和度(鲜艳)过高的颜色
  • 注意使用 反色,使用 对比度高的 反色,设置文本颜色

配色错误示范.png

前面2个按钮的问题显而易见,为什么第三个按钮也有问题?
问题就是,不应该用黄色作为按钮的背景色。

卡片阴影的设计

看看谷歌设计的最早的阴影设计,

卡片_重阴影.png

还有目前MD3的 Card(Flutter 自带组件)卡片质感,

卡片.png

猜猜看,为什么大家拒绝使用谷歌的规范,还不是这东西真太low了。
不是瞎吹,国内设计圈虽然起步慢,抄袭也多,但是不得不说,
对比欧美的 大圆角设计,真的好太多了。

咱们是顶尖的上限不够高,但是平均水平比国外强太多

  • 目前大陆的设计圈,开始流行去阴影化,或弱阴影化
  • 我记得看到别人说过的话,如果用户注意到阴影了,那么你的阴影就是失败的设计。
  • 大陆的阴影设计 都是垂直投影,一般不会有XY轴偏移,如果你使用谷歌Card组件,用户会非常容易注意到差异。

弱阴影设计思路

弱化阴影的设计,让你的阴影没那么突兀,
可以将投影的颜色设置为无限接近于#FFFFFFFF,
常见参考值 可以使用 EE,FE,EF 等,235-245 区间。
范围可以控制在 5-10px像素

弱化阴影.png

BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(10),
            boxShadow: const [
              BoxShadow(
                color: Color(0xFFEFEFEF),
                blurRadius: 5,
                blurStyle: BlurStyle.outer,
              ),
            ],
          ),

去阴影设计思路(目前最推荐 ♥♥♥♥♥)

越来越多的设计 使用“无边界”,“无卡片”,“无阴影” 的设计。
这种设计 非常的简洁,非常考验 布局能力。
做法就是 使用背景色和卡片 色差,产生阶梯感。

色差阴影.png

backgroundColor: const Color(0xFFFAFAFA), //背景白色偏灰
color: Colors.white, // 卡片白色

Theme 主题设计

谷歌的MD设计其实只考虑了少部分的按钮和配色,
但是他们没想到,国内的设计师,设计程序时,
能搞出10几种按钮 和 文字 大小颜色 😂
谷歌都气炸了。

所以在中文互联网上,你搜索Flutter 主题时,这块的内容多数是复制粘贴党,
并不是批评 到处复制粘贴的人,只是这种行为 让真正有独特理解的内容 更难被人看到。

谷歌的主题设计

在MaterialApp下面有常用的三个主题相关字段,
theme:白色主题
darkTheme:黑色主题
themeMode:主题模式

enum ThemeMode {
  system,// 跟随系统变化
  light,// 强制白色主题
  dark,// 强制黑色主题
}

初始代码默认只设置了theme,不设置darkTheme,任何时候都不能切换到黑色模式,始终会显示白色主题。
当设置为system模式时,系统主题切换,App会跟随系统变化,这是一个比较优秀的做法。

MaterialApp的theme,是ThemeData,其中的ColorScheme,是Flutter 中的颜色配置信息, 默认拥有ColorScheme.light,ColorScheme.dark,ColorScheme.fromSeed,三种快速配置,比较适合不在意颜色的APP快速使用。

推荐白色主题使用 ColorScheme.light
推荐黑色主题使用 ColorScheme.dark

Brightness 全解析(部分机翻)

enum Brightness {
  /// 例如,颜色可能是深灰色,需要白色文本。
  dark,
  /// 例如,颜色可能是亮白色,需要黑色文本
  light,
}

Flutter中黑白相关的区分,都可以使用Brightness, 但是需要注意的是,有些地方Brightness可能是相反并且反直觉的。 比如在系统顶部状态栏中的Brightness和IconBrightness就是一个容易搞混的设置

SystemUiOverlayStyle(
  statusBarBrightness: Brightness.light,//状态栏的主题
  statusBarIconBrightness: Brightness.dark,//状态栏的图标主题,需要跟状态栏主题相反
)

关于 ColorScheme

  • brightness 暗色或亮色
  • primary 主要颜色
  • onPrimary 在主要颜色上面的颜色
  • secondary 次要颜色
  • onSecondary 在次要颜色上面的颜色
  • error 错误的颜色
  • onError 在错误颜色上面的颜色
  • surface 表面颜色
  • onSurface 在表面颜色上面的颜色
  • inverse 关键字:颜色反转,一般用于反色

默认的颜色设计,如果严格按照这种规范来,
程序中 只会有 几种颜色。

Flutter 颜色设置名称非常多 非常难以 运用。

如果你的程序颜色配置较少可以使用上面的关键字。 如果你的程序颜色配置较多,推荐使用ThemeData 的 extensions属性,类型为 ThemeExtension

我们先来讲默认的颜色配置,如果想看扩展主题,请跳到下方扩展主题章节。
我们来设计一套兼容黑白主题的主题吧,这是一个最简单的案例。

我们先给MaterialApp定义一个白色主题theme.
如果我们定义appBarTheme,代表App会在白色主题下 使用这个Appbar的主题设置。
同样的,如果我们在darkTheme中定义appBarTheme,那么黑色主题会使用它。

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.white,
          foregroundColor: Colors.black,
          elevation: 0,
          scrolledUnderElevation: 0,
          surfaceTintColor: Colors.black,
          systemOverlayStyle: SystemUiOverlayStyle(
              statusBarColor: Colors.transparent,
              statusBarBrightness: Brightness.light,
              statusBarIconBrightness: Brightness.dark,
              systemNavigationBarColor: Colors.white,
              systemNavigationBarDividerColor: Colors.black,
            ),
        ),

接着是颜色配置

colorScheme: const ColorScheme(
    brightness: Brightness.dark,
    primary: AppColors.themeColor, // 主要颜色,一般是按钮上面会用
    onPrimary: AppColors.themeTextColor, // 在主要颜色 上面,就是文字的颜色,我们用白色
    secondary: Colors.grey, // 次要颜色,用灰色
    onSecondary: Colors.white,// 在次要颜色 上面,就是文字的颜色,我们用白色色
    error: Colors.red,//错误颜色
    onError: Colors.white,// 错误颜色上面的文字颜色
    surface: Colors.black, // 表面颜色
    onSurface: Colors.white,// 表面上的文字
),

主题和Style不建议使用静态字段(将它们写到默认Theme或 ThemeExtension 扩展内部),但是颜色和数值类型可以考虑使用 静态字段,在 theme 和 style中引用这些静态变量达到复用的目的,比如

class AppColors {
  /// 主题色
  static const Color themeColor = Color(0xff00CC74);

  /// 文字颜色
  static const Color themeTextColor = Color(0xff5FB23F);
}

class AppSizes {
  /// 按钮大小
  static const int buttonSize = 48;
}

到这里,已经完成了颜色配置,flutter默认的组件style都会从里面取出颜色使用

          /// 按钮会自动使用主题色,除非你定义了ButtonTheme中的ButtonStyle并修改了背景色
          FilledButton(
            onPressed: () {},
            child: const Text("FilledButton"),
          ),
          Switch(
            value: false,
            onChanged: (value) {},
          ),
          Row(
            children: [
              const Chip(
                label: Text("Chip"),
              ),
              const ChoiceChip(label: Text("ChoiceChip"), selected: true),
              Radio(
                value: 1,
                groupValue: 1,
                onChanged: (value) {},
              )
            ],
          )

效果如下(图片和上方颜色代码不一致,仅供参考):

默认主题按钮.png

当然我们也可以直接取到这个颜色值使用。

/// 获取默认ThemeData下面的主题色
Theme.of(context).colorScheme.primary

/// 直接使用默认 Theme中的颜色
/// 当前 我建议你使用textTheme,而不是直接使用颜色值,具体内容查看本章内容
body: Text(
        "body",
        style: TextStyle(
          color: Theme.of(context).colorScheme.primary,
        ),
      )

如何使用主题的颜色完成自定义样式的组件呢? -> 具体使用方法查看本章Style模块,

从上面的内容上你也看出来了,如果你的程序 颜色 非常单一,谷歌这套设计也是能够满足需求的,
可惜国内的设计环境就不是这样,各种颜色,各种尺寸,
这里有3种方案,

  • 第一种是 跟设计师商量,减少 部分颜色 和 样式设计,让最终的按钮大概就是 2-3种 ,文字颜色 也是 2-3 种。

  • 第二种是 全局静态Style
    在静态类中,写固定函数,返回固定组件的样式和颜色,比如定义一个 BlackButton,在函数中申明它的背景色为黑色。这种做法它的缺陷就是无法跟随主题变化,如果你的程序只有一种主题,那么它也是一种可行的方案。

  • 第三种是 使用扩展 ThemeExtension,查看下方。

扩展主题设计 ThemeExtension

/// 自定义的主题扩展
class DiyTheme extends ThemeExtension<DiyTheme> {
  const DiyTheme({
    required this.brandColor,
    required this.danger,
    required this.dangerTextStyle,
  });

  final Color? brandColor;
  final Color? danger;
  final TextStyle? dangerTextStyle;

  @override
  DiyTheme copyWith({Color? brandColor, Color? danger}) {
    return DiyTheme(
      brandColor: brandColor ?? this.brandColor,
      danger: danger ?? this.danger,
      dangerTextStyle : dangerTextStyle ?? this.dangerTextStyle,
    );
  }

  @override
  DiyTheme lerp(DiyTheme? other, double t) {
    if (other is! DiyTheme) {
      return this;
    }
    return DiyTheme(
      brandColor: Color.lerp(brandColor, other.brandColor, t),
      danger: Color.lerp(danger, other.danger, t),
      dangerTextStyle : *** 省略,
    );
  }

  @override
  String toString() => 'DiyTheme(brandColor: $brandColor, danger: $danger)';
}

//创建方式
theme: ThemeData.light().copyWith(
        extensions: <ThemeExtension<dynamic>>[
          const DiyTheme(
            brandColor: Color(0xFF1E88E5),
            danger: Color(0xFFE53935),
            dangerTextStyle : *** 省略
          ),
        ],
      ),

// 使用扩展颜色的方式
final DiyTheme diy = Theme.of(context).extension<DiyTheme>()!;
Color brandColor = diy.brandColor;

// 使用扩展主题中的样式
Text(
   "自定义样式",
   style: diy.dangerTextStyle,
),
Theme 和 Style 区别是什么?

区分一下 Theme 和 Style
在Flutter中,主题是一些列Style的组合。
所以 并不是换一个颜色 就是一个主题换肤(😀)。

让Theme获取颜色和属性配置,

从Theme中获取Style给组件。

ButtonStyle 如何设计和使用

在Flutter的主题设计中,你需要为最常用的按钮风格设置默认的主题,
总不可能 每写一个按钮 是使用 DiyTheme.buttonStyle吧?这样多麻烦不是吗?

FilledButton(
        style: Theme.of(context).extension<DiyTheme>()!.buttonStyle,
        onPressed: () {},
        child: Text("按钮"),
      )

如果所有的按钮都像上面这样写,开发者会疯掉,
我这里分享我推荐的做法,它不一定是最优,但是是我目前想到的比较好的解决方案。

  • 第一步,我们需要对常用的几种按钮进行Style定义,常见的按钮有
  • TextButton FilledButton ElevatedButton OutlinedButton IconButton
  • 我们需要对 程序中最常用的按钮进行 样式编写,将它编写到默认的ThemeData中。

比如:

// 亮色 主题
      theme: ThemeData(
        colorScheme: const ColorScheme.light(),
        appBarTheme: const AppBarTheme(),
        textButtonTheme: TextButtonThemeData(
          style: ButtonStyle(
            splashFactory: NoSplash.splashFactory,
            overlayColor: WidgetStatePropertyAll(Colors.transparent),
            foregroundColor: WidgetStateProperty.resolveWith((state) {
              if (state.contains(WidgetState.disabled)) {
                return Colors.black26;
              }
              return AppColors.themeColor;
            }),
          ),
        ),
        filledButtonTheme: FilledButtonThemeData(
          style: ButtonStyle(
            shape: WidgetStatePropertyAll(
              RoundedRectangleBorder(borderRadius: 8.borderRadius),
            ),
            minimumSize: const WidgetStatePropertyAll(Size.fromHeight(60)),
            backgroundColor: WidgetStateProperty.resolveWith((state) {
              if (state.contains(WidgetState.disabled)) {
                return AppColors.themeColor.withAlpha(80);
              }
              return AppColors.themeColor;
            }),
            foregroundColor: const WidgetStatePropertyAll(Colors.white),
          ),
        ),
      ),

上面的代码中,我们为 常用的2种按钮定义了外观,在我们日常使用中,
直接使用按钮 即可获得对应的外观

TextButton(
     onPressed: () {},
     child: Text("TextButton"),
),
FilledButton(
     onPressed: () {},
     child: Text("FilledButton"),
),

如果目前的按钮类型, 不足以满足你App的按钮需求,再根据对应的特性,将它们 扩展到相应按钮Style上,写入ThemeExtension中,参考上方ThemeExtension方法,使用时稍微麻烦一些,但是总体来说这不失为一种合理高效的方案。

FilledButton(
     style: Theme.of(context).extension<DiyTheme>()!.buttonStyle,
     onPressed: () {},
     child: Text("FilledButton"),
),

buttonStyle 这个名字可以自己取,当然,需要和你团队定义规范,你们需要在各自使用的时候,从theme中提取样式使用,而不是自己写到行内或者自己单独定义到文件中。

你需要跟你的开发同伴 一起定义规范,大家都遵守这种规范。

想起了上次我给同事说要做这个事情,结果被他怼,”你想怎么写就怎么写,咱们各写各的,我们之前都这样的“,我竟无言以对。

其他组件的Style 如何设计和使用

        //输入框参考:
        theme: ThemeData(
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: const Color(0xFFEEEEEE),
          hintStyle: const TextStyle(
            height: 1,
            fontSize: 18,
            wordSpacing: 1,
            letterSpacing: 1,
            fontWeight: FontWeight.normal,
            color: Color(0xFF999999),
          ),
          border: OutlineInputBorder(
            borderRadius: 8.borderRadius,
            borderSide: BorderSide.none,
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: 8.borderRadius,
            borderSide: BorderSide.none,
          ),
          prefixIconConstraints: const BoxConstraints(
            minWidth: 40,
          ),
          suffixIconConstraints: const BoxConstraints(
            minWidth: 50,
          ),
        ),
      ),

      //Tabbar参考:
      tabBarTheme: TabBarTheme(
          dividerHeight: 0,
          splashFactory: NoSplash.splashFactory,
          overlayColor: WidgetStatePropertyAll(Colors.transparent),
          labelStyle: TextStyle(fontWeight: FontWeight.w600),
          //unselectedLabelStyle: TextStyle(),
        ), 

上面都是默认的主题样式,当一种主题样式不够使用时,将其他样式写入扩展类型中。

几乎所有的组件都支持Style编写!!!

TextStyle 如何设计和使用

Flutter 中的TextStyle 可以说是最难理解和使用的参数了,如果说colorScheme复杂,至少你还能大概知道它会在什么地方出现,但是TextStyle,那就得一个个的尝试才知道它最终会在什么地方使用到,下面的内容可以收藏一波,基本上每一个项目可能都会用到这部分的文档内容,因为它太难记了!

TextTheme(
   /// 最大的显示样式 ,作为屏幕上最大的文本 重要的文字或数字。
   displayLarge: TextStyle(),
   displayMedium: TextStyle(),
   displaySmall: TextStyle(),
   
   /// 标题样式 (小于display)
   headlineLarge: TextStyle(),
   headlineMedium: TextStyle(),
   headlineSmall: TextStyle(),
   
   /// 标题样式(小于headline)
   titleLarge: TextStyle(), // AppBar Title (默认字体)
   titleMedium: TextStyle(),
   titleSmall: TextStyle(),
   
   /// 正文样式
   bodyLarge: TextStyle(), //  大 - 输入框 (默认字体)
   bodyMedium: TextStyle(), // 字体 默认
   bodySmall: TextStyle(), //  小 (部分水印提示信息)
   
   /// 标签样式 标签样式是较小的实用样式,用于UI区域  例如组件内部的文本 或 内容正文,如标题。
   labelLarge: TextStyle(), // 按钮 (默认字体)
   labelSmall: TextStyle(),
   labelMedium: TextStyle(),
)

同样的,为了简单,设计师 应该尽量将 字体的尺寸设计为 上方 15种之一,这样我们就方便使用了(想太多。。。哈哈) 😀
当然如果上面的文字样式 使用起来过于复杂难以记忆,我们依然可以将textStyle放入扩展Theme,使用自定义的名字,就会简单很多。
但是我们遇到修改 某些 组件的字体样式时,先看看默认的textTheme是否能够很好的修改到它(这么做优先级比自定义高)。

关于textTheme 的一些使用案例:

/// 特定的区域中使用时,不需要申明对应的Style

appBar: AppBar(
     title: Text("appBar"),// 自动使用textTheme.titleLarge
),

body: Text("body"), // 自动使用textTheme.bodyMedium

/// 你想使用更大字体时样式时 
body: Text(
   "body",
    style: Theme.of(context).textTheme.bodyLarge, // 申明使用textTheme.bodyLarge
),

TextSize 、 Color 、 TextStyle 冲突的问题。

仔细阅读之前的文章,再经过思考后,你就会发现,我们定义了textTheme,ButtonTheme又可以定义 textStyle,这个时候不是冲突了吗?

其实的确有冲突,但是Flutter讲的是就近原则,也就是说 按钮内的text在寻找样式时,如果你写了行内Style,它直接就用了,如果没有,那它会先寻找buttonTheme中的textStyle,如果没有定义,它才会去找textTheme中对应的textStyle,textStyle如果你没有修改,它就用的初始值,整体理解起来也是非常容易。

所以,在按钮的主题Style中,定义文字颜色时,应该放到foregroundColor,而不是定义TextStyle中的Color

你没有在按钮中定义文字大小,它也会去textTheme中获取 默认的文字大小。

不建议在textTheme(或扩展Theme)之外的区域定义文字大小和颜色属性

GetX 中如何使用?

在Getx中,你可以通过Get快速访问theme对象。

Get.theme.colorScheme

Get.theme.extensions

需要注意的是,如果你使用system模式的主题模式,系统主题发生变化时,Get使用的context并不能从当前的主题变化中获得消息,导致界面不会重新渲染(猜测的😔)。

所以建议使用BuildContext context来进行theme获取,这样你可能需要在页面的函数之间传递这个context 或 传递 themeData,如果读到这里,你有更好的方案,请在评论区留言讨论。

一个支持黑白主题的程序完整源码

(有可能你在阅读本文章时,这个源码还没有编写完成,但是你可以先关注一波。) github.com/DMSkin/flut…