К содержимому

3. SusScreen — экраны, жизненный цикл и подключение ​

Экран (SusScreen) — полноэкранное представление, которым управляет SusRouter. Он наследует SusComponent, поэтому обладает полным core-набором реактивности (Prop<T>, Watch, Build(), Mounted()), плюс жизненный цикл роутера и доступ к параметрам маршрута.


1. Минимальный экран ​

csharp
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 virtual On* (паттерн template-method).

csharp
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() — место для отписки от событий и освобождения ресурсов. Он подчиняется пяти инвариантам:

  1. Один раз. Каждый экран, который навигация убирает из активной цепочки и не кладёт в кэш KeepAlive, получает OnLeft() ровно один раз.
  2. От листа к корню. Тот же набор и порядок, что у OnLeaving: вложенный ребёнок выполняется раньше родителя.
  3. В дереве. Он выполняется до того, как роутер что-либо отсоединит, поэтому panel и parent живы (кроме вытеснения из KeepAlive, см. 5).
  4. После последней точки отмены. Он выполняется после guard'ов, BeforeResolve и BeforeEnter каждого экрана цепочки цели. OnBeforeEnter, возвращающий false, отменяет навигацию на любом уровне вложенной цели (корень, промежуточный или лист) и для экрана, взятого из кэша KeepAlive; OnBeforeRouteUpdate вызывается на каждом переиспользуемом экране общего префикса цепочки (независимо от того, изменились ли его Props), и возврат false из любого из них отменяет навигацию. Решайте до изменения состояния: ничего, что вы изменили внутри отклонившего колбэка, не откатывается. Отменённая навигация никогда не оставляет активный экран, который уже очищен.
  5. KeepAlive. Экран, перемещённый в кэш, при уходе не получает OnLeft(). Он получит его, когда кэш вытеснит экран (LRU) или при ClearKeepAliveCache, и тогда он выполняется вне дерева: в кэше лежат отсоединённые экраны.
ПутьOnLeavingOnLeftДерево в 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, — ещё на месте. Здесь откатывают то, что экран сделал со своими предками:

csharp
// 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. Читайте их через хелперы:

csharp
// 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
ЧленТипНазначение
RouterSusRouterвладелец экрана (навигация, модалки)
PropsDictionary<string,object>params + query + переданные props (никогда не null)
IsActiveboolактивен ли экран сейчас
GetProp<T>(key, def)Tтипизированное чтение с конвертацией
GetParam(key, def) / GetQuery(key, def)stringалиасы в Props (params/query уже объединены)

4. Подключение экрана — три способа ​

4.1 Императивно — router.Register(path, type, config?) ​

csharp
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 ​

csharp
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) ​

csharp
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 ​

ОпцияТипЧто делает
Namestringимя для PushNamed/ReplaceNamed
KeepAliveboolне пересоздавать экран при выходе (кэш инстанса)
AliasList<string>дополнительные пути, ведущие на этот маршрут
ChildrenList<SusRouteRecord>вложенные маршруты (один уровень)
Redirectstringпри входе — перейти сюда вместо этого маршрута
DefaultPropsDictionary<string,object>props по умолчанию для экрана
PropsFnFunc<SusRoute, Dictionary<string,object>>генератор props из маршрута (аналог Vue props: route => ({...}))
LazyFactoryFunc<SusScreen>отложенное создание экрана (вместо Activator.CreateInstance)
GuardISusRouteGuardper-route guard CanEnter/CanLeave
BeforeEnterSusRouterGuardфункциональный guard входа (после Guard.CanEnter)
TransitionSusRouteTransitionанимация перехода (Fade(), SlideLeft(), …)
MetaDictionary<string,object>произвольные метаданные (requiresAuth, title)
CaseSensitiveboolрегистрозависимое сопоставление пути (по умолчанию выкл.)
Strictboolзавершающий слэш имеет значение (/a ≠ /a/)

Примеры для каждой опции ​

csharp
// 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'ом / редирект).

csharp
if (Router.CanGoBack) Router.Back();
Router.Push("/checkout", new() { ["cartId"] = cart.Id });
Router.Modal(typeof(InfoDialog), new() { ["text"] = "Done!" });

7. Вложенные экраны ​

Родительский экран регистрирует дочерний SusRouteView, в который роутер монтирует дочерние экраны:

csharp
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) ​

csharp
[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() не вызывается.

Связанные документы ​