В первой статье я рассказывала, как переводила приложение для торговых представителей с 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.Controls 10.0.110.

Что изменилось в iOS 26

С iOS 26 (Liquid Glass) панель вкладок перестала быть полосой у нижнего края. Теперь она плавает поверх контента: полупрозрачная, со скруглёнными краями и отступом от краёв экрана. Нативные приложения просто прокручивают контент под ней, и он виден сквозь стекло.

Каталог плиткой на iPad: карточки товаров видны сквозь плавающую панель вкладок. Так это выглядит после всех правок. Демо-база.
Каталог плиткой на iPad: карточки товаров видны сквозь плавающую панель вкладок. Так это выглядит после всех правок. Демо-база.

Моё приложение без правок выглядело иначе:

  1. Под плавающей панелью оставалась пустая полоса. Страница отступала снизу на безопасную зону, а в неё теперь входит и сама панель.

  2. Линия-разделитель над панелью висела в воздухе. На Android она отделяет контент от таб-бара, а на iOS под ней больше ничего нет.

  3. Нижние панели страниц оказались вплотную к плавающей панели. На Маршруте внизу были дата маршрута и переключатель «Маршрут / Все», на Документах — период и кнопка «Сегодня». У нижнего края получалось два ряда управления подряд.

  4. Карта маршрута обрезалась белой полосой. Карта — это Яндекс.Карты в 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: раскладка экрана остаётся одна, и поддерживать её проще.

Маршрут на iPad: сверху до правок, снизу после. Дата и «Маршрут / Все» переехали под поиск, карта доходит до низа экрана, подпись Яндекса — над панелью. Демо-база.
Маршрут на iPad: сверху до правок, снизу после. Дата и «Маршрут / Все» переехали под поиск, карта доходит до низа экрана, подпись Яндекса — над панелью. Демо-база.

Агенты на 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, и о нём я расскажу отдельно, когда он состоится.

Полезные материалы

Комментарии (0)