Лекция 5. Протоколы, перечисления enum, Optional. CS193P Spring 2023.

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

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

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

Анимация карт cards

Я сказал вам, что не собираюсь показывать вам анимацию на этой неделе. Я не собираюсь показывать вам это и на следующей неделе, даже после того, как мы потратим всю неделю на изучение анимации. Об анимации нам предстоит многое узнать. Анимация очень важна в мобильных приложениях на iPad или iPhone, и очень важно иметь хорошую анимацию.
Но сегодня
я собираюсь показать вам самую примитивную анимацию, потому что она поведет нас по пути реализации протоколов protocol, соответствия протоколам, превращения наших “не важно каких” (don’t care) Generic ТИПов в “немного важно какие” Generic ТИПы с ограничениями и все такое.

Я добавлю немного анимации к нашим картам cards.
Вот наши карты cards на UI. Вы видите, что здесь есть ScrollView, VStack — это наши карты cards, которые представляют собой сетку LazyVGrid:

Я хочу применить анимацию к моим картам cards, и собираюсь использовать значение по умолчанию .default для вида анимации. Это только один из возможных видов анимации, которую мы можем задать с помощью этого View модификатора. Мы поговорим обо всех других видах анимации позже.

Видите этот ViewModifier? Он меняет этот View, чтобы наделить его анимацией.
Здесь есть аргумент value. Какое значение мы должны ему дать? Все что угодно, любую переменную var, которую вы хотите, и анимация будет происходить только, если внутри этого View значение value изменится. Мы действительно должны указать то, что может вызвать анимацию наших карт cards, и это viewModel.cards:

По сути, когда любая из наших карт изменится, нам нужно её анимировать.
Вот почему я размещаю здесь viewModel.cards в качестве value. Итак, если наш viewModel.cards изменится, то любое из этих изменений вызовет анимацию. Например, когда я кликну на кнопке Shuffle (Перетасовать),  то произойдет анимация перетасовки карт.

Протокол Equatable

Но у меня возникла ошибка, и очень важно понимать эту ошибку.
Давайте посмотрим на неё.
Нам сообщают:
Referencing the method ‘animation (_:value:)’ on ‘Array’ requires that ‘MemoryGame<String>.Card’ conforms to ‘Equatable’” 
(“Метод ‘animation (_:value:)’ с использованием массива ‘Array’ требует, чтобы ‘MemoryGame<String>.Card соответствовала Equatable”). 

Как я и сказал, нам требуется соответствие карты MemoryGame<String>.Card протоколу protocol Equatable.
Вот что сообщает нам эта красная штука. Она говорит, что для использования animation (_:value:) требуется, чтобы карта MemoryGame<String>.Card была Equatable.

Почему так?

Если подумать об этом, то, очевидно, что система анимации хочет видеть, изменились ли эти карты viewModel.cards. Поэтому каждый раз, когда происходит какое-то изменение, она “захватывает” копию viewModel.cards, а затем, когда снова что-то изменилось, она “захватывает” еще одну копию и спрашивает: они == (равны)? Если они НЕ == (равны) , мне нужно анимировать. Вот почему она хочет иметь возможность выполнять операцию == (равно) на каждом следующем этапе изменения.

Массив Array знает, как выполнять операцию ==. Но этого недостаточно. Дело в том, что операция == (равно ) НЕ встроена в язык программирования Swift. В других языках программирования операция == является фундаментальной вещью. В Swift, хотите  —  верьте, хотите  — нет, операция == —  это просто протокол protocol. Фактически, операция == представлена функцией в протоколе  protocol Equatable, и массив  Array реализует эту функцию, но при условии, что элементы массива Array также реализуют функцию ==. Иначе как массив Array сможет это сделать?

Вот почему здесь эта маленькая красная ошибка сообщает, что MemoryGame<String>.Card должна соответствовать протоколу Equatable.
И мы собираемся сделать это.
Для нас это хорошая возможность узнать, как заставить что-то соответствовать протоколу protocol.

Итак, мы вернемся к нашей Model.
Это наша Model, узнаете? И у нас есть карта Card.

Это то, что система анимации просит сделать Equatable. Я просто хочу сказать, конечно, это справедливо, “веди себя какEquatable, соответствуй протоколу Equatable:

Теперь, когда я сделал это, но появилась еще одна ошибка.
Что это за ошибка?
Нам сообщают: 

“Type 'MemoryGame<CardContent>.Card' does not conform to protocol ‘Equatable’”
 (“ТИП 'MemoryGame<CardContent>.Card' не соответствует протоколу ‘Equatable’”)

