Instantly share code, notes, and snippets.
Created
January 10, 2024 22:00
-
Save rodydavis/2cdf073cc2d67e6578187096e2d8c80f to your computer and use it in GitHub Desktop.
Navigation view similar to Fluent UI built with Material Design 3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:go_router/go_router.dart'; | |
import 'package:signals/signals_flutter.dart'; | |
class NavigationView extends StatefulWidget { | |
const NavigationView({ | |
super.key, | |
required this.appBar, | |
required this.items, | |
this.footerItems = const [], | |
required this.child, | |
}); | |
final NavigationAppBar appBar; | |
final List<NavigationViewItem> items; | |
final List<ViewItem> footerItems; | |
final Widget child; | |
@override | |
State<NavigationView> createState() => _NavigationViewState(); | |
} | |
class _NavigationViewState extends State<NavigationView> { | |
final index = signal(0); | |
final tabletExpanded = signal(false); | |
final desktopExpanded = signal(true); | |
@override | |
void dispose() { | |
index.dispose(); | |
tabletExpanded.dispose(); | |
desktopExpanded.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
final size = MediaQuery.of(context).size; | |
const minHeight = 400.0; | |
final isTablet = size.width >= 600 && size.height >= minHeight; | |
final isDesktop = size.width >= 1200 && size.height >= minHeight; | |
const listWidth = 300.0; | |
const railWidth = 50.0; | |
if (isDesktop) { | |
// Show expanded side bar | |
// Show a nav rail collapsed on left | |
final expanded = desktopExpanded.watch(context); | |
return Scaffold( | |
appBar: widget.appBar.build(context, null), | |
body: Row( | |
children: [ | |
SizedBox( | |
width: expanded ? listWidth : railWidth, | |
height: double.infinity, | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Container( | |
alignment: expanded ? Alignment.centerLeft : Alignment.center, | |
child: IconButton( | |
onPressed: () => desktopExpanded.set(!expanded), | |
icon: const Icon(Icons.menu), | |
), | |
), | |
Expanded( | |
child: ListView.builder( | |
itemCount: widget.items.length, | |
itemBuilder: (context, index) { | |
var item = widget.items[index]; | |
if (item is ViewHeader && !expanded) return const Divider(); | |
return item.build(context, expanded); | |
}, | |
), | |
), | |
if (widget.footerItems.isNotEmpty) const Divider(), | |
for (final item in widget.footerItems) ...[ | |
item.build(context, expanded), | |
], | |
], | |
), | |
), | |
const VerticalDivider(width: 1), | |
Expanded(child: buildChild()), | |
], | |
), | |
); | |
} else if (isTablet) { | |
// Show a nav rail collapsed on left | |
final expanded = tabletExpanded.watch(context); | |
return Scaffold( | |
appBar: widget.appBar.build(context, null), | |
body: Row( | |
children: [ | |
SizedBox( | |
width: expanded ? listWidth : railWidth, | |
height: double.infinity, | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Container( | |
alignment: expanded ? Alignment.centerLeft : Alignment.center, | |
child: IconButton( | |
onPressed: () => tabletExpanded.set(!expanded), | |
icon: const Icon(Icons.menu), | |
), | |
), | |
Expanded( | |
child: ListView.builder( | |
itemCount: widget.items.length, | |
itemBuilder: (context, index) { | |
var item = widget.items[index]; | |
if (item is ViewHeader && !expanded) return const Divider(); | |
return item.build(context, expanded); | |
}, | |
), | |
), | |
if (widget.footerItems.isNotEmpty) const Divider(), | |
for (final item in widget.footerItems) ...[ | |
item.build(context, expanded), | |
], | |
], | |
), | |
), | |
const VerticalDivider(width: 1), | |
Expanded(child: buildChild()), | |
], | |
), | |
); | |
} else { | |
// Show a drawer | |
return Scaffold( | |
appBar: widget.appBar.build(context, Builder( | |
builder: (context) { | |
return IconButton( | |
onPressed: () => Scaffold.of(context).openDrawer(), | |
icon: const Icon(Icons.menu), | |
); | |
}, | |
)), | |
drawer: Drawer( | |
child: Column( | |
children: [ | |
Expanded( | |
child: ListView.builder( | |
itemCount: widget.items.length, | |
itemBuilder: (context, index) { | |
var item = widget.items[index]; | |
if (item is ViewItem) { | |
item = item.withTap(() => Navigator.maybePop(context)); | |
} | |
return item.build(context, true); | |
}, | |
), | |
), | |
if (widget.footerItems.isNotEmpty) const Divider(), | |
for (final item in widget.footerItems) ...[ | |
item.withTap(() => Navigator.maybePop(context)).build(context, true), | |
], | |
], | |
), | |
), | |
body: buildChild(), | |
); | |
} | |
} | |
Widget buildChild() { | |
return ClipRRect( | |
child: widget.child, | |
); | |
} | |
} | |
abstract class NavigationViewItem { | |
Widget build(BuildContext context, bool expanded); | |
} | |
class ViewItem extends NavigationViewItem { | |
ViewItem({ | |
required this.title, | |
required this.icon, | |
required this.onTap, | |
this.selected = false, | |
this.implicit = true, | |
}); | |
final String title; | |
final IconData icon; | |
final VoidCallback onTap; | |
final bool selected; | |
final bool implicit; | |
ViewItem withTap(VoidCallback onTap) { | |
return ViewItem( | |
title: title, | |
icon: icon, | |
onTap: () { | |
onTap(); | |
this.onTap(); | |
}, | |
selected: selected, | |
); | |
} | |
@override | |
Widget build(BuildContext context, bool expanded) { | |
final isSelected = selected && !implicit; | |
if (expanded) { | |
return ListTile( | |
leading: Icon(icon), | |
title: Text(title), | |
onTap: isSelected ? null : onTap, | |
selected: selected, | |
); | |
} else { | |
if (!selected) { | |
return IconButton( | |
onPressed: isSelected ? null : onTap, | |
icon: Icon(icon), | |
tooltip: title, | |
); | |
} else { | |
return IconButton.outlined( | |
onPressed: isSelected ? null : onTap, | |
icon: Icon(icon), | |
tooltip: title, | |
); | |
} | |
} | |
} | |
} | |
class ViewHeader extends NavigationViewItem { | |
ViewHeader({ | |
required this.title, | |
this.subtitle, | |
this.icon, | |
}); | |
final String title; | |
final String? subtitle; | |
final IconData? icon; | |
@override | |
Widget build(BuildContext context, bool expanded) { | |
return ListTile( | |
title: Text(title), | |
subtitle: subtitle != null ? Text(subtitle!) : null, | |
leading: icon != null ? Icon(icon) : null, | |
); | |
} | |
} | |
class NavigationAppBar { | |
const NavigationAppBar({ | |
required this.title, | |
this.onTitleTap, | |
this.actions = const [], | |
}); | |
final String title; | |
final VoidCallback? onTitleTap; | |
final List<Widget> actions; | |
PreferredSizeWidget build(BuildContext context, Widget? leading) { | |
return PreferredSize( | |
preferredSize: const Size.fromHeight(kToolbarHeight), | |
child: Builder(builder: (context) { | |
final canPop = context.canPop(); | |
final leadingItems = <Widget>[if (leading != null) leading]; | |
if (canPop) { | |
leadingItems.insert( | |
0, | |
IconButton( | |
onPressed: () => GoRouter.of(context).pop(), | |
icon: const Icon(Icons.arrow_back), | |
), | |
); | |
} | |
return AppBar( | |
leading: leadingItems.isEmpty ? null : Row(children: leadingItems), | |
leadingWidth: leadingItems.isEmpty ? null : leadingItems.length * 50, | |
title: InkWell( | |
onTap: onTitleTap, | |
child: Text(title), | |
), | |
actions: actions, | |
centerTitle: false, | |
); | |
}), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment