SwiftUI-ビューにハードコードされたナビゲーションを回避する方法は?


33

私は、より大きな、本番環境対応のSwiftUIアプリのアーキテクチャーを作ろうとしています。私はいつもSwiftUIの主要な設計上の欠陥を指摘する同じ問題に遭遇しています。

それでも、誰も私に完全に機能する、生産準備の整った答えを与えることができませんでした。

SwiftUIナビゲーションを含む再利用可能なビューを行う方法は?

以下のようSwiftUI NavigationLink強くビューにバインドされ、これは、それが大きなアプリにもスケールするように、単純に不可能です。NavigationLinkこれらの小さなサンプルアプリでは機能します、はい。また、モジュールの境界を越えて再利用することもできます。(例:iOS、WatchOSなどでViewを再利用する...)

設計上の問題:NavigationLinksはビューにハードコードされています。

NavigationLink(destination: MyCustomView(item: item))

しかし、これNavigationLinkを含むビューが再利用可能である場合、宛先をハードコーディングすることはできません。宛先を提供するメカニズムが必要です。私はここでこれを尋ね、かなり良い答えを得ましたが、それでも完全な答えではありません:

SwiftUI MVVMコーディネーター/ルーター/ NavigationLink

アイデアは、宛先リンクを再利用可能なビューに挿入することでした。一般にこのアイデアは機能しますが、残念ながら、これは実際のプロダクションアプリに拡張できません。複数の再利用可能な画面が表示されるとすぐに、1つの再利用可能なビュー(ViewA)に事前構成されたビューの宛先(ViewB)が必要であるという論理的な問題が発生します。しかしViewB、事前に構成されたビューの宛先も必要な場合はどうViewCでしょうか。に注入する前に、すでに注入されてViewBいる方法で作成する必要があります。などなど...しかし、そのときに渡されなければならないデータが利用できないため、構造全体が失敗します。ViewCViewBViewBViewA

私が持っていたもう1つのアイデアはEnvironment、依存関係注入メカニズムとしてを使用しての宛先を注入することでしたNavigationLink。しかし、これは多かれ少なかれハックとして考える必要があり、大規模なアプリのスケーラブルなソリューションではないと思います。基本的にはすべてに環境を使用することになります。しかし、EnvironmentもView内でのみ使用できるため(別個のCoordinatorsやViewModelsでは使用できません)、これもまた、奇妙な構造を作成すると思います。

ビジネスロジック(例えばビューモデルコード)とビューのようにもナビゲーションを分離する必要があり、(例えばコーディネーターパターン)を分離する必要がビューUIKit我々がアクセスするため、それが可能だUIViewControllerUINavigationControllerビューの後ろに。UIKit'sMVCには、「Model-View-Controller」ではなく「Massive-View-Controller」というおもしろい名前になるほど多くの概念がまとまってしまうという問題がすでにありました。現在、同様の問題が続いてSwiftUIいますが、私の意見ではさらに悪化しています。ナビゲーションとビューは強く結合されており、分離することはできません。したがって、ナビゲーションが含まれている場合、再利用可能なビューを実行することはできません。これを解決することは可能でしたUIKitが、今では正気な解決策を見ることができませんSwiftUI。残念ながら、Appleはそのようなアーキテクチャ上の問題を解決する方法を説明しませんでした。いくつかの小さなサンプルアプリを入手しました。

私は間違っていると証明されたいです。大規模な本番環境対応アプリでこれを解決するクリーンなアプリデザインパターンを見せてください。

前もって感謝します。


更新:この賞金は数分で終了しますが、残念ながら、まだ誰も実例を提供できませんでした。しかし、他の解決策が見つからず、ここにリンクできない場合は、この問題を解決するための新しい報奨金を開始します。彼らの多大な貢献に感謝します!


1
同意しました!私は「フィードバックアシスタント」で、この要求を作成し、多くのヶ月前、まだ応答がありません:gist.github.com/Sajjon/b7edb4cc11bcb6462f4e28dc170be245
Sajjon

