SwiftUI имеет отличный API для стилизации view независимо от их реализации. В этом посте мы рассмотрим, как мы можем стилизовать пользовательские view таким же образом.   В прошлом году в ходе нескольких эпизодов на Swift Talk мы продемонстрировали, как создать собственный степпер для увеличения и уменьшения значения. Он был похож на Stepper в SwiftUI, но с API, который делает его стильным. Этот пост является кратким изложением того, что мы рассмотрели тогда, а также несколькими приёмами, которым мы научились с тех пор, чтобы наши пользовательские стили view (представление, вью, вьюшка) работали ещё лучше, как built‑in (встроенные) в SwiftUI. В последующем посте мы рассмотрим несколько более продвинутых вариантов использования. Для начала давайте посмотрим на простую кнопку: Мы можем изменить стиль этой кнопки, добавив несколько модификаторов view и используя другой метод для создания label (метки) кнопки: Хотя SwiftUI делает настройку view очень удобной, но плохо масштабируется. Необходимо каждый раз применять одни и те же модификаторы и оборачивать метку в HStack. Таким образом, нам нужен более многоразовый подход. При создании приложения нам как правило необходимо, чтобы view и элементы управления имели единый стиль во всем приложении, сделать их узнаваемыми для пользователя при переходе с экрана на экран, а также установить тему для приложения или привязку к бренду компании. Чтобы упростить применение одного и того же стиля ко многим view Button, один из вариантов — это создать новое view кнопки с API, аналогичным SwiftUI Button, и применить к нему стиль: Однако создание обёртки view, поддерживающего те же удобные инициализаторы, что и обёртка view, может потребовать некоторой работы, как показано в приведённом ниже коде:   Например, как показано в этом примере кода, Button обрабатывает литерал String как LocalizedStringKey, используя шаблон @_disfavoredOverload. Подобные тонкости могут сделать написание встроенной замены встроенного view более трудоёмкой, чем ожидалось. Хотя стиль, определённый в одном месте, это хорошо, важно не забывать использовать view MyButton вместо кнопки SwiftUI во всём приложении. В противном случае мы получим несогласованный стиль. К счастью, у SwiftUI есть API, решающий эту проблему. API view стилей SwiftUI работают так же, как модификаторы view font(_:) и tint(_:), поскольку они позволяют добавлять стиль в иерархию view и применять этот стиль ко всем соответствующим view в этой иерархии: Здесь цвет переднего плана, шрифт, оттенок и стиль кнопки, указанные в HStack, распространяются на каждую кнопку. Мы можем использовать это поведение, чтобы убедиться, что один и тот же стиль кнопки используется во всем приложении, применяя модификатор в корне иерархии view: SwiftUI имеет несколько встроенных стилей на выбор, а для некоторых view, допускающих стили, можно создавать новые стили. Чтобы полностью настроить стиль кнопки, нам сначала необходимо что‑то, что SwiftUI может использовать для стилизации при отображении кнопки. Модификатор buttonStyle(_:), который мы используем для установки стиля кнопок, принимает тип, соответствующий либо протоколу ButtonStyle, либо протоколу PrimitiveButtonStyle. Итак, чтобы создать собственный стиль, мы создаем новый тип, соответствующий одному из этих протоколов. Для обоих протоколов требуется функция makeBody(configuration:). Конфигурация, переданная этой функции, имеет несколько свойств, представляющие кнопку, которую мы оформляем. Мы можем использовать configuration.label, чтобы получить view, представляющее метку кнопки, и применить наш стиль к этой метке: Приведенный выше код позволяет нам установить стиль кнопки с помощью.buttonStyle(CustomButtonStyle()). Другие встроенные view, для которых мы можем создавать собственные стили: Toggle DatePicker Gauge ProgressView Label LabeledContent DisclosureGroup ControlGroup GroupBox Form Конфигурация стиля также имеет свойства, которые позволяют стилю проверять, нажата ли кнопка и какую роль играет кнопка. Мы можем использовать эти свойства, чтобы придать кнопке другой вид при нажатии или если кнопка выполняет деструктивное действие: Примечательно, что в конфигурации отсутствует флаг, указывающий, выключена ли кнопка. Таким образом, чтобы установить выключенное состояние кнопки, мы используем модификатор view disabled(_:). Поскольку этот модификатор view является расширением view, мы можем установить выключенное состояние в любом месте иерархии view. Это удобно, когда, например, мы хотим отключить все элементы управления во view во время отправки формы. Однако в документации не указано, что этот модификатор устанавливает значение environment (среды). Таким образом, чтобы настроить кнопку, когда она отключена, нам необходимо проверить значение environment isEnabled: Возможность создавать собственные стили для встроенных view SwiftUI может быть очень полезной, когда они выглядят не так, как нам бы того хотелось, но, к сожалению, невозможно создать собственные стили для всех встроенных view SwiftUI. Хотя мы можем прибегнуть к добавлению модификаторов view для встроенного стиля в пользовательских view или обойтись тем, что предоставляет SwiftUI, мы можем добиться большего успеха, сделав наши настраиваемые view стилизуемыми. Давайте попробуем это на пользовательском элементе управления RangeSlider: Итак, что должно произойти, чтобы это view можно было стилизовать? Возвращаясь к API стиля SwiftUI для кнопки, мы видим, что у него есть два протокола для оформления кнопки. ButtonStyle упрощает создание новых стилей, так как позволяет Button позаботиться об обработке жестов, а PrimitiveButtonStyle даёт стилю контроль над реализацией взаимодействия с кнопкой. Другие протоколы стилей в SwiftUI напоминают протокол PrimitiveButtonStyle тем, что они также дают контроль над взаимодействием со стилем. Например, при реализации ToggleStyle view Toggle не обрабатывает жест за нас. Вместо этого он предоставляет привязку для управления своим значением, которое стиль должен обновлять, когда пользователь касается view. Чтобы дать больше контроля над стилями ползунка диапазона, определить, каким должно быть взаимодействие, мы можем основывать наш протокол стилей на примитивной версии API стилей кнопок SwiftUI. API‑интерфейсы стилей состоят из трех частей — модификатора view, используемого для установки стиля, протокола и конфигурации стиля: Чтобы стилизовать RangeSlider так же, как и встроенные view, мы можем скопировать большую часть кода выше. Мы можем начать с определения нашего собственного типа конфигурации стиля. Свойства PrimitiveButtonStyleConfiguration могут показаться немного знакомыми, поскольку они почти напрямую сопоставляются с типами одного из инициализаторов Button: role передается «как есть», action представлено как триггерная функция, а label представляет собой view со стёртым типом. Таким образом, для нашего собственного типа конфигурации мы можем использовать тот же подход и скопировать типы из нашего view в конфигурацию: Как мы видели в случае с отключенным состоянием для кнопки, у нас нет необходимости помещать значения, доступные в environment, в конфигурацию стиля. Вместо этого стили должны иметь прямой доступ к значениям environment. Приведенный выше код прост для значения и границ, поскольку мы можем просто скопировать их как есть. Однако для метки нам необходимо сделать что‑то другое, так как этот тип теперь относится к типу метки SwiftUI, а не к универсальному типу, который RangeSlider имеет для своей метки. Кнопка также имеет общий тип для своей метки, что позволяет нам создавать кнопки, которые используют разные типы view для своих меток. Метка кнопки часто имеет тип Text, Label или HStack, но может быть любого типа. Поскольку существует неограниченное количество возможных типов кнопок, SwiftUI использует стирание типов, чтобы скрыть их все за непрозрачным view ButtonConfiguration.Label. Это изолирует стиль от конкретного параметризованного компонента и позволяет применить единый стиль к любой возможной кнопке. Мы будем использовать тот же шаблон, чтобы сохранить общий RangeSlider над своей меткой, и пользователю API стиля не нужно знать, какие конкретные метки будут использоваться: SwiftUI определяет тип Body как Never, это означает, что он будет использовать частные API для отображения view метки. Мы не можем сделать то, что делает здесь SwiftUI, но мы можем использовать AnyView для получения того же эффекта: Хотя мы также можем использовать AnyView напрямую, это дает нам тип, который мы можем использовать для предоставления дополнительных функций стилям. Например, у нас может быть свойство для текста‑заполнителя текстового поля со стёртым шрифтом, которое можно использовать в стиле плавающей метки.  Для протокола стилей мы можем скопировать то, что делает SwiftUI:  Имея протокол, мы можем реализовать первый стиль, используя значения из конфигурации стиля:  Чтобы установить стиль для иерархии view, мы добавляем расширение к View, которое соответствует сигнатуре buttonStyle(_:), но мы можем использовать ключевое слово some, чтобы сделать его немного более кратким: Встроенные стили спускаются вниз по иерархии view, как и значения environment. Таким образом, мы можем использовать значение environment для передачи стиля через иерархию view во view, которое должно быть оформлено. При этом мы получим то же поведение распространения стиля, что и встроенные стили.Чтобы иметь возможность поместить любой тип стиля, соответствующий нашему протоколу, в среду в качестве значения, нам необходимо сделать тип значения environment любым RangeSliderStyle. Для значений environment требуется определённое значение по умолчанию. Стильные view SwiftUI всегда имеют стиль по умолчанию, поэтому мы можем определить, каким он должен быть для view здесь: Имея значение environment, мы можем реализовать body нашего view, создав конфигурацию стиля с входными данными из нашего компонента, а затем вызвав метод makeBody для стиля, который мы получаем из environment:  Поскольку body ожидает, что мы вернём конкретный тип view, а style, который мы получаем из среды, имеет тип any RangeSliderStyle, нам необходимо обернуть view, которое мы получаем от вызова makeBody, в AnyView: может быть оформлен типом, соответствующим протоколу стилей, и мы можем установить, какой стиль использовать, так же, как мы установили бы стиль для встроенного view SwiftUI:   И точно так же, как с пользовательскими стилями для стилей SwiftUI, мы можем добавить статический член в протокол стилей. Это позволяет нам установить стиль с тем же сокращённым синтаксисом, что и встроенные стили, благодаря чему наши пользовательские view соответствуют встроенным view: При создании пользовательских view важно также сделать их доступными. Мы можем сделать это, добавив модификаторы view, которые улучшают работу при использовании view, например, с VoiceOver: “Громкость 50%, регулируемая. Проведите вверх или вниз одним пальцем, чтобы отрегулировать значение”. - Голос за кадром   Здесь мы предполагаем, что большинство ползунков захотят использовать accessibilityAdjustableAction(_:), поэтому мы можем добавить этот модификатор во view ползунка. Таким образом, командам, работающим над стилями, нет необходимости беспокоиться о том, чтобы сделать стиль доступным, потому что компонент view уже доступен. Для многих view важно иметь возможность адаптировать их к различным значениям environment. Например, элемент управления должен указывать, когда он отключен. Для эффекта подсветки для нажимаемого view может потребоваться другой цвет, если цветовая схема установлена на тёмную, или анимацию изменения состояния, возможно, придётся пропустить, если включен параметр уменьшения движения. Также важно иметь возможность использовать стиль оттенка или размер элемента управления, если он указан. Например, возможно, элементы управления в потоке адаптации должны быть немного больше, чем в других местах приложения. Если стили view настраиваются, когда controlSize большой, мы могли бы установить значение environment controlSize для этой части приложения и избежать создания совершенно новых стилей или view, специфичных для этого потока адаптации. Использование значений environment в стиле для встроенного view SwiftUI работает так, как мы и ожидали, но если мы попробуем то же самое в стиле для view, для которого мы создали собственный протокол стиля, это не сработает должным образом: Раньше это немного сбивало с толку, потому что не было очевидно, почему это не работает. Однако, начиная с Xcode 14.1, запуск этого кода вызовет полезное предупреждение во время выполнения, объясняющее проблему: CheckboxMultipleChoiceStyle не является View, поэтому, чтобы использовать environment, нам необходимо поместить это во view или как‑то обновить значение environment в стиле. Один из способов использования переменной environment из стиля — это обернуть body нашего стиля в новое view: Хотя техника, описанная выше, работает, к сожалению, нам необходимо относиться к стилям для пользовательских view иначе, чем к встроенным стилям view, и не забывать использовать эту технику каждый раз, когда мы создаем стиль для пользовательского view. Мы были не единственными, кто так думал: на SwiftUI Digital Lounges WWDC 2022 кто‑то спросил об этом и получил ответ, что SwiftUI обновляет значения среды для членов, соответствующих DynamicProperty, и делает это рекурсивно. Документация для DynamicProperty не содержит подробностей, но упоминает следующее: «View присваивает значения этим свойствам до пересчёта view»s body». Теперь не сказано, какие значения, но похоже, что это могут быть значения, которые, как мы полагаем, должны работать с @State или @Environment. Так можем ли мы просто согласовать наш стиль с DynamicProperty? Пока стиль находится во view, а оболочка свойства @Environment также соответствует DynamicProperty, это не работает. Вместо этого мы можем попытаться применить стиль к промежуточному view: Затем мы передаем стиль промежуточному view в нашем компоненте:   Но попробовав это, мы видим, что это всё ещё не работает. В наших экспериментах мы обнаружили, что SwiftUI обновляет значения environment для стиля, если у нас есть конкретный тип стиля в среде. Поэтому вместо этого мы пишем обёртку view, которая является общей для стиля: Может показаться, что мы застряли здесь, но мы перемещаем это в расширение протокола стилей, которое даёт нам доступ к конкретному типу через self: Вернувшись в body нашего компонента, мы теперь можем вместо этого вызвать метод resolve для стиля:   Приведённый выше код снова компилируется. Соответствуя стилю DynamicProperty и помещая конкретный стиль в промежуточное view с помощью помощника в протоколе стиля, стили теперь могут использовать @Environment и другие обёртки свойств, соответствующие DynamicProperty, без необходимости использовать обходной путь в каждом стиле. Одним из преимуществ стильных view является то, что нам не требуется задавать стиль непосредственно для каждого view в нашем приложении. Вместо этого мы можем установить стиль во view контейнера для экрана или в корне приложения, чтобы использовать этот стиль во всем приложении: К сожалению, в некоторых случаях стили для встроенных 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 и распространить стили: Затем мы можем применить все наши стили в корне нашего приложения и добавить модификатор preserveStylesInSheets(), чтобы убедиться, что эти стили также используются в любых модальных презентациях.  Заметка: Этот трюк не работает в iOS 13, но там нам также необходимо учитывать значения environment, которые не распространяются на view в модальных презентациях.   С приложением, созданным с использованием стилевых компонентов, мы можем переместить модификаторы стиля из наших view в стили и иметь чёткое разделение между стилем и функциональностью приложения. В результате view становятся более лаконичными и простыми в обслуживании, и мы можем более эффективно работать со стилем нашего приложения, когда он не так сильно переплетается с функциональностью: Размещение всех наших модификаторов стиля view в удобной theme(_:), подобное этому, также удобно при использовании предварительных просмотров Xcode для работы с экраном или функцией. Возможность писать view, которые можно стилизовать последовательно, имеет решающее значение для поддержки систем проектирования в масштабе, и мы надеемся, что Apple в будущем сделает больше встроенных view SwiftUI стилизуемыми. Вы также можете попробовать это сами, загрузив эту Xcode Playground на нашей странице GitHub, которая имеет настраиваемое view с API стилей.В последующем посте мы рассмотрим несколько более продвинутых вариантов использования. Если вы хотите следить за нашей работой и быть в курсе будущих публикаций, подобных этой, рассмотрите возможность подписаться на нас в Mastodon или Twitter или присоединиться к нашему списку рассылки. Спасибо Chris Eidhof и Eric Horacek за отзывы о черновике этого поста и Natalye Childress за редактирование этого поста.