Как стать автором
Обновить

SwiftUI по полочкам

Время на прочтение24 мин
Количество просмотров90K
Каждый раз, когда в языке программирования появляется новый Фреймворк, рано или поздно, появляются люди, которые изучают язык именно с него. Вероятно так было и в IOS разработке во времена появления Swift: поначалу он рассматривался как дополнение к Objective-C — но я этого уже не застал. Сейчас, если начинаешь с нуля, выбор языка уже не стоит. Swift вне конкуренции.

То же самое, но в меньшем масштабе, происходит и с фреймворками. Появление SwiftUI — не исключение. Вероятно, я — представитель первого поколения разработчиков, кто стартовал с изучения SwiftUI, проигнорировав UIKit. У этого есть своя цена — обучающих материалов и примеров работающего кода пока очень мало. Да, в сети уже есть некоторое количество статей, рассказывающих о той или иной особенности, том или ином инструменте. На том же www.hackingwithswift.com уже довольно много примеров кода с объяснениями. Однако, они слабо помогают тем, кто решил изучать SwiftUI с нуля, как я. Большинство материалов в сети — это ответы на конкретные, сформулированные вопросы. Опытный разработчик легко разберется, как все устроено, почему именно так, и зачем это нужно применять. Новичку же, сначала нужно понять, какой вопрос задать, и только тогда он сможет добраться до этих статей.



Под катом я попробую систематизировать и разложить по полочкам то, что сам успел усвоить на текущий момент. Формат статьи — почти гайд, хотя скорее, шпаргалка, составленная мной в том виде, в котором я сам бы хотел ее прочитать в начале своего пути. Для опытных разработчиков, еще не вникавшим глубоко в SwiftUI, тоже найдется пара интересных примеров кода, а текстовые пояснения можно читать по-диагонали.

Надеюсь статья поможет вам сэкономить некоторое время, когда вы тоже захотите ощутить немного магии.

Для начала, немного о себе


У меня практически нет бекграунда мобильной разработки, а существенный опыт
в 1с мало чем мог бы здесь помочь. О том, как и почему я решил осваивать SwiftUI я расскажу как-нибудь в другой раз, если это кому-то будет интересно, конечно.

Так уж сложилось, что начало моего погружения в мобильную разработку совпало с выходом IOS 13 и SwiftUI. Это знак, подумал я, и решил стартовать сразу с него, проигнорировав UIKit. Я посчитал забавным совпадением то, что работать с 1с я начал в подобные времена: тогда только-только появились управляемые формы. В случае с 1с, популяризация новой технологии заняла, без малого, лет пять. Каждый раз, когда разработчику поручали реализовать какой-то новый функционал, он вставал перед выбором: сделать это быстро и надежно, знакомыми инструментами, или потратить кучу времени на возню с новыми, и без гарантий результата. Выбор, обычно, делался в пользу скорости и качества прямо сейчас, а инвестирование времени в новые инструменты очень долго откладывалось.

Сейчас, судя по всему, со SwiftUI примерно такая же ситуация. Всем интересно, все понимают, что за этим будущее, но выделять существенное время на его изучение пока мало кто берется. Разве что для пет-проектов.

Мне, по большому счету, было без разницы, какой фреймворк изучать, и я решил рискнуть несмотря на общее мнение, что в production его можно будет запускать через год-два. И раз уж так получилось, что я оказался среди первопроходцев, я решил поделиться практическим опытом. Я хочу сказать, что я не гуру, и вообще в мобильной разработке — чайник. Тем не менее, я уже прошел определенный путь, в процессе которого перерыл все интернеты в поисках информации, и могу уверенно заявить — ее мало, и она практически не систематизирована. А на русском, само собой, ее вообще практически нет. Раз так, я решил собраться с силами, задвинуть подальше комплекс самозванца, и поделиться с сообществом тем, в чем успел разобраться сам. Я буду исходить из предположения, что читатель уже хотя бы минимально знаком со SwiftUI, и не буду расшифровывать такие вещи как VStack{…}, Text(…) и т.п.

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

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

Что вообще такое, этот ваш SwiftUI


Итак, я бы пожалуй начал с того, что вообще такое, этот ваш SwiftUI. Тут опять всплывает мое 1с-ное прошлое. Аналогия с управляемыми формами стала только сильнее, когда я посмотрел несколько обучающих видео, как верстаются интерфейсы в Storyboard (т.е. при работе с UIKit). Аж ностальгия взяла по «не управляемым» формам в 1с: ручное размещение элементов на форме, а особенно — привязки… О, когда автор обучающего видео минут 20 рассказывал о тонкостях привязок различных элементов друг к другу и краям экрана, я с улыбкой вспоминал 1C — там до управляемых форм было все тоже самое. Ну почти… чуть победнее, разумеется, ну и соответственно — попроще. А SwiftUI — это, грубо говоря, управляемые формы от Apple. Никаких привязок. Никаких сторибордов и сегвеев. Вы просто описываете в коде структуру вашего View. И все. Все параметры, размеры и прочее задается непосредственно в коде — но довольно просто. Точнее, редактировать параметры существующих объектов можно в Canvas, но для этого, их сначала нужно в коде добавить. Честно говоря, не знаю, как это будет работать в больших командах разработчиков, где принято разделять верстку дизайна и наполнение кодом самой View, но мне как инди-разработчику такой подход очень нравится.

