Лекция 11. Жесты, вторая MVVM. CS193P Spring 2023.

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

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

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

Демо: масштабирование и перемещение по экрану EmojiArt

Мы хотим иметь возможность масштабировать и перемещать по экрану наш документ EmojiArtDocument с помощью движения пальцев.
Вот как это выглядит, это немного устаревшая версия приложения Emoji Art, но я собираюсь выполнить жест pinch. Кстати, когда вы используете такой симулятор, вы можете выполнить жест pinch, удерживая клавишу option. И видите, у меня сразу на симуляторе появляются тут два пальца в виде серых кружков. Удерживаю option — и они появляются. Можно также перемещать с помощью жеста drag документ по экрану.
Итак, я увеличиваю масштаб. Видите? Я выполняю жест pinch, чтобы увеличить масштаб. И, конечно, я могу уменьшить масштаб. Возможно, я захочу переместить мой документ и я выполняю жест drag.


Мы хотим перемещать документ с помощью жеста drag и изменять его масштаб с помощью жеста pinch, я собираюсь реализовать эти две вещи.
Что мы должны перетаскивать drag и масштабировать pinch.?
Содержимое нашего документа, это фоновое изображение и все эти эмодзи (смайлики). Все это увеличивается и перемещается, вы видите это представлено синим цветом:

Я собираюсь взять этот код и разместить его в отдельной переменной var с именем documentContents, и это то, что я собираюсь масштабировать и перемещать по экрану.

Я просто вырезаю отсюда этот код и вставляю его в private func documentContents, это функция func потому что мне нужна здесь моя геометрия geometry:

Геометрия geometry нужна во всем этом коде, потому что система координат моего View трансформируется в систему координат EmojiArt, и мой View всегда требует геометрию geometry, потому что в func documentContents мне нужно знать свою систему координат.
Теперь я просто вставляю “вырезанный” код:

И это приводит к ошибке:

Может ли кто-нибудь сказать мне, в чем проблема?
Здесь говорится:
«Function declares an opaque return type but has no defined value.«
Функция объявляет непрозрачный ТИП возвращаемого значения, но не имеет определенного значения”.
Почему такая ошибка? Эта функция возвращает some View.
Как some View узнает, какой View возвращается?
Оно заглядывает внутрь фигурных скобок { }.
И что он там видит?
Два Views: AsyncImage (…) и ForEach(…). Это неправильно, так как у нас действительно два Views, a не один View.

Но это легко исправить.
Добавляем @ViewBuilder, который превратит эту маленькую функцию во внешнюю @ViewBuilder функцию.

И теперь вдруг неожиданно оказывается, что функция func documentContents возвращает одно View, которое представляет собой комбинацию этих двух Views. Это TupleView этих двух Views.
Теперь, когда у меня есть этот documentContents, я могу разместить это в переменной var documentBody:

Заметьте, я не разместил свой белый фон Color.white в documentContents, потому что я не хочу, чтобы мой белый фон увеличивался. Это фон, на котором живет мой документ. Если мой документ маленький по размеру, я вижу белый фон.
Так что я не хочу, чтобы мой белый фон Color.white участвовал в масштабировании и перемещении по экрану.
Я хочу, чтобы масштабировался и перемещался по экрану исключительно мой документ documentContents.
Во-первых, давайте создадим @State private переменную var zoom, которая является CGFloat, и которая показывает, на сколько я буду увеличивать масштаб моего документа. Давайте начнем с 1:

Итак я увеличил размер моего документа в 1 раз, а не в 2 раза и не в 0.5 раза.
@State private переменную var zoom касается размера документа.
У меня будет еще одна @State private переменную var pan, и это будет CGSize = .zero:

Когда мы говорим о смещениях offset в SwiftUI, мы используем CGSize, и это нормально, потому что вы действительно хотите иметь другой ТИП помимо CGSize? Потому что это то же, что и CGSize, но у него будут смещение по оси x и смещение по оси y. Но вы можете себе представить, что у вас будет новый ТИП CGOffset, в котором вместо ширины width и высоты height в этой структуре используются эти две вещи: dx и dy или что-то в этом роде.
Но я люблю напоминать себе, что когда я использую CGSize в качестве смещения offset, я буду называть его CGOffset:

Для этого в файле Extensions.swift я добавил небольшой псевдоним ТИПа:

Это просто напомнит мне, что я реально использую CGSize как CGOffset.
Итак, у меня есть @State переменные var zoom и var pan. Как мне использовать их, чтобы увеличить мой документ documentContents и переместить его на экране?
Это просто невероятно легко.
Я использую View модификаторы .scaleEffect (zoom) и уже известный нам .offset(pan) :

Это все, что мне нужно для масштабирования и перемещения.
.scaleEffect (zoom) — это ViewModifier, который масштабирует любой View, к которому вы его применяете, в указанное zoom число раз. В данном случае я масштабирую документ documentContents в 1 раз, но по мере того, как мы будем его менять, масштаб будет увеличиваться или уменьшаться.
То же самое, со смещением .offset(pan) — это ViewModifier, который смещает View на pan от того места, где оно должно быть. Это то, что мы уже видели раньше.

Итак, давайте посмотрим, работает ли это.
Давайте изменим наш zoom, как насчет того, чтобы сделать zoom равным 0.5?
Запускаем приложение и смотрим, будет ли масштаб равен значения 0.5:

Так оно и есть. Мы уже можем сказать, что это произошло. Наш документ не заполняет весь экран. Он уменьшился вполовину. Мы знаем, что это работает.
А как насчет смещения pan?
Давайте сделайте наше смещение pan равным CGOffset (width: 100, height: 100):

Это CGOffset, это псевдоним ТИПа typealias, поэтому с таким же успехом можно вместо CGOffset написать .init:

… потому что я явно использовал ТИП CGOffset, a Swift “выводит ТИП” из контекста и .init вызовет CGSize.init из-за наших псевдонимов ТИПов typealias.

Посмотрим, сработает ли это.

Всё правильно. Смещение (100, 100).
И помните? Положительная координата Y направлена вниз, так как все это происходит в нашей iOS “перевернутой вверх ногами” системе координат.
Так что определенно всё работает.
Отлично. Итак, у меня уже есть механизм, который может это делать.
Вернем исходные значения zoom и pan:

Теперь мне нужно как-то добавить жесты, которые будут изменять этот масштаб zoom и смещение pan.
Начнем с масштабирования zoom, которое, вероятно, проще из этих двух.
Я говорил вам, что если вы хотите, чтобы какой-нибудь View распознавал жест, вам нужно добавить к нему ViewModifier .gesture(…) и внутри указать жест .gesture (zoomGesture), в нашем случае жест масштабирования zoomGesture, для которого я создам private переменную var с именем zoomGesture, которая будет some Gesture:

В нашем случае это будет жест для масштабирования, MagnificationGesture — это то, что соответствует pinch жесту.

Обратите внимание, что я разместил View модификатор .gesture(…) на ZStack, чтобы его можно было распознать где угодно, даже за пределами моего уменьшенного или увеличенного документа на белом фон, где угодно.

Теперь весь ZStack будет распознавать pinch жест.
Поэтому очень важно подумать о том, какое View должно распознавать ваш жест. Например, в вашем Домашнее Задание, вы добавите DragGesture для перетаскивания отдельных эмодзи (смайликов). Очевидно, вы хотите привязать этот жест к отдельным эмодзи (смайликам) emoji, а не к фоновому изображению background. Для жеста имеет значение, где вы его разместили.
По сути, вы определяете прямоугольник, в котором система собирается этот жест распознавать: “О да, это был pinch жест.”

В вашем Домашнем Задании, вы будете размещать pinch жест на маленьком эмодзи (смайлике), потому что вы не захотите использовать pinch жест в очень маленьком пространстве, занятом этим эмодзи, чтобы просто измените его размер. Вы будете использовать pinch жест на фоновом изображении background и просто изменять размеры всех объектов в вашем выборе selection.
Итак, у нас есть zoomGesture, давайте начнем с того, что разместим для него модификатор .onEnded, внутри которого есть value in, и мы можем делать все, что захотим:

Мы попадаем в это замыкание, когда pinch жест прекратит двигаться и пальцы поднимутся вверх. Мы можем посмотреть, что собой представляет value, если я option-кликну на этом параметре:

ТИП value — это MagnificationGesture.Value. Value — это ТИП, который вложен внутри MagnificationGesture. Давайте кликнем на Value и посмотрим на него:

Это просто псевдоним ТИПа CGFloat, потому что это единственное значение, с которым мы имеем дело для pinch жеста. Супер просто.
Если мы сделаем это для DragGesture, то вы увидите, что value будет структурой struct.

Что мы делаем в .onEnded?
Мы выполняем pinch жест, и, может быть, к концу жеста pinch мы получили продвижение пальцев в 2 раза больше, или вполовину больше, или что-то в этом роде. Мы собираемся значительно изменить наш масштаб zoom на эту величину. У нас уже есть этот zoom, так что давайте просто напишем:

Если поможет вам понять это лучше, я не уверен, что это так, но вместо того, чтобы называть это value, возможно, мы бы назвали это endingPinchScale или что-то в этом роде, потому что так оно и есть. Это всего лишь финальный масштаб жеста pinch, который у нас сформировался в конце этого жеста.

И это все, что нам нужно сделать, чтобы распознать жеста pinch и использовать его результаты.

Запускаем наше приложение. Я удерживать клавишу option, чтобы получить два пальца, а потом начинаем тащить мышку. Но, конечно, пока мы тащим, то ничего не происходит. После того, как я перестаю тащить мышку и отпускаю её, это срабатывает — масштаб фонового изображения изменяется.
Я могу ближе свести серые кружочки и отпустить, тогда размер моего фонового изображение уменьшается, если я развожу серые кружки и отпускаю, то размер моего фонового изображение увеличивается.