@Sajjonありがとう!私もAppleを書くつもりです。返答があるかどうか見てみましょう。
Darko

1
これについては、アップル社に手紙を書いた。応答があるかどうか見てみましょう。
Darko

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/


閉鎖は良い考えです、ありがとう!しかし、それは深いビュー階層ではどのように見えるでしょうか?...私は10のレベルより深く、ディテール、細部に、詳細に、等を行くNavigationViewを持っている想像して
ダルコ

たった3レベルの深さの簡単なコード例を紹介してください。
Darko

7

私のアイデアは、ほぼCoordinatorDelegateパターンの組み合わせになります。まず、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のような要素のID )。

次のステップは、このプロトコルを採用Coordinatorして、提示したいビューを渡すだけです。

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

これまでのところ、私のアプリではうまく機能しています。お役に立てば幸いです。


サンプルコードをありがとう。のようなText("Returned Destination1")ものに変えてもらいたいと思いますMyCustomView(item: ItemType, destinationView: View)。そのMyCustomViewため、データと宛先の挿入も必要です。どのように解決しますか?
Darko

私の投稿で説明している入れ子の問題が発生しました。私が間違っていたら訂正してください。基本的に、このアプローチは、1つの再利用可能なビューがあり、その再利用可能なビューにNavigationLinkを使用した別の再利用可能なビューが含まれていない場合に機能します。これは非常にシンプルなユースケースですが、大きなアプリには対応していません。(ほとんどすべてのビューが再利用可能です)
Darko

これは、アプリの依存関係とそのフローの管理方法に大きく依存します。IMO(コンポジションルートとも呼ばれる)のように依存関係が1か所にある場合は、この問題に遭遇しないでください。
Nikola Matijevic

私にとってうまくいくのは、ビューのすべての依存関係をプロトコルとして定義することです。コンポジションルートのプロトコルに準拠を追加します。依存関係をコーディネーターに渡します。それらをコーディネーターから挿入します。理論的には、4つ以上のパラメーターで終了する必要がdependenciesありdestinationます。
Nikola Matijevic

1
具体的な例を見てみたいです。すでに述べたように、から始めましょうText("Returned Destination1")。これが何をする必要がある場合MyCustomView(item: ItemType, destinationView: View)。何を注射するの?依存関係の注入、プロトコルによる疎結合、コーディネーターとの依存関係の共有について理解しています。それはすべて問題ではありません-それは必要な入れ子です。ありがとう。
ダーコ

2

私に起こることは、あなたが言うとき:

しかし、ViewBが事前構成されたビュー先のViewCも必要とする場合はどうでしょうか。ViewBをViewAに挿入する前に、ViewCがすでにViewBに挿入されているような方法でViewBを作成する必要があります。などなど...しかし、そのときに渡されなければならないデータが利用できないため、構造全体が失敗します。

それは全く真実ではありません。ビューを提供するのではなく、再利用可能なコンポーネントを設計して、オンデマンドでビューを提供するクロージャーを提供できます。

このようにして、オンデマンドでViewBを生成するクロージャは、オンデマンドでViewCを生成するクロージャを提供できますが、ビューの実際の構築は、必要なコンテキスト情報が利用可能なときに発生する可能性があります。


しかし、そのような「閉鎖ツリー」の作成は実際の見方とどう違うのでしょうか。アイテム提供の問題は解決されますが、必要なネストは行われません。ビューを作成するクロージャーを作成します-わかりました。しかし、その閉鎖では、次の閉鎖の作成を提供する必要があります。そして最後に次の。等...しかし、あなたを誤解しているかもしれません。いくつかのコード例が役立ちます。ありがとう。
Darko

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())
    }
}

->一部のビューでは、常に1つのタイプのビューのみを返すように強制されます。
Darko

EnvironmentObjectを使用した依存関係の注入により、問題の一部が解決されます。しかし:UIフレームワークで重要かつ重要な何かはとても複雑でなければなりません...?
Darko

