По следам CS193P 2020 — SwiftUI Playing Card Memorize

Меня настолько впечатлила легкость интеграции UIViews в  SwiftUI, с одной стороны, и возможность настройки карточной игры «на совпадение» MemorizeGame <ContentCard> на любое содержание карты ContentCard, с другой стороны, что я решила попробовать создать такую же игру Memorize с игральными картами PlayingCard вместо карт с эмоджи. Моя задача облегчалась тем, что Пол Хэгерти уже создавал игральную карту на прошлом курсе CS193P 2017 Лекция 6, и я могу взять эту досконально  спроектированную игральную карту в свой проект в неизменном виде:

Помимо работы с игральными картами, я хочу расширить логику и UI игры Memorize на игру с картами, которые могут быть заменены в случае совпадения на новые карты из колоды карт. Эта логика и UI могут пригодится при выполнении Задания 3 курса CS193P 2020, в котором требуется разработать приложение для игры Set, в которой также есть колода карт, оговариваются условия «совпадения», но не 2-х карт, как в нашей игре, а 3-х.

В Xcode 12 (следовательно это SwiftUI 2.0) создаем новый проект PlayingCardMemorize:

Переносим в этот проект файлы, касающиеся игральной карты PlayingCard, из  UIKit проекта:

Таких файлов — 3:
Модель игральной карты — PlayingCard.swift
Модель колоды игральных карт — PlayingCardDeck.swift
View игральной карты — PlayingCardView.swift

Игральная карта представлена структурой struct PlayingCard, основными свойствами которой являются масть suit и ранг rank. Игральная карта PlayingCard должна быть Equatable, чтобы она могла принимать участие в игре «на совпадение» Memorize. В этой игре две игральные карты считаются совпавшими, если совпали их масти suit или ранги rank

 
 Синим цветом выделен код, который добавлен к первоначальному UIKit коду.

Колода игральных карт представлена довольно простой структурой struct PlayingCardDeck, основным свойством которой является массив игральных карт cards:

С помощью метода draw () вы можете вынуть из колоды любую случайную игральную карту PlayingCard.

Третий файл, PlayingCardView.swift, представляет UIView для игральной карты, и это класс class PlayingCardView, основными свойствами которого ожидаемо являются масть suit, ранг rank игральной карты и булевская переменная isFaceUp, определяющая какую сторону карты — лицевую или обратную — представляет это UIView. Нам не нужна обратная сторона игральной карты в UIView, мы сами сможем представлять ее в SwiftUI, поэтому все, что связано со свойством isFaceUp, мы либо уберем, либо закомментируем:

Класс class PlayingCardView интенсивно использует строки с атрибутами NSAttributedString, аналога которым в SwiftUI пока нет.

Интеграция SwiftUI с UIKit.

Давайте создадим на основе UIKit PlayingCardView изображения SwiftUI View для игральной карты, которое я назову PlayingCardPresent.

Как всегда создаем новый файл File-> New -> File. Да, у нас будет SwiftUI View, но так как мы собираемся сделать его UIViewRepresentable, то у него не будет переменной var body, он будет реализовывать для нас протокол UIViewRepresentable. Следовательно, выбираем Swift File:

Я назову его PlayingCardPresent.

Как обычно, когда мы делаем интеграцию SwiftUI и UIKit, то добавляем оба фреймворка — SwiftUI и UIKit

Эта структура struct называется PlayingCardPresent, это SwiftUI View, но это больше, чем View, это UIViewRepresentable. Это другой протокол protocol, который наследует от протокола protocol View, в нем есть много чего дополнительного, что позволит нам осуществить интеграцию с UIKit.

И вы видите, что мы не подтвердили реализацию протокола protocol UIViewRepresentable, потому что мы должны разместить здесь функции, которые помогут нам обращаться с UIView в Мире SwiftUI:

Функция func makeUIView (context:Context) -> MKMapView возвращает UIKit View, с которым мы хотим работать, то есть PlayingCardView. Предварительно мы создаем его и настраиваем на конкретную игральную карту, определяемую переменной var card.

У нас также должна быть функция func updateUIView (_ uiView: PlayingCardView, context: Context), которая получает обратно наш UIView, это PlayingCardView, и ей предоставляется наш контекст context. Внутри этой функции мы можем делай всё, что нам нужно делать, чтобы поддерживать этот PlayingCardPresent в актуальном состоянии по мере того, как наш SwiftUI проходит через обычные для него реактивные механизмы перерисовки. Нам эта функция не понадобится, потому что у нас не интерактивное UIView, мы его нарисовали — и всё, это самая простая интеграция SwiftUi и UIKit.

Теперь, когда у нас есть SwiftUI ViewPlayingCardPresent, мы можем использовать Preview для любой игральной карты:

Для этого достаточно задать масть suit, ранг rank игральной карты.

Полиморфизм в SwiftUI.

Добавляем в наш проект игру «на совпадение» Memorize из демонстрационного примера Лекции 4.

Часть вспомогательных файлов мы поместим в папку Helper, файл MemoryGame с самой Generic игрой » на совпадение»  мы разместим в корневом сегменте, а файлы, относящиеся к Emoji игре в папке EmojiGame. Мы также создадим папку PlayingCardGame и разместим там все файлы, которые в данный момент у нас есть и которые относятся к игральной карте PlayingCard :

В папке EmojiGame размещены ViewEmojiMemoryGameView.swift и ViewModel — EmojiMemoryGame.swift для игры Emoji. Точно такие же файлы нам понадобятся для игры с PlayingCardViewPlayingCardGameView.swift и ViewModelPlayingCardMemoryGame.swift:

Давайте сначала сравним ViewModel для обоих игр:

Обе ViewModel имеют почти в точности одинаковую структуру, но в первом случае переменная var model представляет собой игра MemoryGame с конкретным ТИПом PlayingCard в качестве ContentCard,  а вторая — String (то есть эмоджи) в качестве ContentCard. У этих ViewModel абсолютно одинаковые «Намерения» Intents и доступ к картам игры cards.

Но у них немного разные методы инициализации игры MemoryGame, конечная цель которой получить карты cards, участвующие в игре. В случае с  PlayingCard мы будем последовательно инициализировать все карты, участвующие в игре, доставая их случайным образом из колоды карт deck, а в случае с эмоджи Emoji мы инициализируем их парами, доставая из заранее сформированного массива emojis.

Так что нам придется добавить ещё один инициализатор init в Модель игры MemoryGame для игр, которые имеют дело с картами из колоды карт ( не обязательно игральные карты, это могут быть карты игры Set):

Теперь давайте сравним Views для этих двух игр. 

Основные View в этих двух играх абсолютно одинаковые, за исключением того, что для EmojiGame мы задаем foregroundColor, так как имеем дело со строками String и прямоугольниками RoundedRectangle, а для PlayingCardGame —  background для имитации игрового стола.

Изображения карт CardView и CardPlayingView (мы могли бы оставить для изображения игральной карты то же название CardView , если бы не хотели иметь в одном приложении сразу две игры, основанные на одной Model) естественно разные, хотя имеют одну и ту же схему построения — у обоих есть лицевая и обратная сторона карты.

Логика обоих игр совершенно одинаковая, за исключением того, что в игре Emoji абсолютно должны совпасть строки, представляющие эмоджи, а в игре PlayingCard у карт должны совпасть либо масть, либо ранг:

Мы убедились, что наша Generic Модель игры «на совпадение» MemoryGame прекрасно работает как с игрой EmojiGame, так и с игрой PlayingCardGame.

Теперь давайте оставим игру EmojiGame в том виде, в каком она пребывает сейчас и отодвинем её в сторону. Что касается игры PlayingCardGame, то у нас есть возможность её усовершенствовать, так как у нее полная колода карт и на место выбывшим из игры «совпавшим» картам мы можем разместить новые карты из колоды, и так до тех пор, пока колода карт не исчерпается.
Давайте в нашей Модели MemoryGame добавим колоду карт deck, которая также как и карты cards, находящиеся в игре, является массивом [Card]:

Кроме того, в инициализаторе мы будем инициализировать только колоду карт deck, а карты, находящиеся в игре, нужно будет инициализировать с помощью специальной функции deal (_ numberOfCards:Int = 1), которая будет брать из колоды карт deck карту за картой:

Немного изменим нашу ViewModel. Используем новый инициализатор для колоды карт и добавим «Намерение» (Intent) для сдачи карт, участвующих в игре, их количество задаем переменной var numberOfCardsStart:

Мы должны внести совсем небольшое изменение в наш View: используем модификатор .onAppear для «сетки» Grid, чтобы «сдать» карты с помощью «Намерения» deal( ):

Функционирование игры PlayingCard никак не изменилось, просто мы сделали подготовку к игре с заменой «совпавших» карт из колоды.

Замена совпавших карт.

Замену «совпавших» карт необходимо делать в Модели MemoryGame, но для этого нам нужно знать индексы «совпавших» карт matchedIndices и число карт numberOfCardsToMatch, которые должны совпадать. В нашей игре «на совпадение» должны совпадать 2 карты, но есть игры (например, игра Set), в которой требуется совпадение 3-х карт:

Вычисляемая переменная matchedIndices вычисляет индексы карт, которые лежат «лицевой» стороной вверх и помечены как «совпавшие». На следующем шаге игры после выбора третьей карты, они должны быть заменены на новые карты из колоды карт: 


Красным цветом выделен код, который необходимо добавить в файл MemoryGame.swift.

Если мы запустим приложение (или воспользуемся Preview), то сможем убедиться, что «совпавшие» карты действительно заменяются на новые. Но это не будет наглядно для пользователя, нам придется убедиться в этом ручным способом — мы снова откроем «совпавшие» до этого карты и убедимся, что они заменены на новые :

Конечно, нам бы хотелось, чтобы «совпавшие» карты более наглядно выводились из игры — «улетали» с игрового стола, а те карты из колоды, которые их заменяют, «прилетали» бы на игровой стол с анимацией.

«Полет» карт с помощью Transition.

«Приходы и уходы» Views в SwiftUI анимируются с помощью «переходов» transition (как описано в лекции). «Улет (прилет)» — это просто перемещение. Перемещение от того места, где они в конечном итоге будут (или были) на экране, в какое-то случайное место за пределами экрана. Так что у нас есть две простые функции flyTo и flyFrom, которые вычисляют случайное местоположение за пределами экрана (куда карта может улететь и откуда прилетать).
В SwiftUI есть идеальный transition для перемещения под названием AnyTransition.offset (CGSize):

Мы создаем ассиметричные «переходы» AnyTransition.asymmetric для игральных карт — они «прилетают» из одного места (снизу), а «улетают» — в другое (наверх).

Мы можем усилить ситуацию с «полетом» карт и сделать «улетающие» карты меньшего размера, добавив к ассиметричному «переходу» removal AnyTransition.scale (CGFloat), чтобы как-то разделить, какие карты «улетают», а какие «прилетают»:

Визуализация совпавших карт.

Для более ясной картины происходящего в случае с игральными картами требуется визуально показать сам факт совпадения игральных карт, так как игроку трудно одновременно сопоставлять и масть, и ранг карты. Давайте покажем «совпавшие» карты с помощью определенного цвета, обрамляющего «совпавшую» карту:

Мы обрамили «совпавшие» карты голубым цветом:

Переворот карт.

Теперь заставим карты переворачиваться с «обратной» стороны на «лицевую» и наоборот. Мы будем это делать с помощью пользовательского модификатора ViewModifier с именем Cardify, который профессор разработал на Лекции 5. Мы немного модифицировали код этого модификатора для нашего случая:

Этот модификатор сильно разгрузит наш CardPlayingView, оставив только «лицевую» сторону карты:

И заставив карты переворачиваться:

Сдача карт.

Теперь давайте попробуем имитировать раздачу карт в самом начале игры, когда карты появляются на игровом столе одна за другой. Для этого нужна проводить анимацию появления карт на игральном столе с задержкой cardTransitionDelay, причем задержка в анимации должна действовать только во время сдачи карт, а не во время переворота карт с обратной стороны на лицевую и обратно или во время замены «совпадающих» карт. Так что у нас появляется @State переменная var shouldDelay: Bool, которая будет управлять тем, когда нам требуется задержка, её начальное значение равно true, так как в начале игры идет сдача карт, требующих задержки. Как только сдача карт закончится, переменная shouldDelay устанавливается в false :

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

В чём дело?
А дело в том, что если мы посмотрим на код «сдачи» карт, который находится в модификаторе .onAppear, то окажется, что строка кода, которая устанавливает переменную shouldDelay в false, выполняется в неподходящее время :

Мы хотим изменить наш View только после того, как пройдет сдача карт, а не в тот момент, когда View находится в середине обработки событий. Нужно подождать, пока всё успокоится. Так что я не буду устанавливать переменную shouldDelay в false  до тех пор, пока не буду уверена, что мой UI уже обновился.

Я собираюсь этого добиться с помощью DispatchQueue.main.async:

Это не имеет ничего общего с многопоточностью. Мы ничего не делаем в фоновом (background) потоке. Просто я прошу main queue выполнить это замыкание, она внесет его в свой список на выполнение, и даже если этот код выполняется на main queue прямо сейчас, возможно, даже при обновлении моих Views, main queue сначала закончит делать обновление моего View, а потом обратится к своей очереди на выполнение, захватит следующее замыкание, надеюсь, это будет моё замыкание, и выполнит его.

По сути, это всё равно что сказать: “Сделай это после того, как все уляжется”.

Давайте посмотрим, помогли нам это с задержкой карт при «сдаче»:

Да, всё прекрасно работает.

Сколько карт осталось в колоде и новая игра.

Давайте добавим на наш UI метку, которая будет показывать, сколько карт осталось в колоде, и кнопку для новой игры. Но прежде добавим в наш ViewModel этот доступ к Модели и «Намерение» (Intent) :

На UI добавляем Text с количеством карт в колоде и кнопку Button для новой игры:

И выделим «сдачу» карт в отдельную вспомогательную функция deal( ) :

Посмотрим, как теперь выглядит наша игра:

Сделаем небольшое усовершенствование. В SwiftUI 2.0 появился @StateObject, так что заменим наш @ObservedObject на @StateObject и инициализируем его:

Это упростит App :

И Preview:

Отдельный GameView.

Давайте ещё немного упростим наш основной ViewPlayingCardGameView. Так как не рекомендуется иметь очень большие View, то выделим всё, что относится к «сетке» Grid, в отдельный View :

Назовем новый View GameView:

В новый GameView перенесем необходимые константы и переменные viewModel и shouldDelay, которые будем задавать при инициализации таким образом, чтобы обеспечить единственный источник информации (single source of truth):

Модификатор .onAppear( ) перенесем назад в PlayingCardGameView:

Единственный источник информации (single source of truth) в «дочернем» View обеспечивается заменой @StateObject на @ObservedObject и @State на @Binding.

Приложение будет работать как и прежде, но мы добились того, что. получили универсальное GameView для игры «на совпадение» карт, которые добавляются из колоды. Надеюсь, что для игры Set, в которой другие карты и требуется совпадение 3-х карт вместо 2-х карт в нашей игре Memorize, GameView удастся использовать практически без изменения.
Код находится на Github.