Декларативный стиль


SwiftUI предполагает, что описание структуры вашего View целиком находится в коде. Причем, Apple предлагает нам декларативный стиль написания этого кода. То есть, примерно так:
«Это View. Она (мне почему-то хочется говорить «вьюшка», и соответственно, применять склонения как к слову женского рода) состоит из двух текстовых полей и одного рисунка. Текстовые поля расположены друг за другом горизонтально. Картинка находится под ними и ее края обрезаны в форме круга».
Звучит непривычно, не так ли? Обычно-то мы в коде описываем сам процесс, что нужно сделать чтобы добиться того результата, который у нас в голове:
«Вставить блок, в этот блок вставить текстовое поле, за ним еще одно текстовое поле, а после этого, взять картинку, обрезать ее края скруглив их, и вставить ниже».
Звучит как инструкция к мебели из Икеи. А в swiftUI мы сразу видим то, каким должен быть результат. Даже без Сanvas-а или отладки, структура кода наглядно отражает структуру View. Понятно что и в какой последовательности будет отображаться и с какими эффектами.

Отличная статья о FunctionBuilder, и как он позволяет писать код в декларативном стиле уже есть на Хабре.

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

Из чего состоит View


Но давайте подробнее. Apple предполагает, что декларативный стиль это так:

struct ContentView: View {
    var text1 = "some text"
    var text2 = "some more text"
    var body: some View {
        VStack{
            Text(text1)
                .padding()
                .frame(width: 100, height: 50)
            Text(text2)
                .background(Color.gray)
                .border(Color.green)
        }
    }
}

Обратите внимание, View — это структура с некоторыми параметрами. Что бы структура стала View — нам нужно задать вычисляемый параметр body, который возвращает some View. О том, что это — мы поговорим чуть позже. Содержание замыкания body: some View { … } — это и есть описание того, что будет отражено на экране. Собственно, это все что требуется, чтобы наша структура удовлетворяла требованиям протокола View. Предлагаю в первую очередь сосредоточиться именно на body.

И так, полочки


Всего, я насчитал три типа элементов, из которых строиться тело View:

  • Другие View
    Т.е. Каждая View содержит в себе одну или несколько других View. Те, в свою очередь могут так же содержать как предопределенные системные View вроде Text(), так и кастомные, сложные, написанные самим разработчиком. Получается эдакая матрешка с неограниченным уровнем вложенности.
  • Модификаторы
    С помощью модификаторов и происходит вся магия. Благодаря им, мы коротко и внятно сообщаем SwiftUI, какой мы хотим видеть данную View. Как это работает, мы еще будет разбираться, но главное — модификаторы дописывают к контенту определенной View требуемый кусочек.
  • Контейнеры
    Первые контейнеры, с которых начинается стандартные «Hello, world» — это HStack и VStack. Чуть позже, появляются Group, Section и прочие. Фактически, контейнеры — это те же View, но у них есть особенность. Вы передаете в них некий контент, который нужно отобразить. Вся фишка контейнера в том, что он должны как-то сгруппировать и отобразить элементы этого контента. В этом смысле, контейнеры похожи на модификаторы, с той лишь разницей, что модификаторы предназначены изменять одну уже готовую View, а контейнеры выстраивают эти View (элементы контента, или блоки декларативного синтаксиса) в определенном порядке, например вертикально или горизонтально (VStack{...} HStack{...}). Есть еще специфические контейнеры, например ForEach или GeometryReader, о них еще поговорим чуть позже.

    В общем, контейнерами я считаю любые View, в которые в качестве параметра можно передавать Content.

И это все. Все элементы чистокровного SwiftUI можно отнести к одному из этих типов. Да, этого недостаточно чтобы наполнить ваши View функционалом, но это все что вам нужно, чтобы показать ваш функционал на экране.

.модификаторы() — как они устроены?


Давайте начнем с самого простого. Модификатор — это на самом деле очень простая штука. Он всего лишь берет какую—то View, применяет к ней (или все-таки к нему?) какие-то изменения, и возвращает обратно. Т.е. Модификатор — это функция самой View, которая возвращает self, выполнив предварительно какие-то модификации.

Ниже приведен пример кода, с помощью которого я объявляю собственный модификатор. Точнее, я перегружаю уже существующий модификатор frame(width:height:), с помощью которого можно зафиксировать конкретные размеры определенной View. Из коробки для него нужно указывать ширину и высоту, а мне нужно было передать в него одним аргументом объект CGSize, представляющий собой описание, как раз, длины и ширины. Зачем мне это понадобилось, я расскажу несколько позже.

struct FrameFromSize: ViewModifier{
    let size: CGSize
    func body(content: Content) -> some View {
        content
            .frame(width: size.width, height: size.height)
    }
}

Этим кодом мы создали структуру, удовлетворяющую протоколу ViewModifier. Этот протокол требует от нас, чтобы в данной структуре была реализована функция body(), на входе которой будет некий Content, а на выходе — some View: такой же тип, как и у параметра body нашей View (о some View мы поговорим ниже). Что это за Content такой?

Content + ViewBuilder = View


Во встроенной документации о нем сказано так:
`content` is a proxy for the view that will have the modifier represented by `Self` applied to it.
Это прокси-тип, который представляет собой заготовку View, к которой можно применять модификаторы. Эдакий полуфабрикат. На самом деле Content — это замыкание в декларативном стиле, c помощью которого описывается структура View. Таким образом, если мы для какой-то View вызовем этот модификатор, то все что он сделает — это получит замыкание из body, и передаст его в нашу функцию body, в которой мы добавим к этому замыканию свои пять копеек.

Еще раз, View — это прежде всего структура, которая хранит все параметры, необходимые для генерации изображения на экране. В том числе и инструкцию по сборке, коей и является Content. Таким образом, замыкание в декларативном стиле (Content) обработанное с помощью ViewBuilder возвращает нам View.

Вернемся к нашему модификатору. По идее, объявления структуры FrameFromSize уже достаточно, чтобы начать применять его. Внутри body мы можем написать так:


RoundedRectangle(cornerRadius: 4).modifier(FrameFromSize(size: size))

modifier — это метод протокола View, который извлекает Content из модифицируемой View, передает его в функцию body структуры-модификатора, и передает результат далее, на обработку ViewBuilder, или следующему модификатору, если у нас цепочка модификаций.

Но можно сделать еще лаконичнее, объявив собственный модификатор как функцию, расширив тем самым возможности протокола View.

extension View{
    func frame(_ size: CGSize) -> some View {
        self.modifier(FrameFromSize(size: size))
    }
}

В данном случае, я перегрузил существующий модификатор .frame(width: height:) еще одним вариантом входящих параметров. Теперь, мы можем использовать вариант вызова модификатора frame(size:) для любой View. Как оказалось, ничего сложного.

Немного об ошибках
Кстати, я подумал, что не обязательно было расширять целый протокол, достаточно было бы расширить конкретно RoundedRectangle в моем случае, и оно должно было бы сработать, как мне казалось — но кажется Xcode не ожидал такой наглости, и свалился с маловразумительной ошибкой «Abort trap: 6» и предложением отправить дамп разработчикам. Вообще говоря, в SwiftUI описания ошибок пока очень часто совершенно не раскрывают причину возникновения этой ошибки.

Точно так же можно создавать любые кастомные модификаторы, и использовать их так же, как и встроенные в SwiftUI:

RoundedRectangle(cornerRadius: 4).frame(size)

Удобно, лаконично, наглядно.

Я представляю себе цепочку модификаций как бусины, нанизанные на нитку — нашу View. Эта аналогия верна и в том смысле, что порядок вызова модификаций имеет значение.



Почти все в SwiftUI- это View
Кстати, интересное замечание. В качестве входящего параметра background принимает не цвет, а View. Т.е. Класс Color — это не просто описание цвета, это полноценная View, к которой могут быть применены модификаторы и прочее. И в качестве background, соответственно, можно передавать другие View.

Модификаторы - только для модификаций
Пожалуй, стоит отметить еще один момент. Модификаторы, которые не меняют исходный контент — просто игнорируются SwiftUI и не вызываются. Т.е. У вас не получится сделать на основе модификатора триггер, вызывающий какие-то события, но не выполняющий никаких действий с контентом. Apple настойчиво подталкивают нас к тому, чтобы отказаться от каких-то действий в рантайме при отрисовке интерфейса, и довериться декларативному стилю.

И все же View


Ранее мы говорили о том, из чего состоит body, тело View, или ее инструкция по сборке. Давайте вернемся к самой View. Прежде всего, это структура, в которой могут быть объявлены некоторые параметры, и body — это только один из них. Как мы уже говорили, выясняя что такое Content, body — это инструкция, как собрать нужную View, представляющая собой замыкание в декларативном стиле. Но что же должно возвращать наше замыкание?

some View — удобство




И мы плавно приходим к вопросу, в котором долгое время я не мог разобраться, хотя это и не мешало мне писать работающий код. Что же такое, этот some View? В документации говориться что это описание «opaque result type» — но это мало что проясняет.