Вы видите, что в .onEnded, мы обновили наше постоянное состояние, в данном случае это наш @State var zoom.
Кстати, почему наш масштаб zoom — это @State, а не что-то в нашей модели Model? Потому что масштабирование (zooming) и перемещение документа по экрану (panning) не является частью EmojiArtDocumentView. Это часть View, но это лишь временно, только пока мы его просматриваем и масштабируем. Так что быть @State идеально подходит для нашего случая.
Итак, мы почти у цели. Наш документ прекрасно масштабируется в .onEnded.

Теперь немного более хитроумная часть. Я хочу обновлять масштаб zoom в процессе выполнения pinch жеста.
Для этого мы можем использовать .updating:

Давайте добавим в наш код @GestureState private переменную var, которую я назову gestureZoom, это масштабирование, которое происходит во время выполнения жеста. Его ТИП — CGFloat, и он будет равен 1:

И это значение будет возвращаться нам в каждый момент время.
Теперь, когда у нас есть @GestureState, разместим $gestureZoom в .updating и я должен добавить замыкание с параметрами value, опять же valuex и transaction:

Мы не собираемся использовать транзакцию transaction, потому что мы не делаем никакой анимации. Так что я просто собираюсь поставить здесь символ “подчеркивания” “_”. Помните? Символ “подчеркивания” “_” в Swift означает “меня это не особо волнует”:

Вы могли бы подумать, что здесь я могу сделать то же самое, что и в .onEnded, просто переименовав value в inMotionPinchScale:

Могу ли я просто использовать здесь свой inMotionPinchScale, когда я масштабирую, забыв об этом gestureZoom? Зачем мне вообще нужен этот gestureZoom?
Давайте посмотрим, что произойдет, если мы это сделаем:

Похоже, работает только .onEnded. Почему работает только на .onEnded?
Если мы вернемся к нашему коду, мы увидим что у нас фиолетовая ошибка:

Фиолетовая ошибка — это ошибка времени выполнения (run time), и Xcode показывает вам строку кода, вызвавшую эту ошибку времени выполнения (run time).
Так что это за ошибка?
«Modifying state during update causes undefined behavior inside updating.» 
(“Изменение состояния во время обновления вызывает неопределенное поведение при обновлении”.)
Вы не можете изменить свою @State переменную var или свою модель Model, иначе вы получите эту фиолетовую ошибку прямо здесь. Вся система .updating специально спроектирована только для изменения @GestureState.
Поэтому я переключился на изменение только @GestureState:

Но теперь я должен использовать этот @GestureState в нормальных операциях в моем View, где-то в очевидных местах. Я буду использовать gestureZoom в моем .scaleEffect:

Когда я масштабирую, мой .scaleEffect использует не просто обычный масштаб zoom, a zoom * gestureZoom, когда я не выполняю жест, то это просто zoom, так как gestureZoom равен 1 в то время, когда жест не выполняется.
Я НЕ пишу что zoom = zoom * gestureZoom, я просто пишу zoom * gestureZoom, в результате я не получаю здесь экспоненциального эффекта.
Вот и все.
Итак, я использовал как gestureZoom, так и мой обычный стационарный zoom, если хотите, можно думать о них вместе.
Если я не выполняю жест масштабирования, какое значение имеет gestureZoom? 1. Если мы умножим zoom * 1, то это будет обычный стационарный zoom. Это именно то, чего я хочу, когда жест не выполняется. Я хочу просто использовать свой обычный zoom.
Давайте убедимся, что у нас всё работает и нет фиолетового цвета.
Я буду выполнять крошечные pinch жесты.
Я делаю крошечные pinch движения, и масштаб моего фонового изображения тоже изменяется на крошечные значения. Если я буду выполнять большие pinch движения, масштаб моего фонового изображения также будет изменяться большими порциями.
Так, что .updating отслеживает, насколько велики мои движения.

Самый простой случай жестов — это масштабирование, pinch движения, потому у него такое простое value, представляющее собой всего лишь на сколько вы сжали / развели пальцы.

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

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

  • Жесты (Gestures)
  • Дискретные жесты. .onEnded
  • НЕ дискретные жесты. .onEnded
  • @GestureState переменная
  • НЕ дискретные жесты. .updating
  • НЕ дискретные жесты. .onChanged
  • Демо: масштабирование и перемещение по экрану EmojiArt
  • Комбинация жестов с помощью .simultaneously(with:)
  • Вторая MVVM
  • Model
  • ViewModel
  • View
  • .environmentObject
  • .transition(.asymmetric (…))
  • Модификатор .id(…)
  • AnimatedActionButton
  • Контекстное  меню .contextMenu

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

С полным перечнем Лекций и Домашних Заданий Стэнфордского курса CS193P Весна 2023 «Разработка iOS приложений с помощью SwiftUI» на русском языке можно познакомиться здесь.