Introduction
Theming is an essential part of any application. It helps to create a consistent look and feel across the app.
In Flutter, theming is done using ThemeData
class. However, sometimes you need more control over the theming process.
In this article, we will explore advanced theming techniques in Flutter.
Key results
- Improved User Experience: Consistent look and feel across the app.
- Increased Customization: More control over the theming process.
- Easier Maintenance: Centralized theming logic.
Before you start
Theming is tricky to get right without designer input so before you start, make sure you have a clear idea of what you want to achieve. For example, some designers prefer to create their own color palettes, while others prefer to use the Material Design.
As a Flutter developer you should be familiar with Material Design guidelines, because the theming process is based on it.
Implementation
If you are like me and you prefer to see the code directly, you can jump to the GitHub repo.
Folder Structure
Let’s see what you are working with.
styles/
├── themes/
│ ├── app_theme_base.dart (abstract class for themes)
│ ├── light_theme.dart (implements AppThemeBase)
│ ├── dark_theme.dart (implements AppThemeBase)
│ └── app_theme.dart (Manager class for themes)
├── colors/
│ ├── palette.dart (ThemeExtension class)
│ ├── app_dark_colors.dart (extends Palette)
│ ├── app_light_colors.dart (extends Palette)
│ └── app_gradients.dart (ThemeExtension)
├── style_extensions.dart
└── style.dart (export file)
Colors
We’ll create 4 files for colors, starting with an Theme extension class called Palette
that will be extended by AppDarkColors
and AppLightColors
classes.
ThemeExtension
is a relatively new feature in Flutter, it allows you to create a class that extends ThemeData and provides a way to create a custom theme for your app.
Palette
contains all the colors used in the Design system, which supposed to come from the design file (Figma, Adobe XD, etc.)
You can Add as much color as you want, but make sure to keep it clean and organized. More colors = more confusion.
import 'package:flutter/material.dart';
import 'app_gradients.dart';
class Palette extends ThemeExtension<Palette> {
const Palette._({
required this.primary,
required this.secondary,
required this.surface,
required this.surfaceContainer,
required this.outline,
required this.elPrimary,
required this.elSecondary,
required this.error,
required this.success,
required this.warning,
required this.gradients,
});
const Palette.fromChild({
required this.primary,
required this.secondary,
required this.surface,
required this.surfaceContainer,
required this.outline,
required this.elPrimary,
required this.elSecondary,
required this.error,
required this.success,
required this.warning,
required this.gradients,
});
//Primary
final Color primary;
// Secondary
final Color secondary;
// Surface
final Color surface;
final Color outline;
final Color surfaceContainer;
// Elements (Texts & Icons)
final Color elPrimary;
final Color elSecondary;
// System
final Color error;
final Color success;
final Color warning;
// Gradients
final AppGradients gradients;
@override
ThemeExtension<Palette> copyWith({
Color? primary,
Color? secondary,
Color? surface,
Color? surfaceContainer,
Color? outline,
Color? elPrimary,
Color? elSecondary,
Color? error,
Color? success,
Color? warning,
AppGradients? gradients,
}) {
return Palette._(
primary: primary ?? this.primary,
secondary: secondary ?? this.secondary,
surface: surface ?? this.surface,
surfaceContainer: surfaceContainer ?? this.surfaceContainer,
outline: outline ?? this.outline,
elPrimary: elPrimary ?? this.elPrimary,
elSecondary: elSecondary ?? this.elSecondary,
error: error ?? this.error,
success: success ?? this.success,
warning: warning ?? this.warning,
gradients: gradients ?? this.gradients,
);
}
@override
ThemeExtension<Palette> lerp(
covariant ThemeExtension<Palette>? other,
double t,
) {
if (other is! Palette) {
return this;
}
return Palette._(
primary: Color.lerp(primary, other.primary, t)!,
secondary: Color.lerp(secondary, other.secondary, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surfaceContainer: Color.lerp(surfaceContainer, other.surfaceContainer, t)!,
outline: Color.lerp(outline, other.outline, t)!,
elPrimary: Color.lerp(elPrimary, other.elPrimary, t)!,
elSecondary: Color.lerp(elSecondary, other.elSecondary, t)!,
error: Color.lerp(error, other.error, t)!,
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
gradients: gradients.lerp(other.gradients, t) as AppGradients,
);
}
}
AppLightColors
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_gradients.dart';
class AppLightColors extends Palette {
const AppLightColors()
: super.fromChild(
primary: const Color(0xFF502510),
secondary: const Color(0xFF2E2148),
surface: const Color(0xFFEDF3EF),
surfaceContainer: const Color(0xFFA4B7A7),
outline: const Color(0xFF6478C7),
elPrimary: const Color(0xFF101423),
elSecondary: const Color(0xFF151923),
error: const Color(0xFF6E2525),
success: const Color(0xFF7AEAC9),
warning: const Color(0xFF5B4016),
gradients: const AppGradients(
primary: LinearGradient(
colors: [
Color.fromRGBO(191, 156, 65, 0.78),
Color.fromRGBO(199, 77, 50, 1),
],
begin: Alignment(-1, -0.5),
end: Alignment(1, -0.5),
),
),
);
}
AppDarkColors
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_gradients.dart';
class AppDarkColors extends Palette {
const AppDarkColors()
: super.fromChild(
primary: const Color(0xFFF07E11),
secondary: const Color(0x94257D45),
surface: const Color(0xFF0A1121),
surfaceContainer: const Color(0xFF18254F),
outline: const Color(0xFF282C39),
elPrimary: const Color(0xFFF5F6F6),
elSecondary: const Color(0xFFB5B6BB),
error: const Color(0xFFB05151),
success: const Color(0xFF5AFF8B),
warning: const Color(0xFF4B3717),
gradients: const AppGradients(
primary: LinearGradient(
colors: [
Color.fromRGBO(134, 78, 65, 1),
Color.fromRGBO(75, 61, 21, 0.7803921568627451),
],
begin: AlignmentDirectional.bottomStart,
end: AlignmentDirectional.topEnd,
),
),
);
}
AppGradients
This is an optionl file, you can create it if you need control gradients from the design system.
import 'package:flutter/material.dart';
@immutable
class AppGradients extends ThemeExtension<AppGradients> {
const AppGradients({
required this.primary,
});
final Gradient primary;
@override
ThemeExtension<AppGradients> lerp(
covariant ThemeExtension<AppGradients>? other,
double t,
) {
if (other is! AppGradients) {
return this;
}
return AppGradients(
primary: Gradient.lerp(primary, other.primary, t)!,
);
}
@override
AppGradients copyWith({
Gradient? primary,
Gradient? secondary,
Gradient? tertiary,
}) {
return AppGradients(
primary: primary ?? this.primary,
);
}
}
Themes
We’ll create 4 files for themes, starting with an abstract class called AppThemeBase
that will be implemented by LightTheme
and DarkTheme
classes.
abstract class AppThemeBase {
const AppThemeBase({
required this.themeData,
});
final ThemeData themeData;
}
AppightTheme
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../app_font_families.dart';
import '../colors/app_light_colors.dart';
import 'app_theme_base.dart';
class AppLightTheme implements AppThemeBase {
const AppLightTheme();
@override
ThemeData get themeData {
const palette = AppLightColors();
return ThemeData(
useMaterial3: true,
extensions: const [palette],
brightness: Brightness.light,
colorScheme: ColorScheme.light(
primary: palette.primary,
onPrimary: palette.elPrimary,
secondary: palette.secondary,
onSecondary: palette.elPrimary,
error: palette.error,
onError: palette.elPrimary,
outline: palette.outline,
surface: palette.surface,
onSurface: palette.elPrimary,
onSurfaceVariant: palette.elPrimary,
),
textTheme: TextTheme(
/// Provide as many text styles as you need
bodyMedium: GoogleFonts.poppins(
color: palette.elPrimary,
fontWeight: FontWeight.w400,
fontSize: 16,
),
labelMedium: GoogleFonts.poppins(
color: palette.elPrimary,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
listTileTheme: ListTileThemeData(
tileColor: palette.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
cupertinoOverrideTheme: CupertinoThemeData(
// This sets selectionHandleColor on iOS
primaryColor: palette.primary,
scaffoldBackgroundColor: palette.surface,
),
textSelectionTheme: TextSelectionThemeData(
cursorColor: palette.primary,
selectionColor: palette.primary,
selectionHandleColor: palette.primary,
),
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
appBarTheme: AppBarTheme(
backgroundColor: palette.surface,
elevation: 0,
surfaceTintColor: palette.primary,
),
);
}
}
AppDarkTheme
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../app_font_families.dart';
import '../colors/app_dark_colors.dart';
import 'app_theme_base.dart';
class AppDarkTheme implements AppThemeBase {
const AppDarkTheme();
@override
ThemeData get themeData {
const palette = AppDarkColors();
return ThemeData(
useMaterial3: true,
extensions: const [palette],
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: palette.primary,
onPrimary: palette.elPrimary,
secondary: palette.secondary,
onSecondary: palette.elPrimary,
error: palette.error,
onError: palette.elPrimary,
outline: palette.outline,
surface: palette.surface,
onSurface: palette.elPrimary,
onSurfaceVariant: palette.elPrimary,
),
textTheme: TextTheme(
/// Provide as many text styles as you need
bodyMedium: GoogleFonts.poppins(
color: palette.elPrimary,
fontWeight: FontWeight.w400,
fontSize: 16,
),
labelMedium: GoogleFonts.poppins(
color: palette.elPrimary,
fontWeight: FontWeight.w400,
fontSize: 12,
),
),
listTileTheme: ListTileThemeData(
tileColor: palette.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
cupertinoOverrideTheme: CupertinoThemeData(
// This sets selectionHandleColor on iOS
primaryColor: palette.primary,
scaffoldBackgroundColor: palette.surface,
),
textSelectionTheme: TextSelectionThemeData(
cursorColor: palette.primary,
selectionColor: palette.primary,
selectionHandleColor: palette.primary,
),
checkboxTheme: CheckboxThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
appBarTheme: AppBarTheme(
backgroundColor: palette.surface,
elevation: 0,
surfaceTintColor: palette.primary,
),
);
}
}
AppTheme
import 'package:flutter/material.dart';
import 'app_dark_theme.dart';
import 'app_light_theme.dart';
import 'app_theme_base.dart';
class AppTheme extends ChangeNotifier {
AppTheme({
required BuildContext context,
}) {
_context = context;
_init();
}
late final BuildContext _context;
late final AppThemeBase _darkTheme = const AppDarkTheme();
late final AppThemeBase _lightTheme = const AppLightTheme();
Brightness _brightness = Brightness.dark;
ThemeMode get themeMode {
switch (_brightness) {
case Brightness.light:
return ThemeMode.light;
case Brightness.dark:
return ThemeMode.dark;
}
}
ThemeData get lightTheme => _lightTheme.themeData;
ThemeData get darkTheme => _darkTheme.themeData;
/// Initialize the theme based on the platform brightness AKA system theme
void _init() {
final view = View.of(_context);
final brightness = MediaQueryData.fromView(view).platformBrightness;
onBrightnessChanged(brightness);
}
void toggleThemeMode() {
_brightness =
_brightness == Brightness.light ? Brightness.dark : Brightness.light;
notifyListeners();
}
/// Called when the user changes the system theme
void onBrightnessChanged(Brightness value) {
if (_brightness != value) {
_brightness = value;
notifyListeners();
}
}
}
RootApp
We’ll create a StatefullWidget called RootApp
that will listne to the system theme changes and update the theme accordingly.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../styles/styles.dart';
import 'app_view.dart';
class RootApp extends StatefulWidget {
const RootApp({super.key});
@override
State<RootApp> createState() => _RootAppState();
}
class _RootAppState extends State<RootApp> with WidgetsBindingObserver {
late final AppTheme appTheme = AppTheme(
context: context,
);
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
MediaQueryData get _mediaQuery => MediaQueryData.fromView(View.of(context));
@override
void didChangePlatformBrightness() {
super.didChangePlatformBrightness();
appTheme.onBrightnessChanged(_mediaQuery.platformBrightness);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: appTheme,
builder: (BuildContext context, __) {
return MaterialApp(
theme: appTheme.lightTheme,
darkTheme: appTheme.darkTheme,
themeMode: appTheme.themeMode,
debugShowCheckedModeBanner: false,
home: AppView(appTheme: appTheme),
);
},
);
}
}
AppView
And finally, we’ll create a StatelessWidget called AppView
that will display the app content.
import 'package:flutter/material.dart';
import '../styles/styles.dart';
class AppView extends StatelessWidget {
const AppView({
required this.appTheme,
super.key,
});
final AppTheme appTheme;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Advanced Flutter Examples'),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: ListTile(
leading: const CircleAvatar(
backgroundImage: AssetImage('assets/images/avatar.png'),
),
title: Text('Tornike Kurdadze', style: context.bodyM),
subtitle: Text('Sr. Flutter Developer', style: context.labelS),
),
),
Row(
children: [
Text('Light Mode', style: context.labelS),
Padding(
padding: const EdgeInsets.all(8),
child: Switch(
value: appTheme.themeMode == ThemeMode.dark,
onChanged: (_) {
appTheme.toggleThemeMode();
},
),
),
Text('Dark Mode', style: context.labelS),
],
),
],
),
),
);
}
}
What’s next?
- Saving the theme mode to the local storage.
- Adding more themes.
Conclusion
In this article, we explored advanced theming techniques in Flutter. We created a centralized theming logic that allows us to switch between light and dark themes based on the system theme. This approach makes it easier to maintain the theming logic and provides more control over the theming process.
Full code base cane be found at GitHub. Give it a star if you find it useful.