I use OverlayEntry and The Flow widget.
final menuProvider =
ChangeNotifierProvider.autoDispose<MenuChangeNotifier>((ref) {
return MenuChangeNotifier();
});
class MenuChangeNotifier extends ChangeNotifier {
bool isEnterDropDown = false;
OverlayEntry? floatingDropdown;
bool isDropdownOpened = false;
bool isHighlight = false;
late double height, width, xPosition, yPosition;
String onGoingMenu = '';
void setOverlayChange(BuildContext context, Map itemMenu, String text) {
if (!isEnterDropDown && isDropdownOpened && floatingDropdown != null) {
if (itemMenu.isEmpty) {
isHighlight = false; //Juste pour changer Title
notifyListeners();
return;
}
floatingDropdown!.remove();
isHighlight = false;
isDropdownOpened = false;
onGoingMenu = '';
} else if (!isDropdownOpened) {
onGoingMenu = text;
if (itemMenu.isEmpty) {
isHighlight = !isHighlight; //Juste pour changer Title
notifyListeners();
return;
}
findDropdownData(context);
floatingDropdown = _createFloatingDropdown(itemMenu);
Overlay.of(context)!.insert(floatingDropdown!);
isDropdownOpened = true;
isHighlight = true;
isEnterDropDown = false;
}
notifyListeners();
}
void findDropdownData(BuildContext context) {
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
height = renderBox.size.height + 3;
width = renderBox.size.width;
final Offset offset = renderBox.localToGlobal(Offset.zero);
xPosition = offset.dx;
yPosition = offset.dy;
}
OverlayEntry _createFloatingDropdown(Map itemMenu) {
return OverlayEntry(builder: (context) {
return Stack(
children: [
Positioned(
top: yPosition + height - 4,
child: IgnorePointer(
child: Container(
color: Colors.black45,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
),
),
),
Positioned(
left: xPosition,
top: yPosition + height - 4,
//-4 pour que le curseur sois dans le dropdown
child: DropDown(
itemHeight: height,
listMenu: itemMenu,
key: GlobalKey(),
),
)
],
);
});
}
void closeDropDown() {
if (floatingDropdown == null) {
return;
}
if (isDropdownOpened && isEnterDropDown) {
floatingDropdown!.remove();
isDropdownOpened = false;
isHighlight = false;
isEnterDropDown = false;
notifyListeners();
}
}
}
class TitleDropdown extends HookConsumerWidget {
final String text;
final Map itemMenu;
const TitleDropdown(
{required Key key, required this.text, required this.itemMenu})
: super(key: key);
@override
Widget build(BuildContext context,WidgetRef ref) {
final theme = Theme.of(context);
final animationController = useAnimationController(duration: const Duration(milliseconds: 400));
Animation<double> animTween = Tween<double>(
begin: 0.0, end: 100) //32 le padding
.animate(CurvedAnimation(
parent: animationController, curve: Curves.easeInOut));
return InkWell(
onHover: (onHover) async {
await Future.delayed(const Duration(milliseconds: 10));
ref
.read(menuProvider)
.setOverlayChange(context, itemMenu, text);
},
onTap: () {},
child: Consumer(builder: (context, ref, _) {
final toggle = ref.watch(menuProvider.select((value) => value.isHighlight)) &&
ref.watch(menuProvider.select((value) => value.onGoingMenu)) == text;
WidgetsBinding.instance!.addPostFrameCallback((_) {
animTween = Tween<double>(
begin: 0.0, end: context.size!.width - 32) //32 le padding
.animate(CurvedAnimation(
parent: animationController, curve: Curves.easeInOut));
});
if (toggle) {
animationController.forward();
} else {
animationController.reverse();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
Text(
text,
style: theme.textTheme.headline6,
),
AnimatedBuilder(
animation: animTween,
builder: (context, _) {
return Container(
color: theme.colorScheme.onBackground,
height: 1,
width: animTween.value,
);
},
)
],
),
);
}),
);
}
}
class DropDown extends HookConsumerWidget {
final double itemHeight;
final Map? listMenu;
const DropDown({required Key key, required this.itemHeight, this.listMenu})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = listMenu?.length ?? 0;
final h = heightOfOneItem * l.toDouble();
final theme = Theme.of(context);
final anim = useAnimationController(
duration: const Duration(milliseconds: 250),
);
anim.forward();
final primaryColor = Theme.of(context).colorScheme.primaryVariant;
return MouseRegion(
onEnter: (PointerEnterEvent pointerEnterEvent) {
ref.read(menuProvider).isEnterDropDown = true;
},
onExit: (PointerExitEvent pointerExitEvent) {
ref.read(menuProvider).closeDropDown();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ClipPath(
clipper: ArrowClipper(),
child: Container(
height: arrowHeight,
width: 40,
decoration: BoxDecoration(
color: primaryColor,
),
),
),
SizedBox(
height: h,
width: 200,
child: AnimatedAppear(
children: <Widget>[
if (listMenu != null && listMenu!.isNotEmpty)
...listMenu!.keys.map((key) => Row(
children: [
Expanded(
child: Material(
color: Colors.transparent,
child: SizedBox(
height: heightOfOneItem,
child: DropDownItem(
text: key!.toString(),
key: GlobalKey(),
isFirstItem:
listMenu!.keys.first.toString() == key,
isLastItem:
listMenu!.keys.last.toString() == key,
iconData: Icons.person_outline,
),
),
),
),
],
)),
],
),
),
],
),
);
}
}
class DropDownItem extends StatefulWidget {
final String text;
final IconData iconData;
final bool isFirstItem;
final bool isLastItem;
const DropDownItem(
{Key? key,
required this.text,
required this.iconData,
this.isFirstItem = false,
this.isLastItem = false})
: super(key: key);
@override
_DropDownItemState createState() => _DropDownItemState();
}
class _DropDownItemState extends State<DropDownItem> {
late bool isSelected;
@override
void initState() {
super.initState();
isSelected = false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () {},
onHover: (onHover) {
setState(() {
isSelected = !isSelected;
});
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topRight: widget.isFirstItem ? const Radius.circular(8) : Radius.zero,
bottomLeft: widget.isLastItem ? const Radius.circular(8) : Radius.zero,
bottomRight: widget.isLastItem ? const Radius.circular(8) : Radius.zero,
),
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.primaryVariant,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
widget.text,
style: theme.textTheme.headline5,
),
Icon(
widget.iconData,
color: theme.colorScheme.onPrimary,
),
],
),
),
),
);
}
}
class ArrowClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final Path path = Path();
path.moveTo(0, size.height);
path.lineTo(size.width / 2, 0);
path.lineTo(size.width, size.height);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}
class AnimatedAppear extends HookWidget {
final List<Widget> children;
final bool isForward;
const AnimatedAppear(
{required this.children, this.isForward = true, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
final animationController = useAnimationController(
duration: const Duration(milliseconds: 1000),
);
if (isForward) {
animationController.forward();
} else {
animationController.reverse();
}
return Flow(
delegate: FlowMenuDelegate(animationController: animationController),
children: children,
);
}
}
class FlowMenuDelegate extends FlowDelegate {
final AnimationController animationController;
FlowMenuDelegate({required this.animationController})
: super(repaint: animationController);
@override
void paintChildren(FlowPaintingContext context) {
final size = context.size;
final xStart = size.width / 2;
final yStart = -size.height /2 + arrowHeight/2;
const margin = 0;
final animation = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.elasticOut));
for (int i = context.childCount - 1; i >= 0; i--) {
final childSize = context.getChildSize(i);
if (childSize == null) {
continue;
}
final dy = yStart + (margin + heightOfOneItem) * (i) * animation.value;
context.paintChild(i,
transform: Matrix4.translationValues(
xStart - childSize.width / 2, dy.toDouble(), 0),
opacity: animation.value);
}
}
@override
bool shouldRepaint(covariant FlowDelegate oldDelegate) => false;
}