SwiftUI - как избежать жестко закодированной навигации в представлении?

33

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

Еще никто не мог дать мне полный рабочий, готовый к производству ответ.

Как сделать многоразовые представления, в SwiftUIкоторых содержится навигация?

Поскольку SwiftUI NavigationLinkон сильно привязан к представлению, это просто невозможно, чтобы оно масштабировалось и в более крупных приложениях. NavigationLinkДа, в этих небольших примерах приложения работают, но не сразу, если вы хотите повторно использовать несколько представлений в одном приложении. И, возможно, также повторно использовать границы модуля. (например, повторное использование View в iOS, WatchOS и т. д.)

Проблема дизайна: ссылки NavigationLink жестко закодированы в View.

NavigationLink(destination: MyCustomView(item: item))

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

SwiftUI MVVM Координатор / Маршрутизатор / NavigationLink

Идея заключалась в том, чтобы добавить целевые ссылки в многоразовое представление. Обычно идея работает, но, к сожалению, она не масштабируется для реальных производственных приложений. Как только у меня появляется несколько экранов многократного использования, я сталкиваюсь с логической проблемой, что одному повторно используемому представлению ( ViewA) требуется предварительно сконфигурированный view-destination ( ViewB). Но что, если ViewBтакже необходимо предварительно сконфигурированное представление-назначение ViewC? Мне нужно было создать ViewBуже таким образом , что ViewCвпрыскивается уже ViewBперед тем, как вводить ViewBв ViewA. И так далее ... но поскольку данные, которые должны быть переданы в это время, недоступны, вся конструкция не работает.

Еще одна идея, которая у меня была, заключалась в том, чтобы использовать Environmentмеханизм внедрения зависимостей для внедрения мест назначения NavigationLink. Но я думаю, что это следует рассматривать более или менее как взлом, а не масштабируемое решение для больших приложений. В конечном итоге мы бы использовали Среду в основном для всего. Но поскольку Environment также можно использовать только внутри View (не в отдельных координаторах или ViewModels), это, на мой взгляд, снова приведет к созданию странных конструкций.

Как бизнес-логика (например, код модели представления) и представление должны быть разделены, так и навигация и представление должны быть разделены (например, шаблон Координатора). UIKitЭто возможно, потому что мы имеем доступ к представлению UIViewControllerи UINavigationControllerза ним. UIKit'sУ MVC уже была проблема, заключающаяся в том, что он смешал так много понятий, что стал забавным названием «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/

Mecid
источник
Закрытие это хорошая идея, спасибо! Но как это будет выглядеть в иерархии глубокого обзора? Представьте, что у меня есть 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просто структура, которая может быть идентифицирована, может быть чем-то еще (например, id некоторого элемента, как в a 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-назначение 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())
    }
}
MScottWaller
источник
-> Некоторое представление заставляет вас всегда возвращать только один тип просмотра.
Дарко
Внедрение зависимостей с помощью 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бы этим была решена проблема вложенного многоразового использования ?
Дарко
Я был бы счастлив, чтобы быть неправым, и если я тогда, пожалуйста, объясните мне это. Несмотря на то, что щедрость заканчивается через несколько минут, я надеюсь, что смогу как-то наградить вас очками. (никогда не получал награду раньше, но я думаю, что могу просто создать дополнительный вопрос с новым?)
Дарко
1

Это совершенно необычный ответ, так что, вероятно, это будет чепуха, но я бы соблазнился использовать гибридный подход.

Используйте среду для прохождения через один объект-координатор - давайте назовем его NavigationCoordinator.

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

Попросите повторно используемые представления запросить у NavigationCoordinator представление назначения, передав их идентификатор и идентификатор типа представления, к которому они перемещаются.

Это оставляет NavigationCoordinator в качестве единой точки внедрения, и это объект без просмотра, доступ к которому можно получить за пределами иерархии представления.

Во время установки вы можете зарегистрировать правильные классы представления для его возвращения, используя какое-то соответствие с идентификаторами, которые были переданы во время выполнения. В некоторых случаях может сработать что-то простое, например сопоставление с идентификатором назначения. Или сопоставление с парой идентификаторов хоста и назначения.

В более сложных случаях вы можете написать собственный контроллер, который учитывает другую специфическую для приложения информацию.

Поскольку он внедряется через среду, любое представление может переопределить NavigationCoordinator по умолчанию в любой точке и предоставить другое для своих подпредставлений.

Сэм Дин
источник