Introduce Theme extensions (#98033)
* first pass
* x
* x
* address feedback
* support multiple extensions
* add convenience function, Object ⇒ dynamic, lerping
* remove not-useful comment
* fix examples/api lower sdk constraint
* remove trailing spaces
* remove another pesky trailing space
* improve lerp
* address feedback
* hide map implementation from constructor and copyWith
* use iterableproperty
* Revert "hide map implementation from constructor and copyWith"
This reverts commit a6994af0046e3c90dbc9405cac628feb5b2d3031.
* slow down sample
* make theme extension params required
* add null check
* improve documentation
* fix hashCode and operator == overrides
* modify existing tests
* remove trailing spaces
* add all tests except lerping
* fix lerping bug
* add toString to themeExtension example
* add lerping test
* assume non-nullability in example
* address feedback
* update docs
* remove trailing space
* use Map.unmodifiable
diff --git a/examples/api/lib/material/theme/theme_extension.1.dart b/examples/api/lib/material/theme/theme_extension.1.dart
new file mode 100644
index 0000000..6ea2bb8
--- /dev/null
+++ b/examples/api/lib/material/theme/theme_extension.1.dart
@@ -0,0 +1,126 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flutter code sample for ThemeExtension
+
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+@immutable
+class MyColors extends ThemeExtension<MyColors> {
+ const MyColors({
+ required this.blue,
+ required this.red,
+ });
+
+ final Color? blue;
+ final Color? red;
+
+ @override
+ MyColors copyWith({Color? red, Color? blue}) {
+ return MyColors(
+ blue: blue ?? this.blue,
+ red: red ?? this.red,
+ );
+ }
+
+ @override
+ MyColors lerp(ThemeExtension<MyColors>? other, double t) {
+ if (other is! MyColors) {
+ return this;
+ }
+ return MyColors(
+ blue: Color.lerp(blue, other.blue, t),
+ red: Color.lerp(red, other.red, t),
+ );
+ }
+
+ // Optional
+ @override
+ String toString() => 'MyColors(blue: $blue, red: $red)';
+}
+
+void main() {
+ // Slow down time to see lerping.
+ timeDilation = 5.0;
+ runApp(const MyApp());
+}
+
+class MyApp extends StatefulWidget {
+ const MyApp({Key? key}) : super(key: key);
+
+ static const String _title = 'Flutter Code Sample';
+
+ @override
+ State<MyApp> createState() => _MyAppState();
+}
+
+class _MyAppState extends State<MyApp> {
+ bool isLightTheme = true;
+
+ void toggleTheme() {
+ setState(() => isLightTheme = !isLightTheme);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: MyApp._title,
+ theme: ThemeData.light().copyWith(
+ extensions: <ThemeExtension<dynamic>>{
+ const MyColors(
+ blue: Color(0xFF1E88E5),
+ red: Color(0xFFE53935),
+ ),
+ },
+ ),
+ darkTheme: ThemeData.dark().copyWith(
+ extensions: <ThemeExtension<dynamic>>{
+ const MyColors(
+ blue: Color(0xFF90CAF9),
+ red: Color(0xFFEF9A9A),
+ ),
+ },
+ ),
+ themeMode: isLightTheme ? ThemeMode.light : ThemeMode.dark,
+ home: Home(
+ isLightTheme: isLightTheme,
+ toggleTheme: toggleTheme,
+ ),
+ );
+ }
+}
+
+class Home extends StatelessWidget {
+ const Home({
+ Key? key,
+ required this.isLightTheme,
+ required this.toggleTheme,
+ }) : super(key: key);
+
+ final bool isLightTheme;
+ final void Function() toggleTheme;
+
+ @override
+ Widget build(BuildContext context) {
+ final MyColors myColors = Theme.of(context).extension<MyColors>()!;
+ return Material(
+ child: Center(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ Container(width: 100, height: 100, color: myColors.blue),
+ const SizedBox(width: 10),
+ Container(width: 100, height: 100, color: myColors.red),
+ const SizedBox(width: 50),
+ IconButton(
+ icon: Icon(isLightTheme ? Icons.nightlight : Icons.wb_sunny),
+ onPressed: toggleTheme,
+ ),
+ ],
+ )
+ ),
+ );
+ }
+}
diff --git a/examples/api/pubspec.yaml b/examples/api/pubspec.yaml
index a7bc468..b9b4c64 100644
--- a/examples/api/pubspec.yaml
+++ b/examples/api/pubspec.yaml
@@ -5,7 +5,7 @@
version: 1.0.0
environment:
- sdk: ">=2.14.0-383.0.dev <3.0.0"
+ sdk: ">=2.17.0-0 <3.0.0"
flutter: ">=2.5.0-6.0.pre.30 <3.0.0"
dependencies:
diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart
index b2cc31c..d83002b 100644
--- a/packages/flutter/lib/src/material/theme_data.dart
+++ b/packages/flutter/lib/src/material/theme_data.dart
@@ -53,6 +53,36 @@
export 'package:flutter/services.dart' show Brightness;
+/// An interface that defines custom additions to a [ThemeData] object.
+///
+/// Typically used for custom colors. To use, subclass [ThemeExtension],
+/// define a number of fields (e.g. [Color]s), and implement the [copyWith] and
+/// [lerp] methods. The latter will ensure smooth transitions of properties when
+/// switching themes.
+///
+/// {@tool dartpad}
+/// This sample shows how to create and use a subclass of [ThemeExtension] that
+/// defines two colors.
+///
+/// ** See code in examples/api/lib/material/theme/theme_extension.1.dart **
+/// {@end-tool}
+abstract class ThemeExtension<T extends ThemeExtension<T>> {
+ /// Enable const constructor for subclasses.
+ const ThemeExtension();
+
+ /// The extension's type.
+ Object get type => T;
+
+ /// Creates a copy of this theme extension with the given fields
+ /// replaced by the non-null parameter values.
+ ThemeExtension<T> copyWith();
+
+ /// Linearly interpolate with another [ThemeExtension] object.
+ ///
+ /// {@macro dart.ui.shadow.lerp}
+ ThemeExtension<T> lerp(ThemeExtension<T>? other, double t);
+}
+
// Deriving these values is black magic. The spec claims that pressed buttons
// have a highlight of 0x66999999, but that's clearly wrong. The videos in the
// spec show that buttons have a composited highlight of #E1E1E1 on a background
@@ -243,6 +273,7 @@
AndroidOverscrollIndicator? androidOverscrollIndicator,
bool? applyElevationOverlayColor,
NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
+ Iterable<ThemeExtension<dynamic>>? extensions,
InputDecorationTheme? inputDecorationTheme,
MaterialTapTargetSize? materialTapTargetSize,
PageTransitionsTheme? pageTransitionsTheme,
@@ -390,6 +421,7 @@
}) {
// GENERAL CONFIGURATION
cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault();
+ extensions ??= <ThemeExtension<dynamic>>[];
inputDecorationTheme ??= const InputDecorationTheme();
platform ??= defaultTargetPlatform;
switch (platform) {
@@ -562,6 +594,7 @@
androidOverscrollIndicator: androidOverscrollIndicator,
applyElevationOverlayColor: applyElevationOverlayColor,
cupertinoOverrideTheme: cupertinoOverrideTheme,
+ extensions: _themeExtensionIterableToMap(extensions),
inputDecorationTheme: inputDecorationTheme,
materialTapTargetSize: materialTapTargetSize,
pageTransitionsTheme: pageTransitionsTheme,
@@ -665,6 +698,7 @@
required this.androidOverscrollIndicator,
required this.applyElevationOverlayColor,
required this.cupertinoOverrideTheme,
+ required this.extensions,
required this.inputDecorationTheme,
required this.materialTapTargetSize,
required this.pageTransitionsTheme,
@@ -807,6 +841,7 @@
required this.primaryColorBrightness,
}) : // GENERAL CONFIGURATION
assert(applyElevationOverlayColor != null),
+ assert(extensions != null),
assert(inputDecorationTheme != null),
assert(materialTapTargetSize != null),
assert(pageTransitionsTheme != null),
@@ -1053,6 +1088,32 @@
/// can be overridden using attributes of this [cupertinoOverrideTheme].
final NoDefaultCupertinoThemeData? cupertinoOverrideTheme;
+ /// Arbitrary additions to this theme.
+ ///
+ /// To define extensions, pass an [Iterable] containing one or more [ThemeExtension]
+ /// subclasses to [ThemeData.new] or [copyWith].
+ ///
+ /// To obtain an extension, use [extension].
+ ///
+ /// {@tool dartpad}
+ /// This sample shows how to create and use a subclass of [ThemeExtension] that
+ /// defines two colors.
+ ///
+ /// ** See code in examples/api/lib/material/theme/theme_extension.1.dart **
+ /// {@end-tool}
+ ///
+ /// See also:
+ ///
+ /// * [extension], a convenience function for obtaining a specific extension.
+ final Map<Object, ThemeExtension<dynamic>> extensions;
+
+ /// Used to obtain a particular [ThemeExtension] from [extensions].
+ ///
+ /// Obtain with `Theme.of(context).extension<MyThemeExtension>()`.
+ ///
+ /// See [extensions] for an interactive example.
+ T? extension<T>() => extensions[T] as T;
+
/// The default [InputDecoration] values for [InputDecorator], [TextField],
/// and [TextFormField] are based on this theme.
///
@@ -1588,6 +1649,7 @@
AndroidOverscrollIndicator? androidOverscrollIndicator,
bool? applyElevationOverlayColor,
NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
+ Iterable<ThemeExtension<dynamic>>? extensions,
InputDecorationTheme? inputDecorationTheme,
MaterialTapTargetSize? materialTapTargetSize,
PageTransitionsTheme? pageTransitionsTheme,
@@ -1736,6 +1798,7 @@
androidOverscrollIndicator: androidOverscrollIndicator ?? this.androidOverscrollIndicator,
applyElevationOverlayColor: applyElevationOverlayColor ?? this.applyElevationOverlayColor,
cupertinoOverrideTheme: cupertinoOverrideTheme ?? this.cupertinoOverrideTheme,
+ extensions: (extensions != null) ? _themeExtensionIterableToMap(extensions) : this.extensions,
inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme,
materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize,
pageTransitionsTheme: pageTransitionsTheme ?? this.pageTransitionsTheme,
@@ -1889,6 +1952,34 @@
return Brightness.dark;
}
+ /// Linearly interpolate between two [extensions].
+ ///
+ /// Includes all theme extensions in [a] and [b].
+ ///
+ /// {@macro dart.ui.shadow.lerp}
+ static Map<Object, ThemeExtension<dynamic>> _lerpThemeExtensions(ThemeData a, ThemeData b, double t) {
+ // Lerp [a].
+ final Map<Object, ThemeExtension<dynamic>> newExtensions = a.extensions.map((Object id, ThemeExtension<dynamic> extensionA) {
+ final ThemeExtension<dynamic>? extensionB = b.extensions[id];
+ return MapEntry<Object, ThemeExtension<dynamic>>(id, extensionA.lerp(extensionB, t));
+ });
+ // Add [b]-only extensions.
+ newExtensions.addEntries(b.extensions.entries.where(
+ (MapEntry<Object, ThemeExtension<dynamic>> entry) =>
+ !a.extensions.containsKey(entry.key)));
+
+ return newExtensions;
+ }
+
+ /// Convert the [extensionsIterable] passed to [ThemeData.new] or [copyWith]
+ /// to the stored [extensions] map, where each entry's key consists of the extension's type.
+ static Map<Object, ThemeExtension<dynamic>> _themeExtensionIterableToMap(Iterable<ThemeExtension<dynamic>> extensionsIterable) {
+ return Map<Object, ThemeExtension<dynamic>>.unmodifiable(<Object, ThemeExtension<dynamic>>{
+ // Strangely, the cast is necessary for tests to run.
+ for (final ThemeExtension<dynamic> extension in extensionsIterable) extension.type: extension as ThemeExtension<ThemeExtension<dynamic>>
+ });
+ }
+
/// Linearly interpolate between two themes.
///
/// The arguments must not be null.
@@ -1906,6 +1997,7 @@
androidOverscrollIndicator:t < 0.5 ? a.androidOverscrollIndicator : b.androidOverscrollIndicator,
applyElevationOverlayColor:t < 0.5 ? a.applyElevationOverlayColor : b.applyElevationOverlayColor,
cupertinoOverrideTheme:t < 0.5 ? a.cupertinoOverrideTheme : b.cupertinoOverrideTheme,
+ extensions: _lerpThemeExtensions(a, b, t),
inputDecorationTheme:t < 0.5 ? a.inputDecorationTheme : b.inputDecorationTheme,
materialTapTargetSize:t < 0.5 ? a.materialTapTargetSize : b.materialTapTargetSize,
pageTransitionsTheme:t < 0.5 ? a.pageTransitionsTheme : b.pageTransitionsTheme,
@@ -2006,6 +2098,7 @@
other.androidOverscrollIndicator == androidOverscrollIndicator &&
other.applyElevationOverlayColor == applyElevationOverlayColor &&
other.cupertinoOverrideTheme == cupertinoOverrideTheme &&
+ mapEquals(other.extensions, extensions) &&
other.inputDecorationTheme == inputDecorationTheme &&
other.materialTapTargetSize == materialTapTargetSize &&
other.pageTransitionsTheme == pageTransitionsTheme &&
@@ -2103,6 +2196,8 @@
androidOverscrollIndicator,
applyElevationOverlayColor,
cupertinoOverrideTheme,
+ hashList(extensions.keys),
+ hashList(extensions.values),
inputDecorationTheme,
materialTapTargetSize,
pageTransitionsTheme,
@@ -2200,6 +2295,7 @@
properties.add(EnumProperty<AndroidOverscrollIndicator>('androidOverscrollIndicator', androidOverscrollIndicator, defaultValue: null, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<bool>('applyElevationOverlayColor', applyElevationOverlayColor, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<NoDefaultCupertinoThemeData>('cupertinoOverrideTheme', cupertinoOverrideTheme, defaultValue: defaultData.cupertinoOverrideTheme, level: DiagnosticLevel.debug));
+ properties.add(IterableProperty<ThemeExtension<dynamic>>('extensions', extensions.values, defaultValue: defaultData.extensions.values, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<InputDecorationTheme>('inputDecorationTheme', inputDecorationTheme, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<MaterialTapTargetSize>('materialTapTargetSize', materialTapTargetSize, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<PageTransitionsTheme>('pageTransitionsTheme', pageTransitionsTheme, level: DiagnosticLevel.debug));
diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart
index 3fca00c..c44f718 100644
--- a/packages/flutter/test/material/theme_data_test.dart
+++ b/packages/flutter/test/material/theme_data_test.dart
@@ -6,6 +6,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
+@immutable
+class MyThemeExtensionA extends ThemeExtension<MyThemeExtensionA> {
+ const MyThemeExtensionA({
+ required this.color1,
+ required this.color2,
+ });
+
+ final Color? color1;
+ final Color? color2;
+
+ @override
+ MyThemeExtensionA copyWith({Color? color1, Color? color2}) {
+ return MyThemeExtensionA(
+ color1: color1 ?? this.color1,
+ color2: color2 ?? this.color2,
+ );
+ }
+
+ @override
+ MyThemeExtensionA lerp(ThemeExtension<MyThemeExtensionA>? other, double t) {
+ if (other is! MyThemeExtensionA) {
+ return this;
+ }
+ return MyThemeExtensionA(
+ color1: Color.lerp(color1, other.color1, t),
+ color2: Color.lerp(color2, other.color2, t),
+ );
+ }
+}
+
+@immutable
+class MyThemeExtensionB extends ThemeExtension<MyThemeExtensionB> {
+ const MyThemeExtensionB({
+ required this.textStyle,
+ });
+
+ final TextStyle? textStyle;
+
+ @override
+ MyThemeExtensionB copyWith({Color? color, TextStyle? textStyle}) {
+ return MyThemeExtensionB(
+ textStyle: textStyle ?? this.textStyle,
+ );
+ }
+
+ @override
+ MyThemeExtensionB lerp(ThemeExtension<MyThemeExtensionB>? other, double t) {
+ if (other is! MyThemeExtensionB) {
+ return this;
+ }
+ return MyThemeExtensionB(
+ textStyle: TextStyle.lerp(textStyle, other.textStyle, t),
+ );
+ }
+}
+
void main() {
test('Theme data control test', () {
final ThemeData dark = ThemeData.dark();
@@ -377,6 +433,136 @@
expect(expanded.maxHeight, equals(double.infinity));
});
+ group('Theme extensions', () {
+ const Key containerKey = Key('container');
+
+ testWidgets('can be obtained', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ MyThemeExtensionA(
+ color1: Colors.black,
+ color2: Colors.amber,
+ ),
+ MyThemeExtensionB(
+ textStyle: TextStyle(fontSize: 50),
+ )
+ },
+ ),
+ home: Container(key: containerKey),
+ ),
+ );
+
+ final ThemeData theme = Theme.of(
+ tester.element(find.byKey(containerKey)),
+ );
+
+ expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.black);
+ expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber);
+ expect(theme.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 50));
+ });
+
+ testWidgets('can use copyWith', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ extensions: <ThemeExtension<dynamic>>{
+ const MyThemeExtensionA(
+ color1: Colors.black,
+ color2: Colors.amber,
+ ).copyWith(color1: Colors.blue),
+ },
+ ),
+ home: Container(key: containerKey),
+ ),
+ );
+
+ final ThemeData theme = Theme.of(
+ tester.element(find.byKey(containerKey)),
+ );
+
+ expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.blue);
+ expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber);
+ });
+
+ testWidgets('can lerp', (WidgetTester tester) async {
+ const MyThemeExtensionA extensionA1 = MyThemeExtensionA(
+ color1: Colors.black,
+ color2: Colors.amber,
+ );
+ const MyThemeExtensionA extensionA2 = MyThemeExtensionA(
+ color1: Colors.white,
+ color2: Colors.blue,
+ );
+ const MyThemeExtensionB extensionB1 = MyThemeExtensionB(
+ textStyle: TextStyle(fontSize: 50),
+ );
+ const MyThemeExtensionB extensionB2 = MyThemeExtensionB(
+ textStyle: TextStyle(fontSize: 100),
+ );
+
+ // Both ThemeDatas include both extensions
+ ThemeData lerped = ThemeData.lerp(
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>[
+ extensionA1,
+ extensionB1,
+ ],
+ ),
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ extensionA2,
+ extensionB2,
+ },
+ ),
+ 0.5,
+ );
+
+ expect(lerped.extension<MyThemeExtensionA>()!.color1, const Color(0xff7f7f7f));
+ expect(lerped.extension<MyThemeExtensionA>()!.color2, const Color(0xff90ab7d));
+ expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75));
+
+ // Missing from 2nd ThemeData
+ lerped = ThemeData.lerp(
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ extensionA1,
+ extensionB1,
+ },
+ ),
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ extensionB2,
+ },
+ ),
+ 0.5,
+ );
+ expect(lerped.extension<MyThemeExtensionA>()!.color1, Colors.black); // Not lerped
+ expect(lerped.extension<MyThemeExtensionA>()!.color2, Colors.amber); // Not lerped
+ expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75));
+
+ // Missing from 1st ThemeData
+ lerped = ThemeData.lerp(
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ extensionA1,
+ },
+ ),
+ ThemeData(
+ extensions: const <ThemeExtension<dynamic>>{
+ extensionA2,
+ extensionB2,
+ },
+ ),
+ 0.5,
+ );
+ expect(lerped.extension<MyThemeExtensionA>()!.color1, const Color(0xff7f7f7f));
+ expect(lerped.extension<MyThemeExtensionA>()!.color2, const Color(0xff90ab7d));
+ expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 100)); // Not lerped
+ });
+ });
+
test('copyWith, ==, hashCode basics', () {
expect(ThemeData(), ThemeData().copyWith());
expect(ThemeData().hashCode, ThemeData().copyWith().hashCode);
@@ -506,6 +692,7 @@
fixTextFieldOutlineLabel: false,
useTextSelectionTheme: false,
androidOverscrollIndicator: null,
+ extensions: const <Object, ThemeExtension<dynamic>>{},
);
final SliderThemeData otherSliderTheme = SliderThemeData.fromPrimaryColors(
@@ -606,6 +793,9 @@
fixTextFieldOutlineLabel: true,
useTextSelectionTheme: true,
androidOverscrollIndicator: AndroidOverscrollIndicator.stretch,
+ extensions: const <Object, ThemeExtension<dynamic>>{
+ MyThemeExtensionB: MyThemeExtensionB(textStyle: TextStyle()),
+ },
);
final ThemeData themeDataCopy = theme.copyWith(
@@ -685,6 +875,7 @@
drawerTheme: otherTheme.drawerTheme,
listTileTheme: otherTheme.listTileTheme,
fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel,
+ extensions: otherTheme.extensions.values,
);
expect(themeDataCopy.brightness, equals(otherTheme.brightness));
@@ -763,6 +954,7 @@
expect(themeDataCopy.drawerTheme, equals(otherTheme.drawerTheme));
expect(themeDataCopy.listTileTheme, equals(otherTheme.listTileTheme));
expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel));
+ expect(themeDataCopy.extensions, equals(otherTheme.extensions));
});
testWidgets('ThemeData.toString has less than 200 characters output', (WidgetTester tester) async {
@@ -810,6 +1002,7 @@
'androidOverscrollIndicator',
'applyElevationOverlayColor',
'cupertinoOverrideTheme',
+ 'extensions',
'inputDecorationTheme',
'materialTapTargetSize',
'pageTransitionsTheme',