Ключевое слово some — это «generic» вариант описания типа, возвращаемого замыканием, который не зависит ни от чего, кроме самого написанного кода. Т.е. Результатом обращения к вычисляемому свойству body нашего View должна быть какая-то структура, удовлетворяющая протоколу View. Их может быть много — Text, Image, а может быть, какая-то объявленная вами структура. Вся фишка ключевого слова some — это объявить «generic», удовлетворяющий протоколу View. Он статически определяется кодом, реализованным внутри тела вашей View, и XCode вполне в состоянии разобрать этот код, и вычислить конкретную сигнатуру возвращаемого значения (ну, почти всегда). А some — это всего лишь попытка не обременять разработчика излишними церемониями. Разработчику достаточно сказать: «на выходе будет какая-то View», а какая именно — разбирайтесь сами. Ключевое здесь — конкретный тип определяется не входящими параметрами, как с обычным generic-типом, а непосредственно кодом. Поэтому выше, generic я указывал в кавычках.

Xcode должен быть в состоянии точно определить конкретный тип, не зная, какие именно значения вы передаете в эту структуру. Это важно понимать — после компиляции выражение some View заменяется конкретным типом вашей View. Этот тип вполне детерминирован, и может быть довольно сложным, например, таким: Group<TupleView<(Text, ForEach<[SomeClass], SomeClass.ID, Text>)>>.

Из этого типа можно восстановить примерный код:

Group{
        Text(…)
        ForEach(…){(value: SomeClass)  in
 		Text(…)
 	}
}

ForEach, как видно из сигнатуры типа, это не цикл в рантайме. Это просто View которая построена на базе массива объектов типа SomeClass. в качестве идентификатора конкретной subView, ассоциированной с элементом коллекции, указывается ID элемента, и для каждого элемента формируется subView типа Text. Text и ForEach объединены в TupleView, и все это помещено в Group. О ForEach мы еще поговорим подробнее.

Представляете, сколько писанины было бы, если бы мы были вынуждены описывать точную сигнатуру типа параметра body? Чтобы этого не делать, и было создано ключевое слово some.

Резюме
some, это „generic — наоборот“. Классический дженерик мы получаем извне функции, и уже зная конкретный тип generic-типа, XCode определяет, как работает наша функция. some-тип зависит не от входящих параметров, а только от самого кода. Это просто сокращение, позволяющее не определять конкретный тип, а указать только семейство возвращаемого функцией значения (протокол).

some View — и последствия


Подход с вычислением статического типа выражения внутри body рождает, на мой взгляд, два важных замечания:

  • Xcode при компиляции анализирует содержание body в целях вычисления конкретного возвращаемого типа. В сложных body это может занимать некоторое время. В некоторых особенно сложных body он может вообще не справиться за вменяемое время, и прямо скажет об этом.

    В общем, View нужно держать как можно более простыми. Сложные конструкции лучше выносить в отдельные View. Таким образом, целые цепочки реальных типов заменяйся одним типом — вашей CustomView, что позволяет компилятору не сойти с ума от всей этой мешанины.
    Кстати, это действительно очень удобно — отлаживать маленький кусочек большой View, прямо тут, на лету получая и наблюдая результат в Canvas.
  • Мы не можем напрямую управлять потоком. Если If — else SwiftUI еще умеет обрабатывать, создавая “View Шрёдингера” типа <_ConditionalContent<Text, TextField>> то тринарный оператор условия можно использовать только для выбора конкретного значения параметров, но не типа вью, и даже не для выбора последовательности модификаторов.

    Но стоит восстановить одинаковый порядок модификаторов, и такая запись перестает быть проблемой.

Кроме body


Однако, в структуре могут быть и другие параметры, с которыми можно работать. В качестве параметров мы можем объявлять следующие вещи.

Внешние параметры


Это простые параметры структуры, которые мы должны передавать извне при инициализации, для того чтобы View каким-то образом их визуализировала:

struct TextView: View {
    let textValue: String
    var body: some View {
        Text(textValue)
    }
}

В данном примере textValue для структуры TextView — это параметр, который должен быть заполнен извне, поскольку он не имеет значения по-умолчанию. Учитывая, что структуры поддерживают автоматическую генерацию инициализаторов — мы можем использовать данную View просто:

        TextView(textValue: "some text")

Извне также можно передавать замыкания, которые нужно выполнить, при наступлении какого-то события. Например, Button(lable:action:) так и делает: выполняет переданное замыкание action при нажатии на кнопку.

state — параметры


SwiftUI очень активно использует новую фишку Swift 5.1 — Property Wrapper.

Прежде всего, это переменные состояния — хранимые параметры нашей структуры, изменение которых должно быть отражено на экране. Их оборачивают в специальные обертки @State — для примитивных типов, и @ObservedObject — для классов. Класс должен удовлетворять протоколу ObservableObject — это значит, что данный класс должен уметь оповещать подписчиков (View, которые используют данное значение с оберткой @ObservedObject) об изменении своих свойств. Для этого достаточно обернуть требуемые свойства в @Published.

Если вы не ищете легких путей, или вам нужна дополнительная функциональность, вместо данной обертки можно использовать ObservableObjectPublisher и отправлять уведомления вручную, используя события willSet() данных параметров, как описано, например, тут.

Помните, я говорил, что body — это просто вычислимое свойство? По-началу я не сразу понял всю фишку State-переменных, и пытался объявлять какие-то State-переменные внутри body безо всяких оберток. Проблема оказалась в том, что body — это, как я уже говорил, stateless инструкция. View сгенерировалась по этой инструкции, и весь контекст, объявленный внутри body отправился на свалку. Дальше живут только хранимые параметры структуры. При изменении State-параметров вся наша View обновляется. Снова достается инструкция, в нее подставляются текущие значения всех параметров структуры, собирается изображение на экране, инструкция снова выкидывается до следующего раза. Переменные, объявленные внутри body — вместе с ней. Для опытных разработчиков это может быть очевидно, но я поначалу, намучался с этим, не понимая сути процесса.

И еще одно замечание
Вы не сможете использовать didSet willSet события параметров структуры, обернутых в какие-то обертки. Компилятор позволяет вам написать этот код, но он просто не выполняется. Вероятно потому, что обертка — это и есть какой-то шаблонный код, выполняемый при наступлении этих событий.

update:
Оказывается, это был баг, наблюдатели свойства не срабатывали если значение модифицировалось не прямым присвоением, а как-то иначе. Например до версии XCode 11.5 вот этот код в одной ветке вызывал срабатывание willSet и didSet, а в другой ветке — нет.
struct TestDidSet: View {
    @State var usingWillSet = false
    @State var text: String = ""{
        willSet{
            print("willSet")
        }
        didSet{
            print("didSet")
        }
    }
    var body: some View {
        VStack{
            Text(text)
            Button(action: {
                self.usingWillSet.toggle()
            }){
                Text(usingWillSet ? "willSet is active" : "willSet is not active")
            }
            Button(action: {
                if self.usingWillSet{
                    let newValue = self.text
                    self.text = newValue + "a"
                }else{
                    self.text += "a"
                }
            }){
                Text("toggle from inside")
            }
        }
    }
}

В XCode 11.5 это пофиксили, и обзёрверы срабатывают корректно в обеих ветках кода.

Классический пример State:

struct ContentView: View {
    @State var tapCount = 0
    var body: some View {
        VStack {
            Button(action: {self.tapCount += 1},
                   label: {
                        Text("Tap count \(tapCount)")
                        })
        }
    }
}

Binding- параметры


Хорошо, для отражения каких-то изменений во View служат @State @ObservedObject. Но как эти изменения передаются между View? Для этого в SwiftUI есть еще один PropertyWrapper — @Binding. Чуть усложним наш пример с кнопкой для подсчета кликов. Допустим у нас есть родительская View, которая отражает, в том числе, счетчик кликов, и дочерняя View с кнопкой. В родительской вью счетчик объявлен как @State — оно и понятно, мы же хотим, чтобы счетчик на экране обновлялся. А вот в дочерней, счетчик должен быть объявлен как @Binding. Это еще один Property Wrapper, с помощью которой мы объявляем параметры структуры, которые будут не просто меняться, а и возвращаться в родительскую View. Это своего рода inout маркер для View. При изменении значения в дочерней вью, это изменение транслируется назад, в родительскую View, откуда оно изначально пришло. И как с inout, нам нужно маркировать передаваемые значения специальным символом $, чтобы показать, что мы ждем изменения переданного значения внутри другой вью. React в действии.


struct ContentView: View {
    @State var tapCount = 0
    var body: some View {
        VStack{
            SomeView(count: $tapCount)
            Text("you tap \(tapCount) times")
        }
    }
}

Это находит свое отражение и в типах данных. @Binding var tapCount: Int, например, это уже не просто Int тип, это

Binding<Int>

Это полезно знать, например, если вы захотите написать собственный инициализатор View.

struct SomeView: View{
    @Binding var tapCount: Int
    init(count: Binding<Int>){
        self._tapCount = count
 //если требуется выполнить еще какие-то действия при инициализации
    }
    var body: some View{
        Button(action: {self.tapCount += 1},
               label: {
                    Text("Tap me")
                    })
    }
}

Обратите внимание, внутри init для обращения к параметрам, обернутым в какие-то @PropertyWrapper следует использовать знак подчеркивания self._ — это работает в инициализаторах, когда self еще в процессе создания. Точнее, с помощью self._ мы обращаемся к параметру вместе с его оберткой. Обращение непосредственно к значению внутри обертки осуществляется без подчеркивания.

В свою очередь, если у вас на входе переменная, обернутая в какой-то PropertyWrapper, получим тип-обертку, в данном случае

Binding<Int>

Обращаться непосредственно к значению типа Int можно через .wrappedValue.

И как всегда, личные грабли
Кстати, с Binding связан еще один интересный момент. Для родительской View любое изменение этой переменной вызывает повторную отрисовку всей дочерней View. Это означает уничтожение экземпляра дочерней View и создание новой на ее месте, с новым значением @Binding-переменной. Если вдруг, вы создали View в которой есть одновременно State и @Binding — подумайте, что произойдет с вашей State-переменной при изменении Binding. Скорее всего оно сбросится на значение по-умолчанию, или то, которое прописано в инициализаторе.
update: Как оказалось, SwiftUI умеет восстанавливать значения State переменных после повторной инициализации. Т.е. в описанном сценарии, после отработки init(), в котором вы сбросили значение этой State переменной на стартовое, SwiftUI всеравно достанет из кэша последнее значение, которое было до повторной инициализации и восстановит его. А вот с @ObservedObject такого не происходит, так что будьте аккуратнее.

EnvironmentObject


Если коротко, то EnvironmentObject параметры — это как Binding, только сразу для всех View в иерархии, без необходимости их передавать в явном виде.

ContentView().environmentObject(session)

Обычно, передается текущее состояние приложения, или какой-то его части, которая нужна сразу многим View. Например, данные о пользователе, сессии или чем-то подобном, есть смысл положить в EnvironmentObject один раз, в корневой View. В каждой View, где они нужны, их можно достать из окружения, объявляя переменную с оберткой @EnvironmentObject например так

 @EnvironmentObject var session: Session

Идентификатором конкретного значения является сам тип. Если вы положили в EnvironmentObject несколько значений одного и того же типа, то порядок имеет значение. Чтобы добраться до 3-го, например, значения, вам придется доставать все значения по порядку, даже если они вам не нужны. Поэтому EnvironmentObject хорошо подходит для отражения состояния приложения, но не очень хорошо подходит для передачи нескольких значений одного типа между View. Их придется передавать вручную, через Binding.

@Environment — это почти тоже самое. По смыслу — это состояние среды, т.е. ОС. Через эту обертку удобно доставать, например, положение экрана (вертикальное или горизонтальное), светлая или темная тема используется, и т.п. Так же, через эту обертку можно получать доступ к БД при использовании CoreData:

@Environment(\.managedObjectContext) var moc: NSManagedObjectContext

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

Custom @PropertyWrapper


По большому счету, PropertyWrapper — это ярлык для setter-а и getter-а, одинакового для всех параметров, завернутых в один и тот же property wrapper. Вы можете сами полностью восстановить эту функциональность, убрав объявление обертки и прописав getter{} setter{} параметра, но это придется делать каждый раз, для каждой View, дублируя код. Например, с помощью PropertyWrapper очень удобно скрывать работу с UserDefaults.

@propertyWrapper
struct UserDefault<T> {
    var key: String
    var initialValue: T
    var wrappedValue: T {
        set { UserDefaults.standard.set(newValue, forKey: key) }
        get { UserDefaults.standard.object(forKey: key) as? T ?? initialValue }
    }
}

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

Учитывая это, можно создать тип-заглушку (в данном случае перечисление), в котором объявить статические переменные для доступа к конкретным значениям, хранимым в UserDefaults, используя только что созданную обертку:

enum UserPreferences {
    @UserDefault(key: "isCheatModeEnabled", initialValue: false) static var isCheatModeEnabled: Bool
    @UserDefault(key: "highestScore", initialValue: 10000) static var highestScore: Int
    @UserDefault(key: "nickname", initialValue: "cloudstrife97") static var nickname: String
}

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

UserPreferences.isCheatModeEnabled = true
UserPreferences.highestScore = 25000
UserPreferences.nickname = "squallleonhart”

Пример изначально описан здесь.

Контейнеры


Ну и последнее, что нужно обсудить из моего перечня, это контейнеры. Мы уже частично коснулись этого, когда говорили о body. Фактически, контейнеры, это обычные View. Разница только в том, что в качестве одного из параметров этой структуры мы передаем контент. Напомню, что контент — это замыкание содержащее одно или несколько выражений в декларативном стиле. Это замыкание, если его обработать с помощью @ViewBuilder, вернет нам новую View, комбинирующую определенным образом все View, перечисленные в замыкании (блоки контента). При этом, для разных контейнеров сами механизмы обработки блоков — разные. VStack располагает элементы контента вертикально, HStack горизонтально, ну и так далее. Это как модификатор, только на этот раз модифицируется не одна конкретная View, а весь Content, передаваемый в контейнер, и генерируется новая View. Причем, эта новая View имеет новый тип. Например, для HStack{Text(…)} этим типом будет TupleView<Text, Image>.

Однако, не стоит забывать, что любая View, в том числе и контейнеры — это структура, у которой могут быть и другие параметры, кроме body. Например, я долго не мог разобраться, как убрать небольшой разрыв между Text(«a») Text(«b») внутри HStack. Долго возился с offset() и position(), вычисляя координаты смещения исходя из длин строк, пока случайно не наткнулся на полный синтаксис объявления HStack:
HStack(spacing:, alingment:, context:).
Просто, первые два параметра не являются обязательными, и в большинстве примеров пропускаются. Ошибка новичка — не посмотреть полный синтаксис.

ForEach


Отдельно стоит рассказать о ForEach. Это контейнер, который служит для отражения на экране всех элементов переданной коллекции. В первую очередь, нужно понять, что это не то же самое что для какой-то коллекции вызвать forEach(…). Как мы говорили выше, ForEach возвращает одну единственную View, созданную на базе элементов переданной коллекции. Т.е. это просто очередной контейнер, в который передается коллекция, и инструкция — как отразить элементы коллекции на экране.
Кроме того, ForEach должен быть помещен внутрь какого-то другого контейнера, который уже определит, как именно сгруппировать эту множественную сущность — расположив по вертикали, по горизонтали, или, например, поместив в список (List).

ForEach принимает три параметра: коллекцию (data: RandomAccesCollection), адрес идентификатора элемента коллекции (id: Hashable) и контент (content: ()->Content). Третий мы уже обсуждали: как любой другой контейнер, ForEach принимает Content — т.е. замыкание. Но в отличие от обычных контейнеров, где content не содержит параметров, ForEach передает в замыкание элемент коллекции, который можно использовать при описании контента.

Коллекция для ForEach подойдет не любая, а только RandomAccesCollection. Для различных неупорядоченных коллекций достаточно вызвать метод sorted(by:), с помощью которого можно получить RandomAccesCollection.

ForEach — это набор subView, сгенерированных для каждого элемента коллекции исходя из переданного контента. Важно отметить, что SwiftUI должен знать, какая именно subView с каким элементом коллекции ассоциирована. Для этого, у каждой View должен быть идентификатор. Второй параметр нужен именно для этого. Если элементами коллекции являются Hashable типы, например строки, можно написать просто id: \.self. Это будет значить, что сама строка и будет являться идентификатором. Если элементы коллекции являются классами, и удовлетворяют протоколу Identifiable — то второй аргумент можно упустить. В этом случае, id каждого элемента коллекции станет идентификатором subView. Если у вашего объекта есть какой-то реквизит, обеспечивающий уникальность, и который отвечает протоколу Hashable — можно это указать так:

ForEach(values, id: \.value){item in …}

В моем примере, values — это массив объектов класса SomeObject, для которого объявлен реквизит value: Int. В любом случае, вы должны обеспечить уникальность идентификатора каждой View, сопоставленной с элементом вашей коллекции. Например, у вас в контексте может происходить изменение каких-то параметров вашего объекта. View должна быть сопоставлена 1 к 1 с объектом данных (элементом коллекции), иначе будет непонятно, куда вернуть изменение @Binding параметра View.

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

ForEach(keys.indices){ind in
        SomeView(key: self.keys[ind]) 
}

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

Важный момент, по поводу Content, который передается в ForEach. Он очень капризный, и отказывается нормально работать с замыканиями, больше чем с одним блоком (т.е. контент из одной строки он воспринимает нормально, а вот 2 и более — уже нет). Это решается довольно просто, достаточно просто все содержимое запихнуть в Groupe{} — такой хак перестает быть проблемой.

Вот только объявлять внутренние переменные в области видимости этого замыкания не получится. Любые замыкания, которые передаются во ViewBuilder не могут содержать объявления переменных. Помните, в начале статьи я приводил пример создания модификатора .frame(size:)? Я создал его именно по этой причине. Я вычислял размеры кнопки исходя из количества этих кнопок в ряду и количества рядов (меня не устраивало автоматическое растягивание, разные кнопки должны были быть разных размеров). Функция возвращала CGSize, и внутри происходил обход нескольких уровней вложенных структур. Если бы можно было выполнить функцию один раз, записать ее результат в виде переменной size, а затем вызвать .frame(width: size.width, height: size.height) — я бы так и сделал. Но такой возможности нет, а выполнять функцию дважды не хотелось — потому я обошел это ограничение и вынес часть кода в модификатор.

Custom container View


