Внедряване на новия NavigationStack програмно и без NavigationLink

След като току-що преразгледа тази навигация за SwiftUI 3 тук (който актуализира оригиналния подход за SwiftUI 1 тук), Apple оттогава преосмисли навигацията с новия NavigationStack като част от последната версия на SwiftUI 4. Това е страхотна новина… и покрива повечето от предишните ми предложения!

Преди това NavigationView изискваше изрично дефиниране на навигационни „ръбове“ и използване на множество флагове, което можеше да доведе до объркване. Новият подход използва стек, създаващ не-UI представяне на съществуващата навигация и работи прекрасно с предишния ни програмен подход без много промени.

Този подход първоначално започна с преглед на потока на многоекранно включване със SwiftUI. Както при всички многоекранни потоци за въвеждане на данни, те често представляват интересен проблем за това как да се разделят данните, изгледът и логиката за навигация.

И така, какво прави страхотен многоекранен поток за въвеждане на данни? Ето какво измислих. Поради липса на по-малко грандиозен термин, ще го нарека моя „манифест на екранния поток“. Тук използвам „екран“ вместо изглед, защото изрично говорим за навигация на цял екран.

  1. Екраните не трябва да имат „родителски“ познания, нито да отговарят за навигирането навътре или навън.
  2. Индивидуални модели за изглед за всеки екран.
  3. Цялостната логика за контрол на потока е отделна от изпълнението на потребителския интерфейс и може да се тества без потребителски интерфейс.
  4. Гъвкав и позволява разклоняване към различни екрани в потока.
  5. Колкото е възможно по-опростен, но с възможност за композиране и мащабиране.

Изискване за навигация

Така че включването може да е просто, може би два или три екрана, питащи потребителя за някаква проста лична информация. Бутонът „напред“ ще премести потребителя напред в потока.

Но това, което обикновено е по-типично, е по-сложен поток с разклонения. Може би потребителят все още не е готов да сподели всички тези подробности или може би са необходими повече подробности в зависимост от предишни отговори. Така че може би това е по-представително:

Първоначално изпълнение

Както споменахме по-рано, ще използваме NavigationStack. Това може да бъде обвързано (двупосочно свързване) към навигационен път. В нашата първа реализация само с поток от 3 екрана, ние ще използвамеNavigationPath(), което е последователност с изтрит тип. Ще добавим пътя за навигация към модел на изглед, фокусиран върху навигацията, и ще го предадем (повече по-късно).

В рамките на NavigationStack дефинираме основен изглед (в нашия случай VStack с текст и бутон). Това също така съдържа модификатори на дестинация за навигация, които задействат действителната навигация. Всяко добавяне към навигационния път ще насочи SwiftUI към подходящия нов изглед за новия екран въз основа на типа и ще изпълни натискаща анимация.

В тази реализация използваме модел на изглед, наречен FlowVM, който контролира потока на навигация (различен от моделите на изглед на екрана). Този модел на изглед съдържа пътя за навигация, който ни позволява да задействаме действителната навигация извън изгледите (точки 1 и 3 на манифеста).

В нашия пример добавянето на цяло число към пътя за навигация ще избута aContentView2 към стека, а добавянето на низ към пътя ще избута ContentView3. Сега простото манипулиране само на навигационния път (който е последователност) ще повлияе директно на навигацията, като ни дава пълен програмен контрол. Тук се крие красотата (и покрива точка 4 от манифеста)!

  1. Push: Добавяне на определен тип.
  2. Обратно към корена: Реинициализирайте пътя за навигация.
  3. Назад 1 екран: Премахване на последната стойност.

Можем също да се върнем към няколко екрана сremoveLast(x).

Ще забележите също, че вече изобщо не е необходимо да използвамеNavigationLink. NavigationLink все още е налична опция в SwiftUI 4, но основната му употреба е в навигация от изглед до изглед – нещо, което искаме да избегнем тук!

Преглед на модели и подвързване

Към точка 2 на манифеста — отделни модели на изглед за всеки екран. Използването и внедряването на моделите на изглед може да се различават тук и съм чувал загриженост относно прекомерната употреба на шаблона за проектиране на MVVM в SwiftUI. Това със сигурност не е термин, използван в нещо официално от Apple.

Това, което искам, е не-UI представяне на изгледа, така че да мога чисто да капсулирам не-UI логика, да го тествам без изглед и, разбира се, лесно да се свържа с изгледа (и в двете посоки). Той също така трябва да е специфичен за изгледа, така че изгледът да може да се движи и да не зависи от нищо външно (т.е. да може да се съставя — манифест, точка 5). Това е интерфейсът на изгледа към останалата част от приложението. Наричам това модел на изглед.

В SwiftUIObservableObject (който всъщност е част от Combine) създава добър модел на изглед, който позволява двупосочно обвързване на изглед. По-новият подход с @StateObject създава стабилен модел на изглед, който се зарежда лениво само когато е необходимо.

Обърнете внимание също, че в тази версия на модел на изглед събитията от потребителския интерфейс също се предават в модела на изглед от изгледа и всяка специфична за изгледа логика (напр. мрежови повиквания) може да се задейства оттам (обикновено извикване надолу към API слой за пример).

Имаме и модела за изглед на потока (FlowVM) за управление на навигацията от екран до екран. Той не познава възгледите и е проектиран да може да се тества. Самият той може да изисква извиквания на API, за да определи пътя, който да следва. Имайте предвид, че това е подобно на „координатор“, но за мен се счита за модел на навигацията и затова използвах термина „модел на изглед“.

След това всеки екран също има индивидуални модели на изглед. Тези модели на изглед на екрана обработват събитията от потребителския интерфейс и логиката на екрана. В крайна сметка (при завършване на цялата логика на екрана след докосване „следващ“ например), ние предаваме контрола обратно от моделите на изгледа на екрана към модела на изгледа на потока, за да решим в крайна сметка къде да навигираме.

За завършване, връщайки се обратно от моделите на изглед на екрана обратно „нагоре“ към модела на изглед на поток, можем да използваме различни техники. Делегатите и обратните извиквания са валидни реализации, но аз обичам да използвам PassthroughSubject на Combine, предавайки обратно препратка към самия модел на изглед на екрана.

Така че моделът за изглед на екрана и view биха искали нещо подобно:

И свързан в модела на изгледа на потока, за да слушате събитията за завършване, както следва, като използвате sink и съхранявате това в subscription. Ще забележите, че фабричната функция за създаване на модел на изглед на екрана се обработва тук, което също добавя прослушване на събитие. Тази фабрична функция се извиква от изгледа на потока при инициализацията на изгледа на екрана.

sink извиква метод директно за обработка на всякаква логика (и навигация) и съхранява абонамента в набор, прикрепен към модела на изглед (който може да се използва за всички абонаменти).

Събираме го заедно

В нашето първо внедряване на стека за навигация използвахме NavigationPath и доста безсмислени типове (цели числа и низове), за да управляваме навигацията. Тъй като всеки екран вече е представен с модел на изглед, ние всъщност можем да управляваме навигацията, като добавим самите модели на изглед към пътя.

Можем да добавим моделите на изглед директно към NavigationPath и да създадем множество модификатори на дестинация за навигация за всеки тип модел на изглед. Въпреки това, този тип изтрита последователност предлага само ограничена възможност за запитване (например - не може лесно да се запитва какъв екран е показан в момента).

Вместо това можем да свържем стека за навигация към прост масив enum, който съдържа асоциирана стойност на модела на изгледа. Сега пътят е масив, който имаме максимален контрол и интроспекция на текущото му състояние. Единственото изискване тук е масивът да е Hashable, което от своя страна изисква моделите на изглед да бъдат Hashable. Малко допълнителна работа тук, но ясна.

Вижте repo за пълния код. Това също така включва примери за навигация назад (включително връщане към корен или втори екран и т.н.) и Hashable съответствие.

„Максималният контрол“ също ни позволява да се справим с някои интересни ситуации, които не си спомням да съм успял да направя в UIKit. Можете да промените предишен екран, за да бъде нещо съвсем различно (navigationPath[0] = ... ) и сега бутонът за връщане назад отива на различен екран. Или странно премахване на екран предишен екран по-дълбоко в стека (напр. navigationPath.removeFirst()). Това програмно ще навигира назад с първия екран, премахнат от стека. Може би последното е безсмислено, но харесвам начина, по който SwiftUI работи, както очаквах, дори в тези странни ситуации. Браво Apple!

Тестване

Голяма част от нашия дизайн е да подобрим възможността за тестване и да позволим модулни тестове на навигационния поток, независимо от потребителския интерфейс (манифест, точка 3). Сега с моделите view това се прави лесно. Ето един пример:

Можем да задействаме натискане на бутон „следващ“ и след това да проверим дали навигационната логика е задействана – всичко това без действителен потребителски интерфейс.

Имайте предвид, че това очевидно е проста реализация. Ако моделите на изгледа имаха API извиквания, ще трябва да помислим за някакво инжектиране, за да ги излъжем. Освен това, това очевидно не е UI тест.

Може също да искаме да добавим някои тестове на потребителския интерфейс (може би използвайки тестване на моментни снимки) — но това е извън обхвата на тази статия.

И накрая…

Надявам се, че това е имало смисъл! След 4 версии тази итерация на push навигацията на SwiftUI е подходът, който търсихме. Сега това трябваше да отговори на повечето опасения на общността като цяло (както и на предишните ми предложения тук).

Някакви подобрения? В момента няма очевиден път за създаване на персонализирана навигация за натискане. Допълнителна по-голяма идея е да се създаде единен, конвергиран API за навигация както за push, така и за модална навигация. Apple — вашият часовник започва сега! 😁

Пълният код може да бъде намерен на https://github.com/nickm01/NavigationFlow. Наслади се!