3. SusScreen — экраны, жизненный цикл и подключение
Экран (SusScreen) — полноэкранное представление, которым управляет SusRouter. Он наследует SusComponent, поэтому обладает полным core-набором реактивности (Prop<T>, Watch, Build(), Mounted()), плюс жизненный цикл роутера и доступ к параметрам маршрута.
1. Минимальный экран
using UnityEngine.UIElements;
using Sharq.Router;
public class HomeScreen : SusScreen
{
protected override void Build()
{
AddToClassList("home-screen"); // companion USS: .home-screen { flex-grow: 1; }
Add(new Label("Home"));
var btn = new Button { text = "Go to About" };
btn.RegisterCallback<ClickEvent>(_ => Router.Push("/about"));
Add(btn);
}
}Раскладка на корне экрана — такой же USS-класс, как у любого другого компонента, а не style.flexGrow, — см. Design tokens §1.4.
Build() строит дерево (из SusComponent). Router и Props ещё не установлены в Build() — читайте их из OnBeforeEnter() / OnEntered().
2. Жизненный цикл — переопределяем методы On*
⚠️ Важно: публичные
BeforeEnter/Entered/BeforeLeave/Leaving/Left/BeforeRouteUpdateвызываются самим роутером — не переопределяйте их. Переопределяйте хукиprotected virtualOn*(паттерн template-method).
public class BattleScreen : SusScreen
{
// 1. Enter validation + reading Props. false = enter cancelled.
protected override bool OnBeforeEnter(SusRoute fromRoute)
{
var matchId = GetParam("matchId");
return !string.IsNullOrEmpty(matchId);
}
// 2. Screen is already in the DOM — start animations, load data.
protected override void OnEntered() => StartBattle();
// 3. Props changed on the SAME screen instance (e.g. /users/1 → /users/2).
protected override bool OnBeforeRouteUpdate(SusRoute toRoute) => true;
// 4. Leave guard. false = navigation blocked (e.g. dirty form).
protected override bool OnBeforeLeave(SusRoute toRoute)
{
if (_isDirty)
{
Router.Modal(typeof(ConfirmLeaveDialog), new() { ["message"] = "Leave without saving?" });
return false;
}
return true;
}
// 5. Navigation is committed and the screen is still in the tree —
// undo anything it changed on its parents (shell captions, classes).
protected override void OnLeaving(SusRoute toRoute) => ClearShellCaption();
// 6. Screen is removed and was not cached — unsubscribe, release resources.
protected override void OnLeft() => CleanupBattle();
}Полная последовательность
Push("/battle/42"):
1. Activator.CreateInstance / LazyFactory()
Created() ← SusComponent; Router/Props NOT set yet
Build() ← tree is built
2. screen.Router = router
3. screen.Props = params + query + DefaultProps + PropsFn(...)
4. OnBeforeEnter(from) ← false = cancel entire navigation
5. history updates, SusRouteView swaps screen + transition.PlayIn()
6. OnEntered() ← screen in DOM
7. (next frame) Mounted() ← deferred SusComponent hook
Leave:
8. OnBeforeLeave(to) ← guard, false = cancel
(then CanLeave, BeforeEach, CanEnter, per-route BeforeEnter)
9. OnLeaving(to) ← leave/enter guards passed; screen still in the tree
10. (BeforeResolve, target BeforeEnter — last cancellation points)
11. OnLeft() ← once, leaf → root, screen still in the tree
12. SusRouteView removes screen (or moves it into the KeepAlive cache)
13. Unmounted() ← SusComponent; NOT called on KeepAliveПри KeepAlive экран не пересоздаётся и не получает OnLeft() при уходе: инстанс отсоединяется в кэш (не уничтожается); при возврате — снова OnBeforeEnter() / OnEntered() без нового Build(). OnLeft() придёт позже, когда кэш вытеснит экран (см. ниже).
Контракт очистки OnLeft
OnLeft() — место для отписки от событий и освобождения ресурсов. Он подчиняется пяти инвариантам:
- Один раз. Каждый экран, который навигация убирает из активной цепочки и не кладёт в кэш KeepAlive, получает
OnLeft()ровно один раз. - От листа к корню. Тот же набор и порядок, что у
OnLeaving: вложенный ребёнок выполняется раньше родителя. - В дереве. Он выполняется до того, как роутер что-либо отсоединит, поэтому
panelиparentживы (кроме вытеснения из KeepAlive, см. 5). - После последней точки отмены. Он выполняется после guard'ов,
BeforeResolveиBeforeEnterкаждого экрана цепочки цели.OnBeforeEnter, возвращающийfalse, отменяет навигацию на любом уровне вложенной цели (корень, промежуточный или лист) и для экрана, взятого из кэша KeepAlive;OnBeforeRouteUpdateвызывается на каждом переиспользуемом экране общего префикса цепочки (независимо от того, изменились ли егоProps), и возвратfalseиз любого из них отменяет навигацию. Решайте до изменения состояния: ничего, что вы изменили внутри отклонившего колбэка, не откатывается. Отменённая навигация никогда не оставляет активный экран, который уже очищен. - KeepAlive. Экран, перемещённый в кэш, при уходе не получает
OnLeft(). Он получит его, когда кэш вытеснит экран (LRU) или приClearKeepAliveCache, и тогда он выполняется вне дерева: в кэше лежат отсоединённые экраны.
| Путь | OnLeaving | OnLeft | Дерево в OnLeft |
|---|---|---|---|
| Одиночный маршрут → любой маршрут | один раз | один раз | живое |
Вложенная цепочка → одиночный маршрут (/a/x → /b) | лист, затем корень | лист, затем корень | живое |
Вложенная цепочка → другая цепочка (/a/x → /c/z) | лист, затем корень | лист, затем корень | живое |
Общий префикс сохранён (/a/x → /a/y) | только x | только x; /a остаётся | живое |
Уровень с KeepAlive покинут | один раз | не вызывается; экран в кэше | н/д |
| Закэшированный экран вытеснен или кэш очищен | повторно не вызывается | один раз | отсоединено |
Родитель без KeepAlive покинут, а его ChildView кэширует детей | один раз на экран | закэшированные дети (кэш сначала очищается), затем родитель: лист к корню | кэшированные дети отсоединены, родитель живой |
Навигация отменена (guard, BeforeResolve, OnBeforeEnter / OnBeforeRouteUpdate вернул false на любом уровне вложенной цели, включая экран из KeepAlive) | мог выполниться | не вызывается | н/д |
Практические правила:
- Очистка, которая зависит от дерева (чтение или запись предков, подписи оболочки, фокус), идёт в
OnLeaving: он всегда выполняется с живым деревом. - Очистка, которая от дерева не зависит (отписка, dispose, отмена таймеров), идёт в
OnLeft, и не должна предполагать живыеparentилиpanel, потому что закэшированный экран вытесняется вне дерева. KeepAliveрешается на каждом уровне вложенной цепочки. Оставленный ребёнок кэшируется вChildViewродителя (outlet) и возвращается, только пока переиспользуется тот же инстанс родителя. Если родитель пересоздан, его кэш пуст, и ребёнок строится заново; если родитель уходит безKeepAlive, его кэш очищается, и закэшированные дети получаютOnLeft.- Раньше
OnLeftпропускался, когда вложенную цепочку покидали ради одноуровневого маршрута, и терялся для KeepAlive-ребёнка, покинутого ради соседа. Код, который полагался на то, что он не срабатывает (повторная отписка, обращение к уже освобождённому состоянию), теперь обязан это выдерживать. Порядок относительно следующего экрана тоже изменился:BeforeEnterследующего экрана теперь выполняется доOnLeftстарого.
OnLeaving — последний вызов с живым деревом
OnLeaving(toRoute) вызывается один раз за уход — после того как пройдены все guard'ы ухода и входа, и до того, как роутер отсоединит хоть один экран. В этот момент parent, panel и предки экрана — например, оболочка, в которой живёт вложенный ChildView, — ещё на месте. Здесь откатывают то, что экран сделал со своими предками:
// A nested pane writes a caption into its shell on enter
// and clears it while the shell is still reachable.
protected override void OnEntered() =>
GetFirstAncestorOfType<SettingsScreen>()?.ShowPane("Profile");
protected override void OnLeaving(SusRoute toRoute) =>
GetFirstAncestorOfType<SettingsScreen>()?.ClearPane();Именно так поступает сэмпл AdvancedRouting (SettingsPaneScreen).
Когда вызывается:
- Для каждого экрана, который покидает активную цепочку маршрута: и для удаляемых из дерева, и для уходящих в кэш KeepAlive.
- В порядке лист → корень: вложенный дочерний экран — раньше родителя.
- Не вызывается для экрана, который остаётся: общего префикса вложенной цепочки (
/settings/profile→/settings/privacyсохраняет/settings) или при обновлении props того же инстанса экрана (OnBeforeRouteUpdate). - Не вызывается повторно, когда закэшированный экран вытесняется из KeepAlive, — он уже покинул цепочку.
Имейте в виду:
OnLeavingвыполняется раньшеOnLeftи всегда с живым деревом; о второй половине пары см.OnLeft.- Guard
BeforeResolveвыполняется послеOnLeaving. Если он отменяет навигацию, экран остаётся активным, хотяOnLeavingуже вызван, аOnEnteredповторно не вызывается.OnLeftпри этом не выполнялся, так что экран цел. Тем не менее проверки, которые могут отменить уход, лучше ставить вOnBeforeLeaveили в другой guard, который выполняется доOnLeaving, чтобы побочные эффектыOnLeavingникогда не откатывались на полпути.
3. Доступ к данным маршрута
SusRouter объединяет параметры пути (:id) и query (?tab=x) в один словарь Props. Читайте их через хелперы:
// route "/users/:id", URL "/users/42?tab=profile"
string id = GetParam("id"); // "42" (path param)
string tab = GetQuery("tab"); // "profile" (query)
int page = GetProp("page", 1); // typed, with default
object raw = GetProp("payload"); // untyped| Член | Тип | Назначение |
|---|---|---|
Router | SusRouter | владелец экрана (навигация, модалки) |
Props | Dictionary<string,object> | params + query + переданные props (никогда не null) |
IsActive | bool | активен ли экран сейчас |
GetProp<T>(key, def) | T | типизированное чтение с конвертацией |
GetParam(key, def) / GetQuery(key, def) | string | алиасы в Props (params/query уже объединены) |
4. Подключение экрана — три способа
4.1 Императивно — router.Register(path, type, config?)
var router = new SusRouter();
router.Register("/", typeof(HomeScreen));
router.Register("/about", typeof(AboutScreen));
router.Register("/users/:id", typeof(UserScreen),
new SusRouteConfig { Name = "user", KeepAlive = true });
router.Mount(root, "/"); // or Mount(uiDocument, "/")Register возвращает SusRouteRecord — его можно вложить в родительский Children (см. §5, вложенные).
4.2 Декларативно — SusRouteBuilder через SusApp.UseRouter
SusApp.Create(uiDocument)
.UseTheme(SusTheme.Dark)
.UseRouter(new SusRouter(), routes => routes
.Route("/", typeof(HomeScreen)).Name("home")
.Route<LoginScreen>("/login").Alias("/signin")
.Route("/users/:id", typeof(UserScreen))
.Name("user").KeepAlive().Meta("requiresAuth", true)
.Children(c => c
.Route("profile", typeof(ProfileScreen))
.Route("posts", typeof(PostsScreen))),
initialPath: "/")
.Run();4.3 Императивный UseRouter (Register внутри SusApp)
SusApp.Create(uiDocument)
.UseRouter(new SusRouter(), r =>
{
r.Register("/", typeof(HomeScreen));
r.Register("/settings", typeof(SettingsScreen));
}, initialPath: "/")
.Run();UseRouter встраивает регистрацию + Mount в нужную точку финализации SusApp (после token cascade / custom styles, до применения темы).
5. Все опции SusRouteConfig
| Опция | Тип | Что делает |
|---|---|---|
Name | string | имя для PushNamed/ReplaceNamed |
KeepAlive | bool | не пересоздавать экран при выходе (кэш инстанса) |
Alias | List<string> | дополнительные пути, ведущие на этот маршрут |
Children | List<SusRouteRecord> | вложенные маршруты (один уровень) |
Redirect | string | при входе — перейти сюда вместо этого маршрута |
DefaultProps | Dictionary<string,object> | props по умолчанию для экрана |
PropsFn | Func<SusRoute, Dictionary<string,object>> | генератор props из маршрута (аналог Vue props: route => ({...})) |
LazyFactory | Func<SusScreen> | отложенное создание экрана (вместо Activator.CreateInstance) |
Guard | ISusRouteGuard | per-route guard CanEnter/CanLeave |
BeforeEnter | SusRouterGuard | функциональный guard входа (после Guard.CanEnter) |
Transition | SusRouteTransition | анимация перехода (Fade(), SlideLeft(), …) |
Meta | Dictionary<string,object> | произвольные метаданные (requiresAuth, title) |
CaseSensitive | bool | регистрозависимое сопоставление пути (по умолчанию выкл.) |
Strict | bool | завершающий слэш имеет значение (/a ≠ /a/) |
Примеры для каждой опции
// KeepAlive + Transition
router.Register("/feed", typeof(FeedScreen),
new SusRouteConfig { KeepAlive = true, Transition = SusRouteTransition.Fade() });
// Named + params → PushNamed
router.Register("/users/:id", typeof(UserScreen), new SusRouteConfig { Name = "user" });
router.PushNamed("user", new() { ["id"] = "42" });
// Alias + Redirect
router.Register("/home", typeof(HomeScreen), new SusRouteConfig { Alias = new() { "/start" } });
router.Register("/old", typeof(HomeScreen), new SusRouteConfig { Redirect = "/home" });
// DefaultProps + PropsFn
router.Register("/report", typeof(ReportScreen), new SusRouteConfig
{
DefaultProps = new() { ["format"] = "pdf" },
PropsFn = route => new() { ["id"] = route.Params.GetValueOrDefault("id") },
});
// Lazy + Meta + Guard
router.Register("/admin", typeof(AdminScreen), new SusRouteConfig
{
LazyFactory = () => new AdminScreen(),
Meta = new() { ["requiresAuth"] = true },
BeforeEnter = (from, to) => Session.IsAdmin,
});
// Nested (Children) via Register records
var users = router.Register("/users", typeof(UsersScreen), new SusRouteConfig
{
Children = new()
{
router.Register("/users/:id", typeof(UserDetailScreen)),
router.Register("/users/search", typeof(UserSearchScreen)),
}
});6. Навигация (методы SusRouter)
| Метод | Назначение |
|---|---|
Push(path, props?) | добавить в историю и перейти |
Replace(path, props?) | заменить текущую запись |
PushNamed(name, pathParams?, props?) | перейти по имени маршрута |
ReplaceNamed(name, pathParams?, props?) | заменить по имени |
Back() / Forward() | история (на основе курсора) |
Go(n) | смещение на n шагов |
NavigateWithTransition(path, …) | переход с явной анимацией |
CanGoBack / CanGoForward | доступность истории (для кнопок) |
Modal(type, props?) / CloseModal() | модалки через ModalService |
Все Push*/Replace*/Back/Forward/Go возвращают NavigationResult (успех / отмена guard'ом / редирект).
if (Router.CanGoBack) Router.Back();
Router.Push("/checkout", new() { ["cartId"] = cart.Id });
Router.Modal(typeof(InfoDialog), new() { ["text"] = "Done!" });7. Вложенные экраны
Родительский экран регистрирует дочерний SusRouteView, в который роутер монтирует дочерние экраны:
public class UsersScreen : SusScreen
{
protected override void Build()
{
Add(new Label("Users"));
var childView = new SusRouteView();
RegisterChildView(childView); // router mounts /users/:id etc. here
Add(childView);
}
}ChildView (первый) и ChildViews (все) доступны из кода. Разрешение цепочки выбирает MatchedChain записей от корня до листа.
8. Точка входа (MonoBehaviour)
[RequireComponent(typeof(UIDocument))]
public class AppEntry : MonoBehaviour
{
[SerializeField] private UIDocument _uiDocument;
private void OnEnable()
{
// Create(UIDocument) applies SusDefault.tss to the panel; prefer it over
// Create(rootVisualElement), which skips TSS.
SusApp.Create(_uiDocument)
.UseTheme(SusTheme.Dark)
.UseRouter(new SusRouter(), routes => routes
.Route("/main-menu", typeof(MainMenuScreen)).Name("menu")
.Route("/battle/:matchId", typeof(BattleScreen))
.KeepAlive().Transition(SusRouteTransition.Fade()),
initialPath: "/main-menu")
.Run();
}
}Интеграция с SusComponent
OnEntered()вызывается передMounted(). Логику, которой нужны дочерние элементы, отсутствующие на моментBuild(), кладите вMounted().Router/Propsдоступны сOnBeforeEnter(), но не вCreated()/Build().Unmounted()— при отсоединении от панели (послеOnLeft()); при KeepAlive экран отсоединяется в кэш вне дерева, иUnmounted()не вызывается.
Связанные документы
- 02-router-api.md — полный API
SusRouter - 04-routeview.md —
SusRouteViewи KeepAlive - 05-modals.md — модалки
- 06-guards-transitions.md — конвейер guard'ов и анимации