Лекция 7. Shape, ViewModifier, Constants. CS193P Spring 2023.

Ниже представлен фрагмент Лекции 7 Стэнфордского курса CS193P Весна 2023 «Разработка iOS приложений с помощью SwiftUI«.
Полный русскоязычный неавторизованный конспект Лекции 7 в формате Google Doc и в виде PDF-файла, который можно скачать и использовать offline, доступны на платной основе.
Код находится на GitHub.

С полным перечнем Лекций и Домашних Заданий на русском языке можно познакомиться здесь.

Что мы делаем сегодня? 

. . . . . . . . . .

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

Вы видели, как взлетела маленькая цифра +2? Это говорит о том, сколько я получил баллов.
Позвольте мне еще раз попытаться получить больше очков. Теперь хорошо, количество очков +13.

Демонстрационный пример Shape

Итак, мы собираемся создать этот маленький круглый “пирог” Pie, который ведет обратный отсчет. Мы не будем анимировать его сегодня, но мы создадим свою собственную геометрическую фигуру Shape в виде маленького “пирога”.

Для того, чтобы мы могли увидеть, что здесь происходит, я собираюсь измениться количество карт, которые у меня есть в игре, до четырех карт:

И мы положим все наши карты “лицом” вверх,  потому что мы собираемся разместить там “пирог” Pie:

Давайте начнем с нашего CardView, который вы видите здесь на экране.
Я размещу там круг Circle ( ) вместо “пирога” Pie:

И это ZStack. Они сгруппированы с помощью Group, но это все еще ZStack
И я просто помещаю Circle() позади текста Text. Мы почти закончили, правда?
Это не “пирог” Pie, а круг Circle(), и он имеет ярко-оранжевый цвет, пожалуй, слишком яркий, ведь таймер не так уж и важен. Давайте к нашему кругу Circle() добавим немного непрозрачности
.opacity ( 0.5 ):

Можно было бы снизить до .opacity ( 0.4 ):

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

Давайте переместим это так, чтобы у Circle () были отступы .padding (Constants.inset), которые у нас были раньше. Вы видите эти отступы .padding (Constants.inset) здесь внизу? Я хочу использовать это.
Я мог бы поместить текст Text и круг Circle () в дополнительный ZStack, но мы уже изучили .background и .overlay, помните? Так что в данном случае я собираюсь наложить .overlay наш текст Text поверх круга Circle ().

Это также придает нашему коду более приятный вид.
Единственное, мистер “Призрак” находится близко к  краю?
Возможно, мне тоже нужны отступы для нашего текста Text, скажем: .padding(5):

Этого достаточно? Да, достаточно, чтобы не поставить его на самый край. Некоторые эмодзи (смайлики) могут быть такими большие, что будут вылезать за край, но это, наверное, хороший размер. Мы можем поиграть с отступами .padding позже. Но реально отступы должно быть константами.
Давайте добавим их к нашим константам Constants:

В специальной структуре struct Pie у есть константа для непрозрачности opacity и для отступов inset. Давайте используем их в CardView:

Геометрическая фигура Pie

Я думаю, что теперь мы готовы создать “пирог” Pie вместо круга Circle ( ). К сожалению, в SwiftUI нет встроенной геометрической фигуры Pie, так что нам придется создать свою собственную геометрическую фигуру Shape.
Как мы это будем делать?
File -> New -> File

Мы знаем, что Shape — это SwiftUI View. Но если я выберу этот шаблон, который находится в левом нижнем углу, то получу переменную var body, a мы знаем, xnj протокол Shape сам реализует var body для нас через расширение extension, но у него должна быть своя собственная функция func path. Так что мы выберем просто Swift File:

Я назову свою геометрическую фигуру Pie, это Shape в виде маленького “пирога”. Я должен убедиться, что положил его в нужное место:

Хорошо, теперь у нас есть “пирог” Pie:

Очевидно, Shape: — это UI вещь, так что мы будем импортировать SwiftUI, а не Foundation в данном файле:

Создаем структуру struct Pie, которая является Shape:

Всякий раз, когда мы используем протокол protocol Shape, мы всегда получаем эту вещь, так как вы сказали, что это Shape, но на самом деле ничего для этого не сделали.
Но мы любим разворачивать эту ошибку и использовать Fix:

В большинстве случаев это работает, и на этот раз это сработало, хотя позже на этой Лекции вы увидите, что это не всегда работает. Так что на это нельзя полагаться на 100%, но для Shape это работает, и это хорошо:

Давайте посмотрим на эту функцию func path, которая хочет, чтобы я построил Path.
Но посмотрите, что получает эта функция — прямоугольник in rect: CGRect, в котором вас просят нарисовать вашу геометрическую фигуру. Он может быть любого размера, если вы рисуете вашу геометрическую фигуру в любом размере.
Мы будем рисовать “пирог” Pie, похожий на круг Circle, так что Pie будет рисоваться посередине.

Одна действительно важная вещь, которую нужно понять об этом прямоугольнике и о координатах, в которых вам придется рисовать, это то, что большая фиолетовая точка в левом верхнем углу  — это начало системы координат (origin):

Мы привыкли к декартовой системе координат, когда начало координат в левом нижнем углу, положительный x идет вправо, a положительный y идет вверх, но когда вы рисуете в iOS, ваше начало координат в левом верхнем углу, положительный x идет вправо, a положительный y идет вниз. Это своего рода “перевернутая” система координат. К этому нужно привыкнуть. Для тех из вас, кто привык к декартовой системе координат, потребуется некоторое привыкание, и вы увидите, что это имеет определенный эффект. Это мелочь, но эффект определенно будет.
Просто осознавайте тот факт, что ваше начало координат находится в левом верхнем углу, а ось y направлена вниз.

В остальном, довольно легко нарисовать что-то внутри этого прямоугольника rect, и вы сделаете это, создав переменную var p равную пустому Path ( ) и возвращая её:

.

В промежутке между var и return я могу провести линию, нарисовать дугу, нарисовать кривую Безье, все, что мне нужно нарисовать в этой системе координат, которая у меня есть, в этой “перевернутой” системе координат, задаваемой прямоугольником rect, кстати, это CGRect.
У CGRect есть несколько инициализатор, в которых нужно указать начало координат origin и размер size или ширину width и высоту height.
У него origin и size— основные свойства. Начало координат origin — это точка CGPoint, размер — CGSize. Есть ещё ширина width: CGFloat и высоту height: CGFloat.
Мы будем использовать этот прямоугольник rect как систему координат для размещения всего в нашей переменной p, которая является Path.

Позвольте мне показать вам эту картинку, которую я нарисовал перед Лекцией.

По сути, у меня нарисован мой “пирог” Pie от некоторого начального угла startAngle до некоторого endAngle по дуге. А затем мы собираемся анимировать это. Начальный угол startAngle будет фиксированным, a конечный угол endAngle будет анимироваться.
Вот мой центр center. Мне нужно знать центр. И тогда начальный угол startAngle будет отсчитываться из центра от вертикали, а конечный угол endAngle предположительно находится дальше по дуге.
Чтобы знать, как нарисовать мой “пирог” Pie, мне нужно знать начальный угол startAngle и конечный угол endAngle. Это единственные переменные var в моем пироге Pie.
Переменная var startAngle будет иметь ТИП Angle:

У него есть хороший встроенный в Swift ТИП Angle.
У Angle есть на самом деле только две вещи. Вы можете установить угол Angle в градусах .degrees (30), например, 30 или 50 градусов:

Или вы можете сделать это в радианах .radians(.pi/2), например .pi/2:

Все знают разницу между радианами и градусами? Мы можем задать любой из них.
Я собираюсь установить этой переменной var значение по умолчанию .degrees (0):

Может быть и .radians(0).
Но для нуля 0 даже есть что-то встроенное — Angle.zero:

Это то же самое.
И, конечно, я могу “вывести” ТИП Angle из контекста (infer) и убрать лишний Angle:

Теперь мой конечный угол endAngle, у него не будет значение по умолчанию, это будет просто Angle:

Так что конечный угол endAngle будет обязательным аргументом для моей геометрической фигуры Pie. Итак, если вы предоставите мне эти два угла, то я нарисую “пирог” Pie.
Как я буду это рисовать?

Я начну с центра, поэтому я и отметил этот центр желтой точкой. Затем я перейду к стартовой точке. Затем от стартовой точки и начального угла пройду вдоль дуги до конечного угла, а затем  вернусь в центр. Так я создам свою собственную геометрическую фигуру ShapePie.

Поэтому мне нужен центр, и ему соответствует локальная константа let center, это точка CGPoint, у которой есть значение x, и это будет середина rect.midX моего прямоугольника rect и y — это середина rect.midY моего прямоугольника rect:

где midX и midY — это просто вычисляемые переменные var прямоугольника CGRect, они указывают вам его середину.

Первое, что я сделать в своем Path p — это передвинусь move в эту точку center:

В Path есть разница между перемещением move и рисование линий addLine. В данном случае я просто перемещаюсь move в центр, ничего не рисуя, чтобы начать рисовать оттуда.
Теперь мне нужно получить стартовую точку, чтобы добавить линию из центра в стартовую точку, a затем пойти по дуге.

Как я получу стартовую точку?
Я назову эту точку start, let start: CGPoint. Далее нам нужно заняться геометрией. Я не знаю, помните ли вы уроки геометрии, но это выглядит так:

Какой бы у меня ни был радиус radius, нам придется его определить, и умножить соответственно на cos или sin начального угла startAngle в радианах, конечно. Когда мы вычисляем cos или sin, нам нужны радианы.
Я не собираюсь брать тайм-аут и делать какие-то пояснения по геометрии, так как там всё очевидно.
Мне нужен мой радиус radius. Радиус radius будет определяться меньшей из сторон прямоугольника rect, потому то. что я рисую, должно полностью находится внутри моего прямоугольника rect:

        let radius = min (rect.width, rect.height) / 2

Это будет радиус radius моего круга, a не диаметр:

Вот и все.
Обратите внимание, что здесь возникла ошибка:
“Ambiguous use of cos.” (“Неоднозначное использование косинуса”).
В Swift есть много пакетов, в которых есть косинус, хотите — верьте, хотите — нет.
Мы работаем с CoreGraphics, поэтому я собираюсь импортировать CoreGraphics:

Есть другие библиотеки, у которых там свои косинусы, но мы имеем дело с CoreGraphics.
Обратите внимание на CGRect, CGSize, CGPoint, все эти CG — аббревиатура CoreGraphics.
Всё, что мы рисуем, мы рисуем в CoreGraphics

Итак, теперь нам нужно провести линию из центра center в стартовую точку start. Как мы это делаем?
Мы просто пишем p.addLine(to: start):

Итак, у нас есть эта линия из центра center в стартовую точку start.
Теперь нам нужна дуга (arc). Нам нужно пройти от стартовой точке по дуге до конечного угла endAngle.
Удивительно, но мы можем написать p.addArc, и у нас есть несколько разных версий, но мы собираемся использовать эту хорошую версию:

Я пойду дальше и размещу для удобства каждый аргумент на отдельной строке:

И нам просто нужно предоставить эти аргументы. Невероятно, но у нас действительно есть всё это под рукой, так как мы только что рассчитали всё это:

Мы идем по часовой стрелке, так что мы можем написать let clockwise: true, но мы ходим сделать clockwise аргументом для Pie и дать ему значение true по умолчанию: var clockwise = true, а это значит, что нам это не понадобится указывать ТИП для clockwise из-за того, что работает “вывод ТИПа” из контекста (type inference).

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

Вот и все.
Мы только что создали наш милый маленький “пирог” Pie, идя по дуге от начального угла startAngle до конечного угла endAngle.
Понятно? Это очень просто.
И в вашем Домашнем Задании № 3 вам нужно создать геометрическую фигуру Shape, очень простую геометрическую фигуру в виде ромба или чего-то в этом роде. Вы будете делать это с помощью Path.

Итак, у нас есть Pie, давайте вернемся к нашему CardView и вместо круга Circle ( ) используем Pie, конечно, нам потребуется конечный угол endAngle.
Давайте выберем конечный угол endAngle равный 240 градусам:

И вуаля, это неправильно.
Почему?
Вот как должно быть:

Так почему же наш Pie не рисуется нам с нуля, ведь я не задаю начальный угол startAngle, а, следовательно, это значение по умолчанию равное 0. И почему он не дотягивает до нужного значения конечного угла endAngle? Какого черта здесь происходит?

Отсчет углов и система координат для рисования

Происходят две вещи.

Первая. И оказывается проблема в том, что когда вы рисуете CG линию в Path из центра в стартовую точку, соответствующую начальному углу startAngle равному 0°, вы рисуете не в верхнем направлении (по оси Y), а вправо (по оси X):

Вы привыкли к тому,  что отсчитывается от вертикальной оси как в компасе.
Если бы у нас здесь был компас, то углы , 90°, 180°, 270°, 360° располагались бы следующим образом :

Но, к сожалению, в CG системе — это не по компасу.  Вы видите, где располагается в CG системе  , 90°, 180°, 270°, 360°. Поэтому каждый раз, когда вы указываете угол и думаете, что работаете с компасом, вам нужно вычесть 90° из любого угла, который вы используете.

Теперь нам легко это исправить.
Я хочу, чтобы мой “пирог” Pie считал углы по компасу. Так что я просто вернусь к своему Pie и в самом начале, установлю начальному углу startAngle и конечному углу endAngle значения на 90° меньше:

Это как-то сомнительно, стоит ли нам это делать?
Кто-то может возразить: “Эй, если вы — iOS-программист, вы должны знать, что отсчитывается от оси X, направленной вправо. Зачем вам рисовать в координатах компаса?”
Я поддерживаю этот аргумент. Я видел этот аргумент. Но просто ради вас, ребята, чтобы облегчить вам задачу, чтобы вы четко понимали, что здесь происходит, я использовал координаты компаса.

И все же это всё еще неправильно. Наша дуга рисуется против часовой стрелки, хотя предполагалось, что дуга будет рисоваться по часовой стрелке, так как переменная clockwise установлена в true.
Все происходит из-за этой фиолетовой точки:

В iOS система координат (0,0) расположен в верхнем левом углу и увеличение Y происходит при движении вниз. Такая система координат не является знакомой из тригонометрии декартовой системе координат, когда начало координат (0,0) расположено в нижнем левом углу, a увеличение Y происходит при движении вниз.

Поэтому я бы хотел, чтобы вы все покинули свои места и встали на голову так, что начало координат у вас находилось там вверху, чтобы вы посмотрели и сказали бы мне, это против часовой стрелки или по часовой стрелке?
Видите? Движение идет в противоположную сторону.
Если ты перевернут «вверх ногами» и смотришь, как происходит вращение, то на самом деле это происходит по часовой стрелке.
А у нас здесь все перевернуто с ног на голову.

Итак, clockwise в iOS имеет противоположное направление. В macOS, как ни странно, все наоборот. Это очень раздражает, но это так.
Мы действительно хотим, чтобы здесь clockwise означало семантическое движение по часовой стрелке.
Так что я напишу !clockwise:

И вуаля, всё получилось!!

. . . . . . . . . . . . .

Это небольшой фрагмент Лекции .
Далее на Лекции 7 рассматриваются следующие вопросы:

  • CardView в отдельном файле. Preview
  • Псевдоним typealias
  • Константы
  • Shape
  • Демонстрационный пример Shape
  • Геометрическая фигура Pie
  • Отсчет углов и система координат для рисования
  • Анимация. Общие представления
  • ViewModifier
  • Демонстрационный пример ViewModifier. Cardify
  • Протоколы protocol. Часть 2
  • Расширения extension протоколов protocol
  • protocol View
  • Протоколы protocol + Generics. Протокол Identifiable
  • some и any в протоколах protocol

Полный русскоязычный неавторизованный конспект Лекции 7 в формате Google Doc и в виде PDF-файла, который можно скачать и использовать offline, доступны на платной основе.
Код находится на GitHub.

С полным перечнем Лекций и Домашних Заданий на русском языке можно познакомиться здесь.