つまり、依存性注入がこれに対する唯一の解決策である場合、私はしぶしぶそれを受け入れます。しかし、これは本当ににおいがします...
Darko

1
フレームワークの例でこれを使用できなかった理由はわかりません。未知のビューを提供するフレームワークについて話している場合、それは単にビューを返すことができると想像します。また、親ビューが子の実際のレイアウトから完全に分離されているため、NavigationLink内のAnyViewが実際にはそれほど大きな影響を受けていない場合も、驚かないでしょう。私は専門家ではありませんが、テストする必要があります。要件を完全に理解できないサンプルコードを全員に尋ねる代わりに、UIKitサンプルを作成して翻訳を依頼してみませんか?
jasongregori

1
このデザインは基本的に、私が取り組んでいる(UIKit)アプリがどのように機能するかです。他のモデルにリンクするモデルが生成されます。中央システムは、そのモデルにどのvcをロードするかを決定し、親vcがそれをスタックにプッシュします。
jasongregori

2

私はSwiftUIでMVP +コーディネーターアプローチを作成することに関するブログ投稿シリーズを書いています。

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

完全なプロジェクトはGithubで入手できます:https : //github.com/Lascorbe/SwiftUI-MVP-Coordinator

スケーラビリティの点で大きなアプリであるかのようにそれをやろうとしています。ナビゲーションの問題は解決したと思いますが、現在取り組んでいるディープリンクの方法を確認する必要があります。お役に立てば幸いです。


すごいですね、ありがとうございます!SwiftUIでのコーディネーターの実装については、非常に優れています。NavigationViewルートビューを作成するというアイデアは素晴らしいです。これは、私がこれまで見た中で最も進んだSwiftUIコーディネーターの実装です。
ダーコ

あなたのコーディネーターソリューションが本当に素晴らしいからといって、賞金を授与したいと思います。私が持っている唯一の問題-それは私が説明する問題に実際には対処していません。それは分離しますNavigationLinkが、新しい結合された依存関係を導入することによってそうします。MasterViewあなたの例では、に依存しませんNavigationButtonMasterViewSwiftパッケージに配置することを想像してください-タイプNavigationButtonが不明なため、コンパイルできなくなります。また、ネストされた再利用可能な問題がどのViewsように解決されるかわかりませんか?
ダーコ

私は間違っていて喜んでいます。もしそうなら、私にそれを説明してください。賞金が数分でなくなりますが、どういうわけかポイントを獲得できればと思います。(これまでに賞金を受け取ったことはありませんが、新しい質問でフォローアップの質問を作成できると思いますか?)
Darko

1

これは完全に頭から離れた答えなので、おそらくナンセンスになりますが、ハイブリッドアプローチを使用したくなります。

環境を使用して、単一のコーディネーターオブジェクトを通過させます。これをNavigationCoordinatorと呼びます。

再利用可能なビューに、動的に設定されるある種の識別子を与えます。この識別子は、クライアントアプリケーションの実際の使用例とナビゲーション階層に対応する意味情報を提供します。

再利用可能なビューに宛先ビューのNavigationCoordinatorを照会させ、それらのIDと、ナビゲート先のビュータイプのIDを渡します。

これにより、NavigationCoordinatorは単一の注入ポイントとして残り、ビュー階層の外からアクセスできる非ビューオブジェクトになります。

セットアップ時に、実行時に渡される識別子とのなんらかのマッチングを使用して、返される正しいビュークラスを登録できます。場合によっては、宛先IDとのマッチングと同じくらい簡単なものが機能することがあります。または、ホストと宛先の識別子のペアと照合します。

より複雑なケースでは、他のアプリ固有の情報を考慮したカスタムコントローラーを作成できます。

これは環境を介して挿入されるため、ビューはいつでもデフォルトのNavigationCoordinatorをオーバーライドして、そのサブビューに別のビューを提供できます。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.