Skip to content

Instantly share code, notes, and snippets.

@rodydavis
Created January 10, 2024 22:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rodydavis/2cdf073cc2d67e6578187096e2d8c80f to your computer and use it in GitHub Desktop.
Save rodydavis/2cdf073cc2d67e6578187096e2d8c80f to your computer and use it in GitHub Desktop.
Navigation view similar to Fluent UI built with Material Design 3
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