SwiftUI - jak uniknąć nawigacji zakodowanej na stałe w widoku?

33

Staram się stworzyć architekturę dla większej, gotowej do produkcji aplikacji SwiftUI. Cały czas pracuję nad tym samym problemem, który wskazuje na poważną wadę projektową w SwiftUI.

Nadal nikt nie dał mi pełnej odpowiedzi, gotowej do produkcji.

Jak zrobić widoki wielokrotnego użytku, w SwiftUIktórych znajduje się nawigacja?

Ponieważ SwiftUI NavigationLinkjest to ściśle związane z widokiem, po prostu nie jest to możliwe w taki sposób, że skaluje się również w większych aplikacjach. NavigationLinkw tych małych próbkach działają aplikacje - tak, ale nie tak szybko, jak chcesz ponownie użyć wielu widoków w jednej aplikacji. A może też ponownie wykorzystaj granice modułów. (jak: ponowne użycie Wyświetl w iOS, WatchOS itp.)

Problem projektowy: łącza NavigationLink są zakodowane na stałe w widoku.

NavigationLink(destination: MyCustomView(item: item))

Ale jeśli widok zawierający to NavigationLinkpowinno być wielokrotnego użytku, nie mogę zakodować miejsca docelowego. Musi istnieć mechanizm zapewniający miejsce docelowe. Zapytałem o to tutaj i otrzymałem całkiem dobrą odpowiedź, ale wciąż nie pełną odpowiedź:

SwiftUI MVVM Koordynator / Router / NavigationLink

Pomysł polegał na wprowadzeniu linków docelowych do widoku wielokrotnego użytku. Ogólnie pomysł działa, ale niestety nie można go skalować do prawdziwych aplikacji produkcyjnych. Gdy tylko mam wiele ekranów wielokrotnego użytku, napotykam logiczny problem, że jeden widok wielokrotnego użytku ( ViewA) potrzebuje wstępnie skonfigurowanego miejsca docelowego widoku ( ViewB). Ale co, jeśli ViewBpotrzebuje również wstępnie skonfigurowanego miejsca docelowego widoku ViewC? Musiałbym tworzyć ViewBjuż w taki sposób, który ViewCjest wtryskiwany już ViewBprzed I wstrzyknąć ViewBdo ViewA. I tak dalej .... ale ponieważ dane, które w tym czasie muszą zostać przekazane, nie są dostępne, cała konstrukcja zawodzi.

Innym pomysłem było użycie Environmentmechanizmu wstrzykiwania zależności do wstrzykiwania miejsc docelowych NavigationLink. Myślę jednak, że należy to traktować mniej więcej jako hack, a nie skalowalne rozwiązanie dla dużych aplikacji. Skończylibyśmy na użyciu środowiska w zasadzie do wszystkiego. Ale ponieważ Środowiska można również używać tylko wewnątrz View (nie w osobnych Koordynatorach ani ViewModels), to moim zdaniem ponownie stworzy dziwne konstrukcje.

Podobnie jak logika biznesowa (np. Kod modelu widoku) i widok muszą być oddzielone, również nawigacja i widok muszą być oddzielone (np. Wzorzec koordynatora) UIKitJest to możliwe, ponieważ mamy dostęp do widoku UIViewControlleri UINavigationControllerza nim. UIKit'sMVC miał już problem, że zebrał tak wiele koncepcji, że stał się zabawną nazwą „Massive-View-Controller” zamiast „Model-View-Controller”. Teraz podobny problem utrzymuje się, SwiftUIale jeszcze gorzej, moim zdaniem. Nawigacja i widoki są silnie powiązane i nie można ich rozdzielić. Dlatego nie można tworzyć widoków wielokrotnego użytku, jeśli zawierają nawigację. Możliwe było rozwiązanie tego problemu, UIKitale teraz nie widzę rozsądnego rozwiązaniaSwiftUI. Niestety Apple nie wyjaśniło nam, jak rozwiązać takie problemy architektoniczne. Mamy tylko kilka przykładowych aplikacji.

Chciałbym, aby udowodniono, że się mylę. Pokaż mi czysty wzór projektowania aplikacji, który rozwiązuje ten problem w przypadku dużych aplikacji gotowych do produkcji.

Z góry dziękuję.


Aktualizacja: ta nagroda skończy się za kilka minut i niestety nadal nikt nie był w stanie podać działającego przykładu. Ale zacznę nową nagrodę, aby rozwiązać ten problem, jeśli nie mogę znaleźć innego rozwiązania i połączyć go tutaj. Dziękujemy wszystkim za ich wspaniały wkład!

Darko
źródło
1
Zgoda! Poprosiłem
Sajjon
@Sajjon Thanks! Zamierzam także napisać Apple, zobaczmy, czy dostanę odpowiedź.
Darko
1
Napisał do tego list do Apple. Zobaczmy, czy otrzymamy odpowiedź.
Darko
1
Miły! Zdecydowanie byłby to najlepszy prezent podczas WWDC!
Sajjon

Odpowiedzi:

10

Zamknięcie to wszystko czego potrzebujesz!

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

