SwiftUI по полочкам

Каждый раз, когда в языке программирования появляется новый Фреймворк, рано или поздно, появляются люди, которые изучают язык именно с него. Вероятно так было и в 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 события параметров структуры, обернутых в какие-то обертки. Компилятор позволяет вам написать этот код, но он просто не выполняется. Вероятно потому, что обертка — это и есть какой-то шаблонный код, выполняемый при наступлении этих событий.

Классический пример 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. Скорее всего оно сбросится на значение по-умолчанию, или то, которое прописано в инициализаторе.

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, а затем вызвать .ftame(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 займут столько времени, а статья выйдет такой объемной. Так что, как говориться, продолжение следует.

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

Подробнее
Реклама

Комментарии 27

    0

    Очень полезная статья. Спасибо.

      0
      Благодарю. В процессе ее написания я и для себя многое по полочкам разложил.
      0
      \.self означает что типа замыкания { (it) in it.self }?
        0
        не совсем.
        id нужен для внутренней работы ForeEach, что бы он мог для каждого элемента построить свою View с помощью переданного контента. Эту View нужно связать с элементом с помощью какого-то ключа. Параметр id, это именно указание, какой реквизит использовать для этой связи в качестве уникального ключа элемента коллекции. Для этого используется вариант инициализатора с указанием ключевого атрибута.
        init(_ data: Data, id: KeyPath<Data.Element, ID>, content: @escaping (Data.Element) -> Content)


        Когда указывается id: \.self, это значит, что в качестве ключа будет использован непосредственно сам элемент, а точнее, его хэш. Именно поэтому, указанный реквизит, или сам объект в случае \.self должен удовлетворять протоколу Hashiable

        А в замыкание просто передается элемент коллекции Data, для которого нужно нарисовать отдельную View {Item in ...}

        Подробнее о KeyPath можно почитать, например, тут
          0
          С непревычки немного заморочено. Но вроде понял.
          code
          struct Article {
              let title: String
              let body: String
          }
          let a1 = Article(title:"title", body:"body")
          
          let v1 : (Article)->String = { (it) in it.title }
          print("v1:",type(of:v1),v1(a1))
          
          let v2 : (Article)->String = { $0.title }
          print("v2:",type(of:v2),v2(a1))
          
          let v3 : KeyPath<Article,String> = \.title
          print("v3:",type(of:v3),a1[keyPath:v3])
          
          let v4 = \Article.title
          print("v4:",type(of:v3),a1[keyPath:v4])
          

          v1: (Article) -> String title
          v2: (Article) -> String title
          v3: WritableKeyPath<Article, String> title
          v4: WritableKeyPath<Article, String> title


          ps: почему-то сразу вспомнился perl :)
        +3
        Также уродливо как и flutter =(
          –1
          А есть где-нибудь не уродливо?
          0
          Отлично написано. Сам на таком же старте (учу Swift, начиная с SwitfUI). Статья многое объясняет. Спасибо!
            0
            Пожалуйста. Я довольно много времени потратил впустую, ковыряясь со своим первым проектом. В сети есть несколько примеров простых приложений, но стоит попытаться отойти от них чуть в сторону — и тут же начинаются грабли. Мне по началу очень не хватало системного понимания, что это такое, и как оно работает. Простые листинги кода в этом мало помогали. Пришлось прокачивать английский, и лезть на StackOverflow. Там довольно часто встречаются толковые объяснения.

            Решил что немного систематизированной информации будет полезно для коллег, и написал эту статью.
            0
            Я начал изучать UIKit, но теперь вот думаю делать упор на SwiftUI. Те же аналогии возникли с обычным/управляемым интерфейсом, только в 1с все-таки упр. формы — это необходимость при работе в режиме тонкого и веб-клиента, а SwiftUI — эволюция интерфейса в мире Swift. И вроде бы необходимости нет, но новая концепция мне определенно нравится больше. Хотел поинтересоваться, мобильная разработка — хобби или с 1С удалось завязать?
              0
              Все зависит от того, насколько успешно все с моим пет-проектом. В данный момент, я сосредоточил на нем 100% своего времени. Надеюсь, с его помощью получится закрепиться на ниве мобильной разработки.

              Впрочем, совсем забрасывать 1с я не хочу даже в случае его успеха. Я вижу непаханое поле на пересечении мобильной разработки и автоматизации бизнеса (ритейла в частности, т.к. основные мои компетенции наработаны именно вокруг ритейла).
                0
                А что касается UIKit, то переход на SwiftUI с целью трудоустройства может быть преждевременным. В SwiftUI полно багов. Разработка пока похожа на танцы с бубнами — больше чем обычно. Описание ошибок в SwiftUI — штука такая. Почти рандомная. Проблема может быть в одной строчке кода, а компилятор ругается на совсем другие, с совсем другим описанием ошибки.

                И по поводу багов — это действительно проблема для production. Например, NavigationLink (способ навигации между экранами, замена сегвеям) — сломан в текущем релизе XCode(11.3.1). Он работает только 1 раз. Т.е. вы сходили по ссылке, вернулись назад, кликаете на ту же ссылку — и ничего не происходит. До тех пор пока вы не сходите по другой ссылке — тогда первая заработает, но вторя останется недоступной.
                  0
                  По поводу NavigationLink, у меня эта проблема воспроизводилась только в симуляторе. На девайсе все вроде нормально. Но там других багов хватает.
                    +1
                    Сразу вопрос, вы делали проекты на SwiftUI? Предвижу что вы спросите меня тоже самое. У меня на GitHub есть свой достаточно большой проект github.com/filimo/ReaderTranslator.git на SwiftUI для macOS и iOS. Проблем с багами нет. Если хотите подискутировать и узнать больше о SwiftUI то велком на телеграм канал t.me/swift_ui
                      0
                      Живых работающих проектов — нет, только домашние песочницы. Впрочем, я планирую на основе одной из них сделать несколько публикаций. В первую очередь, чтобы меня ткнули носом, если где-то можно сделать лучше. Ну и сам материал постараюсь сделать полезным.
                        0
                        Посмотрел сценарий использования. В чем преимущество перед встроенной командой в MacOS и iOS Найти/Look up? Можно настроить словари, есть перевод, транскрипция, вызывается одним движением.
                          0
                          Цель проекта:
                          — снять негативный налет со SwiftUI что фреймворк еще не готов для продакшена
                          — демонстрация возможностей SwiftUI, Combine и других новых Apple фреймворков
                          — пример архитектуры SwiftUI проектов на практике
                          С полным списком текущих возможностей проекта можно ознакомиться в разделе Issues, сейчас их больше 70 и в каждом из них есть еще sub-issues.

                          macOS версия:
                          — интегрирует в себя такие сервисы как Google Translate, Yandex переводчик, Reverso, MacMillan, Longman, Collins, MerriamWebster, StackExchange, Wikipedia
                          — имеет встроенный браузер и Safari extension
                          — автоматический перевод выделенного текста в этих сервисах, встроенном браузере, pdf или Safari через эти сервисы
                          — просмотр видео с WWDC с регулировкой скорости воспроизведения, перемоткой, подсветкой произносимых фраз, горячими клавишами и переводом в одно нажатие
                          — озвучкой выделенного текста через Voice engine с настройкой голоса
                          — озвучкой выделенных слов профессиональными дикторами (British, American)
                          — возможностью чтения pdf книг с паралельным прослушиванием, регулировкой скорости воспроизведения и удобной перемоткой
                          — функциональность для составления своего словаря, встроен механиз повторения слов и выражений с примерами предложений и озвучкой профессиональными дикторами

                          Мобильная версия:
                          — прослушивания аудио материалов с регулеровкой скорости и удобной перемоткой
                          — работа со своим словарем с переводом и примерами использование слова в предложениях с озвучкой профессиональх дикторов (British, American)
                          — перевода выделенного текста из других приложениях
                    0
                    Благодарю за подробный туториал, очень понятно всё разобрано, ничего лишнего! Действительно, похоже на Flutter.

                    Сам пишу под Android, но периодически имею дело с iOS, и хотелось быть в курсе технологий. :-)
                      +1
                      Рад помочь. Я планирую написать еще несколько статей в том же стиле, но уже более конкретных, на живых примерах. В частности о CoreData + SwiftUI. Подписывайтесь:)
                      0
                      Поковырялся в SwiftUI. До этого вообще не работал в mobile dev. Очень понравилось. Буду изучать swift. Только расстраивает то что он вообще не готов для продакшна. Но вообще очень приятная вещь.
                        0
                        Хорошо помню, как оно было с управляемыми формами от 1с. Пока перед заказчиками стыдно не стало, что мы все еще пилим на обычных формах, убедить себя с ними разобраться не получалось. А менеджменту вообще без разницы, на чем там мы кодим — главное чтобы спайс не переставал поступать.

                        Так что это в первую очередь люди не готовы, как мне кажется.
                        –1
                        ошибочка вышла. Промахнулся. А удалить комментарий не получается
                          0
                          Спасибо за статью. Изучаю swift для своих пет-проектов (не для работы) и как раз встал вопрос куда двигаться дальше: UIKit / SwiftUI. В частности, хотел бы сделать приложение для вокальных распевок с проигрыванием заготовленных гамм вверх/вниз по клавиатуре. Основные элементы интерфейса: рисованные кнопки “влево / вправо”, анимация клавиатуры, название нот. Что-то подобное реально сделать на SwiftUI? Или тут понадобятся какие-то готовые библиотеки, которых под SwiftUI еще не написаны?
                            0
                            Не могу сказать, что обладаю большим опытом написания реальных приложений на SwiftUI. То, что я пока видел — очень воодушевляюще. Рисованные кнопки — да запросто. Что угодно может быть кнопкой, если к этому прилепить .onTapGesture(), в том числе и Image. Анимация — легко . Подписи к кнопкам — всего-то использовать .overlay(Text("Кнопка")). Единственное, для проигрывания аудио потребуется AVAudioPlayer из Foundation — но его в любом случае использовать, что в SwiftUI, что в UIKit (ну или какие-то другие библиотеки для работы со звуками, все таки SwiftUI — это про изображение).

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

                            Ну и последнее. SwiftUI умеет еще не все. Некоторые задачи решаются только путем создания элемента на UIKit и встраивания его в SwiftUI View с помощью специального контроллера.
                              0
                              Спасибо за развернутый ответ. Пожалуй, попробую двинуть в сторону SwiftUI. Помимо прочего, его новизна и перспективность чисто психологически привлекает гораздо больше :) Может посоветуете какие-то материалы для плавного погружения в тему? Видел курс 100 дней SwiftUI от Hackingwithswift, но, если честно, масштабы пугают (неужели столько времени надо на освоение?).
                                0
                                Если речь о введении в тему, то можно посмотреть русскоязычный курс на SwiftBook.ru.

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

                                Как я уже писал выше, у меня, например, возникли определенные сложности с CoreData. На Hackingwithswift есть раздел на тему SwiftUI + CoreData, но там подразумевается, что вы уже знаете как работать с CoreData в принципе, и нужно только понять, как это к SwiftUI приложить.

                                Следующая статья (или через одну, я еще работаю над планом выхода статей) будет именно об этом.
                                  0
                                  Спасибо!

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое