SwiftUI - как избежать жестко закодированной навигации в представлении? - PullRequest
34 голосов
/ 19 апреля 2020

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

Тем не менее никто не мог дать мне полный рабочий, готовый к работе ответ.

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

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

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

NavigationLink(destination: MyCustomView(item: item))

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

SwiftUI MVVM Coordinator / Router / NavigationLink

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

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

Как бизнес-логика c (например, модель представления). код) и представление должны быть разделены, также навигация и представление должны быть разделены (например, шаблон Координатора). В UIKit это возможно, потому что мы получаем доступ к UIViewController и UINavigationController позади представления. UIKit's MVC уже имела проблему, заключающуюся в том, что она смешала столько понятий, что вместо забавного «Model-View-Controller» она стала забавным названием «Massive-View-Controller». Теперь аналогичная проблема сохраняется в SwiftUI, но, на мой взгляд, еще хуже. Навигация и Виды сильно связаны и не могут быть отделены. Поэтому невозможно сделать повторно используемые представления, если они содержат навигацию. Это было возможно решить в UIKit, но сейчас я не вижу нормального решения в SwiftUI. К сожалению, Apple не предоставила нам объяснения, как решать подобные архитектурные проблемы. У нас есть только несколько небольших примеров приложений.

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

Заранее спасибо.


Обновление: эта награда закончится через несколько минут, и, к сожалению, до сих пор никто не смог привести рабочий пример. Но я начну новую награду, чтобы решить эту проблему, если я не могу найти другое решение и связать его здесь. Спасибо всем за большой вклад!

Ответы [ 7 ]

10 голосов
/ 23 апреля 2020

Закрытие - это все, что вам нужно!

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/

7 голосов
/ 23 апреля 2020

Моей идеей было бы сочетание комбинаций 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 некоторого элемента как в TableView в UIKit)

Следующий шаг - принять этот протокол в Coordinator и просто передать представление, которое вы хотите представить:

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

До сих пор это работало приятно в моих приложениях. Надеюсь, это поможет.

2 голосов
/ 29 апреля 2020

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

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

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

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

2 голосов
/ 23 апреля 2020

Вот забавный пример бесконечной детализации и программного изменения данных для следующего подробного просмотра

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())
    }
}
2 голосов
/ 23 апреля 2020

Что-то, что приходит мне в голову, заключается в том, что когда вы говорите:

Но что, если ViewB также требуется предварительно сконфигурированный View-target View C? Мне нужно было бы создать ViewB уже таким образом, чтобы View C вводился уже в ViewB, прежде чем я добавлю ViewB в ViewA. И так далее ... но поскольку данные, которые должны быть переданы в это время, недоступны, вся конструкция терпит неудачу.

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

Таким образом, замыкание, которое создает ViewB по требованию, может снабжать его замыканием, которое создает View * 1012. * по требованию, но фактическое построение представлений может произойти в тот момент, когда вам доступна контекстная информация.

1 голос
/ 23 апреля 2020

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

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

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

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

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

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

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

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

0 голосов
/ 07 мая 2020

Я постараюсь ответить на ваши вопросы один за другим. Я буду следовать небольшому примеру, где наше представление, которое должно быть многократно использовано, представляет собой простой View, который показывает Text и NavigationLink, который будет go до некоторого Destination. Я создал Gist: SwiftUI - Гибкая навигация с координаторами , если вы хотите взглянуть на мой полный пример.

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

В вашем примере он привязан к представлению, но, как уже показывали другие ответы, вы можете добавить пункт назначения в свой тип представления struct MyView<Destination: View>: View. Вы можете использовать любой тип, соответствующий View в качестве пункта назначения.

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

С изменением, приведенным выше, существуют механизмы для предоставления типа. Один пример:

struct BoldTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .bold()
    }
}
struct NotReusableTextView: View {
    var text: String

    var body: some View {
        VStack {
            Text(text)
            NavigationLink("Link", destination: BoldTextView(text: text))
        }
    }
}

изменится на

struct ReusableNavigationLinkTextView<Destination: View>: View {
    var text: String
    var destination: () -> Destination

    var body: some View {
        VStack {
            Text(text)

            NavigationLink("Link", destination: self.destination())
        }
    }
}

, и вы можете передать пункт назначения следующим образом:

struct BoldNavigationLink: View {
    let text = "Text"
    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: { BoldTextView(text: self.text) }
        )
    }
}

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

Ну, очевидно, вам нужен какой-то лог c, который определит ваш Destination. В какой-то момент вам нужно сообщить представлению, какое представление будет следующим. Я предполагаю, что вы пытаетесь избежать этого:

struct NestedMainView: View {
    @State var text: String

    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: {
                ReusableNavigationLinkTextView(
                    text: self.text,
                    destination: {
                        BoldTextView(text: self.text)
                    }
                )
            }
        )
    }
}

Я собрал простой пример, который использует Coordinator s для передачи зависимостей и создания представлений. Существует протокол для Координатора, и вы можете реализовать определенные c варианты использования, основанные на этом.

protocol ReusableNavigationLinkTextViewCoordinator {
    associatedtype Destination: View
    var destination: () -> Destination { get }

    func createView() -> ReusableNavigationLinkTextView<Destination>
}

Теперь мы можем создать определенный c Координатор, который будет отображать BoldTextView при нажатии на NavigationLink.

struct ReusableNavigationLinkShowBoldViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String

    var destination: () -> BoldTextView {
        { return BoldTextView(text: self.text) }
    }

    func createView() -> ReusableNavigationLinkTextView<Destination> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}

Если вы хотите, вы также можете использовать Coordinator для реализации пользовательских логи c, которые определяют пункт назначения вашего представления. Следующий координатор показывает ItalicTextView после четырех щелчков по ссылке.

struct ItalicTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .italic()
    }
}
struct ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String
    let number: Int
    private var isNumberGreaterThan4: Bool {
        return number > 4
    }

    var destination: () -> AnyView {
        {
            if self.isNumberGreaterThan4 {
                let coordinator = ItalicTextViewCoordinator(text: self.text)
                return AnyView(
                    coordinator.createView()
                )
            } else {
                let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(
                    text: self.$text,
                    number: self.number + 1
                )
                return AnyView(coordinator.createView())
            }
        }
    }

    func createView() -> ReusableNavigationLinkTextView<AnyView> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}

Если у вас есть данные, которые необходимо передать, создайте другого координатора вокруг другого координатора для хранения значения. В этом примере у меня есть TextField -> EmptyView -> Text, где значение из TextField должно быть передано Text.. EmptyView не должно иметь эту информацию.

struct TextFieldView<Destination: View>: View {
    @Binding var text: String
    var destination: () -> Destination

    var body: some View {
        VStack {
            TextField("Text", text: self.$text)

            NavigationLink("Next", destination: self.destination())
        }
    }
}

struct EmptyNavigationLinkView<Destination: View>: View {
    var destination: () -> Destination

    var body: some View {
        NavigationLink("Next", destination: self.destination())
    }
}

Это координатор, который создает представления путем вызова других координаторов (или создает сами представления). Он передает значение от TextField до Text, а EmptyView не знает об этом.

struct TextFieldEmptyReusableViewCoordinator {
    @Binding var text: String

    func createView() -> some View {
        let reusableViewBoldCoordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        let reusableView = reusableViewBoldCoordinator.createView()

        let emptyView = EmptyNavigationLinkView(destination: { reusableView })

        let textField = TextFieldView(text: self.$text, destination: { emptyView })

        return textField
    }
}

Чтобы обернуть все это, вы также можете создать MainView, который имеет некоторые logi c, который решает, какой View / Coordinator следует использовать.

struct MainView: View {
    @State var text = "Main"

    var body: some View {
        NavigationView {
            VStack(spacing: 32) {
                NavigationLink("Bold", destination: self.reuseThenBoldChild())
                NavigationLink("Reuse then Italic", destination: self.reuseThenItalicChild())
                NavigationLink("Greater Four", destination: self.numberGreaterFourChild())
                NavigationLink("Text Field", destination: self.textField())
            }
        }
    }

    func reuseThenBoldChild() -> some View {
        let coordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func reuseThenItalicChild() -> some View {
        let coordinator = ReusableNavigationLinkShowItalicViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func numberGreaterFourChild() -> some View {
        let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(text: self.$text, number: 1)
        return coordinator.createView()
    }

    func textField() -> some View {
        let coordinator = TextFieldEmptyReusableViewCoordinator(text: self.$text)
        return coordinator.createView()
    }
}

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

Кстати, это очень похоже на то, как я использовал Coordinator в приложениях Swift UIKit.

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

...