Сразу начну с замечания о том, что приложение, о котором пойдет речь в этой статье, требует Xcode 11 и MacOS Catalina , если вы хотите использовать Live Previews, и Mojave, если будете пользоваться симулятором. Код приложения находится на Github.

В этом году на WWDC 2019, Apple анонсировала SwiftUI, новый декларативный способ построения пользовательского интерфейса (UI) на всех устройствах Apple. Это практически полное отступление от привычного нам UIKit, и я — как и многие другие разработчики — очень хотела посмотреть этот новый инструмент в действии.

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

Задача связанна с прошлым конкурсом Telegram для Android, iOS and JS разработчиков, который проходил в период 10 — 24 марта 2019 года. В этом конкурсе была предложена простая задача графического отображения интенсивности использования некоторого ресурса в интернете в зависимости от времени на основе JSON данных. Как iOS разработчик вы должны использовать язык Swift для представления на конкурс кода, написанного «с нуля» без использования каких-либо посторонних специализированных библиотек для построения графиков.

Эта задача требовала навыков работы с графическими и анимационными возможностями iOS: Core Graphics, Core Animation, Metal, OpenGL ES. Некоторые из этих инструментов являются низкоуровневыми, не объектно-ориентированными средствами программирования. По существу, в iOS не было приемлемых шаблонов для решения подобных, казалось бы, легких на первый взгляд графических задач. Поэтому каждый конкурсант изобретал свой собственный аниматор (Render) на основе Metal, CALayers, OpenGL, CADisplayLink. Это порождало тонны кода, из которого ничего не удавалось заимствовать и развивать, так как это чисто «авторские» работы, которые реально могут развивать только авторы. Однако так быть не должно.

И вот в начале июня на WWDC 2019 появляется SwifUI - новый framework, разработанный Apple, написанный на Swift и предназначенный для декларативного описания пользовательского интерфейса (UI) в коде. Вы определяете, какие subviews показываются в вашем View, какие данные заставляют эти subviews изменяться, какие модификаторы к ним нужно применить, чтобы заставить их позиционироваться в нужном месте, иметь нужный размер и стиль. Не менее важным элементом SwiftUI является управление потоком изменяемых пользователем данных, которые в свою очередь обновляют UI.

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

Задание


Конкурсное приложение должно показывать одновременно на экране 5 «наборов Графиков», используя предоставленные Telegram данные. Для одного «набора Графиков» UI выглядит следующим образом:



В верхней части расположена «зона Графиков» с общим масштабом по обычной оси Y с отметками и горизонтальными линиями сетки. Чуть ниже расположена «бегущая строка» с временными отметками по оси X в виде дат.

Еще ниже располагается так называемый «mini map» (как в Xcode 11), то есть прозрачное «окошко», определяющее ту часть временного отрезка наших «Графиков», которая более подробно представлена в верхней «зоне Графиков». Этот «mini map» можно не только перемещать вдоль оси X, но и менять его ширину, что сказывается на временном масштабе в «зоне Графиков».

С помощью checkboxs, окрашенных в цвета «Графиков» и снабженных их названиями, можно отказаться от показа соответствующего этому цвету «Графика» в «зоне Графиков».

Таких «наборов Графиков» много, в нашем тестовом примере их, например, 5, и все они должны располагаться на одном экране. 

В UI, проектируемом с помощью SwiftUI нет необходимости в кнопке переключения между Dark и Light режимами, это уже встроено в SwiftUI. Кроме того, в SwiftUI гораздо больше возможностей комбинирования «наборов Графиков» (то есть множества представленных выше экранов), чем просто прокручиваемая вниз таблица, и мы рассмотрим некоторые из этих очень интересных вариантов.

Но сначала остановимся на отображении одного «набора Графиков», для которого в SwiftUI создадим ChartView:



SwiftUI позволяет создавать и тестировать сложный UI по маленьким кусочкам, а потом очень просто собирать эти кусочки в пазл. Мы так и поступим. Наш  ChartView очень хорошо расщепляется на эти маленькие кусочки:

  • GraphsForChart — это собственно графики, построенные для одного конкретного «набора Графиков». «Графики» показаны для временного диапазона, управляемого пользователем с помощью «mini map» RangeView, который будет представлен ниже.
  • YTickerView — ось Y с отметками и соответствующей горизонтальной сеткой.
  • IndicatorView — горизонтально перемещаемый пользователем индикатор, позволяющий посмотреть значения «Графиков» и времени для соответствующего положения индикатора на временной на оси X.
  • TickerView — «бегущая строка», показывающая временные отметки на оси X в виде дат,
  • RangeView — временное «окошко», настраиваемое пользователем с помощью жестов, для задания временного интервала «Графиков»,
  • CheckMarksView — содержит «кнопки», окрашенные в цвета «Графиков» и позволяющие управлять присутствием «Графика» на ChartView .

