Unverified Commit 8c1c2f6a authored by Pierre-Louis's avatar Pierre-Louis Committed by GitHub

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
parent 6af40a70
// 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,
),
],
)
),
);
}
}
......@@ -5,7 +5,7 @@ publish_to: 'none'
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:
......
......@@ -53,6 +53,36 @@ import 'typography.dart';
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 @@ class ThemeData with Diagnosticable {
AndroidOverscrollIndicator? androidOverscrollIndicator,
bool? applyElevationOverlayColor,
NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
Iterable<ThemeExtension<dynamic>>? extensions,
InputDecorationTheme? inputDecorationTheme,
MaterialTapTargetSize? materialTapTargetSize,
PageTransitionsTheme? pageTransitionsTheme,
......@@ -390,6 +421,7 @@ class ThemeData with Diagnosticable {
}) {
// GENERAL CONFIGURATION
cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault();
extensions ??= <ThemeExtension<dynamic>>[];
inputDecorationTheme ??= const InputDecorationTheme();
platform ??= defaultTargetPlatform;
switch (platform) {
......@@ -562,6 +594,7 @@ class ThemeData with Diagnosticable {
androidOverscrollIndicator: androidOverscrollIndicator,
applyElevationOverlayColor: applyElevationOverlayColor,
cupertinoOverrideTheme: cupertinoOverrideTheme,
extensions: _themeExtensionIterableToMap(extensions),
inputDecorationTheme: inputDecorationTheme,
materialTapTargetSize: materialTapTargetSize,
pageTransitionsTheme: pageTransitionsTheme,
......@@ -665,6 +698,7 @@ class ThemeData with Diagnosticable {
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 @@ class ThemeData with Diagnosticable {
required this.primaryColorBrightness,
}) : // GENERAL CONFIGURATION
assert(applyElevationOverlayColor != null),
assert(extensions != null),
assert(inputDecorationTheme != null),
assert(materialTapTargetSize != null),
assert(pageTransitionsTheme != null),
......@@ -1053,6 +1088,32 @@ class ThemeData with Diagnosticable {
/// 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 @@ class ThemeData with Diagnosticable {
AndroidOverscrollIndicator? androidOverscrollIndicator,
bool? applyElevationOverlayColor,
NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
Iterable<ThemeExtension<dynamic>>? extensions,
InputDecorationTheme? inputDecorationTheme,
MaterialTapTargetSize? materialTapTargetSize,
PageTransitionsTheme? pageTransitionsTheme,
......@@ -1736,6 +1798,7 @@ class ThemeData with Diagnosticable {
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 @@ class ThemeData with Diagnosticable {
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 @@ class ThemeData with Diagnosticable {
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 @@ class ThemeData with Diagnosticable {
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 @@ class ThemeData with Diagnosticable {
androidOverscrollIndicator,
applyElevationOverlayColor,
cupertinoOverrideTheme,
hashList(extensions.keys),
hashList(extensions.values),
inputDecorationTheme,
materialTapTargetSize,
pageTransitionsTheme,
......@@ -2200,6 +2295,7 @@ class ThemeData with Diagnosticable {
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));
......
......@@ -6,6 +6,62 @@ import 'package:flutter/foundation.dart';
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 @@ void main() {
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 @@ void main() {
fixTextFieldOutlineLabel: false,
useTextSelectionTheme: false,
androidOverscrollIndicator: null,
extensions: const <Object, ThemeExtension<dynamic>>{},
);
final SliderThemeData otherSliderTheme = SliderThemeData.fromPrimaryColors(
......@@ -606,6 +793,9 @@ void main() {
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 @@ void main() {
drawerTheme: otherTheme.drawerTheme,
listTileTheme: otherTheme.listTileTheme,
fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel,
extensions: otherTheme.extensions.values,
);
expect(themeDataCopy.brightness, equals(otherTheme.brightness));
......@@ -763,6 +954,7 @@ void main() {
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 @@ void main() {
'androidOverscrollIndicator',
'applyElevationOverlayColor',
'cupertinoOverrideTheme',
'extensions',
'inputDecorationTheme',
'materialTapTargetSize',
'pageTransitionsTheme',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment