SwiftUI имеет отличный API для стилизации view независимо от их реализации. В этом посте мы рассмотрим, как мы можем стилизовать пользовательские view таким же образом.

В прошлом году в ходе нескольких эпизодов на Swift Talk мы продемонстрировали, как создать собственный степпер для увеличения и уменьшения значения. Он был похож на Stepper в SwiftUI, но с API, который делает его стильным.

Этот пост является кратким изложением того, что мы рассмотрели тогда, а также несколькими приёмами, которым мы научились с тех пор, чтобы наши пользовательские стили view (представление, вью, вьюшка) работали ещё лучше, как built‑in (встроенные) в SwiftUI. В последующем посте мы рассмотрим несколько более продвинутых вариантов использования.

Стиль кнопки

Для начала давайте посмотрим на простую кнопку:

Мы можем изменить стиль этой кнопки, добавив несколько модификаторов view и используя другой метод для создания label (метки) кнопки:

Button {    
    // Continue
} label: {
    HStack {
        Spacer()
        Text("Continue")
        Spacer()
    }
    .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
    }
    .font(.system(.title2, design: .rounded, weight: .bold))
    .foregroundColor(.yellow)
    .background(Capsule().stroke(.yellow, lineWidth: 2))

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

Такая кнопка во всю ширину является распространённым стилем, но реализовать её непросто.
Такая кнопка во всю ширину является распространённым стилем, но реализовать её непросто.

Повторное использование стиля

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

Чтобы упростить применение одного и того же стиля ко многим view Button, один из вариантов — это создать новое view кнопки с API, аналогичным SwiftUI Button, и применить к нему стиль:

struct MyButton: View {
    var action: () -> Void
    var label: Label

    init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) {
        self.action = action
        self.label = label()
    }

    var body: some View {
        Button {
            action()
        } label: {
            HStack {
                Spacer()
                Label
                Spacer()
            }
            .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
        }
        .font(.system(.title2, design: .rounded, weight: .bold))
        .foregroundColor(.yellow)
        .background(Capsule().stroke(.yellow, lineWidth: 2))
    }
}

Однако создание обёртки view, поддерживающего те же удобные инициализаторы, что и обёртка view, может потребовать некоторой работы, как показано в приведённом ниже коде:

struct MyButton where Label == Text {
    @_disfavoredOverload
    init(_ title: some StringProtocol, role: ButtonRole? = nil, action: @escaping () -> Void) {
        self.action = action
        self.role = role
        self.label = Text(title)
    }
    init(_ titleKey: LocalizedStringKey, role: ButtonRole? = nil, action: @escaping () -> Void) {
        self.action = action
        self.role = role
        self.label = Text(titleKey)
    }
}

Например, как показано в этом примере кода, Button обрабатывает литерал String как LocalizedStringKey, используя шаблон @_disfavoredOverload. Подобные тонкости могут сделать написание встроенной замены встроенного view более трудоёмкой, чем ожидалось.

Хотя стиль, определённый в одном месте, это хорошо, важно не забывать использовать view MyButton вместо кнопки SwiftUI во всём приложении. В противном случае мы получим несогласованный стиль.

VStack {
    MyButton("OK") {
        // Confirm
    }
    Button("Cancel") {
        // Oops, wrong button
    }
}

К счастью, у SwiftUI есть API, решающий эту проблему.

View стилей

API view стилей SwiftUI работают так же, как модификаторы view font(_:) и tint(_:), поскольку они позволяют добавлять стиль в иерархию view и применять этот стиль ко всем соответствующим view в этой иерархии:

HStack {
    Button("Undo") { /* … */ }
    Button("Redo") { /* … */ }
}
.foregroundColor(.black)
.font(.system(.title3, design: .rounded).bold())
.tint(.yellow)
.buttonStyle(.borderedProminent)

Здесь цвет переднего плана, шрифт, оттенок и стиль кнопки, указанные в HStack, распространяются на каждую кнопку.

Мы можем использовать это поведение, чтобы убедиться, что один и тот же стиль кнопки используется во всем приложении, применяя модификатор в корне иерархии view:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .tint(.yellow)
                .buttonStyle(.borderedProminent)
        }
    }
}

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

Создание пользовательского стиля кнопки

Чтобы полностью настроить стиль кнопки, нам сначала необходимо что‑то, что SwiftUI может использовать для стилизации при отображении кнопки.

Модификатор buttonStyle(_:), который мы используем для установки стиля кнопок, принимает тип, соответствующий либо протоколу ButtonStyle, либо протоколу PrimitiveButtonStyle.

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

Для обоих протоколов требуется функция makeBody(configuration:). Конфигурация, переданная этой функции, имеет несколько свойств, представляющие кнопку, которую мы оформляем.

Мы можем использовать configuration.label, чтобы получить view, представляющее метку кнопки, и применить наш стиль к этой метке:

struct CustomButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            Spacer()
            configuration.label
            Spacer()
        }
        .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
        .font(.system(.title2, design: .rounded).bold())
        .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
        .foregroundColor(.yellow)
        .background {
            Capsule()
                .stroke(.yellow, lineWidth: 2)
        }
    }
}

Приведенный выше код позволяет нам установить стиль кнопки с помощью.buttonStyle(CustomButtonStyle()).

Другие встроенные view, для которых мы можем создавать собственные стили:

  • Toggle

  • DatePicker

  • Gauge

  • ProgressView

  • Label

  • LabeledContent

  • DisclosureGroup

  • ControlGroup

  • GroupBox

  • Form

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

.background {
    Capsule()
        .stroke(configuration.role == .destructive ? .red : .yellow, lineWidth: 2)
}
.opacity(configuration.isPressed ? 0.5 : 1)

VStack(spacing: 16) {
    Button(role: .destructive) {
        // Delete
    } label: {
        Label("Delete", systemImage: "trash")
    }
    Button("Cancel", role: .cancel) {
        // Cancel Deletion
    }
}
.buttonStyle(CustomButtonStyle())

Стилизация отключенных состояний

Примечательно, что в конфигурации отсутствует флаг, указывающий, выключена ли кнопка. Таким образом, чтобы установить выключенное состояние кнопки, мы используем модификатор view disabled(_:).

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

Однако в документации не указано, что этот модификатор устанавливает значение environment (среды). Таким образом, чтобы настроить кнопку, когда она отключена, нам необходимо проверить значение environment isEnabled:

@Environment(\.isEnabled) var isEnabled

func makeBody(configuration: Configuration) -> some View {
    // ...
        .saturation(isEnabled ? 1 : 0)
}

Стилизация пользовательских view

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

Хотя мы можем прибегнуть к добавлению модификаторов view для встроенного стиля в пользовательских view или обойтись тем, что предоставляет SwiftUI, мы можем добиться большего успеха, сделав наши настраиваемые view стилизуемыми. Давайте попробуем это на пользовательском элементе управления RangeSlider:

struct RangeSlider: View {
    @Binding
    var range: ClosedRange

    var bounds: ClosedRange?

    var label: Label

    init(value: Binding>, in bounds: ClosedRange? = nil, @ViewBuilder label: () -> Label) {
        self._value = value
        self.bounds = bounds
        self.label = label()
    }

    var body: some View {
        LabeledContent {
            // ...
        } label: {
            Label
        }
    }
}

Итак, что должно произойти, чтобы это view можно было стилизовать?

Возвращаясь к API стиля SwiftUI для кнопки, мы видим, что у него есть два протокола для оформления кнопки.

ButtonStyle упрощает создание новых стилей, так как позволяет Button позаботиться об обработке жестов, а PrimitiveButtonStyle даёт стилю контроль над реализацией взаимодействия с кнопкой.

Другие протоколы стилей в SwiftUI напоминают протокол PrimitiveButtonStyle тем, что они также дают контроль над взаимодействием со стилем. Например, при реализации ToggleStyle view Toggle не обрабатывает жест за нас. Вместо этого он предоставляет привязку для управления своим значением, которое стиль должен обновлять, когда пользователь касается view.

Чтобы дать больше контроля над стилями ползунка диапазона, определить, каким должно быть взаимодействие, мы можем основывать наш протокол стилей на примитивной версии API стилей кнопок SwiftUI.

API‑интерфейсы стилей состоят из трех частей — модификатора view, используемого для установки стиля, протокола и конфигурации стиля:

extension View {
    public func buttonStyle(_ style: S) -> some View where S : PrimitiveButtonStyle
}
public protocol PrimitiveButtonStyle {
    associatedtype Body : View

    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body

    typealias Configuration = PrimitiveButtonStyleConfiguration
}
public struct PrimitiveButtonStyleConfiguration {
    // ...

    public let role: ButtonRole?

    public let label: PrimitiveButtonStyleConfiguration.Label

    public func trigger()
}

Чтобы стилизовать RangeSlider так же, как и встроенные view, мы можем скопировать большую часть кода выше.

Мы можем начать с определения нашего собственного типа конфигурации стиля.

Свойства PrimitiveButtonStyleConfiguration могут показаться немного знакомыми, поскольку они почти напрямую сопоставляются с типами одного из инициализаторов Button:

public init(
    role: ButtonRole?,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
)

role передается «как есть», action представлено как триггерная функция, а label представляет собой view со стёртым типом.

Таким образом, для нашего собственного типа конфигурации мы можем использовать тот же подход и скопировать типы из нашего view в конфигурацию:

struct RangeSliderStyleConfiguration {
    @Binding
    let range: ClosedRange

    let bounds: ClosedRange?

    let label: Label
}

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

Приведенный выше код прост для значения и границ, поскольку мы можем просто скопировать их как есть. Однако для метки нам необходимо сделать что‑то другое, так как этот тип теперь относится к типу метки SwiftUI, а не к универсальному типу, который RangeSlider имеет для своей метки.

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

Поскольку существует неограниченное количество возможных типов кнопок, SwiftUI использует стирание типов, чтобы скрыть их все за непрозрачным view ButtonConfiguration.Label. Это изолирует стиль от конкретного параметризованного компонента и позволяет применить единый стиль к любой возможной кнопке.

Мы будем использовать тот же шаблон, чтобы сохранить общий RangeSlider над своей меткой, и пользователю API стиля не нужно знать, какие конкретные метки будут использоваться:

/// A type-erased label of a button.
public struct Label : View {
    public typealias Body = Never
}

SwiftUI определяет тип Body как Never, это означает, что он будет использовать частные API для отображения view метки.

Мы не можем сделать то, что делает здесь SwiftUI, но мы можем использовать AnyView для получения того же эффекта:

/// A type-erased label of a range slider.
struct Label: View {
    let underlyingLabel: AnyView

    init(_ label: some View) {
        self.underlyingLabel = AnyView(label)
    }

    var body: some View {
        underlyingLabel
    }
}

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

Для протокола стилей мы можем скопировать то, что делает SwiftUI:

protocol RangeSliderStyle {
    associatedtype Body: View

    @ViewBuilder func makeBody(configuration: Configuration) -> Body

    typealias Configuration = RangeSliderStyleConfiguration
}

Имея протокол, мы можем реализовать первый стиль, используя значения из конфигурации стиля:

struct DefaultRangeSliderStyle: RangeSliderStyle {
    func makeBody(configuration: Configuration) -> some View {
        GeometryReader { proxy in
            ZStack {
                Capsule()
                    .fill(.regularMaterial)
                    .frame(height: 4)
                Capsule()
                    .fill(.tint)
                    .frame(height: 4)
                    // Width and position...
                Circle()
                    .frame(width: 27, height: 27)
                    // Gesture handling and positioning...
                Circle()
                    .frame(width: 27, height: 27)
                    // Gesture handling and positioning...
            }
        }
        .frame(height: 27)
    }
}

Чтобы установить стиль для иерархии view, мы добавляем расширение к View, которое соответствует сигнатуре buttonStyle(_:), но мы можем использовать ключевое слово some, чтобы сделать его немного более кратким:

extension View {
    func rangeSliderStyle(_ style: some RangeSliderStyle) -> some View {
        environment(\.rangeSliderStyle, style)
    }
}

Встроенные стили спускаются вниз по иерархии view, как и значения environment. Таким образом, мы можем использовать значение environment для передачи стиля через иерархию view во view, которое должно быть оформлено. При этом мы получим то же поведение распространения стиля, что и встроенные стили.Чтобы иметь возможность поместить любой тип стиля, соответствующий нашему протоколу, в среду в качестве значения, нам необходимо сделать тип значения environment любым RangeSliderStyle.

Для значений environment требуется определённое значение по умолчанию. Стильные view SwiftUI всегда имеют стиль по умолчанию, поэтому мы можем определить, каким он должен быть для view здесь:

struct RangeSliderStyleKey: EnvironmentKey {
    static var defaultValue: any RangeSliderStyle = DefaultRangeSliderStyle()
}

extension EnvironmentValues {
    var rangeSliderStyle: any RangeSliderStyle {
        get { self[RangeSliderStyleKey.self] }
        set { self[RangeSliderStyleKey.self] = newValue }
    }
}

Имея значение environment, мы можем реализовать body нашего view, создав конфигурацию стиля с входными данными из нашего компонента, а затем вызвав метод makeBody для стиля, который мы получаем из environment:

struct RangeSlider: View {
    // ...
    @Environment(\.rangeSliderStyle) var style

    var body: some View {
        let configuration = RangeSliderStyleConfiguration(
            range: $range,
            bounds: bounds,
            label: .init(label)
        )

        style.makeBody(configuration: configuration)
    }
}

Поскольку body ожидает, что мы вернём конкретный тип view, а style, который мы получаем из среды, имеет тип any RangeSliderStyle, нам необходимо обернуть view, которое мы получаем от вызова makeBody, в AnyView:

AnyView(style.makeBody(configuration: configuration))

может быть оформлен типом, соответствующим протоколу стилей, и мы можем установить, какой стиль использовать, так же, как мы установили бы стиль для встроенного view SwiftUI:

Form {
    // ...
    RangeSlider(range: $sizeRange, in: minSize...maxSize) {
        Text("Size Range")
    }
    // ...
}.rangeSliderStyle(VerticalRangeSliderStyle())

И точно так же, как с пользовательскими стилями для стилей SwiftUI, мы можем добавить статический член в протокол стилей. Это позволяет нам установить стиль с тем же сокращённым синтаксисом, что и встроенные стили, благодаря чему наши пользовательские view соответствуют встроенным view:

extension RangeSliderStyle where Self == VerticalRangeSliderStyle {
    static var vertical: Self { .init() }
}

.rangeSliderStyle(.vertical)

Доступность

При создании пользовательских view важно также сделать их доступными. Мы можем сделать это, добавив модификаторы view, которые улучшают работу при использовании view, например, с VoiceOver:

struct MySlider: View {
    // ...

    var body: some View {
        // ...
        AnyView(style.makeBody(configuration: configuration))
            .accessibilityElement(children: .combine)
            .accessibilityValue(valueText)
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment: increment()
                case .decrement: decrement()
                @unknown default:
                    break
                }
            }
    }

    var valueText: Text {
        if bounds == 0.0...1.0 {
             return Text(value.wrappedValue, format: .percent)
         } else {
             return Text(value.wrappedValue, format: .number)
         }
    }
}

“Громкость 50%, регулируемая. Проведите вверх или вниз одним пальцем, чтобы отрегулировать значение”. - Голос за кадром

Здесь мы предполагаем, что большинство ползунков захотят использовать accessibilityAdjustableAction(_:), поэтому мы можем добавить этот модификатор во view ползунка. Таким образом, командам, работающим над стилями, нет необходимости беспокоиться о том, чтобы сделать стиль доступным, потому что компонент view уже доступен.

Использование значений Environment в пользовательском стиле

Для многих view важно иметь возможность адаптировать их к различным значениям environment. Например, элемент управления должен указывать, когда он отключен. Для эффекта подсветки для нажимаемого view может потребоваться другой цвет, если цветовая схема установлена на тёмную, или анимацию изменения состояния, возможно, придётся пропустить, если включен параметр уменьшения движения.

Также важно иметь возможность использовать стиль оттенка или размер элемента управления, если он указан.

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

Использование значений environment в стиле для встроенного view SwiftUI работает так, как мы и ожидали, но если мы попробуем то же самое в стиле для view, для которого мы создали собственный протокол стиля, это не сработает должным образом:

struct CheckboxMultipleChoiceStyle: MultipleChoiceStyle {
    @Environment(\.isEnabled) var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        /* ... */
            .saturation(isEnabled ? 1 : 0)
            .brightness(isEnabled ? 0 : -0.2)
    }
}
MultipleChoice(selection: $extras) {
    ForEach(Extra.allCases) { extra in
        Text(extra.name).tag(extra)
    }
}
.disabled(isOutOfStock)
.multipleChoiceStyle(.checkbox)
Включены
Включены
Отключены, но выглядят так же, как и при включении
Отключены, но выглядят так же, как и при включении

Раньше это немного сбивало с толку, потому что не было очевидно, почему это не работает. Однако, начиная с Xcode 14.1, запуск этого кода вызовет полезное предупреждение во время выполнения, объясняющее проблему:

CheckboxMultipleChoiceStyle не является View, поэтому, чтобы использовать environment, нам необходимо поместить это во view или как‑то обновить значение environment в стиле.

Один из способов использования переменной environment из стиля — это обернуть body нашего стиля в новое view:

struct CheckboxMultipleChoice: View {
    var configuration: MultipleChoiceStyleConfiguration

    @Environment(\.isEnabled) var isEnabled

    var body: some View {
        /* ... */
            .saturation(isEnabled ? 1 : 0)
            .brightness(isEnabled ? 0 : -0.2)
    }
}

struct CheckboxMultipleChoiceStyle: MultipleChoiceStyle {
    func makeBody(configuration: Configuration) -> some View {
        CheckboxMultipleChoice(configuration: configuration)
    }
}
Включены
Включены
Выключены
Выключены

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

Dynamic Property (динамическое свойство)

Мы были не единственными, кто так думал: на SwiftUI Digital Lounges WWDC 2022 кто‑то спросил об этом и получил ответ, что SwiftUI обновляет значения среды для членов, соответствующих DynamicProperty, и делает это рекурсивно.

Документация для DynamicProperty не содержит подробностей, но упоминает следующее:

«View присваивает значения этим свойствам до пересчёта view»s body».

Теперь не сказано, какие значения, но похоже, что это могут быть значения, которые, как мы полагаем, должны работать с @State или @Environment.

Так можем ли мы просто согласовать наш стиль с DynamicProperty?

protocol RangeSliderStyle: DynamicProperty {
    // ...
}

Пока стиль находится во view, а оболочка свойства @Environment также соответствует DynamicProperty, это не работает.

Вместо этого мы можем попытаться применить стиль к промежуточному view:

struct ResolvedRangeSliderStyle: View {
    var configuration: RangeSliderStyleConfiguration

    var style: any RangeSliderStyle

    var body: some View {
        AnyView(style.makeBody(configuration: configuration))
    }
}

Затем мы передаем стиль промежуточному view в нашем компоненте:

var body: some View {
    let configuration = RangeSliderStyleConfiguration(range: $range, bounds: bounds, label: .init(label))
    ResolvedRangeSliderStyle(configuration: configuration, style: style)
}

Но попробовав это, мы видим, что это всё ещё не работает.

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

Поэтому вместо этого мы пишем обёртку view, которая является общей для стиля:

struct ResolvedRangeSliderStyle: View {
    var configuration: RangeSliderStyleConfiguration

    var style: Style

    var body: some View {
        style.makeBody(configuration: configuration)
    }
}
Этот ползунок диапазона должен выглядеть отключенным.
Этот ползунок диапазона должен выглядеть отключенным.

Может показаться, что мы застряли здесь, но мы перемещаем это в расширение протокола стилей, которое даёт нам доступ к конкретному типу через self:

extension RangeSliderStyle {
    func resolve(configuration: Configuration) -> some View {
        ResolvedRangeSliderStyle(configuration: configuration, style: self)
    }
}

Вернувшись в body нашего компонента, мы теперь можем вместо этого вызвать метод resolve для стиля:

var body: some View {
    let configuration = RangeSliderStyleConfiguration(range: $range, bounds: bounds, label: .init(label))
    AnyView(style.resolve(configuration: configuration))
}

Приведённый выше код снова компилируется. Соответствуя стилю DynamicProperty и помещая конкретный стиль в промежуточное view с помощью помощника в протоколе стиля, стили теперь могут использовать @Environment и другие обёртки свойств, соответствующие DynamicProperty, без необходимости использовать обходной путь в каждом стиле.

Этот ползунок диапазона, наконец, выглядит отключенным.
Этот ползунок диапазона, наконец, выглядит отключенным.

Style Propagation

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

WindowGroup {
    ContentView()
        .buttonStyle(.borderedProminent)
        .toggleStyle(.checkbox)
        .rangeSliderStyle(.rounded)
        // ...
}

К сожалению, в некоторых случаях стили для встроенных view SwiftUI, такие как ButtonStyle и ToggleStyle, не распространяются на view, отображаемые в модальной презентации.

Таким образом, если мы представляем view в модальной презентации, такой как sheet, fullscreenCover или popover, нам необходимо обязательно сбросить стили в представленном view, если мы хотим, чтобы стили использовались и там.

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

Заметка: Значения environment также не распространяются на view в модальных презентациях в iOS 13 и удаляются во время интерактивного закрытия в iOS 14.

Будем надеяться, что это будет исправлено в будущих версиях SwiftUI, но пока есть способ заставить это работать.

Если мы немного потанцуем с UIKit, обернув view с модификатором sheet в UIViewControllerRepresentable, который, в свою очередь, использует UIHostingController для отображения этого view, мы сможем представить sheets и распространить стили:

struct UIKitDanceView: UIViewControllerRepresentable {
    var content: Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }

    func makeUIViewController(context: Context) -> UIHostingController {
        return UIHostingController(rootView: content)
    }

    func updateUIViewController(_ uiViewController: UIHostingController, context: Context) {

    }
}
extension View {
    func preserveStylesInSheets() -> some View {
        UIKitDanceView {
            Self
        }
    }
}

Затем мы можем применить все наши стили в корне нашего приложения и добавить модификатор preserveStylesInSheets(), чтобы убедиться, что эти стили также используются в любых модальных презентациях.

Заметка: Этот трюк не работает в iOS 13, но там нам также необходимо учитывать значения environment, которые не распространяются на view в модальных презентациях.

WindowGroup {
    ContentView()
        .preserveStylesInSheets()
        .buttonStyle(.borderedProminent)
        .toggleStyle(.checkbox)
        .rangeSliderStyle(.rounded)
        // ...
}

Подведение итогов

С приложением, созданным с использованием стилевых компонентов, мы можем переместить модификаторы стиля из наших view в стили и иметь чёткое разделение между стилем и функциональностью приложения.

В результате view становятся более лаконичными и простыми в обслуживании, и мы можем более эффективно работать со стилем нашего приложения, когда он не так сильно переплетается с функциональностью:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .theme(.springSummer2023)
        }
    }
}

Размещение всех наших модификаторов стиля view в удобной theme(_:), подобное этому, также удобно при использовании предварительных просмотров Xcode для работы с экраном или функцией.

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

Вы также можете попробовать это сами, загрузив эту Xcode Playground на нашей странице GitHub, которая имеет настраиваемое view с API стилей.В последующем посте мы рассмотрим несколько более продвинутых вариантов использования. Если вы хотите следить за нашей работой и быть в курсе будущих публикаций, подобных этой, рассмотрите возможность подписаться на нас в Mastodon или Twitter или присоединиться к нашему списку рассылки.

Спасибо Chris Eidhof и Eric Horacek за отзывы о черновике этого поста и Natalye Childress за редактирование этого поста.

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


  1. Boniface
    00.00.0000 00:00

    Спасибо! Очень классная статья. Вот бы еще подобную статью про таблицы для desktop SwiftUI.