Я оказался в этой ситуации на реальном проекте — desktop-приложении на Avalonia. Нужно было объяснить пользователю пять экранов, три из которых со скрытыми боковыми панелями и элементами, зависящими от режима. Я написал онбординг прямо внутри приложения. А потом понял, что написал ровно ту вещь, которой в экосистеме Avalonia не хватает в принципе — и вынес её в отдельную библиотеку. Читать далее
Веб-разработка давно решила задачу онбординга. Intro.js, Shepherd.js, Driver.js, react-joyride, Reactour — открой любой SaaS, и с высокой вероятностью первым делом увидишь затемнение экрана, подсвеченную кнопку и карточку с текстом «нажмите сюда». Индустрия договорилась: если интерфейс не тривиален — пользователю нужно провести тур.
В desktop-разработке этого пласта инструментов просто нет. WPF, WinForms, Avalonia, MAUI — хоть один завод. Максимум, что есть «из коробки» — ToolTip и, если повезло, TeachingTip из FluentAvalonia или WinUI: одиночный callout со стрелочкой, без последовательности шагов, без прогресса, без пропуска скрытых элементов, без затемнения.
При этом desktop-интерфейсы не становятся проще. Панели инструментов, дашборды с десятком виджетов, режимы работы, скрытые за условиями элементы — сложность растёт, а инструментов объяснить это пользователю как не было, так и нет. Каждый, кому это понадобилось, изобретает костыль заново, внутри своего приложения, без возможности переиспользовать.
Я оказался в этой ситуации на реальном проекте — desktop-приложении на Avalonia. Нужно было объяснить пользователю пять экранов, три из которых со скрытыми боковыми панелями и элементами, зависящими от режима. Я написал онбординг прямо внутри приложения. А потом понял, что написал ровно ту вещь, которой в экосистеме Avalonia не хватает в принципе — и вынес её в отдельную библиотеку.

screenshot
Что получилось: Coachlight.AvaloniaCoachlight.Avalonia — open-source (MIT) NuGet-пакет для интерактивных туров по интерфейсу в приложениях на Avalonia. Coachmark’и, затемнение-прожектор, модальные шаги, автоопределение стороны карточки, локализация, опциональная персистентность прогресса — и всё это с нулём зависимостей поверх самой Avalonia.
var tour = TourBuilder.Create("demo")
.Modal(s => s
.Title("Добро пожаловать!")
.Text("Короткий тур по интерфейсу."))
.Coachmark("btnConnect", s => s
.Placement(Side.Right)
.Title("Подключение")
.Text("Эта кнопка подключает к роботу."))
.Build();
anchor.StartTour(tour); // anchor — любой Visual, уже прикреплённый к окну
Три строки XAML, чтобы пометить цель:
<Button xmlns:cl="clr-namespace:Coachlight.Avalonia.Targeting;assembly=Coachlight.Avalonia"
cl:Coachmark.Id="btnConnect" Content="Подключиться"/>
И тур уже работает — с затемнением, подсветкой, кнопками навигации и прогрессом.
Принципы, вокруг которых строилась архитектураZero dependencies. Библиотека не тянет за собой ни CommunityToolkit.Mvvm, ни ReactiveUI, ничего. Если потребитель использует другой MVVM-стек (или вообще code-behind) — он не получит в своём дереве зависимостей чужой пакет только потому, что где-то внутри понадобилась команда для кнопки. Пришлось написать собственный 15-строчный RelayCommand — но это разумная цена за то, что библиотекой сможет пользоваться кто угодно, без конфликтов версий MVVM-библиотек в чужом графе зависимостей.
Fully re-themeable. Карточка подсказки — это TemplatedControl с дефолтной ControlTheme, и каждый цвет — DynamicResource. Хочешь другую цветовую схему — переопредели ключи ресурсов. Хочешь другую структуру карточки целиком — подставь свою ControlTheme, она увидит тот же набор bindable-свойств (Title, Content, ProgressText, IsFirst, IsLast, команды навигации). Светлая/тёмная тема переключаются автоматически через ThemeDictionaries и ActualThemeVariant — ничего конфигурировать не нужно.
Opt-in persistence. Библиотека никогда не трогает диск, если её явно об этом не попросили. StartTour(anchor, tour) просто показывает тур. Хочешь показать один раз на пользователя — передай IProgressStore:
static readonly IProgressStore Store = new JsonProgressStore();
anchor.StartTour(tour, Store); // покажется один раз
anchor.StartTour(tour, Store, force: true); // всегда (например, кнопка «?»)
Дефолтная реализация пишет JSON в стандартную папку данных приложения, но это интерфейс — подставь свою (БД, облако, что угодно).
Технические задачи, которые оказались интереснее, чем казалосьПрожектор, который не мешает кликатьЗатемнение с «дыркой» вокруг цели строится через StreamGeometry с FillRule.EvenOdd: два контура (внешний прямоугольник и скруглённый прямоугольник цели) взаимно вычитаются. Но стандартный Path в Avalonia хит-тестится по прямоугольным Bounds, а не по фактической геометрии — значит, дырка тоже «ловила» бы клики, и подсвеченный элемент оказался бы недоступен для взаимодействия.
Решение — ICustomHitTest на кастомном контроле затемнения:
internal class SpotlightDim : Control, ICustomHitTest
{
public bool HitTest(Point point) => Data?.FillContains(point) ?? false;
}
FillContains учитывает EvenOdd — внутри дырки геометрия не залита, значит false, и клик уходит вниз, к реальному элементу. Это же и дало неожиданный бонус: если элемент под прожектором интерактивен (например, стик виртуального геймпада), пользователь может взаимодействовать с ним прямо во время тура.
Первая версия считала прямоугольник цели через голые Bounds и TranslatePoint — и ошибалась всякий раз, когда цель лежала внутри Viewbox или под другим масштабирующим трансформом (типичный кейс — кастомная разметка внутри Canvas, растянутая под весь экран). Правильный способ — проецировать локальный bounding box через полную матрицу трансформации:
if (target.TransformToVisual(this) is not { } matrix) return null;
var rect = new Rect(target.Bounds.Size).TransformToAABB(matrix);
TransformToAABB учитывает масштаб, поворот, вложенность — то, что TranslatePoint в сочетании с сырыми Bounds.Width/Height игнорирует.
Часть шагов привязана к элементам, которые видны не всегда (кнопка «Запустить» только в режиме реального робота, «Подключиться» — только в режиме симулятора). Простое _index++ в Next() могло вывести пользователя на пустое место. Контроллер тура ищет ближайший показываемый шаг в направлении навигации:
private void ShowStepFrom(int start, int direction)
{
for (int i = start; i >= 0 && i < StepCount; i += direction)
if (ShouldShow(_tour.Steps[i])) { ApplyStep(i); return; }
if (direction > 0) Complete(); // дальше показывать нечего — тур завершён
}
А «первый/последний шаг» (для кнопок «Назад»/«Готово») считается уже относительно показываемых шагов, а не по сырому индексу — иначе кнопка «Назад» могла бы вести в никуда.
Первый кадр не должен дёргатьсяСамая коварная задача — плавность. Наивная реализация анимирует позицию карточки через Transitions на Canvas.Left/Top — и первое появление тура выглядит как «карточка телепортируется из левого верхнего угла в нужную точку», потому что до первого позиционирования Canvas.Left/Top не заданы (по умолчанию 0,0), а анимация честно едет из (0,0) в целевую точку.
Решение: разделить «первое размещение когда-либо» и «переход между шагами». Первое — снапаем без анимации (временно выключаем Transitions, ставим позицию, включаем обратно). Всё последующее — едет плавно:
if (double.IsNaN(Canvas.GetLeft(_card)))
{
var transitions = _card.Transitions;
_card.Transitions = null;
Canvas.SetLeft(_card, x);
Canvas.SetTop(_card, y);
_card.Transitions = transitions;
}
else { Canvas.SetLeft(_card, x); Canvas.SetTop(_card, y); }
Плюс дебаунс: пересчёт позиции коалесцируется через DispatcherTimer вместо срабатывания на каждый LayoutUpdated, а после смены шага сохраняется короткое «окно прогрева» (~800мс) — чтобы поймать позднее оседание layout (открытие SplitView, ресайз окна) вместо того, чтобы остановиться на промежуточном, ещё не устоявшемся кадре.
Каждый шаг может иметь OnEnter/OnExit — не просто текст, а произвольный код, выполняемый пока шаг активен. В демо это используется, чтобы список сам скроллился туда-обратно, пока показывается подсказка про него:
.Coachmark("recentItemsList", s => s
.Title("Последние элементы")
.OnEnter(() => StartAutoScroll(anchor))
.OnExit(() => StopAutoScroll(anchor)))
Оба хука обёрнуты в try/catch внутри контроллера — если демонстрация потребителя упадёт с исключением, это не должно сломать навигацию по туру.
Библиотека таргетирует net8.0 и net10.0, а на Avalonia ссылается открытым диапазоном Version="11.0.0" — без верхнего потолка. Отдельно пришлось пройтись по всем публичным членам с XML-документацией (GenerateDocumentationFile).
Это первая публичная версия, и мне интересна обратная связь от тех, кто пишет desktop на Avalonia.
NuGet: dotnet add package Coachlight.Avalonia
Если пишете desktop-приложение сложнее пяти кнопок — попробуйте, буду рад issues и PR.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Plumix: как я переписываю Flutter на C# — вместе с ИИ | 0 | 6.01 | 07-07-2026 |
| 2 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |
| 3 | Навайбкодил десктоп-клиент для «Яндекс Книг» на WinUI 3 | 0 | 11.73 | 11-07-2026 |
| 4 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 5 | redb.Identity: OAuth 2.1 / OpenID сервер на .NET, где протокол отделён от транспорта, по шине или вообще без сети | 0 | 12.74 | 14-07-2026 |
| 6 | В 2008 году на Хабре уже переизобретали рабочий стол. Почему мы снова взялись за это в 2026-м | 0 | 8.42 | 24-07-2026 |
| 7 | Инди-геймдев как образ жизни: честный разговор о цене мечты | 0 | 11.82 | 14-07-2026 |
| 8 | Я ненавижу AutoMapper | 0 | 7.7 | 02-07-2026 |
| 9 | Как я навайбкодил каталог бесплатных парковок Петербурга и России | 5 | 7 | 07-07-2026 |
| 10 | Теплые и влажные Плитки для документов на C#: не проводник | 0 | 9.76 | 30-06-2026 |