iOS has a nice flat design language, and this is an implementation of the iPad menu inside of Flutter
Sun, July 24 2022
Jake Landers
Developer and Creator
A good looking animated slide out menu is essential for any project. Luckily, the engineers at Apple have already created a great looking menu that is used mainly on their iPad and Mac devices. We are going to mimic that in flutter today.
There are two dependencies for this project, Provider for managing state, and Sprung for good looking spring animations.
First, we are going to need an object to hold some information about each view we want represented in the menu. We will use a basic data class for this with the following fields:
1class MenuItem { 2 late String title; 3 late IconData icon; 4 late Widget content; 5 6 MenuItem({ 7 required this.title, 8 required this.icon, 9 required this.content, 10 }); 11 12 MenuItem.empty() { 13 title = ""; 14 icon = Icons.settings; 15 content = Container(); 16 } 17 18 String getTitle() { 19 return title; 20 } 21 22 Widget getContent() { 23 return content; 24 } 25 26 IconData getIcon() { 27 return icon; 28 } 29} 30
To manage the menu's state, we will be using a class that extends change notifier. This will let us handle all of the constraint manipulation in the view in a single place so all views stay updated. The point is not to keep ALL of the state manipulation in one place, just enough for it to make sense.
1class MenuModel extends ChangeNotifier { 2 double offset = 0; 3 double cachedOffset = 0; 4 double dragStart = 0; 5 bool isPan = false; 6 bool isOpen = false; 7 8 bool animate = false; 9 10 MenuItem selectedItem = MenuItem.empty(); 11 12 MenuModel() { 13 selectedItem = items.first; 14 } 15 16 final double sizeThreashold = 1.5; 17 18 void open(Size size) { 19 offset = -size.width / sizeThreashold; 20 cachedOffset = -size.width / sizeThreashold; 21 isOpen = true; 22 // update state 23 notifyListeners(); 24 } 25 26 void close() { 27 offset = 0; 28 cachedOffset = 0; 29 isOpen = false; 30 // update state 31 notifyListeners(); 32 } 33 34 void setSelected(MenuItem item) { 35 selectedItem = item; 36 notifyListeners(); 37 } 38 39 List<MenuItem> items = [ 40 MenuItem( 41 title: "Homepage", 42 icon: Icons.home, 43 content: const BasicPage(title: "Homepage")), 44 MenuItem( 45 title: "Social", 46 icon: Icons.person, 47 content: const BasicPage(title: "Social")), 48 MenuItem( 49 title: "Settings", 50 icon: Icons.settings, 51 content: const BasicPage(title: "Settings")), 52 ]; 53} 54
Now, we need to build an acual menu. This consists of two parts. The menu button, and the container that holds the buttons. Here is an iOS style menu button:
1Widget _menuCell(BuildContext context, MenuModel model, MenuItem item) { 2 return Align( 3 alignment: Alignment.centerLeft, 4 child: SizedBox( 5 width: (MediaQuery.of(context).size.width / model.sizeThreashold) - 6 (2 * padding), 7 child: CupertinoButton( 8 color: Colors.transparent, 9 disabledColor: Colors.transparent, 10 padding: const EdgeInsets.all(0), 11 minSize: 0, 12 child: Material( 13 shape: ContinuousRectangleBorder( 14 borderRadius: BorderRadius.circular(30)), 15 color: item == model.selectedItem ? acc : Colors.transparent, 16 child: Padding( 17 padding: const EdgeInsets.fromLTRB(12, 12, 0, 12), 18 child: Align( 19 alignment: AlignmentDirectional.centerStart, 20 child: Row( 21 children: [ 22 Icon( 23 item.icon, 24 color: item == model.selectedItem 25 ? Colors.white 26 : _textColor(context), 27 ), 28 const SizedBox(width: 16), 29 Text( 30 item.getTitle(), 31 style: TextStyle( 32 fontWeight: FontWeight.w500, 33 fontSize: 18, 34 color: item == model.selectedItem 35 ? Colors.white 36 : _textColor(context), 37 ), 38 ), 39 ], 40 ), 41 ), 42 ), 43 ), 44 onPressed: () { 45 // set the selected page to this items page 46 model.setSelected(item); 47 // close the menu 48 Future.delayed(const Duration(milliseconds: 200), () { 49 model.close(); 50 }); 51 }, 52 borderRadius: BorderRadius.zero, 53 ), 54 ), 55 ); 56} 57
And here is the menu that holds these cells.
Note, you can see here I am looping through the menu items to compose the menu. If you want something more custom you will put that extra logic here.
1Widget _menu(BuildContext context, MenuModel model) { 2 return Container( 3 height: double.infinity, 4 width: double.infinity, 5 color: _bgColor(context), 6 child: SafeArea( 7 top: true, 8 left: false, 9 right: false, 10 bottom: false, 11 child: Padding( 12 padding: EdgeInsets.all(padding), 13 child: Column( 14 children: [ 15 for (var item in model.items) 16 Column( 17 children: [ 18 _menuCell(context, model, item), 19 // padding between menu items 20 if (item != model.items.last) SizedBox(height: padding) 21 ], 22 ), 23 ], 24 ), 25 ), 26 ), 27 ); 28} 29
Now, is the menu. There is a lot of complex logic that occurs in this menu that I am not going to go over here, but feel free to look through what I have here. The basic idea is there is an AnimatedPositioned that controls the position of the content and is controlled by a pan gesture. Then, The menu sits behind this in a stack.
1 2Widget build(BuildContext context) { 3 return ChangeNotifierProvider<MenuModel>( 4 create: (_) => MenuModel(), 5 builder: (context, child) { 6 return _body(context); 7 }, 8 ); 9} 10 11Widget _body(BuildContext context) { 12 var model = Provider.of<MenuModel>(context); 13 var size = MediaQuery.of(context).size; 14 return Stack( 15 // make sure everything plays nice 16 alignment: Alignment.center, 17 children: [ 18 // menu 19 _menu(context, model), 20 // allow view to be in a container that can animate its relative position 21 AnimatedPositioned( 22 duration: model.animate 23 ? const Duration(milliseconds: 800) 24 : const Duration(milliseconds: 0), 25 // custom curve 26 curve: Sprung.overDamped, 27 // offset to the right direction 28 right: model.offset, 29 width: size.width, 30 height: size.height, 31 // let entire view track gestures 32 child: GestureDetector( 33 // absorb pointer so the view cannot be interacted with when the view is open 34 child: AbsorbPointer( 35 absorbing: model.isOpen ? true : false, 36 child: Container( 37 height: double.infinity, 38 width: double.infinity, 39 decoration: BoxDecoration( 40 color: _plain(context), 41 border: Border( 42 left: BorderSide( 43 color: model.offset < 0 44 ? _textColor(context).withOpacity(0.2) 45 : Colors.transparent, 46 width: 0.5), 47 ), 48 ), 49 // keep view out of top safe area 50 child: model.selectedItem.getContent(), 51 ), 52 ), 53 // when the gesture starts 54 onHorizontalDragStart: (value) { 55 // turn off animation so dragging feels natural 56 model.animate = false; 57 // detext if a pan drag 58 if (value.globalPosition.dx < 50) { 59 model.isPan = true; 60 } else { 61 model.isPan = false; 62 } 63 // get starting location for jitterless drag 64 model.dragStart = value.localPosition.dx; 65 // update the state 66 setState(() {}); 67 }, 68 // while drag is occuring 69 onHorizontalDragUpdate: (value) { 70 if (model.isOpen) { 71 // if the menu is being dragged left but not past the screen edge 72 if ((value.localPosition.dx - model.dragStart) < 0 && 73 (value.localPosition.dx - model.dragStart) >= 74 -size.width / model.sizeThreashold) { 75 // set the offset to follow the users finger 76 setState(() { 77 model.offset = (model.cachedOffset - 78 (value.localPosition.dx - model.dragStart)); 79 }); 80 } 81 // if menu is closed, let the user open it 82 // if swipe is going right but not greater than 1/3 of screen width 83 } else if ((value.globalPosition.dx - model.dragStart) <= 84 size.width / model.sizeThreashold && 85 value.globalPosition.dx - model.dragStart > 0 && 86 model.isPan) { 87 setState(() { 88 model.offset = -value.globalPosition.dx + model.dragStart; 89 }); 90 } 91 }, 92 // on drag end 93 onHorizontalDragEnd: (value) { 94 // allow menu movement to animate 95 setState(() { 96 model.animate = true; 97 }); 98 // if menu was open or closed enough / velocity was high enough open / close it 99 if (model.isOpen) { 100 if (model.offset > -size.width / (model.sizeThreashold * 2) || 101 (value.primaryVelocity ?? 0) < -700) { 102 model.close(); 103 } else { 104 model.open(size); 105 } 106 } else { 107 if (model.offset < -size.width / (model.sizeThreashold * 2) || 108 (value.primaryVelocity ?? 0) > 700) { 109 model.open(size); 110 } else { 111 model.close(); 112 } 113 } 114 }, 115 // when the menu is open, let the user tap the screen to close it 116 onTap: () { 117 if (model.isOpen) { 118 model.close(); 119 } 120 }, 121 ), 122 ), 123 ], 124 ); 125} 126
Lastly, here is a quick demo view that I am using to show the views. This also contains a menu button.
1class MenuButton extends StatefulWidget { 2 const MenuButton({Key? key}) : super(key: key); 3 4 5 _MenuButtonState createState() => _MenuButtonState(); 6} 7 8class _MenuButtonState extends State<MenuButton> { 9 10 Widget build(BuildContext context) { 11 var model = Provider.of<MenuModel>(context); 12 return CupertinoButton( 13 color: Colors.transparent, 14 disabledColor: Colors.transparent, 15 padding: const EdgeInsets.all(0), 16 minSize: 0, 17 child: Icon(model.isOpen ? Icons.close : Icons.menu, 18 color: Theme.of(context).colorScheme.primary), 19 // actionn of the button 20 onPressed: () { 21 // allow for animation 22 model.animate = true; 23 // toggle menu 24 if (model.isOpen) { 25 model.close(); 26 } else { 27 model.open(MediaQuery.of(context).size); 28 } 29 }, 30 ); 31 } 32} 33
1class BasicPage extends StatelessWidget { 2 const BasicPage({ 3 Key? key, 4 required this.title, 5 }) : super(key: key); 6 final String title; 7 8 9 Widget build(BuildContext context) { 10 return CustomScrollView( 11 slivers: [ 12 CupertinoSliverNavigationBar( 13 backgroundColor: 14 MediaQuery.of(context).platformBrightness == Brightness.light 15 ? Colors.white 16 : Colors.black, 17 largeTitle: Text(title, 18 style: TextStyle( 19 color: MediaQuery.of(context).platformBrightness == 20 Brightness.light 21 ? Colors.black 22 : Colors.white, 23 )), 24 // close / open menu button 25 leading: const MenuButton(), 26 ), 27 // actual view itself 28 SliverToBoxAdapter( 29 child: Padding( 30 padding: const EdgeInsets.only(top: 50.0), 31 child: Center( 32 child: Text(title, style: const TextStyle(color: Colors.grey))), 33 ), 34 ), 35 ], 36 ); 37 } 38} 39