Site icon AppTractor

Поддержка нескольких версий iOS в SwiftUI без превращения ваших представлений в хаос

Одно из главных достоинств SwiftUI — это скорость его развития. На каждой конференции WWDC представляются новые API, новые модификаторы и небольшие улучшения, повышающие удобство использования, благодаря которым код пользовательского интерфейса становится чище и выразительнее. Естественно, вам хочется начать их использовать.

А потом компилятор напоминает вам, что ваше приложение по-прежнему поддерживает старые версии iOS.

'sectionIndexLabel' is only available in iOS 26.0 or newer

На первый взгляд, это не кажется большой проблемой. Swift поддерживает проверки доступности уже много лет, поэтому обертывание нового кода в директиву if #available кажется очевидным решением.

К сожалению, SwiftUI не всегда так прост.

Большинство представлений построены как длинные цепочки модификаторов, и вы не можете просто добавить проверку доступности в середину одного из них. Дублирование всего представления для разных версий iOS работает, но быстро становится сложным в поддержке по мере роста вашего UI.

Вместо этого цель должна заключаться в изоляции логики совместимости, сохраняя при этом само представление практически идентичным.

В этой статье мы рассмотрим несколько методов, которые значительно упрощают поддержку нескольких версий iOS без ущерба для читаемости.

Если вы предпочитаете не создавать эти вспомогательные функции самостоятельно, я также создал SwiftUIBackportKit — легковесный, не зависящий от зависимостей пакет Swift, который инкапсулирует шаблоны из этой статьи в многократно используемые API, а не в вспомогательные функции, которые нужно просто скопировать и вставить.

Проверка доступности API

Swift уже предоставляет встроенные проверки доступности именно для этой цели.

if #available(iOS 26, *) {
    // New implementation
} else {
    // Older implementation
}

Или, если вам удобнее мыслить категориями неподдерживаемых версий:

if #unavailable(iOS 26) {
    // Older implementation
}

Когда приложение запускается на более новой версии iOS, Swift выполняет первую ветку. В противном случае он безопасно возвращается к альтернативной реализации. Если вы раньше работали с UIKit, этот шаблон, вероятно, вам знаком.

Примечание: Если вы экспериментируете с версией iOS, которая все еще находится в бета-версии, старые версии Xcode пока ее не распознают. Обычно проще сохранять эти изменения в отдельной ветке до тех пор, пока не станет доступен соответствующий релиз Xcode.

Почему это не работает внутри цепочки модификаторов?

Именно здесь SwiftUI создает проблемы для многих разработчиков. Представьте, что у вас есть простое представление:

Text("Hello, World!")
    .padding()
    .foregroundStyle(.blue)

Теперь предположим, что функция newModifier() доступна только в iOS 26.

Ваша первая мысль может быть примерно такой:

Text("Hello, World!")
    .padding()
if #available(iOS 26, *) {
    .newModifier()
}

Но, к сожалению, это не скомпилируется.

Причина в том, что цепочка модификаторов — это не последовательность операторов, а единое выражение.

Каждый модификатор возвращает совершенно новое представление, которое становится входными данными для следующего модификатора. Как только вы прерываете это выражение оператором if, компилятор больше не может построить иерархию представлений.

Таким образом, хотя директива #available прекрасно работает в обычном коде Swift, она не вписывается естественным образом в цепочку модификаторов.

Вместо того чтобы дублировать целое представление только для условного применения модификатора, чище перенести эту условную логику куда-нибудь ещё.

Небольшой хелпер для условных модификаторов

Достаточно простого расширения.

import SwiftUI
extension View {
    @ViewBuilder
    func applying<Content: View>(
        @ViewBuilder _ transform: (Self) -> Content
    ) -> some View {
        transform(self)
    }
}

И вместо того, чтобы прерывать цепочку модификаторов, оберните текущее представление в замыкание.

Section {
}
.applying { view in
    if #available(iOS 26, *) {
        view.sectionIndexLabel(Text("Favorites"))
    } else {
        view
    }
}

Цепочка модификаторов остается неизменной, поскольку Swift по-прежнему видит одно выражение. Условная логика просто находится внутри замыкания, где допускается управление потоком выполнения.

Одна из деталей, которую легко упустить из виду, — это ветка else.

else {
    view
}

Всегда возвращайте исходное представление, если более новый API недоступен. Без этого эта часть вашего интерфейса полностью исчезает в старых версиях iOS.

Этот шаблон хорошо работает, когда вам нужно условно применять один или несколько модификаторов без изменения структуры остальной части представления.

Иногда вам не нужны разные представления

Не каждая проблема совместимости связана с совершенно новым API. Иногда модификатор уже существует в каждой поддерживаемой версии — вам просто нужны разные значения в зависимости от ОС.

Распространенные примеры: отступы, интервалы, время анимации и корректировка макета.

Вместо того чтобы разветвлять иерархию представлений, разветвляйте само значение.

func platformValue<T>(
    new: T,
    old: T
) -> T {
    if #available(iOS 26, *) {
        new
    } else {
        old
    }
}

Теперь модификатор остаётся точно таким же.

.padding(.trailing, platformValue(new: 0, old: 16))

Лично я предпочитаю этот подход, когда изменяется только значение. Он делает цепочку модификаторов легко читаемой, поскольку ничто не прерывает её работу.

Логика совместимости повторного использования

Если вы обнаружите, что пишете одну и ту же проверку доступности во всём проекте, обычно стоит выделить её в отдельный пользовательский модификатор.

extension View {
    @ViewBuilder
    func compatibleSectionIndexLabel(_ label: Text) -> some View {
        if #available(iOS 26, *) {
            self.sectionIndexLabel(label)
        } else {
            self
        }
    }
}

Использовать его очень удобно, как любой встроенный модификатор SwiftUI.

Section {
}
.compatibleSectionIndexLabel(Text("Favorites"))

Это имеет ещё одно преимущество, помимо читаемости.

Когда вы в конечном итоге прекратите поддержку старых версий iOS, обновить нужно будет только одну реализацию. Удалите код совместимости здесь, и каждое использование автоматически начнёт использовать нативный API.

Упорядочиваем всё с помощью пространства имён для обратной совместимости

Для небольших проектов пользовательских модификаторов обычно вполне достаточно.

Однако по мере роста проекта могут накапливаться десятки вспомогательных средств для обеспечения совместимости. Префиксы вроде compatible... или legacy... со временем начинают только засорять код.

Более аккуратный подход — объединить такие средства в отдельном пространстве имён.

import SwiftUI
struct Backport<Content> {
    let content: Content
}
extension View {
    var backport: Backport<Self> {
        Backport(content: self)
    }
}

После этого можно добавить интерфейсы совместимости, которые по структуре и стилю будут максимально близки к собственным API SwiftUI.

extension Backport where Content: View {
    @ViewBuilder
    func sectionIndexLabel(_ label: Text) -> some View {
        if #available(iOS 26, *) {
            content.sectionIndexLabel(label)
        } else {
            content
        }
    }
}

Это позволяет вам писать:

Section {
}
.backport.sectionIndexLabel(Text("Favorites"))

Мне нравится этот подход, потому что код совместимости становится сразу узнаваемым. Каждый раз, когда я вижу .backport, я понимаю, что передо мной интерфейс, который существует только из-за ограничений минимальной версии системы.

Кроме того, в будущем такой код гораздо проще удалять. Как только вы повышаете минимальную поддерживаемую версию системы, весь код совместимости находится в одном месте, а не разбросан по всему проекту.

Практичный вариант в виде SDK

На этом этапе есть два варианта:

Для многих команд второй вариант удобнее. Именно поэтому я создал SwiftUIBackportKit.

Это лёгкий пакет Swift без внешних зависимостей, который превращает описанные в статье приёмы в готовые к повторному использованию интерфейсы. Благодаря этому вам не придётся заново создавать одну и ту же инфраструктуру в каждом проекте.

В него входят:

Вот простой пример:

import SwiftUI
import SwiftUIBackportKit
struct RowView: View {
    let title: String
    var body: some View {
        Text(title)
            .font(.headline)
            .modify {
                if #available(iOS 17, *) {
                    $0.contentTransition(.numericText())
                } else {
                    $0
                }
            }
            .padding(
                .vertical,
                platformValue(12, ifAtLeast: OSVersion(17), else: 8)
            )
    }
}

В пакет также входит постоянно пополняющаяся коллекция перенесенных API SwiftUI, модульные тесты и пример приложения.

Если эти шаблоны покажутся вам полезными, вы можете найти их здесь. Не забудьте поставить звездочку ⭐️ репозиторию, если он вам понравился и оказался полезным!

Какой подход следует использовать?

Единого «правильного» решения не существует. Вот подход, которому я обычно следую:

Ситуация Рекомендуемый подход
Разовая проверка совместимости if #available
Условное применение модификатора applying {}
Изменяется только значение Вспомогательная функция
Многократное использование одной и той же логики Пользовательский модификатор
Поддержка большого количества интерфейсов совместимости Пространство имён .backport

Как и в большинстве случаев, начинать стоит с простого.

Если проверка доступности встречается только один раз, не нужно усложнять решение. Если же такие проверки начинают появляться по всему проекту, обычно это хороший признак того, что логику пора вынести в повторно используемый компонент.

Заключение

Поддержка нескольких версий iOS начинается довольно просто, но по мере роста приложения быстро может превратиться в хаос. Цель не в том, чтобы полностью отказаться от проверок доступности, а в том, чтобы не позволить им захватить весь код SwiftUI.

Если изолировать логику совместимости во вспомогательных функциях, повторно используемых модификаторах или отдельном пространстве имён для обратной совместимости, представления смогут по-прежнему описывать интерфейс, не отвлекаясь на ограничения минимальной поддерживаемой версии системы.

Такие подходы особенно полезны при внедрении новейших возможностей SwiftUI с одновременной поддержкой старых версий iOS. Хороший пример — новый язык дизайна Liquid Glass, представленный в последних релизах. Новые интерфейсы и визуальный стиль можно внедрять постепенно, не отказываясь от совместимости и не засоряя код представлений многочисленными проверками версий.

Спасибо за чтение и удачной разработки! 🚀

Источник

Exit mobile version