iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🙄

Deep Dive into Flutter's primarySwatch

に公開

Hi there, I'm Sugit ٩( ᐛ )و
Today's theme is primarySwatch.

When you create a Flutter app using flutter create my_app, it's probably one of the first things you see.

As shown in the implementation below, primarySwatch: Colors.blue should be specified in ThemeData.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

I decided to dive deep into this today.
By the way, when you run the app in its initial state, it looks like this.
(Actually, this counter app is the most used Flutter app in the world! ...Probably.)

flutter_default

Why did I want to dive deep into primarySwatch?

Because I wondered why primarySwatch: Colors.blue is specified in the initial app template.

To be honest, the appearance doesn't change even if you remove this setting.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

Even with this,

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      // theme: ThemeData(
      //   primarySwatch: Colors.blue,
      // ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

It's the same. It doesn't change.

Would the Flutter team implement a setting that doesn't change anything in an app that serves as the starting line for all Flutter engineers without any intention?

There must be some intention...

That was the motivation.

By the way, the template for the app created by flutter create my_app is implemented here:

https://github.com/flutter/flutter/blob/master/packages/flutter_tools/templates/app/lib/main.dart.tmpl

Reading this might be interesting in its own way! Check it out if you're interested.

Let's look at the implementation of ThemeData

Here is the definition of ThemeData (tada!)

Okay, I already lost the motivation to look at it.

Let's check the API documentation for this.
https://api.flutter.dev/flutter/material/ThemeData-class.html

Having said that, from here on, I will proceed while checking the actual implementation of Flutter as well.

It is OSS, after all. Let's take a look at the implementation by these super engineers.

Note that from this point, let's proceed while self-hypnotizing that we can't see anything except primarySwatch.

First, as a partial excerpt, let's look at the factory (constructor) of ThemeData.

  factory ThemeData({
    Brightness? brightness,
    VisualDensity? visualDensity,
    MaterialColor? primarySwatch,
    Color? primaryColor,
    Brightness? primaryColorBrightness,
    Color? primaryColorLight,
    Color? primaryColorDark,
    Color? accentColor,
    Brightness? accentColorBrightness,
    // to be continued...

primarySwatch is defined with a class called MaterialColor.

So, let's start by looking at MaterialColor.

Let's properly understand MaterialColor

Have you ever looked into MaterialColor?

https://api.flutter.dev/flutter/material/MaterialColor-class.html

Unlike the simple Color class, the MaterialColor class is a color system defined in accordance with the Material Design Color System.

https://material.io/design/color/the-color-system.html

material_design_color_system
Borrowed from the Material Design page

In this color system, a base color is handled together with a set of 10 shades of varying lightness. The same mechanism is implemented in the MaterialColor class as follows:

class MaterialColor extends ColorSwatch<int> {
  /// Creates a color swatch with a variety of shades.
  ///
  /// The `primary` argument should be the 32 bit ARGB value of one of the
  /// values in the swatch, as would be passed to the [new Color] constructor
  /// for that same color, and as is exposed by [value]. (This is distinct from
  /// the specific index of the color in the swatch.)
  const MaterialColor(int primary, Map<int, Color> swatch) : super(primary, swatch);

  /// The lightest shade.
  Color get shade50 => this[50]!;

  /// The second lightest shade.
  Color get shade100 => this[100]!;

  /// The third lightest shade.
  Color get shade200 => this[200]!;

  /// The fourth lightest shade.
  Color get shade300 => this[300]!;

  /// The fifth lightest shade.
  Color get shade400 => this[400]!;

  /// The default shade.
  Color get shade500 => this[500]!;

  /// The fourth darkest shade.
  Color get shade600 => this[600]!;

  /// The third darkest shade.
  Color get shade700 => this[700]!;

  /// The second darkest shade.
  Color get shade800 => this[800]!;

  /// The darkest shade.
  Color get shade900 => this[900]!;
}

This means that the blue in primarySwatch: Colors.blue is not just a single blue color. Many of you probably already know this, but let's take a closer look at the implementation.

First, let's look at the Colors class. Not the Color class, but the Colors class with an s.

Actually, this Colors class is just a container. It simply defines many const values for convenient colors. The key point is that the colors defined there can be from the Color class or the MaterialColor class.

https://api.flutter.dev/flutter/material/Colors-class.html

This can be unexpectedly tricky for beginners: they might try to use a color from the Color class where a MaterialColor is expected, resulting in an error like this. It's quite straightforward:

flutter: type 'Color' is not a subtype of type 'MaterialColor'

Now, let's look at Colors.blue.

  static const MaterialColor blue = MaterialColor(
    _bluePrimaryValue,
    <int, Color>{
       50: Color(0xFFE3F2FD),
      100: Color(0xFFBBDEFB),
      200: Color(0xFF90CAF9),
      300: Color(0xFF64B5F6),
      400: Color(0xFF42A5F5),
      500: Color(_bluePrimaryValue),
      600: Color(0xFF1E88E5),
      700: Color(0xFF1976D2),
      800: Color(0xFF1565C0),
      900: Color(0xFF0D47A1),
    },
  );
  static const int _bluePrimaryValue = 0xFF2196F3;

This is the implementation of Colors.blue. It is implemented exactly according to the definition of the MaterialColor class we saw earlier. Different color codes are properly implemented for different levels of brightness. These color codes are defined on the Material Design page mentioned earlier.

https://material.io/design/color/the-color-system.html#tools-for-picking-colors

Now, back to MaterialColor. Let's look at its definition once more (simplified version).

class MaterialColor extends ColorSwatch<int> {
  const MaterialColor(int primary, Map<int, Color> swatch)
   : super(primary, swatch);

  // so on...

Since it extends ColorSwatch<int>, it seems appropriate to look at the ColorSwatch class next. This is the second installment in the series of "tricky things for beginners": inheritance. (It's one of those topics where even people who think they know it might get a little burned. Experts love "inheritance vs delegation," after all...)

Swatch, right!
I know that!!
It's that Swiss watch brand, isn't it!!!

٩( ᐛ )و

And here is the ColorSwatch class!!

@immutable
class ColorSwatch<T> extends Color {
  const ColorSwatch(int primary, this._swatch) : super(primary);

  @protected
  final Map<T, Color> _swatch;

  Color? operator [](T index) => _swatch[index];

  @override
  bool operator ==(Object other) {
    if (identical(this, other))
      return true;
    if (other.runtimeType != runtimeType)
      return false;
    return super == other
        && other is ColorSwatch<T>
        && mapEquals<T, Color>(other._swatch, _swatch);
  }

  @override
  int get hashCode => hashValues(runtimeType, value, _swatch);

  @override
  String toString() => '${objectRuntimeType(this, 'ColorSwatch')}(primary value: ${super.toString()})';
}

Alright, there's quite a bit going on here.
Just look at this part:

@protected
final Map<T, Color> _swatch;

It's a generic. A Map object that has Color with type T as the key.
Indeed, it implements the concept of a color swatch as is.

In MaterialColor, this type T is an int, representing the shades.

To summarize what we've learned so far:

  • You specify a MaterialColor for primarySwatch.
  • MaterialColor is an interpretation of ColorSwatch, which implements the concept of color samples, based on Material Design guidelines.
    • It's convenient to choose MaterialColor from those defined as const in the Colors class.
    • If you create your own, you need to define a color set of 10 or more shades.

That's about it.

What does primarySwatch affect?

Next, let's go back to ThemeData.
In the initial app's ThemeData, primarySwatch: Colors.blue was specified.
Now, let's look at what effects changing this color has.

First, an important (?) point is that primarySwatch is not a property of ThemeData. What does this mean?

I won't explain classes and properties in detail, but being a property means it's something contained in the instance (the actual entity) of that class, and it's a value that can be accessed (ignoring access modifiers for a moment) after the instance is created.

In other words, you cannot access primarySwatch after the initialization of ThemeData.
In fact, even if you try to access it via Theme.of(context).xxx, you'll find that the property doesn't exist.

theme_properties

Wait... I can't access... the color I defined... 😇

primarySwatch exists in the constructor, but not in the properties. I believe there is an intention by the Flutter team here.

The fact that primarySwatch only exists in the constructor means that when creating ThemeData, some properties are constructed based on the primarySwatch.

This basically means:

Setting individual values one by one is a pain for developers! So we'll handle it for you!

Or, perhaps the true intent is:

If developers set random values however they like, the goodness of Flutter and the brilliance of Material Design won't be properly conveyed. Let's create a convenient simple setting. Then they'll be dancing in the palm of our hands!

It's similar to this interaction:

Hey Siri, play something to get me pumped up!
Now playing a playlist for when you want to lift your mood.

Now, let's look at the scope of impact of primarySwatch.
From here on, we'll look at excerpts from the implementation of ThemeData.

You can see the actual implementation of ThemeData on GitHub here (↓). Much appreciated.
https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/theme_data.dart

First, primarySwatch itself:

primarySwatch ??= Colors.blue;

Here, the mystery we first encountered is solved.

If not specified in the constructor, primarySwatch automatically becomes Colors.blue.

Seeing this further increases the feeling of dancing (or rather, being allowed to dance) in the palm of the Flutter team's hands through the following template!

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

Now, let's look a bit wider.

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

primarySwatch ??= Colors.blue;

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;
primaryColorLight ??= isDark ? Colors.grey[500]! : primarySwatch[100]!;
primaryColorDark ??= isDark ? Colors.black : primarySwatch[700]!;

primarySwatch appears everywhere. Let's look at them one by one.

[case:1] primaryColor is primarySwatch when Dark Mode is OFF

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;

It is branched by isDark. However, it seems that primarySwatch is set when isDark == false. (Note that primaryColor is a MaterialColor, not just a Color.)

As for isDark, it follows brightness specified in the constructor or brightness specified in colorScheme; otherwise, it's light.

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

As a side note, let's look at brightness.
When building a theme for Dark Mode in Flutter, it's common to use darkTheme instead of theme in MaterialApp.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

These theme and darkTheme are themes that switch according to the themeMode specified for MaterialApp. Since the initial value of themeMode is ThemeMode.system, it switches the theme according to the device settings. If you always want the dark theme, you can set themeMode: ThemeMode.dark. In short, theme and darkTheme are simply themes loaded according to this flag. No implementation is done to specifically force them to look "darkish." Everything is left to the developer.

Therefore, to make it easier for developers to set darkTheme (?), brightness is used.

The following is famous as a convenient implementation that easily creates light and dark themes:

theme: ThemeData.light(),
darkTheme: ThemeData.dark(),

What do these implementations look like...?

factory ThemeData.light() => ThemeData(brightness: Brightness.light);
factory ThemeData.dark() => ThemeData(brightness: Brightness.dark);

They're just changing the brightness!!!!

This means that as long as you specify primarySwatch properly, the ThemeData class will create the dark theme on its own.

Now, for those of you who were laboriously implementing darkTheme manually, honestly raise your hand and check your implementation. You might only need brightness.

Now, back to the main topic:

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;

primaryColor becomes the color of primarySwatch only when brightness is light; otherwise, it's grey[900].

For those who have felt frustrated that "the colors don't change at all when implementing Dark Mode!!", this is the truth.

The "people who have experience with XX" I keep mentioning is my past self.

As for primaryColor, as described below, it is the default color for themes used in major parts of the app. There are a few more explanations, but let's put them aside for now.

/// The background color for major parts of the app (toolbars, tab bars, etc)
///
/// The theme's [colorScheme] property contains [ColorScheme.primary], as
/// well as a color that contrasts well with the primary color called
/// [ColorScheme.onPrimary]. It might be simpler to just configure an app's
/// visuals in terms of the theme's [colorScheme].
final Color primaryColor;

Similarly, let's briefly touch on primaryColorLight and primaryColorDark.

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;
primaryColorLight ??= isDark ? Colors.grey[500]! : primarySwatch[100]!;
primaryColorDark ??= isDark ? Colors.black : primarySwatch[700]!;

primaryColorLight and primaryColorDark are colors for icons or text that sit on top of items where primaryColor is applied. Light and Dark are switched according to primaryColorBrightness, which is a brightness calculated based on the primaryColor. In other words, the text color sitting on the background is automatically selected. If you're interested in this automatic brightness determination, take a look at the implementation of estimateBrightnessForColor(Color color) in theme_data.dart.

However, it's extremely difficult to correctly grasp and implement which components primaryColorLight and primaryColorDark affect. There's almost no explanation in the API documentation. Therefore, it's probably best to leave this part alone.

That's all for primaryColor.

Note that let's put aside colorScheme.brightness in the isDark implementation for now.

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

[case:2] accentColor is also primarySwatch when Dark Mode is OFF

I'll skip most of the explanation here. It's the same as the previous content...
As you can see from the implementation below, the accent color in Dark Mode is tealAccent[200], regardless of primarySwatch. This is another point where you might get stuck, so keep it in mind.

accentColor ??= isDark ? Colors.tealAccent[200]! : primarySwatch[500]!;

[case:misc.] Many things become primarySwatch when Dark Mode is OFF!!

I looked for others as well.

  • secondaryHeaderColor
    • The color for header level 2.
  • textSelectionColor
    • The text highlight color.
  • textSelectionHandleColor
    • The color of the anchor handles when selecting text.
  • backgroundColor
    • The background color for various things.
  • buttonColor
    • The color of buttons.
secondaryHeaderColor ??= isDark ? Colors.grey[700]! : primarySwatch[50]!;
textSelectionColor ??= isDark ? accentColor : primarySwatch[200]!;
textSelectionHandleColor ??= isDark ? Colors.tealAccent[400]! : primarySwatch[300]!;
backgroundColor ??= isDark ? Colors.grey[700]! : primarySwatch[200]!;
buttonColor ??= isDark ? primarySwatch[600]! : Colors.grey[300]!;

This might not give you a clear image, so let's check with a concrete example.

Let's look at everyone's favorite, AppBar.

First, I'll specify primarySwatch: Colors.green.

appbar_green

As you can see, the background color of the AppBar has turned green.
Do you understand what happened to make it green?

The implementation of the AppBar at this time is still in its initial state, like this:

appBar: AppBar(
  title: Text(widget.title),
),

The answer lies in the specifications of the AppBar.

https://api.flutter.dev/flutter/material/AppBar-class.html

The answer is in the description of the backgroundColor property in the AppBar specifications.

https://api.flutter.dev/flutter/material/AppBar/backgroundColor.html

The fill color to use for an app bar's Material.
If null, then the AppBarTheme.backgroundColor is used. If that value is also null, then AppBar uses the overall theme's ColorScheme.primary if the overall theme's brightness is Brightness.light, and ColorScheme.surface if the overall theme's brightness is Brightness.dark.

Here is a summary:

This is the color for the AppBar.

  1. Is backgroundColor specified in the AppBar widget? (YES -> use that color / No -> next)
  2. Is AppBarTheme specified? (YES -> use AppBarTheme.backgroundColor / No -> next)
  3. If brightness is light, use ColorScheme.primary. If brightness is dark, use ColorScheme.surface.

If you've read this long explanation so far, you probably understand what this is saying.
In this example, neither (1) nor (2) applies. It's (3).

Finally, here it is! ColorScheme!!

It's been a while, but here is ColorScheme. It's a color sample. Many of Flutter's theme settings refer back to color samples if nothing else is set.

In other words, building your own theme means you have to think about how to coexist with the color samples properly.

As soon as you add a new component, it doesn't turn the color you expected, forcing you to review the theme, and because it affects other components, you end up setting local colors... it's a nightmare.

Now, for the last topic. What exactly is ColorScheme in ThemeData?

Checking the implementation of ThemeData again, I found this:

    // Create a ColorScheme that is backwards compatible as possible
    // with the existing default ThemeData color values.
    colorScheme ??= ColorScheme.fromSwatch(
      primarySwatch: primarySwatch,
      primaryColorDark: primaryColorDark,
      accentColor: accentColor,
      cardColor: cardColor,
      backgroundColor: backgroundColor,
      errorColor: errorColor,
      brightness: _brightness,
    );

And here is the implementation of the colorScheme property:

  /// A set of thirteen colors that can be used to configure the
  /// color properties of most components.
  ///
  /// This property was added much later than the theme's set of highly
  /// specific colors, like [cardColor], [buttonColor], [canvasColor] etc.
  /// New components can be defined exclusively in terms of [colorScheme].
  /// Existing components will gradually migrate to it, to the extent
  /// that is possible without significant backwards compatibility breaks.
  final ColorScheme colorScheme;

Is it all starting to connect?

colorScheme = ColorScheme.fromSwatch(
  primarySwatch: primarySwatch,
  // ~~
);

This part! Alright, let's look at fromSwatch()!!

  factory ColorScheme.fromSwatch({
    MaterialColor primarySwatch = Colors.blue,
    Color? primaryColorDark,
    Color? accentColor,
    Color? cardColor,
    Color? backgroundColor,
    Color? errorColor,
    Brightness brightness = Brightness.light,
  }) {
    assert(primarySwatch != null);
    assert(brightness != null);

    final bool isDark = brightness == Brightness.dark;
    final bool primaryIsDark = _brightnessFor(primarySwatch) == Brightness.dark;
    final Color secondary = accentColor ?? (isDark ? Colors.tealAccent[200]! : primarySwatch);
    final bool secondaryIsDark = _brightnessFor(secondary) == Brightness.dark;

    return ColorScheme(
      primary: primarySwatch,
      primaryVariant: primaryColorDark ?? (isDark ? Colors.black : primarySwatch[700]!),
      secondary: secondary,
      secondaryVariant: isDark ? Colors.tealAccent[700]! : primarySwatch[700]!,
      surface: cardColor ?? (isDark ? Colors.grey[800]! : Colors.white),
      background: backgroundColor ?? (isDark ? Colors.grey[700]! : primarySwatch[200]!),
      error: errorColor ?? Colors.red[700]!,
      onPrimary: primaryIsDark ? Colors.white : Colors.black,
      onSecondary: secondaryIsDark ? Colors.white : Colors.black,
      onSurface: isDark ? Colors.white : Colors.black,
      onBackground: primaryIsDark ? Colors.white : Colors.black,
      onError: isDark ? Colors.black : Colors.white,
      brightness: brightness,
    );
  }

The focus this time is here!!

    return ColorScheme(
      primary: primarySwatch,

It sets primary to primarySwatch, which is Colors.green!
Now we know why the AppBar turned green!!

Conclusion: Let's dance in the palm of their hands

  • It's better to build your theme using primarySwatch as much as possible.
  • You should check if you haven't mistakenly written primaryColor instead.

"I meant to use primarySwatch but ended up using primaryColor!! Oops!"
For those who have written code like this with that kind of vibe, it might be dangerous.

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primaryColor: Colors.green, // <- specifying primaryColor
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );

Why??

Because this is what happens!!

incorrect_example

The FAB (FloatingActionButton) has become quite unfortunate!!

I hope this somewhat geeky content helps those who find Flutter's Themes a bit confusing.

Sugit ٩( ᐛ )و

GitHubで編集を提案

Discussion