Napisałem post o zamianie wzoru delegowanego w SwiftUI na zamknięcia. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Mecid
źródło
Zamknięcie to dobry pomysł, dzięki! Ale jak by to wyglądało w hierarchii głębokiego widzenia? Wyobraź sobie, że mam NavigationView, który idzie o 10 poziomów głębiej, szczegółowo, w szczegóły, w szczegóły itp.
Darko,
Chciałbym zaprosić cię do pokazania prostego przykładowego kodu o głębokości zaledwie trzech poziomów.
Darko
7

Moim pomysłem byłoby połączenie Coordinatori Delegatewzór. Najpierw utwórz Coordinatorklasę:


struct Coordinator {
    let window: UIWindow

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

Dostosuj SceneDelegatedo użycia 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()
        }
    }

Wewnątrz ContentViewmamy to:


struct ContentView: View {
    var delegate: ContentViewDelegate?

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

Możemy zdefiniować ContenViewDelegateprotokół w następujący sposób:

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

Gdzie Itemjest tylko struktura, która jest możliwa do zidentyfikowania, może to być cokolwiek innego (np. Identyfikator jakiegoś elementu, np. TableViewW UIKit)

Następnym krokiem jest przyjęcie tego protokołu Coordinatori po prostu przekazanie widoku, który chcesz przedstawić:

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

Do tej pory działało to dobrze w moich aplikacjach. Mam nadzieję, że to pomoże.

Nikola Matijevic
źródło
Dzięki za przykładowy kod. Chciałbym zaprosić cię do zmiany Text("Returned Destination1")na coś podobnego MyCustomView(item: ItemType, destinationView: View). To MyCustomViewtakże wymaga podania niektórych danych i miejsca docelowego. Jak byś to rozwiązał?
Darko
Napotykasz problem zagnieżdżenia, który opisuję w moim poście. Proszę, popraw mnie jeśli się mylę. Zasadniczo to podejście działa, jeśli masz jeden widok wielokrotnego użytku, a ten widok wielokrotnego użytku nie zawiera innego widoku wielokrotnego użytku z NavigationLink. Jest to dość prosty przypadek użycia, ale nie można go skalować do dużych aplikacji. (gdzie prawie każdy widok jest wielokrotnego użytku)
Darko
Zależy to w dużej mierze od sposobu zarządzania zależnościami aplikacji i ich przepływem. Jeśli masz zależności w jednym miejscu, podobnie jak IMO (znany również jako root root), nie powinieneś napotykać tego problemu.
Nikola Matijevic
To, co działa dla mnie, to zdefiniowanie wszystkich twoich zależności dla widoku jako protokołu. Dodaj zgodność do protokołu w katalogu głównym kompozycji. Przekaż koordynatorowi zależności. Wstrzyknij je koordynatorowi. Teoretycznie powinieneś otrzymać więcej niż trzy parametry, jeśli zrobione poprawnie nigdy nie więcej niż dependenciesi destination.
Nikola Matijevic
1
Chciałbym zobaczyć konkretny przykład. Jak już wspomniałem, zacznijmy od Text("Returned Destination1"). Co jeśli to musi być MyCustomView(item: ItemType, destinationView: View). Co tam zamierzasz wstrzyknąć? Rozumiem wstrzykiwanie zależności, luźne sprzęganie przez protokoły i wspólne zależności z koordynatorami. Wszystko to nie stanowi problemu - to potrzebne zagnieżdżanie. Dzięki.
Darko
2

Coś, co przychodzi mi do głowy, to to, że kiedy mówisz:

Ale co, jeśli ViewB potrzebuje również wstępnie skonfigurowanego ViewC-View ViewC? Musiałem utworzyć ViewB już w taki sposób, że ViewC jest wstrzykiwany już w ViewB, zanim wstrzyknę ViewB do ViewA. I tak dalej .... ale ponieważ dane, które w tym czasie muszą zostać przekazane, nie są dostępne, cała konstrukcja zawodzi.

to nie do końca prawda. Zamiast dostarczać widoki, możesz zaprojektować komponenty wielokrotnego użytku, aby dostarczyć zamknięcia, które dostarczają widoki na żądanie.

W ten sposób zamknięcie, które produkuje ViewB na żądanie, może zapewnić zamknięcie, które produkuje ViewC na żądanie, ale faktyczna konstrukcja widoków może się zdarzyć w czasie, gdy potrzebne są potrzebne informacje kontekstowe.

Sam Deane
źródło
Ale czym różni się tworzenie takiego „drzewa zamknięcia” od rzeczywistych poglądów? Problem z dostarczaniem przedmiotu zostałby rozwiązany, ale nie potrzebne zagnieżdżanie. Tworzę zamknięcie, które tworzy widok - ok. Ale w tym zamknięciu musiałbym już zapewnić utworzenie kolejnego zamknięcia. A w ostatnim następnym. Itd ... ale może źle cię zrozumiałem. Pomógłby w tym przykład kodu. Dzięki.
Darko
2

Oto zabawny przykład wiercenia w nieskończoność i programowej zmiany danych do następnego widoku szczegółów

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
źródło
-> niektóre Widok zmusza Cię do zwrócenia tylko jednego rodzaju Widoku.
Darko
Wstrzyknięcie zależności za pomocą EnvironmentObject rozwiązuje jedną część problemu. Ale: czy coś kluczowego i ważnego w ramach interfejsu użytkownika powinno być tak złożone ...?
Darko
Mam na myśli - jeśli zastrzyk zależności jest jedynym rozwiązaniem tego problemu, niechętnie to zaakceptuję. Ale to naprawdę pachnie ...
Darko,
1
Nie rozumiem, dlaczego nie możesz tego użyć w swoim przykładzie frameworka. Jeśli mówisz o frameworku, który zmienia nieznany widok, wyobrażam sobie, że może on po prostu zwrócić widok. Nie zdziwiłbym się również, gdyby AnyView wewnątrz NavigationLink nie był tak wielkim hitem wstępnym, ponieważ widok rodzica jest całkowicie oddzielony od faktycznego układu dziecka. Nie jestem ekspertem, trzeba by to przetestować. Zamiast pytać wszystkich o przykładowy kod, w którym nie mogą w pełni zrozumieć Twoich wymagań, dlaczego nie napiszesz próbki UIKit i nie poprosisz o tłumaczenie?
jasongregori
1
Ten projekt jest w zasadzie jak działa aplikacja (UIKit), nad którą pracuję. Generowane są modele, które łączą się z innymi modelami. System centralny określa, jaki vc powinien zostać załadowany dla tego modelu, a następnie vc macierzysty wypycha go na stos.
jasongregori
2

Piszę serię postów na blogu o tworzeniu podejścia MVP + Coordinators w SwiftUI, które może być przydatne:

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

Pełny projekt jest dostępny na Github: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator

Próbuję to zrobić, jakby była to duża aplikacja pod względem skalowalności. Wydaje mi się, że rozwiązałem problem z nawigacją, ale wciąż muszę zobaczyć, jak wykonać głębokie linkowanie, nad czym obecnie pracuję. Mam nadzieję, że to pomoże.

Luis Ascorbe
źródło
Wow, to wspaniale, dziękuję! Wykonałeś całkiem dobrą robotę, wdrażając koordynatorów w SwiftUI. Pomysł wykonania NavigationViewwidoku głównego jest fantastyczny. To zdecydowanie najbardziej zaawansowana implementacja SwiftUI Coordinators, jaką do tej pory widziałem.
Darko
Chciałbym przyznać ci nagrodę tylko dlatego, że twoje rozwiązanie koordynatora jest naprawdę świetne. Jedyny problem, jaki mam - tak naprawdę nie rozwiązuje problemu, który opisuję. Odsprzęga, NavigationLinkale robi to poprzez wprowadzenie nowej zależności sprzężonej. W MasterViewtwoim przykładzie nie zależy od NavigationButton. Wyobraź sobie umieszczenie MasterVieww Szybkim Pakiecie - nie będzie się już kompilować, ponieważ ten typ NavigationButtonjest nieznany. Nie rozumiem też, w jaki sposób można Viewsby przez to rozwiązać problem zagnieżdżonego wielokrotnego użytku ?
Darko
Byłbym szczęśliwy, że się mylę, a jeśli tak, to proszę, wytłumacz mi to. Mimo że nagroda skończy się za kilka minut, mam nadzieję, że uda mi się jakoś przyznać ci punkty. (nigdy wcześniej nie otrzymałem nagrody, ale myślę, że mogę po prostu stworzyć nowe pytanie?)
Darko
1

To jest całkowicie nierealna odpowiedź, więc prawdopodobnie okaże się nonsensem, ale skusiłbym się na podejście hybrydowe.

Użyj środowiska, aby przejść przez pojedynczy obiekt koordynatora - nazwijmy go NavigationCoordinator.

Daj swoim widokom wielokrotnego użytku jakiś identyfikator, który jest ustawiany dynamicznie. Ten identyfikator podaje informacje semantyczne odpowiadające rzeczywistemu przypadkowi użycia aplikacji i hierarchii nawigacji.

Poproś, aby widoki wielokrotnego użytku wysłały zapytanie do NavigationCoordinator o widok docelowy, przekazując swój identyfikator i identyfikator typu widoku, do którego nawigują.

Pozostawia to NavigationCoordinator jako pojedynczy punkt iniekcji i jest to obiekt niewidoczny, do którego można uzyskać dostęp poza hierarchią widoków.

Podczas instalacji możesz zarejestrować odpowiednie klasy widoku, aby mógł zwrócić, używając pewnego rodzaju dopasowania z identyfikatorami przekazywanymi w czasie wykonywania. W niektórych przypadkach może działać coś tak prostego jak dopasowanie do identyfikatora docelowego. Lub dopasowanie do pary identyfikatorów hosta i miejsca docelowego.

W bardziej skomplikowanych przypadkach możesz napisać niestandardowy kontroler, który uwzględnia inne informacje specyficzne dla aplikacji.

Ponieważ jest wstrzykiwany przez środowisko, każdy widok może w dowolnym momencie zastąpić domyślnego Koordynatora Nawigacji i podać inny do swoich widoków podrzędnych.

Sam Deane
źródło