С ChartView пользователь может взаимодействовать тремя способами:

1. управлять«mini map» с помощью жеста DragGesture — он может сдвигать временное «окошко» вправо и влево и уменьшать / увеличивать его размер:



2. перемещать в горизонтальном направлении индикатор, показывающий значения «Графиков» в фиксированный момент времени:



3. скрывать / показывать определенные «Графики» с помощью кнопок, окрашенных в цвета «Графиков» и расположенных в самом низу ChartView:



Мы можем комбинировать различные «Наборы Графиков» ( их у нас 5 в тестовых данных) разными способами, например, расположив их все одновременно на одном экране с помощью списка List (наподобие прокручиваемой вниз-вверх таблицы):



или с помощью ScrollView и горизонтального стека HStack c 3D эффектом:



… или в виде ZStack наложенных друг на друга «карт», порядок которых можно менять: верхнюю «карту» с "«набором Графиков» можно оттянуть вниз достаточно далеко, чтобы посмотреть на следующую карту, и если продолжать тянуть ее вниз, то она «уходит» на последнее место в ZStack, а вперед «выходит» эта следующая «карта»:



В этих сложных UI — «прокручиваемая таблица», горизонтальный стек с 3D эффектом, ZStack наложенных друг на друга «карт» — полноценно работают все средства взаимодействия с пользователем: перемещение по временной шкале и изменение «масштаба» mini - map, индикатор и кнопки скрытия «Графиков».

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

Итак, решение нашей задачи разбилось на несколько этапов:

  • Закачать данные из JSON-файла и представить их в удобном «внутреннем» формате
  • Создать UI для одного «набора Графиков»
  • Комбинировать различные «наборы Графиков»

Закачиваем данные


В наше распоряжение Telegram предоставил JSON данные, содержащие несколько «наборов Графиков». Каждый отдельный «набор Графиков» chart содержит несколько «Графиков» (или «Линий») chart.columns. У каждого «Графика» («Линии») есть метка в позиции 0"x", "y0", "y1", "y2", "y3", за которой следуют либо значения времени на оси X («x»), либо значения «Графика» («Линии») ("y0", "y1", "y2", "y3") на оси Y :



Присутствие всех «Линий» в «наборе Графиков» — необязательно. Значения для «столбца» x представляют собой UNIX метки времени в миллисекундах.

Кроме того, каждый отдельный «набор Графиков» chart снабжается цветами chart.colors в формате 6-ти шестнадцатеричных цифр (например, "#AAAAAA") и именами chart.names.

Для построения Модели данных, находящихся в JSON-файле, я воспользовалась прекрасным сервисом quicktype. На этом сайте вы вставляете кусок текста из JSON файла и указываете язык программирования (Swift), имя структуры (Chart), которая сформируется после «парсинга» этих JSON данных и всё.

В центральной части экрана формируется код, который мы скопируем в наше приложение в отдельный файл с именем Chart.swift. Именно там мы будем размещать Модель данных JSON формата. Воспользовавшись заимствованным из демонстрационных примерах SwiftUI Generic загрузчиком load данных из JSON файла в Модель, я получила массив columns: [ChartElement], представляющий собой совокупность «наборов Графиков» в заданном Telegram формате.

Cтруктура данных ChartElement, содержащая массивы разнотипных элементов, не очень подходит для интенсивной интерактивной работы с графиками, кроме того метки времени представлены в UNIX формате в миллисекундах (например, 1542412800000, 1542499200000, 1542585600000, 1542672000000), а цвета — в формате 6-ти шестнадцатеричных цифр (например, "#AAAAAA").

Поэтому внутри нашего приложения мы будем пользоваться теми же данными, но в другом «внутреннем» и довольно простом формате [LinesSet]. Массив [LinesSet] представляет собой совокупность «наборов Графиков» LinesSet, каждый из которых содержит временные метки xTime в формате "Feb 12, 2019" (ось X) и несколько «Графиков» lines (ось Y):



Данные для каждого «Графика»( «Линии») Line представлены

  • массивом целых чисел points: [Int],
  • именем «Графика» title: String
  • типом «Графика» type: String?,  
  • цветом color : UIColor в свойственном для Swift формате UIColor,
  • количеством точек countY: Int.

Кроме того, любой «График» может быть скрыт или показан в зависимости от значения isHidden: Bool. Параметры lowerBound и upperBound регулировки временного диапазона принимают значения от 0 до 1 и показывают для заданного «набора Графиков» не только размер временного «окошка» «mini map» (upperBound -  lowerBound), но и его местоположение на временной оси X:



Структуры JSON данных [ChartElement] и структуры данных «внутреннего» представления LinesSet и Line находятся в файле Chart.swift. Код для загрузки JSON данных и преобразования их во внутреннюю структуру находится  в файле Data.swift. Подробно об этих преобразованиях можно узнать здесь.

В результате мы получили данные о «наборах Графиков» во внутреннем формате в виде массива chartsData.



Это и есть наша Модель данных, но для работы в SwiftUI необходимо сделать так, чтобы любые изменения, выполненные пользователем в  массиве chartsData ( изменение временного «окошка», скрытие / показ «Графиков») приводили к автоматическим обновлениям наших Views.

Мы создадим @EnvironmentObject. Это позволит нам использовать Модель данных везде, где это необходимо, и кроме этого, автоматически обновлять наши Views, если данные будут меняться. Это что-то типа Singleton или глобальных данных.

@EnvironmentObject требует от нас создания некоторого класса final class UserData, который находится в файле UserData.swift, запоминает данные chartsData и реализует протокол ObservableObject:



Наличие @Published «обертки» позволит разместить «новости» о том, что данные свойства charts класса UserData изменились, так что любые Views, «подписанные на эти новости» в SwiftUI, смогут автоматически выбрать новые данные и обновиться. 

Напомним, что в свойстве charts могут меняться значения isHidden для любого «Графика» (они позволяют скрывать или показывать эти «Графики»), а также нижняя lowerBound и верхняя upperBound границы временного интервала для каждого отдельного «набора Графиков».

Свойство charts класса UserData мы хотим использовать повсюду в нашем приложении и нам не придется синхронизировать их с UI вручную благодаря @EnvironmentObject.

Для этого при старте приложения мы должны создать экземпляр класса UserData (), чтобы впоследствие иметь к нему доступ где угодно в нашем приложении. Мы сделаем это в файле SceneDelegate.swift внутри метода scene (_ : , willConnectTo: , options: ). Именно там создается и запускается наш ContentView, и именно здесь мы должны передавать ContentView любые созданные нами @EnvironmentObject так, чтобы SwiftUI мог сделать их доступными для любого другого View:



Теперь, в любом View для доступа к @Published данным класса UserData нам нужно создать переменную var, используя @EnvironmentObject обертку. Например, при настройке временного диапазона в RangeView мы создаем переменную var userData, имеющую ТИП UserData:



Итак, как только мы внедрили некоторый объект @EnvironmentObject в «среду» приложения, мы можем немедленно начать его использовать либо на самом верхнем уровне, либо 10-ю уровнями ниже — это не имеет значения. Но что более важно, всякий раз, когда какое-то View изменит «среду», все Views, имеющие этот @EnvironmentObject, автоматически обновятся, обеспечивая тем самым синхронизацию с данными.

Перейдем к проектированию пользовательского интерфейса (UI).

Пользовательский Интерфейс (UI) для одного «набора Графиков»


SwiftUI предлагает композиционную технологию создания UI из множества небольших Views, а мы уже видели, что наше приложение очень хорошо ложится на эту технологию, так как расщепляется на маленькие кусочки: «набор Графиков» ChartView, «Графики» GraphsForChart, отметки на оси YYTickerView, управляемый пользователем индикатор значений «Графиков» IndicatorView, «бегущую» строку TickerView с временными отметками на оси X , управляемое пользователем «временное окно» RangeView, отметки о скрытии / показе «Графиков» CheckMarksView. Все эти Views мы можем не только создавать независимо друг от друга, но тут же и тестировать в Xcode 11 с помощью Previews (предварительных «живых» просмотров) на тестовых данных. Вы удивитесь насколько прост код для их создания из других более элементарных Views .

GraphView — «График» («Линия»)


Первое View, с которого мы начнем, — это собственно сам «График» (или «Линия»). Мы назовем его GraphView:



Создание GraphView, как обычно, начинается с создания нового файла в Xcode 11 с помощью меню File > New > File:



Затем мы выбираем нужный ТИП файла — это SwiftUI файл:



… даем название «GraphView» нашему View и указываем его местоположение:



Кликаем на кнопке "Create" и получаем стандартное View с текстом Text ( "Hello  World!") в середине экрана:



Наша задача — заменить текст Text ("Hello World!") на «График», но сначала давайте посмотрим, какими исходными данными для создания «Графика» мы располагаем:

  • у нас есть значения line.points «Графика» line: Line,
  • временной диапазон rangeTime, представляющий собой диапазон индексов Range временных отметок xTime на ОСИ X,
  • диапазон значений rangeY: Range «Графика» для ОСИ Y,
  • толщина линии обводки «Графика» lineWidth.

