Лекция 13. Представляющие (Presenting) Views. Навигация. CS193P Spring 2023.

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

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

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

TextField и @Binding в действии

Следующее, самое важное — я хочу иметь возможность редактировать название палитры, а также добавлять эмодзи (смайлики).
Как мне сделать это редактируемым текстовым полем?
Редактируемые текстовые поля в Swift — это View отличные от обычного текста Text. Вместо Text они называются TextField.

TextField имеет два аргумента.
Первый аргумент — это то, что мы называем Placeholder (Заполнитель) текста или слово, которое можно использовать, чтобы помочь пользователю понять, о чем мы здесь просим.
Второй аргумент, он называется text — это тот текст, который мы редактируем внутри этого текстового поля TextField.
И еще этот второй аргумент является привязкой Binding.

Если подумать о том, что здесь происходит, то у нас есть текстовое поле TextField, в котором текстом text является “Vehicles”. Мы хотим иметь возможность передать текстовому полю “Vehicles” как начальное значение, и всякий раз, когда что-то меняется в нем, мы хотим знать об этом.
И то, как мы собираемся это сделать, заключается в создании единственного “источника истины” (single source of truth) для этого текста text. Мы будем делать это с помощью привязки Binding.
В частности, этот второй аргумент text текстового поля TextField является привязкой Binding.
TextField знает, что он не хочет поддерживать копию того, что редактируется, он хочет редактировать эту вещь напрямую. Поэтому он просит вас дать ему привязка Binding к этому “источнику истины” (source of truth).
Ну a что является “источником истины” (source of truth) для этого palette.name?
Он находится в нашей ViewModel, в нашем PaletteStore. Следовательно, нам нужно дать здесь нашему TextField обратную привязку Binding к нашей ViewModel.
Для этого нам нужна привязка Binding к палитре palette, которую нам дали отредактировать в верхней части нашего PaletteEditor:

Сделав эту переменную var @Binding, мы заставляем того, кто создаст этот PaletteEditor, дать нам привязку Binding к “источнику истины” (source of truth) для этой палитры palette.
Теперь каждый раз, когда мы ссылаемся на эту палитру palette где угодно в нашем коде здесь, в редакторе PaletteEditor, на самом деле мы будем ссылаться в обратном порядке на палитру в нашей ViewModel.
И мы также можем использовать эту привязку Binding для передачи привязки Binding к имени палитры palette.name в нашем TextField:

Это потому, что для $, то есть projectedValue, для @Binding — это еще одна привязка Binding к той привязке @Binding.
Итак, $palette.name здесь означает привязку Binding к этой @Binding var palette, которая будет привязана в обратный порядке в конечном итоге к нашей ViewModel. Теперь наше текстовое поле TextField будет редактировать имя name палитры palette напрямую в ViewModel.
Вы видите, что эти $, эти привязки Binding, проходят через всю нашу систему Views.

Итак, вернемся к нашему PaletteChooser:

Вы видите, что мы получаем здесь ошибку, потому что это должно быть привязкой Binding к “источнику истины” (source of truth). Здесь я передаю “источнику истины” (source of truth), и это Value ТИП, так что “источник истины” (source of truth) копируется. Я не хочу копировать “источник истины”, я хочу иметь привязку Binding к нему, и я могу получить это, поставив знак $, потому что store — это @EnvironmentObject:

И привязка Binding, $, projectedValue, для @EnvironmentObject, как и для @ObservedObject, является привязкой Binding к этому хранилищу store или этой переменной var в ViewModel, включая его палитры palettes. В нашем PaletteStore есть переменная var palettes. Это даже вычисляемая переменная var, но мы все равно можем “привязаться” Binding прямо к ней. На самом деле, я даже могу “привязаться” Binding к определенному элементу этого массива.
Привязка Binding может выполняться насквозь по цепочке в обратном порядке. Поэтому я создал привязку Binding к этому “источнику истины” store.

@EnvironmentObject var store — это “источнику истины” (source of truth). Я передаю эту привязку Binding своему редактору PaletteEditor. Теперь все, что мой редактор делает с этой палитрой palette, будет менять ее в “источнике истины” (source of truth), это меняется НЕ локально или как-то ещё, это действительно “вживую” меняется в самом “источнике истины”.
Давайте запустим приложение, вернемся к нашему редактору и посмотрим на это.
Мы редактируем палитру Edit. Там написано Vehicles, потому что показывается имя палитры, которая находится в  модели. Теперь я меняю это на Travel. Видите, что происходит? Даже когда я удаляю некоторые буквы, все это меняется непосредственно в модели. И теперь вы видите там Travel. Мы непосредственно редактируем эту вещь в Модели, как единственный источник истины (source of truth).

Давайте также привяжем Binding наш текст “Add Emojis Here”.
Но самое интересное в “Add Emojis Here” вот что: на самом деле этого нет в моей палитре palette. Когда я буду печатать в этом текстовом поле TextField c “Add Emojis Here”, это должно добавляться в мою palette.emojis. Оно не заменяет мою palette.emojis.
Я не выбрал UI, в котором можно вводить эмодзи (смайлики) напрямую в palette.emojis.
Мы будем добавлять эмодзи (смайлики) в текстовом поле TextField c “Add Emojis Here”, a если понадобиться какой-то эмодзи удалить, то кликаем на нем ниже, и он удаляется.
Мой UI не соответствует тому, что представлено в моей ViewModel, что совершенно нормально.

В этом случае “источником истины” (source of truth) для текстовом поле TextField “Add Emojis Here” будет локальная @State переменная var. Но каждый раз, когда @State переменная меняется, я буду обновлять свою Модель palette.emojis, добавляя туда эти эмодзи (смайлики).
Давайте посмотрим, как это выглядит.
Вот “Add Emojis Here”, мы сделаем его текстовым полем TextField с подсказкой “Add Emojis Here”, a текстом text будет, по сути, $emojisToAdd. И мы создадим @State private переменную var emojisToAdd: String, начальное значение которой — пустая строка, то есть нет добавляемых эмодзи:

Буквально вслед за TextField я напишу:

Модификатор .onChange

В замыкании модификатора .onChange вам предоставляется новая версия emojisToAdd. Здесь я буду добавлять эти эмодзи (смайлики). Я собираюсь разместить новые эмодзи (смайлики) в начале списка, предполагая, что они новые, и мы хотим, чтобы они были там. Я также собираюсь сделать их уникальными и избавиться от всего, что не является эмодзи. Если вы зайдете в наш редактор и наберёте “Hello”, то в palette.emojis это не появится, у нас будут там исключительно просто эмодзи (смайлики). Конечно, можно представить, что однажды я захочу размещать слова или что-то ещё в моих палитрах, но не сегодня. На этом курсе только эмодзи (смайлики).

Я напишу в замыкании palette.emojis =

Это важное предложение. 
Когда я пишу palette.emojis = , речь идет о палитре palette во ViewModel. Я меняю здесь ViewModel напрямую, потому что у меня привязка @Binding var palette в обратном порядке по цепочке до самого ViewModel. И это напрямую изменит ViewModel. Я пишу palette.emojis = (emojisToAdd + palette.emojis), отфильтровываю их, чтобы убедиться, что это всего лишь эмодзи (смайлики) с помощью .filter { $0.isEmoji}. Об isEmoji расскажу через секунду.
Я также сделаю их уникальными, мы уже знаем об uniqued:

isEmoji — это не то, что есть в Swift. Я действительно написал эту функцию и разместил в моём файле Extensions. Это не идеальный тест на эмодзи, но достаточно хорош, он улавливает большинство эмодзи (смайликов). Суть в том, что мне нужны здесь только эмодзи (смайлики).
Все поняли, что я тут сделал?
Я взял emojisToAdd и добавил их в свою палитру palette, затем я делаю их уникальными.

Примечание переводчика. В Xcode 15.1  и iOS 17 модификатор .onChange изменился и мы получим предупреждение:

onChange (of:perform:)’ упразднен в iOS 17.0: Используйте вместо этого ‘onChange’ с замыканием с двумя параметрами или вообще без параметров.
Мы будем использовать замыкание с двумя параметрами:

.onChange(of:emojiToAdd) { oldState, newState in
 .  .  .  .  . .  .        
}

Старое значение oldState нам не нужно, a новое значение newState мы переименуем в emojiToAdd:

Давайте посмотрим, как это выглядит.
Это палитра Travel (путешествие).
Давайте посмотрим на её эмодзи (смайлики).
У нас есть раздел Travel с эмодзи для ввода.
Где Travel?
Вот он. Там есть пара кораблей.
Обратите внимание, что я на самом деле дважды щелкаю и печатаю.
Это нормально, потому что мы отслеживаем их уникальность, и добавляет только один эмодзи.

Посмотрите, как добавляются новые эмодзи в начало списка эмодзи (смайликов) для этой палитры. Этот список эмодзи (смайликов) отображается прямо здесь, чуть ниже, в списке  эмодзи (смайликов) для удаления, который также приходит непосредственно из Модели.
Когда я что-то добавляю в Модель, это обновляется автоматически.
Вы начинаете понимать общую картину того, что здесь происходит, когда всё “привязывается” Binding, все работает и обновляется автоматически.
Вы добавляете эмодзи в свою Модель, и они тут же появляются на экране.
Они появляются не только в сетке эмодзи для удаления, но и в самой панели Travel (присмотритесь повнимательнее). Я только что добавил туда эти корабли.

Конечно, наша палитра является постоянно сохраняемой в UserDefaults.
Каждый раз, когда мы уходим из приложения и приходим снова, отредактированная палитра все еще там.
А как насчет удаления эмодзи (смайликов)?
Давайте используем жест Tap для удаления.
Удалить эмодзи (смайликов) очень просто, используем .onTapGesture и я собираюсь сделать это с анимацией withAnimation. Я хочу, чтобы они красиво анимировались:

Я просто пишу palette.emojis.remove (emoji.first!).
Это должна быть строка emoji, содержащая единственный эмодзи (смайлик), но я пишу .first!, чтобы получить первый и единственный символ в этой строке String.

remove — это еще одна функция, которую я написал, просто чтобы было проще:

Я также собираюсь удалить этот эмодзи (смайлик) из emojisToAdd тоже, потому что если вы его удаляете, наверное, вам он больше не нужен:

Я удалил эмодзи (смайлик) из обоих “источником истины” (source of truth).
Опять же, когда я пишу palette.emojis.remove(emoji.first!), я модифицирую Модель.
Запускаем приложение. Давайте посмотрим, как это выглядит.

Вот наша палитра Travel (путешествие), у нас есть лодки.
Давайте уберем лодки.
У нас нет лодок. Видите? Мы их убрали.
Я возвращаюсь сюда и я передумал, я действительно хотел эту лодку. 
Есть лодка, она опять расположилась там. И еще одна лодка.
На самом деле я вовсе не хочу этих лодок. Это удалило их оттуда.

Это не лучший интерфейс для добавления или удаления эмодзи (смайлик).
Я выбрал этот интерфейс, потому что хотел показать вам , как мы обновляем Модель повсюду, и она действительно просто обновляется везде.
Это фундаментальная часть целого декларативного подхода, которая заключается в том, что существует единственный “источник истины” (source of truth). Если вы редактируете этот “источник истины” (source of truth), то он просто отображается в пользовательском интерфейсе.
Не требуется большого количества дополнительных усилий, чтобы это произошло.

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

P.S.  iOS 17 Реактивный UI: Observation

Что отслеживает  @Observable?

Макрос @Observable позволяет вам строить модели Model или ViewModel так, как вы хотите. У вас могут быть массивы наблюдаемых моделей или ТИПы моделей, которые содержат другие наблюдаемые ТИПы моделей, наподобие “матрешки”. Общее правило (для @Observable ): если используемое свойство изменится, View обновится.

На Лекции 12 этого курса в примечании мы использовали макрос @Observable для нашей второй ViewModel класса class PaletteStore.
На Лекции 13 в PalleteManager у нас есть массив [PaletteStore], который также является @Observable, и может использоваться напрямую в отличие от своего предшественника ObservableObject, о чем Пол Хагэрти говорил в конце Лекции 13.

В EditablePaletteList используется @Bindable для аргумента store:

Конечно, все хранилища палитр в Emoji_ArtApp используют @State переменные var :

В PaletteChooser, который выбирает из хранилища PaletteStore палитру palette для редактирования в редакторе PaletteEditor, мы сталкиваемся с тем, что в наше распоряжение поступает хранилище палитр в виде @Environment (PaletteStore.self) var store, так как хранилища палитр PaletteStore является @Observable. A редактировать нам нужно палитру palette, которая не является @Observable и для редактирования требует Binding, то есть $store.palettes[store.cursorIndex].
В этом случае мы получаем ошибку:

В руководстве Apple по “Миграции от протокола ObservableObject к макросу @Observable” :

Если View требуется привязка Binding к Observable ТИПу, замените ObservedObject оболочкой свойства @Bindable. Это обеспечивает поддержку Binding к Observable ТИПу, чтобы View, ожидающий привязки Binding, мог изменять Observable свойство.

В нашем случае нам пришлось вставить код @Bindable var store = store в var body, чтобы получает привязку к store.palettes[store.cursorIndex]:

Пришлось внести изменения в PaletteList и заменить @EnvironmentObject на @Environment:

Во всех предварительных просмотрах Preview следует заменить .environmentObject(paletteStore) на .environment(paletteStore):

Всё прекрасно работает без каких-либо нюансов.
Есть прекрасное видео, поясняющее эту ситуацию, — Nested Observables in SwiftUI.

Смотрите код на Github EmojiArt L13 и EmojiArt L!3 Observation palettes.
На Лекции 13 рассматриваются следующие вопросы:

  • Рекомендации к Домашнему Заданию № 6
  • Добавление Menu в .contextMenu
  • Редактор палитры PaletteEditor
  • Модальные презентации .sheet и .popover
  • Форма Form и секции Section
  • Шрифты .font в .sheet
  • TextField и @Binding в действии
  • Модификатор .onChange
  • @FocusState
  • Preview c @Binding аргументом
  • Создание списка палитр PaletteList
  • NavigationStack, NavigationLink, .navigationDestination
  • .navigationTitle
  • Редактируемый список палитр EditablePaletteList
  • NavigationSplitView и PaletteManager
  • List с аргументом selection:. Hashable
  • .detail и .content
  • NavigationStack внутри NavigationSplitView

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

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