SwiftUI - як уникнути навігації, жорстко закодованої у вигляд?


33

Я намагаюся зробити архітектуру для більшого, готового до виробництва SwiftUI додатка. Я весь час стикаюся з тією ж проблемою, яка вказує на головний недолік дизайну в SwiftUI.

Ще ніхто не міг дати мені повну робочу, готову відповідь.

Як зробити перегляди для багаторазового використання, в SwiftUIяких міститься навігація?

Оскільки SwiftUI NavigationLinkце суворо пов'язане з представленням даних, це просто неможливо таким чином, що він масштабується і у великих додатках. NavigationLinkу цих невеликих зразках Apps працює, так - але не відразу, коли ви хочете повторно використовувати багато переглядів в одному додатку. А може бути також повторне використання за межами модулів. (наприклад: повторне використання перегляду в iOS, WatchOS тощо).

Проблема дизайну: Навігаційні посилання жорстко кодуються у Перегляд.

NavigationLink(destination: MyCustomView(item: item))

Але якщо перегляд, що містить це, NavigationLinkповинен бути багаторазовим, я не можу жорстко кодувати місце призначення. Має бути механізм, який забезпечує призначення. Я запитав це тут і отримав досить хорошу відповідь, але все ж не повну відповідь:

SwiftUI Координатор MVVM / Маршрутизатор / НавігаціяLink

Ідея полягала в тому, щоб ввести цільові посилання у перегляд для багаторазового використання. Як правило, ідея працює, але, на жаль, це не досягає реальних виробничих додатків. Як тільки у мене є кілька екранів для багаторазового використання, я стикаюся з логічною проблемою, що для одного виду повторного використання ( ViewA) потрібна попередньо налаштована панель перегляду ( ViewB). Але що робити, якщо ViewBтакож потрібен попередньо налаштований вид перегляду ViewC? Мені потрібно створити ViewBвже таким чином, що ViewCвводиться вже до того, ViewBяк я вводяться ViewBв ViewA. І так далі .... але оскільки дані, які на той момент потрібно передавати, недоступні, вся конструкція виходить з ладу.

Іншою ідеєю у мене було використання Environmentмеханізму введення залежності для введення пунктів призначення NavigationLink. Але я думаю, що це слід вважати більш-менш хакером, а не масштабним рішенням для великих додатків. Ми врешті-решт використовуємо Середовище в основному для всього. Але оскільки середовище також можна використовувати лише всередині View (а не в окремих координаторах чи ViewModels), це, на мою думку, знову створить дивні конструкції.

Як і бізнес-логіка (наприклад, код моделі перегляду), і перегляд повинні бути розділені, і навігація та перегляд повинні бути відокремлені (наприклад, шаблон Координатора). UIKitЦе можливо, тому що ми отримуємо доступ до UIViewControllerта UINavigationControllerпоза ним. UIKit'sMVC вже мав проблему, що він переплутав так багато понять, що він став найменуванням забави "Massive-View-Controller" замість "Model-View-Controller". Зараз подібна проблема триває, SwiftUIале на мою думку ще гірше. Навігація та Перегляди сильно поєднані і не можуть бути роз'єднані. Тому неможливо робити перегляди для багаторазового використання, якщо вони містять навігацію. Це можна було вирішити, UIKitале зараз я не бачу розумного рішенняSwiftUI. На жаль, Apple не дала нам пояснення, як вирішити подібні архітектурні питання. У нас є лише кілька невеликих прикладних програм.

Я хотів би, щоб він був неправильним. Покажіть мені, чистий шаблон дизайну додатків, який вирішує це для великих програм, готових до виробництва.

Заздалегідь спасибі.


Оновлення: цей виграш закінчиться через кілька хвилин, і, на жаль, досі ніхто не зміг надати робочий приклад. Але я розпочну нову винагороду, щоб вирішити цю проблему, якщо не зможу знайти іншого рішення і зв’яжу його тут. Дякуємо всім за великий внесок!


1
Домовились! Я створив запит на це у "Помічнику зворотного зв’язку" багато місяців тому, відповіді ще немає: gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Саджон

@Sajjon Дякую! Я також маю намір написати Apple, давайте подивимось, чи отримаю я відповідь.
Дарко

1
Написав лист щодо Apple щодо цього. Подивимось, чи отримаємо ми повтор.
Дарко

1
Приємно! Це був би найкращий подарунок під час WWDC!
Саджон

Відповіді:


10

Закриття - все, що вам потрібно!

struct ItemsView<Destination: View>: View {
    let items: [Item]
    let buildDestination: (Item) -> Destination

    var body: some View {
        NavigationView {
            List(items) { item in
                NavigationLink(destination: self.buildDestination(item)) {
                    Text(item.id.uuidString)
                }
            }
        }
    }
}

Я написав пост про заміну шаблону делегата в SwiftUI на закриття. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/


Закриття - хороша ідея, дякую! Але як би це виглядало в ієрархії глибокого огляду? Уявіть, що у мене є NavigationView, який іде на 10 рівнів глибше, докладно, до деталей, до деталей тощо ...
Дарко,

Я хотів би запропонувати вам показати простий приклад коду глибиною лише трьох рівнів.
Дарко

7

Моя ідея в значній мірі була б поєднанням Coordinatorта Delegateзразком. Спочатку створіть Coordinatorклас:


struct Coordinator {
    let window: UIWindow

      func start() {
        var view = ContentView()
        window.rootViewController = UIHostingController(rootView: view)
        window.makeKeyAndVisible()
    }
}

Пристосуйте SceneDelegateдля використання Coordinator:

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let coordinator = Coordinator(window: window)
            coordinator.start()
        }
    }

Всередині ContentViewми маємо це:


struct ContentView: View {
    var delegate: ContentViewDelegate?

    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: delegate!.didSelect(Item())) {
                    Text("Destination1")
                }
            }
        }
    }
}

Ми можемо визначити ContenViewDelegateпротокол так:

protocol ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView
}

Де Itemтільки структура, яку можна ідентифікувати, може бути що-небудь інше (наприклад, ідентифікатор якогось елемента, як TableViewв UIKit)

Наступним кроком є ​​прийняття цього протоколу Coordinatorта просто перехід подання, яке ви хочете представити:

extension Coordinator: ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView {
        AnyView(Text("Returned Destination1"))
    }
}

Це поки добре працює в моїх додатках. Я сподіваюся, що це допомагає.


Дякуємо за зразок коду. Я хотів би запропонувати вам змінити Text("Returned Destination1")щось на зразок MyCustomView(item: ItemType, destinationView: View). Так що MyCustomViewтакож потрібно ввести деякі дані та місце призначення. Як би ви вирішили це?
Дарко

Ви стикаєтеся з проблемою гніздування, яку я описав у своєму дописі. Будь ласка, виправте мене, якщо я помиляюся. В основному такий підхід працює, якщо у вас є один перегляд для багаторазового використання, і цей перегляд для багаторазового використання не містить іншого виду для багаторазового використання з NavigationLink. Це досить простий випадок використання, але не наближається до великих додатків. (де майже кожен вид є багаторазовим)
Дарко,

Це дуже залежить від того, як ви керуєте залежностями свого додатка та їх потоком. Якщо у вас є місця в одному місці, як і IMO (також відомий як корінь складу), ви не повинні стикатися з цією проблемою.
Микола Матійович

Що для мене працює - це визначити всі ваші залежності для погляду як протоколу. Додайте відповідність до протоколу в корінь композиції. Передайте залежності координатору. Введіть їх від координатора. Теоретично, ви повинні в кінцевому підсумку з більш ніж трьома параметрами, якщо не зроблено належним чином і не більше ніж dependenciesі destination.
Микола Матійович

1
Я хотів би бачити конкретний приклад. Як я вже згадував, почнемо з цього Text("Returned Destination1"). Що робити , якщо це необхідно , щоб бути MyCustomView(item: ItemType, destinationView: View). Що ти там будеш вводити? Я розумію введення залежності, слабке з'єднання протоколів і спільні залежності з координаторами. Все це не проблема - це необхідне гніздування. Дякую.
Дарко