Если я просто сказал, что Card соответствует протоколу Equatable, то это не означает, что так оно и есть. Точно так же, как если бы мы сказали, что что-то соответствует протоколу View, “ведет себя как” View, нам пришлось бы реализовать переменную var body: some View.
Очевидно, что мы должны что-то сделать, чтобы соответствовать протоколу Equatable.
Я собираюсь кликнуть на Fix, чтобы получить “заглушки” для моего протокола Equatable. По сути, компилятор пытается помочь вам, подсказывая, что вам нужно сделать, чтобы соответствовать протоколу Equatable. Кстати, это не всегда работает. Некоторые протоколы protocol немного сложнее, чем другие, поэтому это не всегда работает. Но в случае с Equatable эта подсказка всегда срабатывает.
Итак, я кликаю на “Fix”, и это дает мне то, что требуется для реализации протокола Equatable.

И конечно же, это static функция func ==.
Это глобальная функция  func ==, потому, что мы хотим, чтобы любой во всем нашем приложении глобально имел возможность сравнить на равенство с помощью функции == две карты, и эта функция НЕ является private.
Итак, static функция func ==, и у неё есть левая сторона lhs, которая представляет собой карту MemoryGame<CardContent>.Card, и правая сторона rhs, которая представляет собой также карту MemoryGame<CardContent>.Card, и функция возвращает Bool значение, которое зависит от того, являются ли эти две вещи равными.
Понятно?
Это немного многословно: MemoryGame<CardContent>.Card, я избавлюсь от MemoryGame<CardContent>.Card. Мы знаем контекст, в котором находимся, поэтому можем использовать просто Card:

Это простая функция, и нам легко её реализовать.
Я просто напишу:

Все согласны с тем, что если эти три вещи одной карты равны аналогичным вещам другой карте, то эти карты равны? Конечно. 
Но у нас здесь тоже есть проблема, мы прямо гоняемся за проблемами.
Что нам сообщает эта ошибка?

“Referencing operator function '==' on 'Equatable' requires that 'CardContent' conform to ‘Equatable’”
“Использование оператора == в Equatable требует, чтобы ТИП CardContent соответствовал протоколу Equatable”.

Это произошло потому, что я написал lhs.content == rhs.content.
Какой ТИП имеют эти две вещи? 
CardContent или “не важно, какой” ТИП. Так что у нас имеется небольшая проблема. И почему это происходит?
CardContent — это “не важно, какой” (don’t care) ТИП. Это может быть что угодно и необязательно что-то, что реализует протокол Equatable.

До сих пор нам было все равно (don’t care) какой ТИП у CardContent, но теперь на самом деле нам не совсем все равно, нас волнует, чтобы ТИП CardContent соответствовал протоколу Equatable. Иначе мы не можем сказать, что две карты == (равны), если их содержимое content не одинаково.
Как мы собираемся это сделать?

“Немного важно, какой” (care a little bit) ТИП

Вернемся наверх нашем коде, туда, где мы говорили, что CardContent — это “не важно, какой” (don’t care) ТИП. И там я напишу where CardContent: Equatable:

Мы только что сделали “не важно, какой” (don’t care)  ТИП CardContentнемного важно, каким” (care a little bit) ТИПом. CardContent может быть всем, что вы захотите, но он должен быть Equatable.
Понятно, что мы здесь сделали?
Если мы вернемся к нашей ошибке, то увидим, что наша проблема решена:

Никакой ошибки, потому что мы знаем, что этот CardContent должен быть Equatable.
Еще одна интересная особенность Swift — это когда ваша функция func == выглядит так, что вы просто сравниваете каждое отдельное свойство. В этом случае вам вообще не нужно реализовывать такую функцию. 
Swift синтезирует её для вас. Так что я действительно могу взять эту функцию удалить, и все заработает нормально:

Больше никто не “говорит”: “Ой, подожди, ты не соответствуешь Equatable”. Наоборот, компилятор”говорит”: “О, я вижу, что все твои переменные var являются Equatable, так что я могу сделать тебя Equatable”.
Следующее, что мы хотим сделать, это вернуться в наш View и посмотреть, сработало ли то, что мы сделали массив viewModel.cards Equatable:

Действительно, жалоб нет, теперь это массив Equatable вещей.
Можем ли мы кликнуть на кнопке Shuffle и анимировать перетасовку карт?
Давайте попробуем.

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

ForEach идентичность

Наш ForEach проходится по индексам массива карт viewModel.cards.indices. Эта карта имеет индекс равный 0, следующая карта — индекс 1, следующая карта — индекс 2, следующая карта — индекс 3, следующая карта — индекс 4, следующая карта — индекс 5, следующая карта — индекс 6 и далее по порядку. Для каждого индекса создается CardView и размещается на экране.
Вот что делает наш ForEach.
Когда перетасовываются карты, мы перемещаем одну из наших карт с номером 7 на место карты с номером 0, a карта с номером 0 перемещается на место карты с номером 4. 

