222 lines
7.7 KiB
Dart
222 lines
7.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../core/theme.dart';
|
|
|
|
class KrowNavItem {
|
|
const KrowNavItem({
|
|
required this.icon,
|
|
required this.label,
|
|
this.showDot = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
|
|
/// Marks the tab as needing attention, without saying how much.
|
|
final bool showDot;
|
|
}
|
|
|
|
/// Bottom navigation whose indicator slides between tabs.
|
|
///
|
|
/// Material's own [NavigationBar] fades its indicator in at the destination
|
|
/// and out at the origin, so the selection appears to teleport. Here the pill
|
|
/// travels, and the icons hand it over as it passes.
|
|
class KrowNavBar extends StatelessWidget {
|
|
const KrowNavBar({
|
|
super.key,
|
|
required this.items,
|
|
required this.currentIndex,
|
|
required this.onSelect,
|
|
});
|
|
|
|
final List<KrowNavItem> items;
|
|
final int currentIndex;
|
|
final ValueChanged<int> onSelect;
|
|
|
|
static const _pillWidth = 58.0;
|
|
static const _pillHeight = 32.0;
|
|
|
|
/// Breathing room between the bar's top edge and the icons. The pill and the
|
|
/// icons both start here, which is also what keeps them aligned — centring
|
|
/// the icons while pinning the pill to a fixed offset left the two a couple
|
|
/// of pixels apart.
|
|
static const _topPadding = 12.0;
|
|
|
|
static const _labelSize = 11.0;
|
|
static const _dotSlot = 6.0;
|
|
|
|
/// Lets tests find the travelling indicator unambiguously.
|
|
static const indicatorKey = ValueKey('krow-nav-indicator');
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border(top: BorderSide(color: KrowPalette.line)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
// No fixed height: the Stack below takes the height of the item row,
|
|
// and the pill is positioned within it. A hard number here has to be
|
|
// retuned every time the label grows — a bigger text scale, a longer
|
|
// word, a different font — and overflows the moment it is not.
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final slot = constraints.maxWidth / items.length;
|
|
|
|
// The selected index animated as a double, so the pill's
|
|
// position and every icon's colour come from one value.
|
|
return TweenAnimationBuilder<double>(
|
|
tween: Tween(end: currentIndex.toDouble()),
|
|
duration: const Duration(milliseconds: 360),
|
|
curve: Curves.easeOutQuint,
|
|
builder: (context, position, _) {
|
|
final travel = (position - position.roundToDouble()).abs();
|
|
final stretch = 20 * (travel * 2);
|
|
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left:
|
|
position * slot +
|
|
(slot - _pillWidth) / 2 -
|
|
stretch / 2,
|
|
top: _topPadding,
|
|
width: _pillWidth + stretch,
|
|
height: _pillHeight,
|
|
child: DecoratedBox(
|
|
key: indicatorKey,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.blue,
|
|
borderRadius: BorderRadius.circular(10),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: KrowPalette.blue.withValues(alpha: 0.28),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
for (var i = 0; i < items.length; i++)
|
|
Expanded(
|
|
child: _NavItem(
|
|
item: items[i],
|
|
// 1 when the pill is centred on this tab.
|
|
proximity: (1 - (position - i).abs()).clamp(
|
|
0.0,
|
|
1.0,
|
|
),
|
|
onTap: () {
|
|
if (i != currentIndex) {
|
|
HapticFeedback.selectionClick();
|
|
}
|
|
onSelect(i);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NavItem extends StatelessWidget {
|
|
const _NavItem({
|
|
required this.item,
|
|
required this.proximity,
|
|
required this.onTap,
|
|
});
|
|
|
|
final KrowNavItem item;
|
|
final double proximity;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The icon only turns white once the pill is actually under it — a linear
|
|
// ramp would leave it half-white over bare white while the pill is still
|
|
// in transit. easeInCubic holds it grey, then flips on arrival.
|
|
final onPill = Curves.easeInCubic.transform(proximity);
|
|
final iconColor = Color.lerp(KrowPalette.mute, Colors.white, onPill)!;
|
|
// The label is never covered, so it can ramp gently the whole way.
|
|
final labelColor = Color.lerp(
|
|
KrowPalette.mute,
|
|
KrowPalette.blue,
|
|
Curves.easeIn.transform(proximity),
|
|
)!;
|
|
|
|
// Material's NavigationBar announces each tab and its selected state;
|
|
// rolling our own means saying so explicitly. container + excludeSemantics
|
|
// collapse the icon, dot and label into a single node — otherwise the
|
|
// annotation is split across the children and never reaches the reader.
|
|
return Semantics(
|
|
container: true,
|
|
excludeSemantics: true,
|
|
button: true,
|
|
selected: proximity > 0.5,
|
|
label: item.showDot ? '${item.label}, needs attention' : item.label,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Column(
|
|
// Started from the top rather than centred, so the icon sits exactly
|
|
// where the pill is drawn.
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: KrowNavBar._topPadding),
|
|
SizedBox(
|
|
height: KrowNavBar._pillHeight,
|
|
child: Center(child: Icon(item.icon, size: 21, color: iconColor)),
|
|
),
|
|
const SizedBox(height: 3),
|
|
// The dot's slot is always reserved, so tabs with and without one
|
|
// keep their labels on the same line.
|
|
SizedBox(
|
|
height: KrowNavBar._dotSlot,
|
|
child: Center(
|
|
child: AnimatedScale(
|
|
scale: item.showDot ? 1 : 0,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutBack,
|
|
child: Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.error,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.label,
|
|
style: TextStyle(
|
|
fontSize: KrowNavBar._labelSize,
|
|
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
|
|
color: labelColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|