Добавляем эти свойства в структуру GraphView:



Если мы хотим использовать для нашего «Графика» Previews (предварительные просмотры), которые возможны только для MacOS  Catalyna, то мы должны инициировать GraphView с диапазон индексов rangeTime и данными line самого «Графика»:



У нас уже есть тестовые данные chartsData, которые мы получили из JSON файла chart.json, и мы их использовали для Previews.

В нашем случае это будет первый «набор Графиков» chartsData[0] и первый «График» в этом наборе chartsData[0].lines[0], который мы предоставим GraphView в качестве параметра line.

В качестве временного интервала rangeTime мы будем использовать полный диапазон индексов 0..<(chartsData[0].xTime.count - 1).
Параметры rangeY и lineWidth можно задавать извне, а можно и не задавать, так как у них уже есть начальные значения: у rangeY — это nil, а у lineWidth1.

Мы намеренно сделали ТИП свойства rangeY  Optional ТИПОМ, так как в случае, если rangeY не задается извне и rangeY = nil, то мы вычисляем минимальное minY и максимальное maxY значения «Графика» непосредственно из данных line.points:



Этот код компилируется, но мы по-прежнему имеем на экране  стандартное View с текстом Text ("Hello World!") в середине экрана:



Потому что в body мы должны заменить текст Text ("Hello World!") на Path, который по точкам line.points с помощью команды addLines(_:) ( почти как в Core Graphics) будет строить наш «График:




Мы обведем stroke (...) наш Path линией, толщина которой равняется lineWidth, при этом цвет линии обводки будет соответствовать цвету „по умолчанию“ ( то есть „черному“):



Мы можем заменить черный цвет для линии обводки на цвет, заданный в нашем конкретном „Графике“ line.color:



Для того, чтобы наш „График“ мог размещаться в прямоугольниках любых размеров, мы используем контейнер GeometryReader. В документации Apple GeometryReader - это „контейнер“ View, который определяет свое содержимое как функцию от собственных размера size и координатного пространства. По существу, GeometryReader — это еще одно View! Потому что почти ВСЁ в SwiftUI является ViewGeometryReader позволит ВАМ в отличие от других Views получить доступ к некоторой дополнительной полезной информации, которой можно воспользоваться при проектировании вашего пользовательского View.

Мы используем контейнер GeometryReader и Path для создания адаптируемого к любым размерам GraphView. И если мы посмотрим внимательно на наш код, то увидим в замыкании для GeometryReader переменную с именем geometry:



Эта переменная имеет ТИП GeometryProxy, который в свою очередь является структурой struct со множеством „сюрпризов“:

public var size: CGSize { get }
public var safeAreaInsets: EdgeInsets { get }
public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
public subscript<T>(anchor: Anchor<T>) -> T where T : Equatable { get }

Из определения GeometryProxy мы видим, что там присутствуют две вычисляемые переменные var size и var safeAreaInsets, одна функция frame( in:) и subscript getter. Нам понадобилась только переменная size для определения ширины geometry.size.width и высоты geometry.size.height области рисования „Графика“.

Кроме того, мы даем возможность нашему „Графику“ анимировать с помощью модификатора animation (.linear(duration: 0.6)).



GraphView_Previews позволяет нам очень просто тестировать любые „Графики“ из любого „набора“. Ниже представлен „График“ из „набора Графиков“ с индексом 4 : chartsData[4] и индексом 0 »Графика" в этом наборе: chartsData[4].lines[0] :



Мы задали высоту height «Графика» равной 400 с помощью frame (height: 400), ширина осталась равной ширине экрана. Если бы мы не использовали frame (height: 400), то «График» занял бы весь экран. Мы не задали диапазон значений rangeY и GraphView использовал значение nil, которое задано по умолчанию, в этом случае «График» берет свои минимальное и максимальное значения на временном интервале rangeTime:



Хотя мы применили для нашего Path модификатор animation (.linear(duration: 0.6)), никакой анимации происходить не будет, например, при изменении диапазона rangeY значений «Графика». «График» будет просто «прыгать» от одного значения диапазона rangeY к другому без всякой анимации. 

Причина простая: мы научили SwiftUI тому, как нарисовать «График» для конкретного диапазона rangeY , но мы не научили SwiftUI тому, как воспроизводить «График» многократно с промежуточными  значениями диапазона rangeY между начальным и конечным, а за это в SwiftUI отвечает протокол Animatable

К счастью, если ваш View -  «фигура», то есть View, которое реализует протокол Shape, то для него уже реализован протокол Animatable. Это означает, что существует вычисляемое свойство animatableData, с помощью которого мы можем управлять процессом анимации, но по умолчанию оно установлено в  EmptyAnimatableData, то есть никакой анимации не происходит.

 Для того, чтобы решить проблему с анимацией, мы сначала должны превратить наш «График» GraphView в Shape. Это очень просто, нам нужно только реализовать функцию func path (in rect:CGRect) -> Path, которая у нас, по существу, уже есть и указать с помощью вычисляемого свойства animatableData, какие данные мы хотим анимировать:



Отметим, что тема управления анимацией является продвинутой темой в SwiftUI и вы можете более подробно с ней познакомиться в статье «Advanced SwiftUI Animations – Part 1: Paths».

Полученную «фигуру» Graph мы можем использовать в значительно более простом GraphViewNew для «Графика» с анимацией: 



Вы видите, что нам не понадобился GeometryReader для нашего нового «Графика» GraphViewNew, так как благодаря протоколу Shape наша «фигура» Graph сможет адаптироваться к любому размеру родительского View.

Естественно в Previews мы получили тот же самый результат, что и в случае с GraphView:



В последующих комбинациях мы будем использовать GraphViewNew для отображения значений одного «Графика».

GraphsForChart — совокупность «Графиков» («Линий»)


Задача этого View - отображать ВСЕ «Графики» («Линии») из «набора Графиков» chart в заданном временном диапазоне rangeTime с общей осью Y, при этом ширина «Линий» равна lineWidth:



Также как и для GraphView и GraphViewNew, мы создадим для GraphsForChart новый файл GraphsForChart.swift и определяем исходные данные для «набора Графиков»:

  • сам «набор Графиков» chart: LineSet (значения на ОСИ Y),
  • диапазон rangeTime: Range  (ОСЬ X) индексов временных отметок «Графиков»,
  • толщина линии обводки «Графиков» lineWidth

Диапазон значений rangeY: Range для «набора Графиков» (ОСЬ Y) вычисляется как объединение диапазонов отдельных не cкрытых ( isHidden = false ) «Графиков», входящих в данный «набор»:



Для этого мы используем функцию rangeOfRanges:



Все НЕ скрытые «Графики» ( isHidden = false ) мы показываем в ZStack с помощью конструкции ForEach, наделяя при этом каждый «График» возможностью появления на экране и ухода с экрана «с помощью модификатора „перемещения“ transition(.move(edge: .top)):



Благодаря этому модификатору процесс скрытия и возвращения „Графика“ в ChartView будет проходить на экране с анимацией и даст понять пользователю, почему изменился масштаб по оси Y.

Использование drawingGroup() означает использование Metal для рисования графических фигур. На наших тестовых данных и на симуляторе вы не почувствуете разницы в скорости рисования с Metal и без Metal, но если вы воспроизводите множество достаточно громоздких графиков на любом iPhone, то вы заметите эту разницу. Для более подробного ознакомления, когда следует использовать drawingGroup(), можно посмотреть статью »Advanced SwiftUI Animations – Part 1: Paths" или посмотреть видео сессии 237 WWDC 2019 (Building Custom Views with SwiftUI).

Как и в случае с GraphViewNew при тестировании GraphsForChart с помощью предварительных просмотров Previews мы можем установить любой «набор Графиков», например, с индексом 0:



IndicatorView — горизонтально перемещаемый индикатор «Графика».


Этот индикатор позволяет получить точные значения «Графиков» и времени для соответствующей точки на временной на оси X:



Индикатор создается для определенного «набора Графиков» chart и состоит из скользящей вдоль оси X вертикальной ЛИНИИ с ОТМЕТКАМИ на ней в виде «кружочков» в месте значений «Графиков». К верхней части этой вертикальной линии прикреплен небольшой «ПЛАКАТ», содержащий численные значения «Графиков» и времени.



Скольжение индикатора производит пользователь с помощью жеста DragGesture:



Мы используем так называемое “инкрементное” выполнение жеста. Вместо непрерывного расстояния от стартовой точки value.translation.width, мы будем в обработчике onChanged постоянно получать расстояние от того места, где были в прошлый раз, когда выполняли жест: value.translation.width - self.prevTranslation. Это обеспечит нам плавное перемещение индикатора.

Для тестирования индикатора IndicatorView с помощью Previews для заданного «набора Графиков» chart мы можем привлечь уже готовое View построения «Графиков» GraphsForChart:



Мы можем задать любой, но согласованный друг с другом, диапазон времени rangeTime как для индикатора IndicatorView, так и для «Графиков» GraphsForChart. Это позволит нам убедиться, что «кружочки», обозначающие значения «Графиков», находятся на правильных местах.

TickerView - ОСЬ X с отметками.