Но с точки зрения нашего ForEach, он все равно показывает карту с индексом 0. Как бы не менялся массив viewModel.cards, ForEach все еще показывает карту с нулевым индексом, хотя карта уже поменялась. Вот почему карта сначала исчезает, a потом проявляется, но уже с другим эмодзи (смайликом).
Проблема в том, что мы хотим, чтобы ForEach не “проходил” по индексам карт, a “проходил” бы по самим картам. Мы хотим, чтобы он ассоциировал CardView с реальной картой. Поэтому, когда карта перемещается в массиве, CardView также перемещается.

Как же нам установить связь между этим CardView и самой картой card вместо того, чтобы CardView ассоциировался с её индексом?
На самом деле это невероятно просто, и именно для этого и предназначен ForEach, то есть вместо ForEach (viewModel.cards.indices, id: \.self) мы напишем ForEach (viewModel.cards, id: \.self). Внутри замыкания мы поменяем index на card:

Все с этим согласны? Мы больше не работаем с индексом index. Мы передаем CardView непосредственно саму карту card.
Это почти работает, но у нас две небольшие проблемы.
Одна из них — это ошибка:

Generic struct 'ForEach' requires that 'MemoryGame<String>.Card' conforms to 'Hashable'
(“Generic структура struct 'ForEach' требует, чтобы 'MemoryGame<String>.Card' соответствовала 'Hashable' ”).

Hashable — это еще один протокол protocol.
Причина, по которой мы получили такое сообщение, заключается в этом id: \.self, и я обещал вам, что расскажу, что такое id: \.self, и время пришло.
В ForEach (viewModel.cards, id: \.self) аргумент id: \.self означает, что я, ForEach, пытаюсь идентифицировать каждую из вещей в viewModel.cards. Это просто массив Array, a раньше был диапазон Range, но я стараюсь идентифицировать каждого из них, чтобы я мог “подцепить” это к каждому отдельному View.

Что мне следует использовать, чтобы идентифицировать элементы массива Array или диапазона Range?

id: \.self означает использование самого элемента. Это отлично работает для целых чисел Int, например Int 0,1,2,3 … Но, допустим, что это не индексы, а какой-то массив целых чисел: 0,1,2,2,3,4,1,2. Тогда это работать не будет, ForEach будет сбит с толку и даже будет “жаловаться”: “Ты передал мне массив и сказал, что это уникальные идентификаторы, a они не уникальны”.
Когда у нас был диапазон Range или массив индексов viewModel.cards.indices это работало, потому что мы знали, что наш диапазон всегда будет 0,1,2,3,4,5 и далее сколько угодно карт и числа никогда не дублируются, поэтому всё работало.

Причина, по которой написано ‘Hashable‘ заключается в том, что нужно иметь возможность хешировать viewModel.cards, потому что ForEach собирается построить небольшую хэш-таблицу, небольшой словарь между каждым элементом viewModel.cards и соответствующим CardView.
Я же говорил вам, что ForEach просто пытается “подцепить” эти две вещи друг к другу и ему нужна хэш-таблица, такой небольшой словарь.
Но для нас это все равно не сработает, потому что если бы мы хешировали наши карты viewModel.cards, это включало бы такие свойства, как isFaceUp и isMatched, a они меняются.
Когда вы кликаем на карте, свойство isFaceUp меняется, и с точки зрения хэширования это будет уже другая карта. 

Поэтому нам нужна какая-то другая вещь, которая идентифицирует карту навсегда и однозначно. Неважно, что произойдет с этой картой, неважно как сильно она поменяется, мы знаем, что это та же самая карта, то есть первая “ведьма” или вторая “тыква”. Когда такие карты движутся, они могут лежать “лицом” вверх или “лицом” вниз, наш ForEach всегда будет точно знать, с какой картой он имеет дело. Так что мы не будем использовать здесь id: \.self, в нашем случае не имеет смысла это делать:

Итак, вы видите здесь обычный классический ForEach. У вас будет здесь массив Array или некоторый диапазон Range, и у вас не будет этого id: \.self, но вы получите эту ошибку и придется исправлять её, если вы хотите, чтобы в вашем ForEach все работало.
Ошибка сообщает :

"'ForEach' requires that ‘MemoryGame<String>.Card’ conforms to 'Identifiable’”
('ForEach' требует, чтобы 'MemoryGame<String>.Card’ соответствовала 'Identifiable’)

И это еще один протокол protocolIdentifiable.

Протокол Identifiable

Мы видели требование соответствовать протоколу Equatable, затем Hashable. Теперь, чтобы решить нашу проблему мы должны реализовать протокол Identifiable.
Вы, наверное, уже догадались, что делает этот протокол. Он делает эту карту “идентифицируемой”, так что ForEach может найти её и знает, что это за карта.

Давайте заставим нашу карту соответствовать протокол Identifiable.
Вернемся к нашей карте Card, мы уже заставили её соответствовать протоколу Equatable. Теперь я добавлю ещё Identifiable, и получаем ошибку, что понятно, так как требуется что-то сделать, когда вы пишите Identifiable:

Мы собираемся кликнуть на Fix, это вроде бы работает:

Нам говорят: «Если хотите соответствовать протоколу Identifiable, у вас должна быть переменная var с именем id«. Понятно?
В качестве ТИПа этой переменной размещается заменитель ObjectIdentifier, вы знаете, почему он на самом деле ставит здесь этот заменитель. Вот почему я сказал, что это вроде как работает.
На самом деле ТИП нашего id и протокола Identifiable — это “не важно какой” ТИП.
 Ну, возможно, что “чуть-чуть важно”. ObjectIdentifier — это “не важно какой” ТИП реализующий протокол Hashable, и это замечательно.
Обычно в качестве ТИПа id в протоколе Identifiable мы используем строку String или что-то, что хешируется, и мы можем задать одно из его значений для каждой из наших карт Card.
Я собираюсь использовать строку String в качестве ТИПа id. Кроме того, я перемещу переменную var id: String в самый низ, потому что эта переменная не так важна, как все остальные мои переменные var:

Конечно, Identifiable — это важно, но не так как то, что карты могут лежать “лицом” вверх или вниз, это часть игры.
Как только я добавил этот код, у меня выскочила ошибка:

“Missing argument for parameter 'id' in call”
(“ Отсутствует аргумент для параметра ‘id’ при вызове.”):

Да, сейчас у меня есть переменная var id, и она не имеет значения, поэтому мне нужно присвоить ей значение при вызове.
Давайте сделаем это, id — это строка String, и в качестве значения id я буду использовать строки “1a” и “1b” для первой карты:

“2a” и “2b” — для второй карты, “3a” и “3b” — для третьей,

Все согласны, что это хороший идентификатор, так как все карты будут иметь уникальные простые для понимания id, если я когда-нибудь захочу посмотреть это в моем коде, что я не часто делаю. Итак, как мне получить эту строку с цифрой “1”?
Мне нужна строка «1», и цикле for у меня есть индекс pairIndex, который меняется от 0 до числа пар карт numberOfPairsOfCards — 1, поэтому мне нужно как-то конвертировать этот pairIndex в строку String прямо в процессе выполнения цикла for. Для этого я хочу использовать действительно крутую “фишку” в Swift  — интерполяцию строки (String interpolation).

Что это такое?

Вы можете написать \( ), а затем поместить внутри круглых скобок практически любое какое хотите выражение, и выражение будет вычислено и превращено в строку String и вы можете вставить его прямо в середину вашей другой строки.
В нашем случае это будет \(pairIndex + 1), верно? Мне ведь не нужна карта “0a”, мне нужна карта “1a”, именно поэтому в обоих картах мы используем pairIndex + 1:

Итак, эту маленькую строковую интерполяция \( ) действительно очень круто использовать для отладки, с её помощью очень просто печатать на консоли сложные выражения.
Никаких ошибок, это все должно быть работать, неужели это так просто? Теперь у каждой карты есть уникальный идентификатор id.
Давайте вернемся к нашему View и посмотрим, будет ли это работать. 
Вот наш View, здесь ошибок здесь нет:

Давайте попробуем перетасовать наши карты, скрестите пальцы и смотрите на это:

Теперь, когда ForEach может определить, какие карты соответствуют каким Views, то при нажатии кнопки Shuffle происходит фактическое перемещение карт на новое место в массиве.
И если мы хотим увидеть это еще лучше, почему бы нам не разместить здесь небольшой VStack с зазором spacing: 0 и не добавить просто для понимания Text(card.id):

Итак, видите? 1a, 1b, 2a, 2b, 3a, 3b, они красиво упорядочены.
Когда я нажимаю  на кнопку «Shuffle” происходит перемещение карт и это хорошо видно по их идентификаторам id:

Итак, вы увидели силу анимации. Swift в этом плане невероятный! Мы написали всего одну строку кода, но как только мы реализовали протоколы Equatable and Identifiable, мы получили всю эту анимацию. Это просто удивительно. Через две недели вы увидите сколько всего можно получить за очень небольшой объем кода.

На этом достаточно. Давайте избавимся от этого вспомогательного VStack и вернемся к тому, что было раньше:

. . . . . . . . . .

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

  • Протокол CustomDebugStringConvertible
  • ViewModel Intents (Намерения)
  • ТИП перечисление enum
  • ТИП Optional
  • Демонстрационный пример с Optional
  • Развернутое Optional и восклицательный ! знак 
  • Получение значения Optional с помощью if let
  • Функции как аргументы других функций
  • Заставляем игру MemoryGame играть
  • Удаляем “совпадающие” карты с UI
  • Вычисляемые свойства (computed properties)
  • Расширения extension

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

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