Skip to content

Instantly share code, notes, and snippets.

@rydmike
Last active September 13, 2022 23:10
Show Gist options
  • Save rydmike/53c393a6620eabb83bb9d48114c9c5d9 to your computer and use it in GitHub Desktop.
Save rydmike/53c393a6620eabb83bb9d48114c9c5d9 to your computer and use it in GitHub Desktop.
Vikings 2022 Theme Talk - MaterialStateColor
// MIT License
//
// Copyright (c) 2022 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.
import 'package:flutter/material.dart';
// Used as M3 seed color
const Color seedColor = Color(0xFF386A20);
// Make a seed generated M3 light mode ColorScheme.
final ColorScheme myLightScheme = ColorScheme.fromSeed(
brightness: Brightness.light,
seedColor: seedColor,
);
// Make a seed generated M3 light mode ColorScheme.
final ColorScheme myDarkScheme = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: seedColor,
);
// A simple custom theme
ThemeData myTheme(Brightness mode) => ThemeData.from(
colorScheme: mode == Brightness.light ? myLightScheme : myDarkScheme,
useMaterial3: true,
).copyWith(
inputDecorationTheme: inputDecorationTheme(
colorScheme: mode == Brightness.light ? myLightScheme : myDarkScheme,
),
visualDensity: VisualDensity.standard,
);
extension MyColorExtensions on Color {
/// Blend in the given input Color with an alpha value.
///
/// You typically apply this on a background color, light or dark
/// to create a background color with a hint of a color used in a theme.
///
/// This is a use case of the alphaBlend static function that exists in
/// dart:ui Color. It is used to create the branded surface colors in
/// FlexColorScheme and to calculate dark scheme colors from light ones,
/// by blending in white color with light scheme color.
///
/// Defaults to alpha 0x0A alpha blend of the passed in Color value,
/// which is 10% alpha blend.
Color blendAlpha(final Color input, [final int alpha = 0x0A]) {
// Skip blending for impossible value and return the instance color value.
if (alpha <= 0) return this;
// Blend amounts >= 255 results in the input Color.
if (alpha >= 255) return input;
return Color.alphaBlend(input.withAlpha(alpha), this);
}
}
/// A custom InputDecorationTheme for TextField demo purposes.
InputDecorationTheme inputDecorationTheme({
/// Typically the same [ColorScheme] that is also use for your [ThemeData].
required final ColorScheme colorScheme,
/// The decorated input fields corner border radius.
///
/// If not defined, defaults to 20dp.
final double? radius,
/// If true the decoration's container is filled with [fillColor].
///
/// Typically this field set to true if [border] is an
/// [UnderlineInputBorder].
///
/// The decoration's container is the area, defined by the border's
/// [InputBorder.getOuterPath], which is filled if [filled] is
/// true and bordered per the [border].
///
/// Defaults to true.
final bool filled = true,
/// The border width when the input is selected.
///
/// Defaults to 2.0.
final double focusedBorderWidth = 2,
/// The border width when the input is unselected or disabled.
///
/// Defaults to 1.5.
final double unfocusedBorderWidth = 1.5,
/// Horizontal padding on either side of the border's
/// [InputDecoration.labelText] width gap.
///
/// Defaults to 4, which is also the default in SDK default input decorator.
final double gapPadding = 4,
/// Unfocused input decoration has a border.
///
/// Defaults to true.
///
/// Applies to both outline and underline mode. You would typically
/// use this in a design where you use a fill color and want unfocused
/// input fields to only be highlighted by the fill color and not even
/// have an unfocused input border style.
///
/// When set to false, there is no border bored on states enabledBorder and
/// disabledBorder, there is a border on focusedBorder, focusedErrorBorder
/// and errorBorder, so error thus has a border also when it is not focused.
final bool unfocusedHasBorder = true,
/// Unfocused input decoration border uses ColorScheme.primary color.
///
/// Applies to both outline and underline mode.
///
/// When set to true, the unfocused borders uses the [ColorScheme.primary]
/// as its border color, but with alpha [0xA7].
///
/// If set to false, the color uses the SDK M2 default unselected border
/// color, which is [ColorScheme.onSurface] with 38% opacity.
///
/// The unfocused border color selection also applies to it hovered state.
///
/// Defaults to true.
final bool unfocusedBorderIsColored = true,
}) {
final Color enabledBorder = unfocusedBorderIsColored
? colorScheme.primary.withAlpha(0xA7)
: colorScheme.onSurface.withOpacity(0.38);
final double effectiveRadius = radius ?? 20;
return InputDecorationTheme(
// FORGET these and use MaterialStateColor.resolveWith on fillColor!
// hoverColor: colorScheme.tertiaryContainer,
// focusColor: colorScheme.primaryContainer,
// fillColor: colorScheme.secondaryContainer,
filled: true,
fillColor: MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.error) &&
states.contains(MaterialState.focused)) {
// Error with some opacity when focused.
return colorScheme.error.withAlpha(0x1F);
}
if (states.contains(MaterialState.error)) {
// Error with opacity when not focused, less than when focused.
return colorScheme.error.withAlpha(0x0F);
}
if (states.contains(MaterialState.disabled)) {
// A mix of primary and onSurface for disabled background.
return colorScheme.primary
.blendAlpha(colorScheme.onSurface, 0x66)
.withAlpha(0x31);
}
if (states.contains(MaterialState.focused)) {
// Use primaryContainer color for focused state.
return colorScheme.primaryContainer;
}
if (states.contains(MaterialState.hovered)) {
// Use tertiaryContainer color for hover state.
return colorScheme.tertiaryContainer;
}
// Anything else use secondaryContainer.
return colorScheme.secondaryContainer;
}),
// We use the same principle on the custom floatingLabelStyle
floatingLabelStyle:
MaterialStateTextStyle.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.error) &&
states.contains(MaterialState.focused)) {
return TextStyle(color: colorScheme.error);
}
if (states.contains(MaterialState.error)) {
return TextStyle(
color: colorScheme.error.withAlpha(0xA7),
);
}
if (states.contains(MaterialState.disabled)) {
return TextStyle(
color: colorScheme.primary
.blendAlpha(colorScheme.onSurface, 0x66)
.withAlpha(0x31),
);
}
return TextStyle(color: colorScheme.primary);
}),
// Rest of state properties we want to modify exists as direct props and
// work well for our other design needs.
focusedBorder: OutlineInputBorder(
gapPadding: gapPadding,
borderRadius: BorderRadius.all(Radius.circular(effectiveRadius)),
borderSide: BorderSide(
color: colorScheme.primary,
width: focusedBorderWidth,
),
),
enabledBorder: OutlineInputBorder(
gapPadding: gapPadding,
borderRadius: BorderRadius.all(Radius.circular(effectiveRadius)),
borderSide: unfocusedHasBorder
? BorderSide(
color: enabledBorder,
width: unfocusedBorderWidth,
)
: BorderSide.none,
),
disabledBorder: OutlineInputBorder(
gapPadding: gapPadding,
borderRadius: BorderRadius.all(Radius.circular(effectiveRadius)),
borderSide: unfocusedHasBorder
? BorderSide(
// A mix of primary and onSurface for disabled background.
color: colorScheme.primary
.blendAlpha(colorScheme.onSurface, 0x66)
.withAlpha(0x31),
width: unfocusedBorderWidth,
)
: BorderSide.none,
),
focusedErrorBorder: OutlineInputBorder(
gapPadding: gapPadding,
borderRadius: BorderRadius.all(Radius.circular(effectiveRadius)),
borderSide: BorderSide(
color: colorScheme.error,
width: focusedBorderWidth,
),
),
errorBorder: OutlineInputBorder(
gapPadding: gapPadding,
borderRadius: BorderRadius.all(Radius.circular(effectiveRadius)),
borderSide: BorderSide(
color: colorScheme.error.withAlpha(0xA7),
width: unfocusedBorderWidth,
),
),
);
}
void main() {
runApp(const TextFieldDemoApp());
}
class TextFieldDemoApp extends StatefulWidget {
const TextFieldDemoApp({super.key});
@override
State<TextFieldDemoApp> createState() => _TextFieldDemoAppState();
}
class _TextFieldDemoAppState extends State<TextFieldDemoApp> {
bool useMaterial3 = false;
ThemeMode themeMode = ThemeMode.light;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
themeMode: themeMode,
theme: myTheme(Brightness.light),
darkTheme: myTheme(Brightness.dark),
home: Scaffold(
appBar: AppBar(
title: const Text(('TextField States Demo')),
actions: [
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: const HomePage(),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({
super.key,
});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const SizedBox(height: 8),
Text(
'TextField MaterialStateColor Demo',
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'Different colors background for hover and focus',
style: Theme.of(context).textTheme.bodyLarge,
),
const ShowColorSchemeColors(),
const SizedBox(height: 8),
const TextInputStatesDemo(),
],
);
}
}
class TextInputStatesDemo extends StatefulWidget {
const TextInputStatesDemo({Key? key}) : super(key: key);
@override
State<TextInputStatesDemo> createState() => _TextInputStatesDemoState();
}
class _TextInputStatesDemoState extends State<TextInputStatesDemo> {
late TextEditingController _textController1;
late TextEditingController _textController2;
bool _errorState1 = false;
bool _errorState2 = false;
@override
void initState() {
super.initState();
_textController1 = TextEditingController();
_textController2 = TextEditingController();
}
@override
void dispose() {
_textController1.dispose();
_textController2.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
TextField(
onChanged: (String text) {
setState(() {
if (text.contains('a') | text.isEmpty) {
_errorState1 = false;
} else {
_errorState1 = true;
}
});
},
key: const Key('TextField1'),
controller: _textController1,
decoration: InputDecoration(
hintText: 'Write something...',
labelText: 'Text entry',
errorText: _errorState1
? "Any entry without an 'a' will trigger this error"
: null,
),
),
const SizedBox(height: 8),
TextField(
onChanged: (String text) {
setState(() {
if (text.contains('a') | text.isEmpty) {
_errorState2 = false;
} else {
_errorState2 = true;
}
});
},
key: const Key('TextField2'),
controller: _textController2,
decoration: InputDecoration(
hintText: 'Write something...',
labelText: 'Another text entry',
errorText: _errorState2
? "Any entry without an 'a' will trigger this error"
: null,
),
),
const SizedBox(height: 8),
const TextField(
enabled: false,
decoration: InputDecoration(
labelText: 'Disabled text input',
),
),
],
);
}
}
/// 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;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
final bool useMaterial3 = theme.useMaterial3;
const double spacing = 6;
// 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,
),
);
}
// Wrap this widget branch in a custom theme where card has a border outline
// if it did not have one, but retains in 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(
'Used ColorScheme Colors',
style: theme.textTheme.titleMedium,
),
),
Wrap(
alignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: spacing,
runSpacing: spacing,
children: <Widget>[
ColorCard(
label: 'BORDER\nPrimary',
color: colorScheme.primary,
textColor: colorScheme.onPrimary,
),
ColorCard(
label: 'FOCUSED\nPrimary\nContainer',
color: colorScheme.primaryContainer,
textColor: colorScheme.onPrimaryContainer,
),
ColorCard(
label: 'DEFAULT\nSecondary\nContainer',
color: colorScheme.secondaryContainer,
textColor: colorScheme.onSecondaryContainer,
),
ColorCard(
label: 'HOVER\nTertiary\nContainer',
color: colorScheme.tertiaryContainer,
textColor: colorScheme.onTertiaryContainer,
),
ColorCard(
label: 'Error',
color: colorScheme.error,
textColor: colorScheme.onError,
),
ColorCard(
label: 'DISABLED\nonSurface',
color: colorScheme.onSurface,
textColor: colorScheme.surface,
),
],
),
],
),
);
}
}
/// 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 Aug 29, 2022

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