2

Щось мені трапляється, це те, що ти кажеш:

Але що робити, якщо ViewB також потребує попередньо налаштованого перегляду ViewC? Мені потрібно було б створити ViewB вже таким чином, що ViewC вводиться вже в ViewB до того, як я ввести ViewB в ViewA. І так далі .... але оскільки дані, які на той момент потрібно передавати, недоступні, вся конструкція виходить з ладу.

це не зовсім правда. Замість того, щоб подавати представлення даних, ви можете спроектувати компоненти, які можна повторно використовувати, таким чином, щоб ви постачали закриття, які надають види на вимогу.

Таким чином, закриття, яке виробляє ViewB на вимогу, може забезпечити його закриттям, яке виробляє ViewC на вимогу, але фактична побудова поглядів може відбутися в той момент, коли потрібна контекстна інформація, яка вам потрібна.


Але чим створення такого «дерева закриття» відрізняється від реальних поглядів? Проблема із забезпеченням предмета буде вирішена, але не потрібне вкладення. Я створюю закриття, яке створює вигляд - добре. Але в цьому закритті мені вже потрібно було б забезпечити створення наступного закриття. І в останньому наступний. І т.д. ... але, можливо, я вас неправильно зрозумів. Деякі приклади коду допоможуть. Дякую.
Дарко

2

Ось цікавий приклад того, як нескінченно висвітлювати та змінювати свої дані для наступного перегляду детально програмно

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    var body: some View {
        NavigationView {
            DynamicView(viewModel: ViewModel(message: "Get Information", type: .information))
        }
    }
}

struct DynamicView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    let viewModel: ViewModel

    var body: some View {
        VStack {
            if viewModel.type == .information {
                InformationView(viewModel: viewModel)
            }
            if viewModel.type == .person {
                PersonView(viewModel: viewModel)
            }
            if viewModel.type == .productDisplay {
                ProductView(viewModel: viewModel)
            }
            if viewModel.type == .chart {
                ChartView(viewModel: viewModel)
            }
            // If you want the DynamicView to be able to be other views, add to the type enum and then add a new if statement!
            // Your Dynamic view can become "any view" based on the viewModel
            // If you want to be able to navigate to a new chart UI component, make the chart view
        }
    }
}

struct InformationView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.blue)


            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct PersonView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.red)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ProductView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ChartView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ViewModel {
    let message: String
    let type: DetailScreenType
}

enum DetailScreenType: String {
    case information
    case productDisplay
    case person
    case chart
}

class NavigationManager: ObservableObject {
    func destination(forModel viewModel: ViewModel) -> DynamicView {
        DynamicView(viewModel: generateViewModel(context: viewModel))
    }

    // This is where you generate your next viewModel dynamically.
    // replace the switch statement logic inside with whatever logic you need.
    // DYNAMICALLY MAKE THE VIEWMODEL AND YOU DYNAMICALLY MAKE THE VIEW
    // You could even lead to a view with no navigation link in it, so that would be a dead end, if you wanted it.
    // In my case my "context" is the previous viewMode, by you could make it something else.
    func generateViewModel(context: ViewModel) -> ViewModel {
        switch context.type {
        case .information:
            return ViewModel(message: "Serial Number 123", type: .productDisplay)
        case .productDisplay:
            return ViewModel(message: "Susan", type: .person)
        case .person:
            return ViewModel(message: "Get Information", type: .chart)
        case .chart:
            return ViewModel(message: "Chart goes here. If you don't want the navigation link on this page, you can remove it! Or do whatever you want! It's all dynamic. The point is, the DynamicView can be as dynamic as your model makes it.", type: .information)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
        .environmentObject(NavigationManager())
    }
}

-> деякий Перегляд змушує вас завжди повертати лише один тип перегляду.
Дарко

Ін'єкційна залежність від EnvironmentObject вирішує одну частину проблеми. Але: чи має щось важливе і важливе в рамках інтерфейсу користувача бути таким складним ...?
Дарко

Я маю на увазі - якщо введення залежності є єдиним рішенням для цього, то я б неохоче прийняв це. Але це справді пахне ...
Дарко,

1
Я не бачу, чому ви не могли використовувати це у своєму прикладі рамки. Якщо ви говорите про рамку, яка продає невідомий погляд, я думаю, що це може просто повернути деякий погляд. Я також не був би здивований, якщо AnyView всередині NavigationLink насправді не є великим попереднім хітом, оскільки батьківський вигляд повністю відокремлений від фактичного макета дитини. Я не експерт, але це доведеться перевірити. Замість того, щоб просити всіх про вибір коду там, де вони не можуть повністю зрозуміти ваші вимоги, чому б вам не написати зразок UIKit і не попросити перекладів?
jasongregori

1
Це в основному, як працює додаток (UIKit), над яким я працюю. Створюються моделі, які посилаються на інші моделі. Центральна система визначає, який vc повинен бути завантажений для цієї моделі, а потім батьківський vc висуває його на стек.
jasongregori

2

Я пишу серію публікацій у блозі про створення підходу MVP + Coordinators в SwiftUI, який може бути корисним:

https://lascorbe.com/posts/2020-04-27-MVPCoordinators-SwiftUI-part1/

Повний проект доступний на Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Я намагаюся зробити це так, ніби це було б великим додатком з точки зору масштабованості. Я думаю, що я розібрався з проблемою навігації, але я все ще повинен побачити, як зробити глибоке посилання, над чим я зараз працюю. Я сподіваюся, що це допомагає.


Ого, це чудово, дякую! Ви зробили досить непогану роботу по впровадженню координаторів у SwiftUI. Ідея зробити NavigationViewкореневий погляд є фантастичною. Це, безумовно, найсучасніша реалізація координаторів SwiftUI, яку я бачив на сьогоднішній день.
Дарко

Я хотів би присвоїти вам нагороду лише тому, що ваше рішення Координатора справді чудово. Єдина проблема, яка в мене є, - це насправді не вирішує проблему, яку я описую. Він декупаж, NavigationLinkале це робить це, вводячи нову пов'язану залежність. MasterViewУ вашому прикладі не залежить від NavigationButton. Уявіть, що розміщуєте MasterViewв пакеті Swift - він більше не збиратиметься, оскільки тип NavigationButtonневідомий. Також я не бачу, як проблема вкладеного багаторазового використання Viewsбуде вирішена ним?
Дарко

Я був би радий помилятися, і якщо я є, то, будь ласка, поясніть мені це. Незважаючи на те, що щедрості закінчуються за кілька хвилин, я сподіваюся, що я можу вам якось нагородити бали. (ніколи не робив щедрості раніше, але я думаю, що я можу просто створити подальше запитання з новим?)
Darko

1

Це абсолютно невідповідна відповідь, тому, ймовірно, це виявиться нісенітницею, але я б спокусився використовувати гібридний підхід.

Використовуйте середовище для проходу через єдиний об'єкт координатора - давайте назвати його NavigationCoordinator.

Надайте своїм повторно використовуваним представленням якийсь ідентифікатор, який встановлюється динамічно. Цей ідентифікатор дає семантичну інформацію, що відповідає фактичному випадку використання клієнтської програми та ієрархії навігації.

Запропонуйте переглядати переглянуті користувачі запити на NavigationCoordinator для цільового перегляду, передаючи його ідентифікатор та ідентифікатор типу перегляду, до якого вони переходять.

Це залишає NavigationCoordinator як єдину точку введення, і це об'єкт без перегляду, до якого можна отримати доступ поза ієрархією перегляду.

Під час налаштування ви можете зареєструвати класи правильного перегляду для повернення, використовуючи якесь відповідність з ідентифікаторами, які передаються під час виконання. У деяких випадках може працювати щось таке ж просте, як збіг з ідентифікатором призначення. Або збігання з парою ідентифікаторів хоста та призначення.

У більш складних випадках ви можете написати спеціальний контролер, який враховує іншу інформацію, яка стосується додатків.

Оскільки він вводиться через навколишнє середовище, будь-який вид може замінити за замовчуванням NavigationCoordinator в будь-якій точці і надати інший його підпідглядам.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.