Ну и как повелось, приведу пример создания кастомного контейнера. Довольно часто, взаимосвязь нескольких объектов типа “1:N” может быть удобно представлять в виде словаря. Выполнить запрос и конвертировать его результат в словарь типа dict: [KeyObject: [SomeObject]] не сложно.

В данном случае, в качестве ключа словаря выступают объекты класса KeyObject (для этого он должен поддерживать протокол Hashable), а в качестве значений — массивы объектов другого класса — SomeObject.

class SomeObject: Identifiable{
    let value: Int
    public let id: UUID = UUID()
    init(value: Int){
        self.value = value
    }
}

class KeyObject: Hashable, Comparable{
    var name: String
    init(name: String){
        self.name = name
    }
    
    static func < (lhs: KeyObject, rhs: KeyObject) -> Bool {
        lhs.name < rhs.name
    }
    
    static func == (lhs: KeyObject, rhs: KeyObject) -> Bool {
        return lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

Если в вашем приложении планируется какая-то аналитика с группировкой, то есть смысл создать отдельный контейнер для отображения такого рода словарей, чтобы не дублировать весь код в каждой вью. А с учетом того, что группировки могут меняться пользователем, нам придется использовать generic. Я не стал усложнять, добавляя визуальное оформление, оставил только структуру нашего контейнера:

struct TreeView<K: Hashable, V: Identifiable, KeyContent, ValueContent>: View where K: Comparable, KeyContent: View, ValueContent: View{
    let data: [K: [V]]
    let keyContent: (K)->KeyContent
    let valueContent: (V)->ValueContent
    var body: some View{
        VStack(alignment: .leading, spacing: 0){
            ForEach(data.keys.sorted(), id: \.self){(key: K) in
                VStack(alignment: .trailing, spacing: 0){
                    self.keyContent(key)
                    ForEach(self.data[key]!){(value: V) in
                        self.valueContent(value)
                    }
                }
            }
        }
    }
}

Как видно, контейнер принимает словарь типа [K: [V]] (где K — тип объектов-ключей словаря, V — тип массива, из которого состоят значения словаря), и два контекста: один для отображения ключей словаря, другой — для отображения значений. К сожалению, я не нашел примеров создания кастомных ViewBuilder-ов для кастомных контейнеров (вероятно такой возможности просто не существует), так что нам придется использовать стандартный ForEach. Поскольку он принимает на входе только RandomAccessCollection, а dict.keys ею не является, нам придется воспользоваться сортировкой. Отсюда возникло требование поддержки протокола Comparable к KeyObject.

Я использовал два вложенных ForEach контейнера. В первом случае, я использовал хэш элемента коллекции (\.self) в качестве идентификатора каждой вложенной View. Я мог это сделать, т.к. ключи словаря и так должны поддерживать протокол Hashable. Во втором случае, я добавил к классу SomeObject поддержку протокола Identifiable. Это позволило мне вообще не указывать ключ связи — автоматически используется id. В моем случае id нигде не хранится. При каждом создании объекта — будь то создание в коде, или получение с помощью запроса к БД — генерируется новый id. Для интерфейса, это не существенно. Он не будет меняться на протяжении жизни объекта т.е. сессии, и этого достаточно чтобы выводить его на экран под этим id. А если при следующем открытии приложения он будет иметь другой id — ничего страшного не случиться. Если же у вашего объекта уже есть ключевые поля — можно просто сделать id вычисляемым параметром, и всеравно использовать поддержку этого протокола и сокращенный вариант синтаксиса ForEach.

Пример использования нашего контейнера:

struct ContentView: View {
    let dict: [KeyObject: [SomeObject]] = [
        KeyObject(name: "1st group") : [SomeObject(value: 1),
             SomeObject(value: 2),
             SomeObject(value: 3)],
        KeyObject(name: "2nd group") : [SomeObject(value: 4),
             SomeObject(value: 5),
             SomeObject(value: 6)],
        KeyObject(name: "3rd group") : [SomeObject(value: 7),
             SomeObject(value: 8),
             SomeObject(value: 9)]
    ]
    var body: some View {
        TreeView(data: dict,
                 keyContent: {keyObject in
                    Text("the key is: \(keyObject.name)")
                 }
        ){valueObject in
            Text("value: \(valueObject.value)")
        }
    }
}

и результат на экране в Canvas:



to be continued


На этом пока все. Я хотел так же осветить все грабли, на которые я наступил, пытаясь использовать CoreData в связке с SwiftUI, но, откровенно говоря, не ожидал, что только основы SwiftUI займут столько времени, а статья выйдет такой объемной. Так что, как говориться, продолжение следует.

Если есть что добавить, или исправить — добро пожаловать в комментарии. Существенные замечания постараюсь отразить в статье.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 19: ↑19 и ↓0+19
Комментарии30

Публикации

Истории

Работа

Swift разработчик
12 вакансий
iOS разработчик
13 вакансий

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань