Skip to content

Instantly share code, notes, and snippets.

@rydmike
Created January 17, 2023 15:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rydmike/9e5e3e14b23e8972116494a4419c43ee to your computer and use it in GitHub Desktop.
Save rydmike/9e5e3e14b23e8972116494a4419c43ee to your computer and use it in GitHub Desktop.
Button Theming Demo - Requires Flutter 3.7.0 beta or later
// MIT License
//
// Copyright (c) 2023 Mike Rydstrom
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// This demo requires Flutter (beta) 3.7.0-1.4.pre or later
import 'package:flutter/material.dart';
// A seed color for the M3 ColorScheme.
const Color seedColor = Color(0xff386a20);
// Example theme
ThemeData demoTheme(Brightness mode, bool useMaterial3, bool useCustomTheme) {
// Make an M3 ColorScheme.from a seed color.
final ColorScheme scheme = ColorScheme.fromSeed(
brightness: mode,
seedColor: seedColor,
);
return ThemeData(
colorScheme: scheme,
useMaterial3: useMaterial3,
// Toggle to custom themes if so selected.
elevatedButtonTheme: useCustomTheme
? elevatedButtonTheme(
disabledColor: scheme.onSurface,
foregroundColor: scheme.onPrimary,
backgroundColor: scheme.primary,
// Add custom radius if desired.
// radius: 12,
)
: null,
filledButtonTheme: useCustomTheme
? filledButtonTheme(
disabledColor: scheme.onSurface,
foregroundColor: scheme.onTertiaryContainer,
backgroundColor: scheme.tertiaryContainer,
// Add custom radius if desired.
radius: 12,
)
: null,
);
}
void main() {
runApp(const DemoApp());
}
class DemoApp extends StatefulWidget {
const DemoApp({super.key});
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
bool useMaterial3 = true;
bool useCustomThemes = true;
ThemeMode themeMode = ThemeMode.light;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: demoTheme(Brightness.light, useMaterial3, useCustomThemes),
darkTheme: demoTheme(Brightness.dark, useMaterial3, useCustomThemes),
home: Scaffold(
appBar: AppBar(
title: const Text('Ale Button Theming Demo'),
actions: [
IconButton(
icon: useMaterial3
? const Icon(Icons.filter_3)
: const Icon(Icons.filter_2),
onPressed: () {
setState(() {
useMaterial3 = !useMaterial3;
});
},
tooltip: "Switch to Material ${useMaterial3 ? 2 : 3}",
),
IconButton(
icon: useCustomThemes
? const Icon(Icons.palette)
: const Icon(Icons.palette_outlined),
onPressed: () {
setState(() {
useCustomThemes = !useCustomThemes;
});
},
tooltip: useCustomThemes
? 'Remove custom button themes'
: 'Apply custom button themes',
),
IconButton(
icon: themeMode == ThemeMode.dark
? const Icon(Icons.wb_sunny_outlined)
: const Icon(Icons.wb_sunny),
onPressed: () {
setState(() {
if (themeMode == ThemeMode.light) {
themeMode = ThemeMode.dark;
} else {
themeMode = ThemeMode.light;
}
});
},
tooltip: "Toggle brightness",
),
],
),
body: HomePage(useFilledButtonTheme: useCustomThemes),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key, required this.useFilledButtonTheme});
final bool useFilledButtonTheme;
@override
Widget build(BuildContext context) {
final String filledTheme =
useFilledButtonTheme ? 'Custom theme used' : 'Default null theme';
final String themeStatus = useFilledButtonTheme ? 'Themed' : 'Default';
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const SizedBox(height: 8),
Text(
'Simple Button Theming Demo',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text('FilledButtonTheme: $filledTheme'),
const SizedBox(height: 8),
Text('$themeStatus ElevatedButton'),
const ElevatedButtonShowcase(),
const SizedBox(height: 16),
Text('FilledButtonTheme: $filledTheme'),
const SizedBox(height: 8),
const Text(
'ISSUE: Cannot theme FilledButton and FilledButton.tonal '
'independently. If e.g. colors and border radius are applied to '
'FilledButtonThemeData, both FilledButton and FilledButton.tonal '
'receive the same style and look identical.\n\n'
'EXPECT: To be able to theme FilledButton variants separately!',
),
const SizedBox(height: 16),
Text('$themeStatus FilledButton'),
const FilledButtonShowcase(),
const SizedBox(height: 16),
Text('$themeStatus FilledButton.tonal'),
const FilledButtonTonalShowcase(),
const SizedBox(height: 16),
const ShowColorSchemeColors(),
],
);
}
}
class ElevatedButtonShowcase extends StatelessWidget {
const ElevatedButtonShowcase({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: <Widget>[
ElevatedButton(
onPressed: () {},
child: const Text('Elevated button'),
),
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Elevated icon'),
),
const ElevatedButton(
onPressed: null,
child: Text('Elevated button'),
),
],
);
}
}
class FilledButtonShowcase extends StatelessWidget {
const FilledButtonShowcase({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton(
onPressed: () {},
child: const Text('Filled button'),
),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Filled icon'),
),
const FilledButton(
onPressed: null,
child: Text('Filled button'),
),
],
);
}
}
class FilledButtonTonalShowcase extends StatelessWidget {
const FilledButtonTonalShowcase({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.tonal(
onPressed: () {},
child: const Text('Filled tonal button'),
),
FilledButton.tonalIcon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Filled tonal icon'),
),
const FilledButton.tonal(
onPressed: null,
child: Text('Filled tonal button'),
),
],
);
}
}
/// An opinionated [ElevatedButtonThemeData] theme.
ElevatedButtonThemeData elevatedButtonTheme({
/// The foreground of the ElevatedButton.
required final Color foregroundColor,
/// The background of the ElevatedButton.
required final Color backgroundColor,
/// The disabled base of the ElevatedButton.
required final Color disabledColor,
/// The button corner radius.
final double? radius,
}) {
final MaterialStateProperty<Color?> background =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return disabledColor.withOpacity(0.12);
}
return backgroundColor;
});
final MaterialStateProperty<Color?> foreground =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return disabledColor.withOpacity(0.38);
}
return foregroundColor;
});
final MaterialStateProperty<Color?> overlayColor =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return foregroundColor.withOpacity(0.08);
}
if (states.contains(MaterialState.focused)) {
return foregroundColor.withOpacity(0.12);
}
if (states.contains(MaterialState.pressed)) {
return foregroundColor.withOpacity(0.12);
}
return null;
});
// The correct elevation for a FilledButton in M3 applied to ElevatedButton.
MaterialStateProperty<double>? elevation =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return 0.0;
}
if (states.contains(MaterialState.hovered)) {
return 1.0;
}
if (states.contains(MaterialState.focused)) {
return 0.0;
}
if (states.contains(MaterialState.pressed)) {
return 0.0;
}
return 0.0;
});
return ElevatedButtonThemeData(
style: ButtonStyle(
foregroundColor: foreground,
backgroundColor: background,
overlayColor: overlayColor,
elevation: elevation,
shape: radius == null
? null
: ButtonStyleButton.allOrNull<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(radius),
),
),
),
),
);
}
/// An opinionated [FilledButtonThemeData] theme.
FilledButtonThemeData filledButtonTheme({
/// The foreground of the FilledButton.
required final Color foregroundColor,
/// The background of the FilledButton.
required final Color backgroundColor,
/// The disabled base of the FilledButton.
required final Color disabledColor,
/// The button corner radius.
final double? radius,
}) {
final MaterialStateProperty<Color?> background =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return disabledColor.withOpacity(0.12);
}
return backgroundColor;
});
final MaterialStateProperty<Color?> foreground =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.disabled)) {
return disabledColor.withOpacity(0.38);
}
return foregroundColor;
});
final MaterialStateProperty<Color?> overlayColor =
MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return foregroundColor.withOpacity(0.08);
}
if (states.contains(MaterialState.focused)) {
return foregroundColor.withOpacity(0.12);
}
if (states.contains(MaterialState.pressed)) {
return foregroundColor.withOpacity(0.12);
}
return null;
});
return FilledButtonThemeData(
style: ButtonStyle(
foregroundColor: foreground,
backgroundColor: background,
overlayColor: overlayColor,
shape: radius == null
? null
: ButtonStyleButton.allOrNull<OutlinedBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(radius),
),
),
),
),
);
}
/// Draw a number of boxes showing the colors of key theme color properties
/// in the ColorScheme of the inherited ThemeData and its color properties.
class ShowColorSchemeColors extends StatelessWidget {
const ShowColorSchemeColors({super.key, this.onBackgroundColor});
/// The color of the background the color widget are being drawn on.
///
/// Some of the theme colors may have semi transparent fill color. To compute
/// a legible text color for the sum when it shown on a background color, we
/// need to alpha merge it with background and we need the exact background
/// color it is drawn on for that. If not passed in from parent, it is
/// assumed to be drawn on card color, which usually is close enough.
final Color? onBackgroundColor;
// Return true if the color is light, meaning it needs dark text for contrast.
static bool _isLight(final Color color) =>
ThemeData.estimateBrightnessForColor(color) == Brightness.light;
// On color used when a theme color property does not have a theme onColor.
static Color _onColor(final Color color, final Color bg) =>
_isLight(Color.alphaBlend(color, bg)) ? Colors.black : Colors.white;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
final bool useMaterial3 = theme.useMaterial3;
const double spacing = 4;
// Grab the card border from the theme card shape
ShapeBorder? border = theme.cardTheme.shape;
// If we had one, copy in a border side to it.
if (border is RoundedRectangleBorder) {
border = border.copyWith(
side: BorderSide(
color: theme.dividerColor,
width: 1,
),
);
// If
} else {
// If border was null, make one matching Card default, but with border
// side, if it was not null, we leave it as it was.
border ??= RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(useMaterial3 ? 12 : 4)),
side: BorderSide(
color: theme.dividerColor,
width: 1,
),
);
}
// Get effective background color.
final Color background =
onBackgroundColor ?? theme.cardTheme.color ?? theme.cardColor;
// Wrap this widget branch in a custom theme where card has a border outline
// if it did not have one, but retains its ambient themed border radius.
return Theme(
data: Theme.of(context).copyWith(
cardTheme: CardTheme.of(context).copyWith(
elevation: 0,
shape: border,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'ColorScheme Colors',
style: theme.textTheme.titleMedium,
),
),
Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: spacing,
runSpacing: spacing,
children: <Widget>[
ColorCard(
label: 'Primary',
color: colorScheme.primary,
textColor: colorScheme.onPrimary,
),
ColorCard(
label: 'on\nPrimary',
color: colorScheme.onPrimary,
textColor: colorScheme.primary,
),
ColorCard(
label: 'Primary\nContainer',
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
),
ColorCard(
label: 'onPrimary\nContainer',
color: colorScheme.onPrimaryContainer,
textColor: colorScheme.primaryContainer,
),
ColorCard(
label: 'Secondary',
color: colorScheme.secondary,
textColor: colorScheme.onSecondary,
),
ColorCard(
label: 'on\nSecondary',
color: colorScheme.onSecondary,
textColor: colorScheme.secondary,
),
ColorCard(
label: 'Secondary\nContainer',
color: colorScheme.secondaryContainer,
textColor: colorScheme.onSecondaryContainer,
),
ColorCard(
label: 'on\nSecondary\nContainer',
color: colorScheme.onSecondaryContainer,
textColor: colorScheme.secondaryContainer,
),
ColorCard(
label: 'Tertiary',
color: colorScheme.tertiary,
textColor: colorScheme.onTertiary,
),
ColorCard(
label: 'on\nTertiary',
color: colorScheme.onTertiary,
textColor: colorScheme.tertiary,
),
ColorCard(
label: 'Tertiary\nContainer',
color: colorScheme.tertiaryContainer,
textColor: colorScheme.onTertiaryContainer,
),
ColorCard(
label: 'on\nTertiary\nContainer',
color: colorScheme.onTertiaryContainer,
textColor: colorScheme.tertiaryContainer,
),
ColorCard(
label: 'Error',
color: colorScheme.error,
textColor: colorScheme.onError,
),
ColorCard(
label: 'on\nError',
color: colorScheme.onError,
textColor: colorScheme.error,
),
ColorCard(
label: 'Error\nContainer',
color: colorScheme.errorContainer,
textColor: colorScheme.onErrorContainer,
),
ColorCard(
label: 'onError\nContainer',
color: colorScheme.onErrorContainer,
textColor: colorScheme.errorContainer,
),
ColorCard(
label: 'Background',
color: colorScheme.background,
textColor: colorScheme.onBackground,
),
ColorCard(
label: 'on\nBackground',
color: colorScheme.onBackground,
textColor: colorScheme.background,
),
ColorCard(
label: 'Surface',
color: colorScheme.surface,
textColor: colorScheme.onSurface,
),
ColorCard(
label: 'on\nSurface',
color: colorScheme.onSurface,
textColor: colorScheme.surface,
),
ColorCard(
label: 'Surface\nVariant',
color: colorScheme.surfaceVariant,
textColor: colorScheme.onSurfaceVariant,
),
ColorCard(
label: 'onSurface\nVariant',
color: colorScheme.onSurfaceVariant,
textColor: colorScheme.surfaceVariant,
),
ColorCard(
label: 'Outline',
color: colorScheme.outline,
textColor: colorScheme.background,
),
// Removed: Not in Flutter stable 3.3, but is in beta 3.7.x
//
// ColorCard(
// label: 'Outline\nVariant',
// color: colorScheme.outlineVariant,
// textColor: colorScheme.onBackground,
// ),
ColorCard(
label: 'Shadow',
color: colorScheme.shadow,
textColor: _onColor(colorScheme.shadow, background),
),
// Removed: Not in Flutter stable 3.3, but is in beta 3.7.x
//
// ColorCard(
// label: 'Scrim',
// color: colorScheme.scrim,
// textColor: _onColor(colorScheme.scrim, background),
// ),
ColorCard(
label: 'Inverse\nSurface',
color: colorScheme.inverseSurface,
textColor: colorScheme.onInverseSurface,
),
ColorCard(
label: 'onInverse\nSurface',
color: colorScheme.onInverseSurface,
textColor: colorScheme.inverseSurface,
),
ColorCard(
label: 'Inverse\nPrimary',
color: colorScheme.inversePrimary,
textColor: colorScheme.inverseSurface,
),
ColorCard(
label: 'Surface\nTint',
color: colorScheme.surfaceTint,
textColor: colorScheme.onPrimary,
),
],
),
],
),
);
}
}
/// A [SizedBox] with a [Card] and string text in it. Used in this demo to
/// display theme color boxes.
///
/// Can specify label text color and background color.
class ColorCard extends StatelessWidget {
const ColorCard({
super.key,
required this.label,
required this.color,
required this.textColor,
this.size,
});
final String label;
final Color color;
final Color textColor;
final Size? size;
@override
Widget build(BuildContext context) {
const double fontSize = 11;
const Size effectiveSize = Size(86, 58);
return SizedBox(
width: effectiveSize.width,
height: effectiveSize.height,
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
color: color,
child: Center(
child: Text(
label,
style: TextStyle(color: textColor, fontSize: fontSize),
textAlign: TextAlign.center,
),
),
),
);
}
}
@rydmike
Copy link
Author

rydmike commented Jan 17, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment