Skip to content

Instantly share code, notes, and snippets.

@davidhicks980
Last active February 23, 2024 05:36
Show Gist options
  • Save davidhicks980/c421375cdcdfb952b79ee59d5212d3ae to your computer and use it in GitHub Desktop.
Save davidhicks980/c421375cdcdfb952b79ee59d5212d3ae to your computer and use it in GitHub Desktop.
Decomposable Menu Item
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
void main() => runApp(const Main());
class Main extends StatefulWidget {
const Main({super.key});
@override
State<Main> createState() => _MainState();
}
class _MainState extends State<Main> with SingleTickerProviderStateMixin {
static BoxDecoration filledStyle = BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.deepOrange[600],
);
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: Colors.blue,
builder: (BuildContext context, Widget? widget) => FocusScope(
autofocus: true,
child: ColoredBox(
color: Colors.white,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
MenuItem(
onPressed: () {},
defaultPaint: filledStyle,
trailing: const Icon(Icons.arrow_forward, color: Colors.white, size: 20),
leading: const Icon(Icons.access_alarm, color: Colors.white, size: 20),
hoveredPaint: filledStyle.copyWith(color: Colors.red[600]),
focusedPaint: filledStyle.copyWith(color: Colors.redAccent[700]),
pressedPaint: filledStyle.copyWith(color: Colors.deepOrangeAccent),
child: const Text(
'Howdy',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 15,
),
),
),
const SizedBox(height: 20),
MenuItem(
onPressed: () {},
defaultPaint: filledStyle,
hoveredPaint: filledStyle.copyWith(color: Colors.red[600]),
focusedPaint: filledStyle.copyWith(color: Colors.redAccent[700]),
pressedPaint: filledStyle.copyWith(color: Colors.deepOrangeAccent),
child: const Text(
'Partner',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
);
}
}
/// A simple base class made for new users to get started.
/// Similar to the Material library. It should be easy to read. Styles should be
/// decoupled from behavior (e.g. I shouldn't need to implement the material
/// library in order to get InkWell behavior).
class MenuItem extends StatelessWidget with MenuItemParentContract {
const MenuItem({
super.key,
required this.child,
this.subtitle,
this.leading,
this.leadingWidth,
this.trailing,
this.trailingWidth,
this.constraints,
this.onHover,
this.onFocusChange,
this.onPressed,
this.hoveredPaint,
this.focusedPaint,
this.pressedPaint,
this.defaultPaint = const BoxDecoration(),
});
final Widget child;
final Widget? leading;
final Widget? trailing;
final Widget? subtitle;
final VoidCallback? onPressed;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocusChange;
final BoxDecoration? pressedPaint;
final BoxDecoration? focusedPaint;
final BoxDecoration? hoveredPaint;
final BoxDecoration defaultPaint;
final double? leadingWidth;
final double? trailingWidth;
final BoxConstraints? constraints;
@override
bool get allowLeadingSeparator => true;
@override
bool get allowTrailingSeparator => true;
@override
bool get hasLeading => leading != null;
BoxDecoration _resolveStyle(ButtonState buttonState) {
if (buttonState.isPressed) {
return pressedPaint ?? defaultPaint;
} else if (buttonState.isHovered) {
return hoveredPaint ?? defaultPaint;
} else if (buttonState.isFocused) {
return focusedPaint ?? defaultPaint;
} else {
return defaultPaint;
}
}
@override
Widget build(BuildContext context) {
return MenuItemGestureHandler(
onPressed: onPressed,
onHover: onHover,
onFocusChange: onFocusChange,
builder: (BuildContext context, ButtonState buttonState) {
return MenuItemSemantics(
enabled: onPressed != null,
child: MenuItemStyle(
style: _resolveStyle(buttonState),
child: MenuItemStructure(
constraints: constraints,
leading: leading,
subtitle: subtitle,
trailing: trailing,
leadingWidth: leadingWidth,
trailingWidth: trailingWidth,
child: MenuItemTitleStyle(child: child),
),
),
);
},
);
}
}
/// Information necessary for the menu item to be properly handled by it's
/// parent should be clearly established. In this example, the user shouldn't
/// need to reimplement an entire menu for their custom menu item to behave
/// properly. This could also be achieved via an inherited widget, as long as
/// it's not private. What's important is that a user is able to achieve the
/// same behavior in a custom menu item.
///
/// To give an example, while extends MenuAnchor, lines like the
/// following made it so that I had to completely re-implement the entire menu
/// item. Ideally, I could have looked at the "contract" (ignore the nomenclature)
/// and implemented the necessary behavior without having to think about it.
///
/// ```dart
/// void _handleFocusChange() {
/// if (!_focusNode.hasPrimaryFocus) {
/// // Close any child menus of this button's menu.
/// _MenuAnchorState._maybeOf(context)?._closeChildren();
/// }
/// }
/// ```
mixin MenuItemParentContract {
bool get allowLeadingSeparator;
bool get allowTrailingSeparator;
bool get hasLeading;
}
/// Style the widget's container.
class MenuItemStyle extends StatelessWidget {
const MenuItemStyle({
super.key,
required this.child,
required this.style,
});
// final MenuButtonTheme theme
final Widget child;
final BoxDecoration style;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
decoration: style,
child: IconTheme(
data: const IconThemeData(color: Color(0xff0000ff)),
child: DefaultTextStyle(
style: const TextStyle(color: Color(0xff0000ff)),
child: child,
),
),
);
}
}
// If a slot has a more-specific style associated with it, a unique wrapper
// widget could be used.
class MenuItemTitleStyle extends StatelessWidget {
const MenuItemTitleStyle({
super.key,
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return DefaultTextStyle(
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w700),
child: child,
);
}
}
/// A widget that handles the structure of the item.
class MenuItemStructure extends StatelessWidget {
/// Creates a [MenuItemStructure]
const MenuItemStructure({
super.key,
required this.child,
this.leading,
this.trailing,
this.subtitle,
this.shortcut,
this.constraints = defaultConstraints,
this.leadingAlignment = defaultLeadingAlignment,
this.trailingAlignment = defaultTrailingAlignment,
this.leadingWidth,
this.trailingWidth,
});
// Easily accessible constants for the default values.
static const double defaultHorizontalWidth = 16.0;
static const double leadingWidgetWidth = 32.0;
static const double trailingWidgetWidth = 44.0;
static const AlignmentDirectional defaultLeadingAlignment =
AlignmentDirectional(1 / 6, 0.0);
static const AlignmentDirectional defaultTrailingAlignment =
AlignmentDirectional(-3 / 11, 0.0);
static const BoxConstraints defaultConstraints = BoxConstraints(
maxHeight: 44,
minHeight: 44,
maxWidth: 200,
);
final Widget? leading;
final Widget? trailing;
final double? leadingWidth;
final double? trailingWidth;
final AlignmentGeometry leadingAlignment;
final AlignmentGeometry trailingAlignment;
final BoxConstraints? constraints;
final Widget child;
final Widget? subtitle;
final Widget? shortcut;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: constraints ?? defaultConstraints,
child: Row(
children: <Widget>[
// The leading and trailing widgets are wrapped in SizedBoxes and
// then aligned, rather than just padded, because the alignment
// behavior of the SizedBoxes appears to be more consistent with
// AutoLayout (iOS).
SizedBox(
width: leadingWidth ??
(leading != null
? leadingWidgetWidth
: defaultHorizontalWidth),
child: leading != null
? Align(alignment: leadingAlignment, child: leading)
: null,
),
Expanded(
child: subtitle == null
? child
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
child,
const SizedBox(height: 1),
subtitle!,
],
),
),
if (shortcut != null)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: shortcut,
),
SizedBox(
width: trailingWidth ??
(trailing != null
? trailingWidgetWidth
: defaultHorizontalWidth),
child: trailing != null
? Align(alignment: trailingAlignment, child: trailing)
: null,
),
],
),
);
}
}
/// A widget that handles the semantics of the item.
///
/// Perhaps combine with [MenuItemGestureHandler]?
class MenuItemSemantics extends StatelessWidget {
const MenuItemSemantics(
{super.key, required this.child, this.enabled = true});
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
return Semantics(
button: false,
enabled: enabled,
child: child,
);
}
}
/// A widget that handles the behavior of the item behavior (e.g. hover, focus, press)
class MenuItemGestureHandler extends StatefulWidget {
const MenuItemGestureHandler({
super.key,
required this.builder,
this.onPressed,
this.onHover,
this.onFocusChange,
});
final Widget Function(BuildContext, ButtonState) builder;
final VoidCallback? onPressed;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocusChange;
bool get enabled => onPressed != null;
@override
State<MenuItemGestureHandler> createState() =>
_MenuItemGestureHandlerState();
}
class _MenuItemGestureHandlerState extends State<MenuItemGestureHandler> {
// Actions could also be parameterized
late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _simulateTap),
ButtonActivateIntent:
CallbackAction<ButtonActivateIntent>(onInvoke: _simulateTap),
};
bool _isPressed = false;
bool _isHovered = false;
bool _isFocused = false;
@override
void didUpdateWidget(covariant MenuItemGestureHandler oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.onPressed != oldWidget.onPressed) {
if (!widget.enabled) {
_isPressed = false;
_isFocused = false;
_isHovered = false;
}
}
}
void _handleTapDown(TapDownDetails event) {
setState(() {
_isPressed = true;
});
}
void _handleTapUp(TapUpDetails event) {
widget.onPressed?.call();
setState(() {
_isPressed = false;
});
}
void _handleTapCancel() {
setState(() {
_isPressed = false;
});
}
void _handlePointerExit(PointerExitEvent event) {
widget.onHover?.call(false);
if (_isHovered) {
setState(() {
_isHovered = false;
});
}
}
void _handlePointerEnter(PointerEnterEvent event) {
widget.onHover?.call(true);
if (!_isHovered) {
setState(() {
_isHovered = true;
});
}
}
void _handleFocusChange(bool value) {
widget.onFocusChange?.call(value);
if(value != _isFocused) {
setState(() {
_isFocused = value;
});
}
}
void _simulateTap([Intent? intent]) {}
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return widget.builder(
context,
const ButtonState(
isPressed: false,
isHovered: false,
isFocused: false,
));
}
return MetaData(
metaData: this,
child: MouseRegion(
onEnter: _handlePointerEnter,
onExit: _handlePointerExit,
hitTestBehavior: HitTestBehavior.deferToChild,
child: Actions(
actions: _actionMap,
child: Focus(
canRequestFocus: true,
skipTraversal: false,
onFocusChange: _handleFocusChange,
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: widget.builder(
context,
ButtonState(
isPressed: _isPressed,
isHovered: _isHovered,
isFocused: _isFocused,
),
),
),
),
),
),
);
}
}
@immutable
class ButtonState {
const ButtonState({
required this.isPressed,
required this.isHovered,
required this.isFocused,
});
final bool isPressed;
final bool isHovered;
final bool isFocused;
@override
String toString() =>
'ButtonState(isPressed: $isPressed, isHovered: $isHovered, isFocused: $isFocused)';
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ButtonState &&
other.isPressed == isPressed &&
other.isHovered == isHovered &&
other.isFocused == isFocused;
}
@override
int get hashCode =>
isPressed.hashCode ^ isHovered.hashCode ^ isFocused.hashCode;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment