Плавающая панель вкладок iOS 26 в .NET MAUI: что сломалось и как я это исправила

В первой статье я рассказывала, как переводила приложение для торговых представителей с Xamarin.Forms на .NET MAUI, а во второй — как ускоряла в нём списки. На Android новая версия уже работает у агентов, следующий шаг — переход на iPad. И перед этим шагом iOS 26 и Xcode 27 подкинули мне новую задачу.
В iOS 26 панель вкладок стала плавающей, и интерфейс, который до этого нормально выглядел на iPad, перестал с ней сочетаться. А сборка с Xcode 27 вообще перестала запускаться. В этой статье — что именно сломалось, как я это исправляла и какой код для этого понадобился.
Спойлер: платформенного кода получилось немного — один класс на 60 строк, один стиль и пара файлов для запуска. А ушло на всё два с половиной дня.
Исходные условия
Приложение — .NET MAUI 10, навигация через
ShellсTabBarна пять вкладок: Главная, Каталог, Маршрут, Документы, Ещё.Панель вкладок у
Shellвнизу на всех платформах и версиях Android и iOS, но выглядит везде по-разному. До iOS 26 на iPad это была обычная полоса внизу экрана.Устройства — iPad с iOS 26 и 27, а также старые iPad с панелью-полосой.
Сборка — Xcode 27,
Microsoft.Maui.Controls10.0.110.
Что изменилось в iOS 26
С iOS 26 (Liquid Glass) панель вкладок перестала быть полосой у нижнего края. Теперь она плавает поверх контента: полупрозрачная, со скруглёнными краями и отступом от краёв экрана. Нативные приложения просто прокручивают контент под ней, и он виден сквозь стекло.

Моё приложение без правок выглядело иначе:
Под плавающей панелью оставалась пустая полоса. Страница отступала снизу на безопасную зону, а в неё теперь входит и сама панель.
Линия-разделитель над панелью висела в воздухе. На Android она отделяет контент от таб-бара, а на iOS под ней больше ничего нет.
Нижние панели страниц оказались вплотную к плавающей панели. На Маршруте внизу были дата маршрута и переключатель «Маршрут / Все», на Документах — период и кнопка «Сегодня». У нижнего края получалось два ряда управления подряд.
Карта маршрута обрезалась белой полосой. Карта — это Яндекс.Карты в
WebView, и отступы MAUI на неё не действуют.
И отдельно — приложение, собранное с Xcode 27, падало сразу при запуске. С него и пришлось начать, потому что без этого остальное было не проверить.
Что я делала
1. Запуск с Xcode 27: переход на UIScene
Apple предупреждала об этом заранее. В TN3187 сказано, что в следующем крупном выпуске после iOS 26 жизненный цикл на сценах (UIScene) станет обязательным при сборке с актуальным SDK, а иначе приложение не запустится. Так и вышло: моя сборка с Xcode 27 на iOS 27 падала при запуске.
MAUI сцены поддерживает, их нужно только включить. Понадобились два файла.
Platforms/iOS/SceneDelegate.cs:
using Foundation;
namespace MyApp;
// Имя совпадает с UISceneDelegateClassName в Info.plist.
[Register("SceneDelegate")]
public class SceneDelegate : MauiUISceneDelegate
{
}
И манифест сцены в Platforms/iOS/Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>__MAUI_DEFAULT_SCENE_CONFIGURATION__</string>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
UIApplicationSupportsMultipleScenes стоит в false: приложение работает в одном окне, многооконность на iPad ему не нужна. Apple обязательным делает только переход на сцены, поддержка нескольких окон остаётся по желанию.
Заодно я обновила Microsoft.Maui.Controls с 10.0.80 до 10.0.110 под актуальный набор воркладов. У этого обновления был побочный эффект с MeasureFirstItem на iOS — о нём ниже, в граблях.
2. Страницы вкладок уходят под панель
В .NET 10 у MAUI появилось свойство SafeAreaEdges. Оно задаётся отдельно для каждой стороны: Container — отступить от системных панелей, None — рисовать до самого края.
Задача была такая: у страниц вкладок снять нижний отступ, чтобы контент уходил под плавающую панель, как в нативных приложениях. Но так, чтобы последнюю строку списка всё же можно было прокрутить выше панели, а не оставить под ней навсегда.
Одного SafeAreaEdges у страницы для этого мало: отступ безопасной зоны применяют и вложенные макеты. Поэтому я написала небольшой класс, который обходит дерево страницы:
// iOS 26+: панель вкладок плавает поверх страницы. Страница вкладки уходит под неё до низа
// экрана: ни она, ни её макеты не отступают снизу (кроме тех, кому отступ задан явно), а
// вертикальные списки получают «подвал», чтобы последняя строка прокручивалась выше панели.
// Прокрутка (ScrollView) отступает сама. Остальные страницы не трогаем.
public static class TabBarInset
{
public const double FooterHeight =
#if IOS
100;
#else
0;
#endif
public static void ExtendUnder(ContentPage page)
{
#if IOS
page.SafeAreaEdges = AllButBottom;
foreach (var element in page.GetVisualTreeDescendants().OfType<Element>())
Apply(element);
page.DescendantAdded += (_, e) => Apply(e.Element);
#endif
}
#if IOS
private static readonly SafeAreaEdges AllButBottom = new(
SafeAreaRegions.Container, SafeAreaRegions.Container, SafeAreaRegions.Container, SafeAreaRegions.None);
private static void Apply(Element element)
{
// Внутри списков и прокрутки отступы безопасной зоны не действуют.
if (IsInsideScrollingContent(element))
return;
switch (element)
{
case Layout layout when !layout.IsSet(Layout.SafeAreaEdgesProperty):
layout.SafeAreaEdges = AllButBottom;
break;
// Списки фиксированной высоты (меню, чипы) и горизонтальные — без «подвала».
case CollectionView { Footer: null, HeightRequest: < 0, ItemsLayout: ItemsLayout { Orientation: ItemsLayoutOrientation.Vertical } } list:
list.Footer = new ContentView { HeightRequest = FooterHeight };
break;
}
}
private static bool IsInsideScrollingContent(Element element)
{
for (var parent = element.Parent; parent is not null; parent = parent.Parent)
{
if (parent is ItemsView or ScrollView)
return true;
}
return false;
}
#endif
}
Подключается он одной строкой в конструкторе каждой страницы-вкладки — Главной, Каталога, Маршрута, Документов, «Ещё» и KPI — сразу после InitializeComponent, когда дерево страницы уже построено:
InitializeComponent();
Controls.TabBarInset.ExtendUnder(this);
Несколько решений, которые стоит пояснить:
«Подвал» у списка вместо нижнего
Padding.Footerпрокручивается вместе со строками: в покое контент виден сквозь стекло панели, а последний товар можно докрутить выше неё. НижнийPaddingуCollectionViewдал бы ту же пустую полосу, только внутри списка.Высота «подвала» — 100. Это значение я подобрала на устройствах: на iOS 26 и новее последняя строка уходит выше плавающей панели, а на старых iPad, где панель внизу отделена полоской, запас не выглядит дырой. На Android и Windows
FooterHeightравен нулю: там панель вкладок ничего не перекрывает.Без «подвала» для коротких и горизонтальных списков. В приложении есть вертикальные списки фиксированной высоты — меню, чипы. «Подвал» в 100 точек им только навредил бы, поэтому класс пропускает всё, у чего задан
HeightRequest.Явно заданный отступ не трогается. Если у макета
SafeAreaEdgesзадан в XAML, класс его не перезаписывает. Так отдельный блок может остаться над панелью — это пригодилось для карусели.DescendantAdded. Часть элементов появляется после конструктора: например, представления каталога создаются лениво, при первом выборе режима. Их класс обрабатывает по мере добавления.Всё под
#if IOS. На Android и Windows панель вкладок обычная, там ничего не меняется.

Каталог на iPad: сверху до правок — пустая полоса под списком и линия над панелью, снизу после — список уходит под панель. Строки-карточки вместо линий — отдельная правка того же дня: тонкие разделители на iPad пропадали у части строк. Демо-база.
3. Линия-разделитель — только на Android
Линия над таб-баром была на пяти страницах. Чтобы не расставлять OnPlatform по каждой, я вынесла её в стиль:
<!-- Линия над нижним таб-баром. На iOS не нужна: панель вкладок там своя (с iOS 26 — плавающая). -->
<Style x:Key="TabBarSeparatorLine" TargetType="BoxView" BasedOn="{StaticResource HorizontalLineStyle}">
<Setter Property="IsVisible" Value="{OnPlatform iOS=False, Default=True}" />
<Setter Property="InputTransparent" Value="True" />
</Style>
На страницах теперь просто <BoxView Style="{StaticResource TabBarSeparatorLine}" />.
4. Нижние панели страниц — наверх
Это уже решение по интерфейсу, а не по коду. Дату маршрута с переключателем «Маршрут / Все» и период документов с кнопкой «Сегодня» я перенесла наверх, под поиск. Сделала это сразу на всех платформах, без OnPlatform: раскладка экрана остаётся одна, и поддерживать её проще.

Агенты на Android этот перенос никак не прокомментировали. Возможно, так даже удобнее: дата и переключатель теперь рядом с поиском, и всё управление списком собрано в одном месте.
5. Исключение: карусель остаётся над панелью
В каталоге есть режим карусели: один большой товар на экране и строка под ним со счётчиком «1 / 56». Под стеклом эта строка уехала бы под панель, поэтому карусель должна оставаться над ней. Здесь пригодилось правило «явно заданный отступ не трогается»:
<!-- Карточка с нижней панелью целиком над плавающей панелью вкладок iOS. -->
<Grid RowDefinitions="*,Auto" RowSpacing="0"
SafeAreaEdges="{OnPlatform iOS='None, None, None, Container', Default=Container}">
TabBarInset видит, что свойство задано, и оставляет этот Grid как есть.

6. Карта в WebView: viewport-fit=cover
Страница внутри WebView считает безопасную зону сама, поэтому для карты понадобились правки в HTML.
Первая — разрешить странице рисовать под системными панелями:
<!-- viewport-fit=cover: на iOS карта рисуется до низа экрана, под плавающей панелью вкладок,
а не обрезается белой полосой; высоту панели даёт env(safe-area-inset-bottom). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
Вторая — поднять подпись Яндекса над панелью. Высоту панели CSS получает через env(safe-area-inset-bottom):
[class*="copyrights-pane"] { transform: translateY(calc(-1 * env(safe-area-inset-bottom, 0px))); }
Третья — чтобы балун и автоподбор масштаба не прятали метки под панелью. Здесь нужна та же высота, но уже в JavaScript, а env() из скрипта напрямую не прочитать. Я добавила на страницу невидимый элемент, которому эта высота задана в CSS, и измеряю его:
#safe-area-probe { position: absolute; visibility: hidden; height: env(safe-area-inset-bottom, 0px); }
// Поля [сверху, справа, снизу, слева] под кнопки MapView сверху и подпись Яндекса снизу
// (вместе с панелью вкладок iOS) — балун и пины не прячутся под ними.
function overlayMargin() {
var probe = document.getElementById('safe-area-probe');
return [64, 30, 40 + (probe ? probe.offsetHeight : 0), 30];
}
Эти поля передаются в zoomMargin при подборе масштаба и в balloonAutoPanMargin у меток. Такой приём с невидимым элементом пригодится в любом гибридном экране, где JavaScript должен знать размер безопасной зоны.
Обратные ходы и грабли
Опция Яндекса не убрала лишний блок. В углу карты был блок «Открыть в Яндекс Картах / Создать свою карту». В приложении для этого уже есть своя кнопка «Открыть в картах», поэтому блок был лишним. Опция suppressMapOpenBlock его не убирала, и пришлось скрыть его стилями:
[class*="gotoymaps"], [class*="gototech"], [class*="copyrights-promo"] { display: none !important; }
У такого решения понятный минус: если Яндекс поменяет имена классов, блок вернётся.
Чёрные тени на iOS 27. У меток и балунов на карте появились чёрные тени. Их пришлось выключить явно: iconShadow: false у меток и balloonShadow: false у балунов.
MeasureFirstItem после обновления MAUI. Обновление до 10.0.110, которое понадобилось для сборки под Xcode 27, принесло баг с перемериванием строк каталога на iOS: переиспользованная строка оставалась с размерами предыдущего товара. Подробно об этом — в прошлой статье, в разделе про грабли.
Что дальше
Приложение собирается под Xcode 27 и на iOS 26 и 27 выглядит так, как ожидают пользователи iPad. Впереди — сам переход агентов на новую версию на iPad, и о нём я расскажу отдельно, когда он состоится.
Полезные материалы
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.