multilevel_navigator 2.0.0
multilevel_navigator: ^2.0.0 copied to clipboard
Мультиуровневая адаптивная навигация для Flutter поверх go_router: 4 уровня, автоподбор колонок под экран, resizable-колонки, модалки и сохранение состояния.
📱 Multilevel Navigator #
Адаптивная навигационная система для Flutter с поддержкой 4 уровней навигации
Библиотека для создания адаптивных многоуровневых интерфейсов (list → detail → detail → detail) с автоматической подстройкой количества видимых колонок под ширину экрана. Построена поверх go_router.
✨ Возможности #
- 4-уровневая навигация (level1 — панель навигации, level2 — список, level3/level4 — детали) с автоматическим расчётом видимости уровней под ширину экрана
- Resizable-колонки — пользователь тянет разделитель и меняет ширину колонки (
SplitPane); можно задать и начальную ширину колонки per-фича - Сворачивание level2/level3/level4 — по повторному тапу на активную фичу (
Level2CollapseMode) или на мобильных при уходе с уровня (MobileLevelCollapseMode), с сохранением состояния виджета/BLoC - Сохранение состояния:
- запоминает полный URI каждой фичи при переключении между фичами
- сохраняет виджеты и BLoC между показом/скрытием уровня (
PersistentBlocProvider)
- Модальные окна через query-параметр
?modal=id, с раздельными билдерами под платформу (mobile/desktop/web) и ширину экрана (dialog/bottom sheet) - Система табов с автоматической синхронизацией активного таба с URL
- Авторизация — разделение фич на требующие и не требующие авторизации, редиректы через
AuthenticationStateNotifier - Dependency Injection — DI-контейнер поверх
InheritedWidget, скрытый заMultilevelNavigatorProvider
🏗️ Архитектура #
Структура библиотеки: #
lib/
├── core/ # Ядро
│ ├── cache/ # LRU-кэш (внутренний механизм, не публичный API — см. ниже)
│ ├── contracts/ # AuthenticationStateNotifier, RouterParams
│ ├── models/ # Level, DisplayMode, VisibilityMap, FeatureMultilevelRules, Level2CollapseMode, MobileLevelCollapseMode
│ ├── providers/ # PersistentBlocProvider
│ ├── utils/ # MultilevelDimensions, NavigationUtils, NavigationStorage, PathUtils
│ └── validators/ # Валидация конфигурации фич
│
├── features/ # Фичи пакета (Clean Architecture)
│ ├── multilevel_layout/ # Расчёт layout: MultilevelBloc, LevelsWidthBloc, SplitPane
│ ├── modal_system/ # Модальные окна через query-параметр `modal`
│ └── navigation/ # Табы, back button handler, mixins
│
├── dependency_injection/ # MultilevelNavigatorDependencies (InheritedWidget)
├── configuration/ # FeatureNavigationConfiguration, FeatureLevelItemConfiguration
├── router/ # MultilevelNavigator, построение GoRouter
├── extensions/ # context.goNamed/pop/openModal/switchToFeature/...
└── multilevel_navigator.dart # Public API (barrel-файл)
🚀 Быстрый старт #
1. Установка #
dependencies:
multilevel_navigator: ^1.0.0
2. Создание приложения #
import 'package:multilevel_navigator/multilevel_navigator.dart';
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Порядок важен: сначала неавторизованные фичи, потом авторизованные
final features = [
AuthenticationNavigationConfiguration(),
ChatsNavigationConfiguration(),
CallsNavigationConfiguration(),
ProfileNavigationConfiguration(),
];
final multilevelNavigator = MultilevelNavigator(
features: features,
authenticationStateNotifier: AuthenticationStateNotifierImpl(authRepository),
// navigationPanelBuilder обязателен — библиотека не рисует level1 "из коробки",
// приложение само строит панель навигации (sidebar/bottom nav) для переданного
// StatefulNavigationShell.
navigationPanelBuilder: (navigationShell) => NavigationPanel(navigationShell: navigationShell),
header: null, // опциональный header над контентом
overlay: null, // опциональный overlay поверх всего layout
);
runApp(
MultilevelNavigatorProvider(
multilevelNavigator: multilevelNavigator,
child: MaterialApp.router(
title: 'My App',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
routerConfig: multilevelNavigator.router,
),
),
);
}
Рабочий пример со всеми фичами (chats, calls, profile, authentication, guest) лежит в example/.
📋 Конфигурация фичи #
Каждая фича приложения реализует FeatureNavigationConfiguration:
final class ChatsNavigationConfiguration implements FeatureNavigationConfiguration {
const ChatsNavigationConfiguration();
@override
String? get initialLocation => null; // опциональный начальный путь фичи
@override
bool get isVisibleInNavigation => true;
@override
bool get requiresAuthentication => true;
@override
String name(BuildContext context) => 'Чаты'; // метод, а не геттер — можно локализовать через context
@override
String get key => 'chats'; // уникальный ключ фичи (используется в switchToFeature и т.п.)
@override
IconData get emoji => Icons.chat;
@override
String get basePath => 'chats';
@override
FeatureLevelItemConfiguration get level2 => FeatureLevelItemConfiguration(
path: '',
name: 'chats_level2',
builder: (context) => const ChatsLevel2Page(),
placeholderBuilder: (context) => const ChatLevel2Placeholder(),
);
@override
List<FeatureLevelItemConfiguration> get level3 => [
FeatureLevelItemConfiguration(
path: ':chatId',
name: 'chats_level3_messages',
builder: (context) => ChatsLevel3MessagesPage(chatId: _chatId(context)),
placeholderBuilder: (context) => const ChatLevel3Placeholder(),
parentName: 'chats_level2',
),
];
@override
List<FeatureLevelItemConfiguration> get level4 => const [];
@override
List<MultilevelModalBuilderAbstraction> get modals => [ModalCall(), ModalVideo()];
// Провайдеры, доступные из BuildContext во всех уровнях (level2-4) фичи.
// При implements (в отличие от extends) этот геттер обязателен.
@override
List<SingleChildWidget> get providers => const [];
}
Остальные члены контракта имеют значения по умолчанию (переопределяйте по необходимости):
| Член | Назначение |
|---|---|
defaultLevel2Path |
путь level2 фичи, по умолчанию /$basePath |
rules |
FeatureMultilevelRules — какие уровни видны при какой глубине навигации в каждом из режимов колонок (по умолчанию FeatureMultilevelDefaultRules) |
collapseMode |
Level2CollapseMode.remove (убрать level2 из дерева) или .hide (скрыть, ширина 0, сохранив state level3/level4) при повторном тапе на активную фичу |
collapseAnimationDuration |
длительность анимации сворачивания level2 (только для .hide) |
level3CollapseMode / level4CollapseMode |
MobileLevelCollapseMode.preserve (по умолчанию, состояние сохраняется) или .discard (обычная навигация назад) при сворачивании level3/level4 на мобильных |
initialLevel2Width / initialLevel3Width / initialLevel4Width |
начальная ширина колонки в пикселях при первом построении layout фичи (до ручного resize); для level3 действует как минимально гарантированная ширина |
FeatureLevelItemConfiguration (level2/level3/level4-элемент): path, name (уникальное имя роута), builder, placeholderBuilder (рендерится, когда уровень скрыт по правилам), parentName (связывает уровень с родительским), providers (доступны только в поддереве этого уровня).
🧩 Адаптивность: уровни, брейкпоинты, колонки #
Видимость уровней определяется количеством колонок, которое умещается в ширину экрана (MultilevelDimensions, lib/core/utils/multilevel_dimensions.dart):
level1Width = 50 // панель навигации
level2Width = 420
level3MinWidth = 750 // level3 — гибкая колонка, тянет оставшееся место, это минимум
level4Width = 250
widthForTwoColumns = 800
widthForThreeColumns = level1Width + level2Width + level3MinWidth // = 1220
widthForFourColumns = widthForThreeColumns + level4Width // = 1470
Пороги трёх- и четырёхколоночного режима вычисляются из ширин колонок, а не заданы отдельными константами — изменение level2Width сдвигает и порог перехода на 3 колонки.
Фактический режим отображения — DisplayMode (5 значений, а не просто «N колонок»):
floatingPanel— узкий экран, level1 показан как плавающая панельsingleColumn— одна колонка на весь экранsingleColumnWithBottomNav— одна колонка + нижняя навигацияfixedMultiColumn— несколько колонок фиксированной шириныresizableMultiColumn— несколько колонок с ручным resize (SplitPane)
Какие именно уровни (и плейсхолдеры) видны при данном режиме и текущей глубине навигации (1–4), решает FeatureMultilevelRules.{single,two,three,four}ColumnVisibility(activeDepth) — фича может переопределить rules, чтобы задать собственную матрицу видимости вместо FeatureMultilevelDefaultRules.
💾 Сохранение состояния #
- URI фичи запоминается при переключении между фичами и восстанавливается при возврате (
NavigationStorage, используется автоматически вcontext.switchToFeature). - BLoC уровня, который может временно скрываться по адаптивным правилам, сохраняется через
PersistentBlocProviderвместо обычногоBlocProvider:
FeatureLevelItemConfiguration(
path: ':chatId',
name: 'chats_level3_messages',
builder: (context) => const ChatsLevel3MessagesPage(),
placeholderBuilder: (context) => const ChatLevel3Placeholder(),
providers: [
PersistentBlocProvider<ChatMessagesBloc>(
cacheKey: 'chat_messages_${GoRouterState.of(context).pathParameters['chatId']}',
create: (context) => ChatMessagesBloc()..add(LoadMessages()),
),
],
)
Инстанс BLoC живёт в кэше на уровне фичи (FeatureBlocCacheScope) и переживает скрытие/показ уровня — состояние не сбрасывается, пока сама фича смонтирована.
🔐 Авторизация #
class AuthenticationStateNotifierImpl extends AuthenticationStateNotifier {
AuthenticationStateNotifierImpl(this._authRepository) {
_subscription = _authRepository.authStateStream.listen((_) => notifyListeners());
}
final AuthRepository _authRepository;
StreamSubscription? _subscription;
@override
Future<bool> get isAuthenticated => _authRepository.isAuthenticated();
@override
Future<bool> get isGuest async => false; // опционально: гостевой режим
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
AuthenticationStateNotifier — это ChangeNotifier с двумя геттерами (isAuthenticated, isGuest), а не с методами. Фичи с requiresAuthentication: false (например, экран логина или гостевой режим) доступны без авторизации; при true — роутер редиректит неавторизованного пользователя. Можно завести любое количество неавторизованных фич.
🪟 Модальные окна #
Модалки открываются через query-параметр ?modal=id и рендерятся как dialog или bottom sheet — раздельно для мобильных/десктопа/веба и для одно-/многоколоночного режима. Рекомендуемый API — MultilevelModalPlatformBuilder:
class ModalCall implements MultilevelModalPlatformBuilder {
@override
String get id => 'call';
@override
bool get isDismissible => true;
@override
PlatformModalBuilders? get mobile => PlatformModalBuilders(
singleColumn: PlatformWidthModalBuilders(
bottomSheetBuilder: (context) => const CallBottomSheet(),
),
multiColumn: PlatformWidthModalBuilders(
dialogBuilder: (context) => const CallDialog(),
),
);
@override
PlatformModalBuilders get desktop => PlatformModalBuilders(
singleColumn: PlatformWidthModalBuilders(dialogBuilder: (context) => const CallDialog()),
multiColumn: PlatformWidthModalBuilders(dialogBuilder: (context) => const CallDialog()),
);
@override
PlatformModalBuilders? get web => desktop;
}
Регистрируется в modals фичи (см. пример выше) и открывается/закрывается через контекст:
context.openModal('call');
context.closeModal('call');
// с ожиданием результата
final result = await context.openModalAsync<String>('call');
context.closeModalWithResult('call', 'done');
// с произвольными аргументами вместо query-параметров
context.openModalWithArgs('call', callId);
final callId = context.readModalArgs<String>('call'); // внутри билдера модалки
Старый интерфейс MultilevelModalBuilder (единый dialogBuilder/bottomSheetBuilder без разделения по платформе) помечен @Deprecated — используйте MultilevelModalPlatformBuilder в новом коде.
🗂️ Табы #
TabManagerConfiguration автогенерирует уникальное имя query-параметра из типа страницы (например, ChatsLevel2Page → chats_level2_page_tab), так что несколько независимых наборов табов на разных уровнях не конфликтуют:
class ChatsLevel2Page extends StatefulWidget {
const ChatsLevel2Page({super.key});
@override
State<ChatsLevel2Page> createState() => _ChatsLevel2PageState();
}
class _ChatsLevel2PageState extends State<ChatsLevel2Page>
with TickerProviderStateMixin, TabControllerMixin {
@override
void didChangeDependencies() {
super.didChangeDependencies();
initializeTabController(
TabManagerConfiguration(
ChatsLevel2Page,
tabs: const [
TabConfiguration(name: 'all', title: 'Все', icon: Icons.chat, content: AllChatsTab()),
TabConfiguration(name: 'unread', title: 'Непрочитанные', icon: Icons.mark_chat_unread, content: UnreadChatsTab()),
],
),
);
syncTabControllerWithQuery();
}
@override
Widget build(BuildContext context) {
syncTabControllerWithQuery();
return MultilevelTabContent(
tabController: tabController,
configuration: configuration!,
onTabChanged: switchToTab,
);
}
}
🧭 Навигация из кода #
Основные методы доступны как расширения на BuildContext (сохраняют текущие query-параметры/extra/фрагмент при переходе):
context.goNamed('chats_level3_messages', pathParameters: {'chatId': id});
context.pop(level: 3); // закрыть level3 и всё, что выше
context.switchToFeature(ChatsNavigationConfiguration()); // с восстановлением сохранённого URI фичи
context.toggleLevel2(); // свернуть/развернуть level2 (Level2CollapseMode.hide)
context.collapseMobileLevel3(); // свернуть level3 на мобильном, сохранив state (или .discard — см. level3CollapseMode)
context.expandMobileLevel3();
⚙️ Внутреннее устройство (не публичный API) #
- DI —
MultilevelNavigatorDependencies(InheritedWidget) собираетMultilevelBloc, back-button handler и другие зависимости; полностью скрыт заMultilevelNavigatorProvider, приложению обращаться к нему напрямую не нужно. - Кэш-менеджер (
core/cache/*, LRU с TTL/статистикой) используется внутриMultilevelBlocиSplitPaneCalculatorдля кэширования вычислений видимости/layout/сохранённых ширин колонок. Это внутренняя инфраструктура пакета — классыCacheManagerImpl/CacheKeys/CacheNamesне экспортируются изpackage:multilevel_navigator/multilevel_navigator.dartи не предназначены для использования в коде приложения.
🧪 Тестирование #
Тестами покрыто ядро адаптивности:
test/
├── core/cache/cache_manager_test.dart
├── core/utils/navigation_utils_test.dart
├── core/utils/path_utils_test.dart
├── features/multilevel_layout/application/bloc/multilevel_bloc_test.dart
├── features/multilevel_layout/presentation/widgets/split/split_pane_test.dart
└── router/feature_configuration_resolver_test.dart
Модальная система, табы, DI-контейнер и PersistentBlocProvider тестами пока не покрыты.
📜 История изменений #
Актуальный список изменений по версиям — в CHANGELOG.md.