Сразу начну с замечания о том, что приложение, о котором пойдет речь в этой статье, требует 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
, отметки на оси Y
- YTickerView
, управляемый пользователем индикатор значений «Графиков» 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
, а у lineWidth
— 1
.
Мы намеренно сделали ТИП свойства 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
является View
! GeometryReader
позволит ВАМ в отличие от других 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:
- «прокручиваемая таблица»
List
, - горизонтальный стек
HStack
с 3D эффектом, 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
:
- «прокручиваемой таблице»
ListChartViews
, - горизонтальному стеку с 3D эффектом
HStackChartViews
, - 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, у которого заимствовала несколько идей.
Автор: WildGreyPlus