How to create stylish Tabs on AppBar like this in Flutter?
CodePudding user response:
I think the tricky part in this AppBar is the overlapping of the primary navigation items. This could be achieved using an OverflowBox
.
Then, for the bottom curves of the active navigation item, you could use a CustomClipper
.
This sample has minimum interactivity, you may click on the primary navigation items to toggle the active one.
Full source code
Just copy-paste this in the main.dart
file of a new Flutter project.
import 'package:flutter/material.dart';
// Constants
const kGap = 6.0;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = ThemeData.light();
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Umyt App',
theme: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
primary: const Color(0xFFED6324),
secondary: const Color(0xFF611B63),
),
),
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Text("Hello, Umyt!"),
Text("I hope this helps you."),
],
),
),
);
}
}
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
const CustomAppBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.primary,
child: SafeArea(
child: Column(
children: const [
SizedBox(height: kGap),
PrimaryNavBar(),
SizedBox(
height: kGap *
0.96), // Hack without which I have a thin line on the emulator.
SecondaryNavBar(),
],
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(106);
}
class PrimaryNavBar extends StatefulWidget {
const PrimaryNavBar({Key? key}) : super(key: key);
@override
State<PrimaryNavBar> createState() => _PrimaryNavBarState();
}
class _PrimaryNavBarState extends State<PrimaryNavBar> {
String _activeItem = 'market';
void _toggleActiveItem(String item) {
setState(() => _activeItem = item);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(width: kGap),
PrimaryNavItem(
onTap: _toggleActiveItem,
title: 'market',
activeItem: _activeItem,
),
const SizedBox(width: kGap),
PrimaryNavItem(
onTap: _toggleActiveItem,
title: 'store',
activeItem: _activeItem,
),
],
);
}
}
class SecondaryNavBar extends StatelessWidget {
const SecondaryNavBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 2 * kGap, vertical: 2 * kGap),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SecondaryNavItem(
icon: Icons.location_on_outlined, text: 'Balkanabat'),
Container(
height: 24.0,
width: 0.5,
color: Theme.of(context).colorScheme.primary,
),
const SecondaryNavItem(icon: Icons.border_all, text: 'Kategoriýalar'),
],
),
);
}
}
class PrimaryNavItem extends StatelessWidget {
final String title;
final String activeItem;
final void Function(String title)? onTap;
bool get active => activeItem == title;
const PrimaryNavItem({
required this.title,
required this.activeItem,
this.onTap,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100.0),
child: InkWell(
onTap: () => onTap?.call(title),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 2 * kGap, vertical: kGap),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(kGap),
),
child: Stack(children: [
if (active)
Positioned.fill(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return OverflowBox(
maxWidth: constraints.maxWidth 8 * kGap,
maxHeight: constraints.maxHeight 4 * kGap,
child: ClipPath(
clipper: PrimaryNavItemClipper(),
child: Container(color: Colors.white)),
);
},
),
),
Positioned(
top: 0,
left: 0,
child: Image.asset(
'assets/images/logo.png',
width: 8 * kGap,
),
),
Padding(
padding: const EdgeInsets.only(top: kGap),
child: Text(
title,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: active
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
),
),
)
]),
),
),
);
}
}
class PrimaryNavItemClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
path.moveTo(0, size.height);
path.quadraticBezierTo(
2 * kGap, size.height, 2 * kGap, size.height - 2 * kGap);
path.lineTo(size.width - 2 * kGap, size.height - 2 * kGap);
path.quadraticBezierTo(
size.width - 2 * kGap, size.height, size.width, size.height);
path.lineTo(0, size.height);
path.close();
return path;
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}
class SecondaryNavItem extends StatelessWidget {
final IconData icon;
final String text;
const SecondaryNavItem({Key? key, required this.icon, required this.text})
: super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon),
Text(text),
],
);
}
}
Happy coding!