Лекция 9. Анимация (часть 2). CS193P Spring 2023.

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

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

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

Колоду карт довольно просто реализовать.

Это может быть private переменной var, которая является some View и представляет собой ZStack несданных карт undealtСards. Не сданные карты undealtСards находятся прямо вверху и я создал их ранее. Мы будем рисовать несданных карт undealtСards с помощью того же CardView, что мы нарисовали любые другие карты:

Но есть кое-что, что я хочу сделать с этим — я хочу явно указать размер колоды карт с помощью маленького ViewModifier, который я покажу сегодня — это .frame, им очень легко злоупотребить, будьте очень осторожны при его использовании.
О чем .frame?
Я говорил вам, что Views сами решают, какого размера они хотят быть, помните это?
Они могут это сделать с помощью .frame. Но сам по себе .frame в реальности не будет определять размер самого View. Это больше похоже на создание контейнера этого размера и предложения View этого пространства.

Помните, как при размещении Views на экране (Layout) вам предлагается пространство, a View сам определяет свой размер, чтобы там поместиться?
Таким образом, .frame предоставит вам пространство.
Теперь CardView использует всё предоставленное ему пространство.
Итак, я могу написать .frame(width: deckWidth, height: deckWidth / aspectRatio), где deckWidth — это ширина моей колоды карт, и я позже сделаю для нее константу. Высота колоды, конечно же, будет равна ширине колоды деленной на соотношение сторон aspectRatio:

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

Теперь причина, по которой я сказал, что этим легко злоупотребить. 
Посмотрите, как мы программируем, мы никогда не указываем точные размеры вещей, мы просто размещаем их в VStacks и HStacks или располагаем в AspectVGrid и все такое. И мы даже использовали GeometryReader, чтобы узнать, a сколько у меня места? Потом мы разделяли полученное пространство между нужными нам Views, которые как бы отреагировали на то, сколько места им дали..
Единственный раз, когда мы выбрали фиксированный размер — это наш теперешний случай, когда мы создали пространство фиксированного размера, чтобы наш View (колода карт) в нем разместился.
И мы даже использовали GeometryReader, чтобы узнать, a сколько у меня места? Потом мы разделяли полученное пространство между нужными нам Views, которые как бы отреагировали на то, сколько места им дали.
Здесь я выбираю конкретную ширину колоды deckWidth равную 50:

Скорее всего вы не будете делать этого почти никогда.
Если вы обнаружите в процессе разработки приложений, что используете .frame с фиксированными шириной и высотой, то реально подумайте, действительно ли это то, чего вы хотите?
Я делаю это в демонстрационном примере потому, чтобы показать вам, что это можно сделать, но у вас будет целое реальное приложение, в которых вы никогда этого не будете делать.
Никогда не выбирайте определенную высоту и ширину для .frame.
Я буду сдавать карты, когда я кликну на моей колоде карт:

Давайте поместим нашу колоду карт deck между счетом Score и Shuffle в этот HStack:

Вот как теперь выглядит UI:

Маленькая колода карт шириной около 50 points. Она черная, потому что я не устанавливал ей цвет с помощью foregroundColor.
Запускаем нашу игру и кликаем на колоде карт:

Карты пришли, но не из колоды карт.
А что случилось с колодой карт?
Кто-нибудь может мне сказать, почему эта колода исчезла?
Точно, потому что не сданных карт там больше нет.
Если мы посмотрим на нашу колоду deck, то у нас есть ZStack не сданных undealtCards карт.

И когда я добавил все мои карты viewModel.cards в множество сданных карт dealt:

… не сданные карты undealtCards исчезли.

Эти Views в колоде deck, что с ними случилось?
Они осуществили “переход” с непрозрачностью .transition (.opacity), потому что “переход” с непрозрачностью — это “переход” по умолчанию.
Так что смотрите еще раз на колоду, когда я на неё кликаю, карты из неё исчезнет с непрозрачностью opacity.

matchedGeometryEffect

Мы хотим, чтобы геометрия карт CardView соответствовала тем картам, которые «вылетели» из колоды, и тем, которые должны попасть туда, где они должны быть.
Это то, что у нас было на слайдах и называется matchedGeometryEffect.
Нам нужна геометрия, а это значит, что обе карты, их размеры и местоположение должны соответствовать (match) друг другу. И когда одна из них уходит, а другая появляется, их геометрия должна автоматически анимироваться, чтобы соответствовать друг другу.

Итак, давайте использовать matchedGeometryEffect:

В matchedGeometryEffect нужно задать идентификатор id для этого View, чтобы он знал, какому другому View он будет соответствовать (match). CardView показывает нашу карту card, так что card.id — очень хороший способ идентифицировать этот CardView.

И нам нужно определить пространство имен Namespace, в котором этот id ”живет”.

Нам не важно пространство имён, потому что у нас только одно такое пространство имен, но это важно для вас в вашем Домашнем Задании № 4, потому что в Домашнем Задании № 4 я прошу вас сдать карты в игре Set, a также сбрасывать «совпавшие» карты в стопку сброса (discard pile), так что у вас будет как колода карт, так и стопка сброса для «совпавших» карт, следовательно, вам понадобятся два разных пространства имен, чтобы идентификаторы карт card.id знали, говорите ли вы о Views, которые находятся в колоде карт, или это о тех Views, которые отправляются в стопку сброса (discard pile).

Итак, как мне создать пространство имен Namespace?
Это действительно легко.
Вы просто придумываете его прямо здесь и пишите:

Моё пространство имен @Namespace называется dealingNamespace. Я разместил его в matchedGeometryEffect для CardView.
Как я говорил на слайдах, вам не обязательно указывать ТИП dealingNamespace, то есть не надо писать dealingNamespace: Namespace.
@Namespace похоже на @State, но это другое.
@Namespace знает, что вы всегда создаете таким образом пространства имен, так что не надо дополнительно указывать ТИП переменной var.

Итак, это один matchedGeometryEffect, но нам всегда нужен еще один matchedGeometryEffect, которому он будет соответствовать.
Идем в AspectVGrid и размещаем matchedGeometryEffect для CardView. И там тот же сard.id в том же пространстве имен dealingNamespace. Если вы не поместите его в одно и то же пространство имен, очевидно, они не будут соответствовать (match) друг другу.

Давайте попробуем запустить приложение.

Ну да, вроде что-то похоже на то, что нам надо, но немного странно, потому что они изначально были черными, а потом они как бы “растворяются» (fading out) и превращаются в оранжевые.
Видели?
Там есть соответствие геометрии, но они также выполняют “переход” с непрозрачностью .transition (.opacity).
Один черный CardView выполняет “переход” с непрозрачностью .transition (.opacity) и “растворяется” (fading out), a другой оранжевый CardView также выполняет “переход” с непрозрачностью .transition (.opacity), но при этом “проявляется” (fading in).

Это не совсем то, чего я хочу. И вывод, который мы должны сделать из этого эксперимента, состоит в том, что “переход” с непрозрачностью .transition (.opacity) все еще в силе.
Даже если вы используете matchedGeometryEffect и это будет соответствовать геометрии, “переходы” .transition все равно есть.

На самом деле мы хотим, чтобы, как только я кликнул на колоде карт, немедленно начали бы показываться мои оранжевые карточки.
Мы хотим, чтобы карты исчезали и появлялись мгновенно без всяких “растворений” (fading out) и  “проявлений” (fading in).
Кто-нибудь помнит, какой “переход” .transition используется, если вы не хотите, чтобы что-то произошло, никакой непрозрачности .opacity, никакого масштабирования .scale или чего-то еще?
Да, .identity.
Что произойдет, если я для CardView применю .transition (.identity)?

И мы сделаем то же самое для нашей колоды карт deck:

Теперь это должно сработать. Потому что карты CardView не собираются  “растворяться” (fading out) или выполнять любой другой вид перехода .transition.
Давай посмотрим что происходит.

Вот наша колода и мы кликаем на ней. Карты просто “прыгнули” на свое место.
Мы потеряли весь наш matchedGeometryEffect.
Вывод — “переход” .transition (.identity) означает отсутствие вообще каких-либо “переходов” .transition: ни matchedGeometryEffect, ничего.

Теперь у нас проблемы. Как нам остановить это?
Но, есть действительно важная маленькая хитрость, которую нужно знать, которая состоит в том, что мы можем создать асимметричный “переход” .transition (.asymmetric) для вставки insertion и удаления removal c “переходом” .identity:

                  .transition(.asymmetric(insertion: .identity, removal: .identity))

… что по вашему мнению, возможно, является в точности тем же самым, что и .transition (.identity).

Итак, у нас есть “переход” .identity с обеих сторон: вставка View и удаление View, но это НЕ отменяет matchedGeometryEffect:

Итак, если вы хотите выполнить matchedGeometryEffect и вам не нужен другой “переход” .transition, вам нужно использовать один из этих асимметричных “переходов” .transition (.asymmetric) для вставки и удаления.
И мне нужно сделать это с обеими моими вещами:

Итак, давайте запустим приложение и кликнем на колоде:

Здорово! Это именно то, чего я хотел.
Я кликнул на колоде карт, карты сразу стали оранжевыми начали “полет” на свои места.
Однако я не хочу, чтобы эта колода deck изначально была черной. Я хочу, чтобы она была оранжевой с самого начала. Я собираюсь установите цвет .foregroundColor (viewModel.color) для моей колоды deck точно так же, как я делаю это со своими картами cards:

Давайте попробуем и запустим приложение:

Я не слышу, чтобы кто-то из вас жаловался, что никогда не видел, чтобы в Вегасе так сдавали карты. Я имею в виду, что сдаются сразу все карты, хотя, наверное, это было бы здорово, не так ли?
Если бы вы могли бросить карты одним махом на нужные места, вы бы были отличным крупье в Вегасе.
Крупье сдают по одной карте за раз.
Поэтому мы хотим, чтобы наша сдача карт была точно такой же.
Как мы это сделаем?
Мы выбрасываем все эти карты сразу, потому что когда мы кликаем на колоду карт deck, мы проходим через цикл for и сдаем все карты одновременно:

Ничего страшного, мы можем продолжать это делать. Помните, что анимация не показывает нам  мгновенно что происходит, это проявляется со временем.
Всё, что нам действительно нужно делать, это немного задерживать анимацию каждой последующей карты, так что мы все еще бросаем все карты сразу, но видим каждую последующую карту все с большей и большей задержкой.
Это действительно легко сделать.
Я вынесу for цикл для карт вовне и создам переменную var delay, которая представляет собой временной интервал TimeInterval. Мы начнем с 0, то есть без задержек:

А затем, когда мы делаем .easeInOut (duration: 2), я добавлю .delay(delay), a затем в цикле я добавлю к задержке delay четверть секунды:

Каждую следующую карту мы сдаём четверть секунды спустя.
И на сдачу каждой карты уходит 2 секунды. Так что это будет своего рода медленная сдача карт.
Теперь, когда я это сделал, я получил эту действительно досадное предупреждения:

Result of call to ‘withAnimation’ is unused
(Результат вызова ‘withAnimation’ не используется)

Почему мне так говорят? Это просто смешно. Я ничего не делал, и теперь у меня такое сообщение.
Что это значит здесь?
У withAnimation, хорошо ли, плохо ли, но есть “фича”, которая состоит в том, что он возвращает все, что возвращает его внутренний код в этом замыкании. Что бы ни возвращало ваше замыкание, withAnimation тоже вернет это.
И почему оно это делает? Потому, что у замыкания withAnimation имеется неявный возврат return:

Мы знаем, что если у вас есть замыкание в одну строку, у которого есть неявный возврат return и вставка insert, которая вставляет что-то в множество Set<Card.ID>. На самом деле insert что-то возвращает, я не помню точно, что возвращается, я думаю, что кортеж, но меня не волнует, что возвращается.
Этот неявный возврат return происходит просто потому, что это однострочное замыкание.
Так как же нам избавиться от этого назойливого предупреждения?
Нужно написать символ “подчеркивания” _ = :

Символ “подчеркивания” _ , по сути, означает: “Дай мне то, что эта вставка insert возвращает, а потом разместите результат просто в “никуда””.
Символ “подчеркивания” _ “ничто”,  “никуда”.
Это способ избавиться от этого предупреждения.
Другой способ сделать то же самое — поместить это в функции, которая ничего не возвращает:

                        private func deal (_ card: Card) {
dealt.insert (card.id)
}

Это был бы другой путь, но наш способ — это более простой способ сделать это.
Посмотрим, сработает ли это.

Ух ты, здорово, правда немного медленно, но наш код определенно делает то, что мы хотим. Он задерживает анимацию каждой карты.

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

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

  • “Летающие числа” FlyingNumber (продолжение)
  • Последнее изменение счета lastScoreChange. Кортеж (tuple)
  • Реализация функции private func scoreChange
  • Модификатор .offset
  • .onAppear и .onDisappear
  • Модификатор .zIndex
  • Рефакторинг кода. private func choose(_ card: Card)
  • Бонусные очки таймера обратного отсчета в Model
  • Анимация “пирога” Pie в TimelineView
  • Демонстрация “перехода” .transition
  • “Сдача” карт deal
  • Демонстрация matchedGeometryEffect

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

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