SwiftUI для конкурсного задания Telegram Charts (март 2019 года). Часть 1.

Сразу начну с замечания о том, что приложение, о котором пойдет речь в этой статье, требует Xcode 11 и MacOS Catalina (в настоящий момент последняя находятся в Beta 9). Код приложения находится на Github.

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

Очень много было написано о SwifUI за последние три месяца, начиная с Xcode 11 beta 1 и до  нынешней версии Xcode 11.0. Этот пост вовсе не имеет целью дать какое-то масштабное введение в 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 появляется SwiftUI — новый framework, разработанный Apple, написанный на Swift и предназначенный для декларативного описания пользовательского интерфейса (UI) в коде.

SwiftUI позволяет нам описывать Views, используя «декларативный» синтаксис в противоположность «императивному». Вы определяете, какие subviews показываются в вашем View, какие данные заставляют эти subviews изменяться, какие модификаторы к ним нужно применить, чтобы заставить их позиционироваться в нужном месте, иметь нужный размер и стиль.

Это очень значимое событие для мира iOS-разработки, и я хочу показать, как просто и быстро решается задача конкурса Telegram на SwiftUI. Кроме того, она служит прекрасным «полигоном» для «обкатки» и понимания используемых в SwiftUI различных семантический и синтаксических конструкций типа контейнеров VStack, HStack, ZStack, List, NavigationView, ScrollView, жестов DragGesture, TapGesture, LongPressGesture, анимаций animation, перемещений transition и т.д. 

Задание

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

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

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

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

В наше распоряжение 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 файла и указываете имя структуры, которая сформируется после «парсинга» этих JSON данных:

В правой части сайта вы указываете язык, на котором нужно сформировать код, и что хотите получить на выходе; класс class или структуру struct. Мы хотим получить код на языке Swift и нам нужна структура struct:

В центральной части экрана формируется код, который мы скопируем в наше приложение в отдельный файл с именем Chart.swift. Именно там мы будем размещать Модель данных JSON формата:

import UIKit

typealias Chat = [ChartElement]

struct ChartElement: Codable {
    let columns: [[Column]]
    let types, names, colors: Names
}

struct Names: Codable {
    let y0, y1: String
    let y2, y3, x: String?
}

enum Column: Codable {
    case integer(Int)
    case string(String)
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let x = try? container.decode(Int.self) {
            self = .integer(x)
            return
        }
        if let x = try? container.decode(String.self) {
            self = .string(x)
            return
        }
        throw DecodingError.typeMismatch(Column.self, 
            DecodingError.Context(codingPath: decoder.codingPath, 
                         debugDescription: "Wrong type for Column"))
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .integer(let x):
            try container.encode(x)
        case .string(let x):
            try container.encode(x)
        }
    }
}

Практически не изменив полученный в quicktype код, я воспользовалась заимствованным из демонстрационных примерах SwiftUI Generic загрузчиком load данных в Модель из JSON файла :

В результате мы имеем массив columns:  [ChartElement], представляющий собой совокупность «наборов Графиков» в заданном Telegram формате.

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

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

Все данные для LinesSet формируются из JSON файла. По ходу преобразования JSON данных [ChartElement]?  во внутреннюю структуру [LinesSet] мы будем вычислять количество элементов countY для всех «Линий». Для преобразования меток времени, представлены в UNIX формате  в миллисекундах, в требуемый для отображения формат «Feb 12, 2019» нам понадобится dateFormatter, настроенный на представление даты в этом формате:

let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en-US")
        dateFormatter.setLocalizedDateFormatFromTemplate("MMM d yyyy")

С помощью  dateFormatter преобразование меток времени проводить легко:

case "x":
          graph.namex = "x"
          graph.xTime = values.map{ dateFormatter.string (
               from:Date(timeIntervalSince1970: TimeInterval($0/1000))
          )}

Преобразование цветов в UIColor выполняется с помощью вспомогательной функции hexStringToUIColor, размещенной в extension String:

extension String {
    func hexStringToUIColor () -> UIColor {
        var cString:String = self.trimmingCharacters(in:
                                  .whitespacesAndNewlines).uppercased()
        
        if (cString.hasPrefix("#")) {
            cString.remove(at: cString.startIndex)
        }
        
        if ((cString.count) != 6) {
            return UIColor.gray
        }
        
        var rgbValue:UInt32 = 0
        Scanner(string: cString).scanHexInt32(&rgbValue)
        
        return UIColor(
            red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
            green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
            blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
            alpha: CGFloat(1.0)
        )
    }
}

Преобразование данных ChartElement во внутренюю структуру  LinesSet осуществляется функцией convertToInternalModel(_ chatti: ChartElement ) -> LinesSet, которую мы используем в compactMap для получения «наборов Графиков» chartsData во внутреннем формате.

Еще мы добавляем с помощью функции addID идентификатор id для того, чтобы сделать наши «наборы Графиков» Identifiable, это поможет нам легко работать со списком «наборов Графиков» в SwiftUI.

Структуры JSON данных [ChartElement] и структуры данных «внутреннего» представления  LinesSet и Line находятся в файле Chart.swift. Код для  загрузки  JSON данных и преобразования их во внутреннюю структуру находится  в файле Data.swift. В результате мы получили данные о «наборах Графиков» во внутреннем формате в виде массива chartsData. Это и есть наша Модель данных, но для работы в SwiftUI необходимо сделать так, чтобы любые изменения, выполненные пользователем в  массиве chartsData.( изменение временного «окошка», скрытие / показ «Графиков») приводили к автоматическим обновлениям наших Views.

По образцу демонстрационных примеров  Apple для SwiftUI  для данных, которые будут использоваться в SwiftUI практически во всех 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:

Нет необходимости в инициализации экземпляра userData класса UserData или задании значения по умолчанию, потому что он автоматически будет считан из «среды», так как является @EnvironmentObject.

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

Данные внедрены в наше приложение, они могут изменяться пользователем,  а пользовательский интерфейс благодаря SwiftUI будет немедленно реагировать на эти изменения. Теперь пора поговорить о самом пользовательском интерфейсе (UI).

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

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

GraphView — «График»

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

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

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

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

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

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

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

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

Если мы хотим использовать для нашего «Графика» Previews (предварительные просмотры), которые возможны только для MacOS  Catalyna, то мы должны инициировать GraphView с диапазон индексов rangeTime временных отметок xTime и данными 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<Range<Int>>, так как в случае, если 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 (значения),
  • временной диапазон rangeTime: Range<Int> (ОСЬ X), представляющий собой диапазон индексов временных отметок «Графика»,
  • толщина линии обводки «Графика» lineWidth.

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

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

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

Благодаря этому модификатору процесс скрытия и возвращения «Графика»  в ChartViewNew будет проходить на экране с анимацией  и даст понять пользователю, почему изменился масштаб по оси 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 …

.overlay(XAxisView(color: self.colorXAxis))

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

.offset(x: self.indent - scaleTime * CGFloat(self.rangeTime.lowerBound))

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

YTickerView — ось Y с отметками и сеткой.

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

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

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

.overlay(YAxisView(color: self.colorYAxis))

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

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

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

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

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

  • сам «набор Графиков» chart: LineSet (значения Y),
  • высота height «мини — мэп» RangeView,
  • ширина widthRange «мини мэп» RangeView,
  • отступ indent «мини мэп» 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 var 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, содержащий Text и 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

Рассматривается в следующем посте.

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

SwiftUI для конкурсного задания Telegram Charts (март 2019 года). Часть 1.: 2 комментария

    • Следующий Стэнфордский курс будет только весной.
      Время есть и я разбираюсь с SwiftUI.
      Сейчас, с выходом Xcode 11.0, SwiftUI работает более менее стабильно и программирование на нем очень увлекательно и захватывающе.
      Сейчас много материалов (конечно, на английском) по SwiftUI, но есть реально просто фантастические:
      — бесплатная книжка «SwiftUI by example» и видео https://www.hackingwithswift.com/quick-start/swiftui
      — платная книжка, но половина ее можно скачать бесплатно https://www.bigmountainstudio.com/swiftui-views-book
      — курс 100 дней с SwiftUI https://www.hackingwithswift.com/articles/201/start-the-100-days-of-swiftui, который начинается сейчас и закончится 31 декабря,
      — впечатляющие вещи в SwiftUI делаются на https://swiftui-lab.com
      — на pointFree.co https://www.pointfree.co/ «марафон» постов про использование Reducers в SwiftUI (супер интересно)
      А еще хочется освоиться с Combine — фреймворком для реактивного программирования, тем более, что он может быть сильно завязан со SwiftUI.
      Есть еще iPadOS, с которым вообще пока не знаю, что делать.
      Есть еще Swift 5.1, о котором очень неполная и устаревшая информация в русском интернете, и iOS 13 с новыми «фишками».
      Все таки думаю попробовать переводить курс 100 дней с SwiftUI (там есть интересные проекты) и серию постов про Reducers в SwiftUI.
      Конечно, сильно не хватает Пола Хэгерти, который все бы расставил на свои места.

Обсуждение закрыто.