Пока наши «Графики» обезличены в том смысле, что у них НЕТ ОСЕЙ X и Y с соответствующими масштабами и отметками. Давайте нарисуем ОСЬ X с временными отметками TickerMarkView на ней. Сами отметки TickerMarkView представляют собой очень простой View с вертикальным стеком VStack, в котором размещены Path и Text:



Совокупность отметок на временной оси для определенного «набора Графиков» chart : LineSet формируется в TickerView в соответствие с выбранным пользователем временным диапазоном rangeTime и приблизительным количеством отметок estimatedMarksNumber, которые должны оказаться в поле зрения пользователя:



Для расположения «бегущих» отметок времени используем ScrollView и горизонтальный стек HStack, который будет смещаться по мере изменения временного диапазона rangeTime.

В TickerView мы формируем шаг step, с которым появляются отметки времени TimeMarkView, основываясь на заданном временном диапазоне rangeTime и ширине экрана widthRange



… а затем выбираем отметки времени c шагом step из массива chart.xTime с помощью индексов indexes.

Собственно ОСЬ X — горизонтальную прямую — мы наложим overlay …



… на горизонтальный стек HStack, с отметками времени TimeMarkView, который мы продвигаем с помощью offset:



Кроме этого, мы можем задавать цвета самой ОСИ X - colorXAxis, и отметок — colorXMark:



YTickerViewОСЬ Y с отметками и сеткой.


Этот View рисует ОСЬ Y с цифровыми отметками YMarkView. Сами отметки YMarkView представляют собой очень простой View с вертикальным стеком VStack, в котором размещены Path (горизонтальная линия) и Text с числом:



Совокупность отметок на ОСИ Y для определенного «набора Графиков» chart формируется в YTickerView. Диапазон значений rangeY вычисляется как объединение диапазонов значений всех «Графиков», входящих в данный «набор Графиков» с помощью функции rangeOfRanges. Приблизительное количество отметок на ОСИ Y задается параметром estimatedMarksNumber:



В YTickerView мы отслеживаем изменение диапазона значений «Графиков» rangeY. Собственно ОСЬ Y - вертикальную прямую — мы накладываем overlay на наши отметки…



Кроме этого, мы можем задавать цвета самой ОСИ Y — colorYAxis, и отметок — colorYMark:



RangeView - настройка временного диапазона с помощью «mini-map».


Самой подвижной частью нашего пользовательского интерфейса является настройка временного диапазона ( lowerBound, upperBound) для отображения «набора Графиков»:



RangeView — это своеобразный  mini - map для выделения  определенного временного участка  с целью более подробного рассмотрения" набора Графиков" в других Views.

Как и в предыдущих View, исходными данные для RangeView  являются:



  • сам «набор Графиков» chart: LineSet (значения Y),
  • высота height "mini-map" RangeView,
  • ширина widthRange "mini-map" RangeView,
  • отступ indent "mini-map" RangeView.

В отличие от других рассмотренных выше Views, мы должны изменять с помощью жеста DragGesture временной диапазон (lowerBound, upperBound) и тут же видеть его изменение, поэтому настраиваемый пользователем временной диапазон (lowerBound, upperBound), с которым мы будем работать, хранится в изменяемой переменной @EnvironmentObject var userData: UserData:



Любое изменение переменной var userData приведет к перерисовке всех Views, зависящих от него.

Главным действующим лицом в RangeView является прозрачное «окно», положение и размер которого регулируются пользователем с помощью жеста DragGesture:

1. если мы используем жест внутри прозрачного «окна», то изменяется ПОЛОЖЕНИЕ «окна» вдоль ОСИ X, а размер его не изменяется:



2. если мы используем жест в левой затемненной части, то изменяется только ЛЕВАЯ ГРАНИЦА «окна» lowerBound, позволяя уменьшаться или увеличиваться ширине прозрачного «окна»:



3. если мы используем жест в правой затемненной части, то изменяется только ПРАВАЯ ГРАНИЦА «окна» upperBound, позволяя уменьшаться или увеличиваться ширине прозрачного «окна»:



RangeView состоит из 3-х основных очень простых элементов: двух прямоугольников Rectangle () и изображения Image, границы которых определяются свойствами lowerBound и upperBound из @EnvironmentObject var userData: UserData и регулируются с помощью жестов DragGesture:



На эту конструкцию мы «накладываем» (overlay ) уже знакомое нам GraphsForChartView с «Графиками» из заданного «набора Графиков» chart:



Это позволит нам следить за тем, какая часть «Графиков» попадает в «окно».

Всякое изменение прозрачного «окна» ( его перемещение целиком или изменение границ), является следствием изменения свойствlowerBound и upperBound в userData в функциях onChanged обработки жестов DragGesture в двух прямоугольниках Rectangle () и изображении Image... 



Это, как мы уже знаем, автоматически приводит к перерисовке других Views ( в нашем случае «Графиков», оси X с отметками, оси Y c отметками и индикатора в СhartView):



Так как наш View содержит изменяемую переменную  @EnvironmentObject userData: UserData, то для предварительных просмотров Previews, мы должны задать ее начальное значение с помощью  .environmentObject (UserData()):



CheckMarksView — «скрытие» и показ «Графиков».


CheckMarksView представляет собой горизонтальный стек HStack с рядом checkBoxes для переключения свойства isHidden  каждого отдельного «Графика» в «наборе Графиков» chart:



CheckBox в нашем проекте может реализоваться либо с помощью обычной кнопки Button и называется CheckButton, либо с помощью имитирующей кнопки SimulatedButton.



Кнопку Button пришлось имитировать потому, что при размещении нескольких таких кнопок в List, расположенном выше по иерархии, они «отказываются» правильно работать. Это давняя ошибка, которая держится в Xcode 11, начиная с бэта 1 и до нынешней версии. В текущей версии приложения используется имитирующая кнопка SimulatedButton.

И имитирующая кнопка SimulatedButton, и настоящая кнопкаCheckButton используют одно и то же View для своего «внешнего облика» — CheckBoxView. Это HStack, содержащий Tex и Image:



Заметьте, что параметром инициализации CheckBoxView является @Binding переменная var line: Line. Свойство isHidden этой переменной определяет «внешний облик» CheckBoхView:




При использовании CheckBoхView в SimulatedButton и в CheckButton необходимо использовать знак $ для line при инициализации:




Свойство isHidden переменной line переключается в SimulatedButton с помощью onTapGesture



… а в CheckButton — с помощью обычного action для кнопки Button:



Заметьте, что параметром инициализации для SimulatedButton  и CheckButton  также является @Binding  переменная var line: Line . Поэтому при их использовании нужно применить $  в CheckMarksView  при переключении переменной  userData.charts[self.chartIndex].lines[self.lineIndex(line: line)].isHidden , которая хранится в изменяемой глобальной переменной @EnvironmentObject var userData :



Мы сохранили в проекте неиспользуемый в настоящий момент CheckButton на тот случай, если вдруг Apple исправит эту ошибку. Кроме того, вы можете попробовать использовать CheckButton в CheckMarksView вместо  SimulatedButton и убедиться, что она не работает для случая композиции множества «наборов Графиков» ChartView с помощью List в ListChartsView.

Так как наш View содержит изменяемую переменную  @EnvironmentObject var userData: UserData , то для предварительных просмотров Previews, мы должны задать ее начальное значение с помощью .environmentObject(UserData())



Комбинирование различных Views.


SwiftUI — это прежде всего комбинирование различных маленьких Views в большие, а больших Views — в очень большие и т.д., как в игре Lego. В SwiftUI есть множество средств такого комбинирования Views:

  • вертикальный стек VStack,
  • горизонтальный стек HStack,
  • «глубинный» стек ZStack,
  • группа Group,
  • ScrollView,
  • список List,
  • форма Form,
  • контейнер с «закладками» TabView
  • и т.д.

Начнем наше комбинирование с самого простейшего GraphsViewForChart, который наделяет «безликий» «набор Графиков» GraphsForChart ОСЬЮ Y и индикатором, перемещающимся по ОСИ X, с помощью «глубинного» стек ZStack:



Мы добавили в Previews нашего нового GraphsViewForChart контейнер NavigationView для того, чтобы отобразить его в Dark режиме с помощью модификатора .collorScheme(.dark).

Продолжим комбинирование и присоединим к полученному выше «набору Графиков» с ОСЬЮ Y и индикатором, ОСЬЮ X в виде «бегущей строки», а также средствами управления: временным диапазоном «mini — map» RangeView и переключателями CheckMarksView отображения «Графиков».

В результате мы получим заявленный выше ChartView, который отображает «набор Графиков» и позволяет управлять его отображением на временной оси:



В этом случае мы выполняем комбинирование с помощью вертикального стека VStack:



Теперь рассмотрим 3 варианта комбинирования множества уже полученных «наборов Графиков» ChartView:

  1. «прокручиваемая таблица» List,
  2. горизонтальный стек HStack с 3D эффектом,
  3. ZStack наложенных друг на друга «карт»

«Прокручиваемая таблица»  ListChartsView организуется с помощью списка List:



Горизонтальный стек с 3D эффектом организуется с помощью ScrollView, горизонтального стека HStack и списка в виде ForEach:



В этом виде полноценно работают все средства взаимодействия с пользователем: перемещение по временной шкале и изменение «масштаба» mini- map, индикатор и кнопки скрытия «Графиков».

ZStack наложенных друг на друга «карт».


Сначала создаём CardView для «карты»- это «набор Графиков» с ОСЯМИ X и Y,  но без элементов управления: без «mini — map» и без кнопок управления появлением / скрытием графиков. CardView  очень похож на ChartView, но так как мы собираемся накладывать «карты» друг на друга, то нам нужно, чтобы они были непрозрачными, С этой целью мы используем дополнительный ZStack для расположения на «заднем плане» цвета cardBackgroundColor. Кроме того, мы сделаем для «карты» рамку с закругленными краями:



Наложенные друг на друга «карты» организуются с помощью стеков VStack, ZStack и списка в виде ForEach:



Но мы будем накладывать друг на друга не просто «карты», а «3D-маcштабируемые» карты CardViewScalable, размер которых уменьшается с возрастанием индекса indexChat и они немного смещаются по вертикали. 

Порядок «3D-маcштабируемых карт» можно менять с помощью  последовательности (sequenced) жестов LongPressGesture и DragGesture, которая действует только на самую верхнюю «карту» с indexChat == 0:



Можно нажать (LongPress) на  верхнюю «карту» с «набором Графиков», а ЗАТЕМ оттянуть (Drag) ее вниз достаточно далеко, чтобы посмотреть на следующую карту, и если продолжать тянуть ее вниз, то она «уходит» на последнее место в ZStack, а вперед «выходит» следующая «карта»:



Кроме того для верхней «карты» мы можем применить TapGesture, который будет действовать одновременно с жестами LongPressGesture и DragGesture:



Tap жест будет модально показывать «набор Графиков» ChartView с элементами управления RangeView и CheckMarksView:



Применение TabView для объединения на одном экране всех 3-х вариантов композиции «набор Графиков» ChartView.





У нас 3 закладки c изображением Image и текстом Text, вертикальный стек VStack для их совместного представления не нужен.

Они соответствуют 3-м нашим способам комбинации «наборов Графиков» ChartViews

  1. «прокручиваемой таблице» ListChartViews,
  2. горизонтальному стеку с 3D эффектом HStackChartViews,
  3. ZStack наложенных друг на друга «карт» OverlayCardsViews.

Все элементы взаимодействия с пользователем: перемещение по временной шкале и изменение «масштаба» с помощью mini - map, индикатор и кнопки скрытия «Графиков». полноценно работают во всех 3-х случаях.

Код находится на Github.

Если вы хотите изучать SwiftUI...


Вам следует познакомиться с видео уроками, книгами и блогами:

Mang To, Lets Build That Application, а также описание некоторых SwiftUI приложений,
— бесплатная книжка «SwiftUI by example» и видео www.hackingwithswift.com/quick-start/swiftui
— платная книжка, но половина ее можно скачать бесплатно www.bigmountainstudio.com/swiftui-views-book
— курс 100 дней с SwiftUI www.hackingwithswift.com/articles/201/start-the-100-days-of-swiftui, который начинается сейчас и закончится 31 декабря 2019 года,
— впечатляющие вещи в SwiftUI делаются на swiftui-lab.com
Majid блог,
— на pointFree.co www.pointfree.co «марафон» постов про использование Reducers в SwiftUI (супер интересно),
— замечательное приложение MovieSwiftUI, у которого заимствовала несколько идей.

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


  1. ivlevAstef
    11.10.2019 19:14

    Для начала — спасибо за статью, так как на текущий момент информации по swiftUI крайне мало, и обычно сложные кейсы не рассматриваются.

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

    Вопрос- как по ощущениям скорость работы swiftUI? Эквивалентна констрейнам, или же медленней? Возможно даже эквивалентна тому если бы писали все на фреймах?

    P.S. Сам к сожалению дальше стандартного туториал от apple не продвинулся, и больше всего меня расстроила в swiftUI навигация между экранами, если использовать обычный navigation bar — слишком жёсткая привязка к экрану на который осуществляется переход.


    1. WildGreyPlus Автор
      11.10.2019 22:02
      +2

      Да, я знаю, что одним из критериев конкурса было быстродействие.
      Я конечно, не замеряла, но быстродействие очень хорошее.
      Те, кто специально этим занимался отмечают существенное быстродействие по сравнению с UIKit.
      Что касается навигации, то есть одно очень хорошее решение «Recreation of calculator-checklist project in SwiftUI», где очень просто воссоздается CustomSegue:

      image


  1. androidovshchik
    11.10.2019 21:44

    Мое уважение, сам пробовал участвовать в том конкурсе, так и не осилил, бросил до первого этапа


  1. Pro-invader
    11.10.2019 21:48

    Очень круто, хоть и не знаком с ios-разработкой, но блин, класс!