Глубокая программная навигация SwiftUI NavigationView

Я пытаюсь привести в порядок стек глубокой вложенной программной навигации. Следующий код работает должным образом, когда навигация выполняется вручную (то есть при нажатии ссылок). Когда вы нажимаетеSet Nav кнопка, стек навигации действительно изменяется - но не так, как ожидалось - и вы получаете сломанный стек [start -> b -> bbb] с большим переключением между видами

class NavState: ObservableObject {
    @Published var firstLevel: String? = nil
    @Published var secondLevel: String? = nil
    @Published var thirdLevel: String? = nil
}

struct LandingPageView: View {

    @ObservedObject var navigationState: NavState

    func resetNav() {
        self.navigationState.firstLevel = "b"
        self.navigationState.secondLevel = "ba"
        self.navigationState.thirdLevel = "bbb"
    }

    var body: some View {

        return NavigationView {
            List {
                NavigationLink(
                    destination: Place(
                        text: "a",
                        childValues: [ ("aa", [ "aaa"]) ],
                        navigationState: self.navigationState
                    ).navigationBarTitle("a"),
                    tag: "a",
                    selection: self.$navigationState.firstLevel
                ) {
                    Text("a")
                }
                NavigationLink(
                    destination: Place(
                        text: "b",
                        childValues: [ ("bb", [ "bbb"]), ("ba", [ "baa", "bbb" ]) ],
                        navigationState: self.navigationState
                    ).navigationBarTitle("b"),
                    tag: "b",
                    selection: self.$navigationState.firstLevel
                ) {
                    Text("b")
                }

                Button(action: self.resetNav) {
                    Text("Set Nav")
                }
            }
            .navigationBarTitle("Start")
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}


struct Place: View {
    var text: String
    var childValues: [ (String, [String]) ]

    @ObservedObject var navigationState: NavState

    var body: some View {
        List(childValues, id: \.self.0) { childValue in
            NavigationLink(
                destination: NextPlace(
                    text: childValue.0,
                    childValues: childValue.1,
                    navigationState: self.navigationState
                ).navigationBarTitle(childValue.0),
                tag: childValue.0,
                selection: self.$navigationState.secondLevel
            ) {
                Text(childValue.0)
            }
        }
    }
}

struct NextPlace: View {
    var text: String
    var childValues: [String]

    @ObservedObject var navigationState: NavState

    var body: some View {
        List(childValues, id: \.self) { childValue in
            NavigationLink(
                destination: FinalPlace(
                    text: childValue,
                    navigationState: self.navigationState
                ).navigationBarTitle(childValue),
                tag: childValue,
                selection: self.$navigationState.thirdLevel
            ) {
                Text(childValue)
            }
        }
    }
}

struct FinalPlace: View {
    var text: String
    @ObservedObject var navigationState: NavState

    var body: some View {
        let concat: String = "\(navigationState.firstLevel)/\(navigationState.secondLevel))/\(navigationState.thirdLevel)/"

        return VStack {
            Text(text)
            Text(concat)
        }
    }
}

Первоначально я пытался решить анимацию перехода навигации как источник проблемы, но как отключить анимацию push и pop анимации NavigationView предполагает, что это не настраивается

Есть ли какие-нибудь нормальные примеры программной навигации>1 уровня?

Изменить: часть того, что я ищу здесь, также является начальным состоянием для правильной работы навигации - если я прихожу из внешнего контекста с состоянием навигации, которое я хочу отразить (то есть: из уведомления с некоторым контекстом в приложении для запуска из или из состояния, закодированного на диск), то я бы ожидал, что смогу загрузить верхнийViewс навигацией, правильно указывающей на правый дочерний вид. По сути - заменитьnilс в NavStateс реальными ценностями. Qt QML и ReactRouter могут делать это декларативно - SwiftUI тоже должен это делать.

3 ответа

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

Со следующей модификацией работает:

func resetNav() {
    self.navigationState.firstLevel = "b"
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        self.navigationState.secondLevel = "ba"
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.navigationState.thirdLevel = "bbb"
        }
    }
}

Помещаю здесь свое собственное решение в качестве варианта на тот случай, если кто-то выполняет те же требования к навигации, что и NavigationViewпохоже, не может выполнить. В моей книге - функциональность исправить намного сложнее, чем графические элементы и анимацию. На основе https://medium.com/swlh/swiftui-custom-navigation-view-for-your-applications-7f6effa7dbcf

Он основан на идее единой декларативной переменной на корневом уровне, определяющей стек навигации - привязка к отдельным переменным кажется странной, когда такая операция, как переход с a цепь к b цепь в моей демонстрации выше или требуется начало с определенной позиции

Я использовал концепцию строкового пути, такого как URI, в качестве концепции переменной - ее, вероятно, можно было бы заменить более выразительной моделью (например, вектором enums)

Большие предостережения - это очень грубо, без анимации, вообще не выглядит родным, использует AnyView, вы не можете иметь одно и то же имя узла более одного раза, только отражает StackNavigationViewStyleи т. д. - если я сделаю что-то более красивое, разумное и универсальное, я помещу это в Gist.

struct NavigationElement {
    var tag: String
    var title: String
    var viewBuilder: () -> AnyView
}

class NavigationState: ObservableObject {
    let objectWillChange = ObservableObjectPublisher()

    var stackPath: [String] {
        willSet {
            if rectifyRootElement(path: newValue) {
                _stackPath = newValue
                rectifyStack(path: newValue, elements: _stackElements)
            }
        }
    }

    /// Temporary storage for the most current stack path during transition periods
    private var _stackPath: [String] = []

    @Published var stack: [NavigationElement] = []

    var stackElements: [String : NavigationElement] = [:] {
        willSet {
            _stackElements = newValue
            rectifyStack(path: _stackPath, elements: newValue)
        }
    }

    /// Temporary storage for the most current stack elements during transition periods
    private var _stackElements: [String : NavigationElement] = [:]

    init(initialPath: [String] = []) {
        self.stackPath = initialPath
        rectifyRootElement(path: initialPath)
        _stackPath = self.stackPath
    }

    @discardableResult func rectifyRootElement(path: [String]) -> Bool {
        // Rectify root state if set from outside
        if path.first != "" {
            stackPath = [ "" ] + path
            return false
        }
        return true
    }

    private func rectifyStack(path: [String], elements: [String : NavigationElement]) {
        var newStack: [NavigationElement] = []
        for tag in path {
            if let elem = elements[tag] {
                newStack.append(elem)
            }
            else {
                print("Path has a tag '\(tag)' which cannot be represented - truncating at last usable element")
                break
            }
        }
        stack = newStack
    }
}

struct NavigationStack<Content: View>: View {
    @ObservedObject var navState: NavigationState
    @State private var trigger: String = "humperdoo"    //HUMPERDOO! Chose something that would not conflict with empty root state - could probably do better with an enum

    init(_ navState: NavigationState, title: String, builder: @escaping () -> Content) {
        self.navState = navState
        self.navState.stackElements[""] = NavigationElement(tag: "", title: title, viewBuilder: { AnyView(builder()) })
    }

    var backButton: some View {
        Button(action: { self.navState.stackPath.removeLast() }) {
            Text("Back")
        }
    }

    var navigationHeader: some View {
        HStack {
            ViewBuilder.buildIf( navState.stack.count > 1 ? backButton : nil )
            Spacer()
            ViewBuilder.buildIf( navState.stack.last?.title != nil ? Text(navState.stack.last!.title) : nil )
            Spacer()
        }
        .frame(height: 40)
        .background(Color(.systemGray))
    }

    var currentNavElement: some View {
        return navState.stack.last!.viewBuilder()
    }

    var body: some View {
        VStack {
            // This is an effectively invisible element which primarily serves to force refreshes of the tree
            Text(trigger)
            .frame(width: 0, height: 0)
            .onReceive(navState.$stack, perform: { stack in
                self.trigger = stack.reduce("") { tag, elem in
                    return tag + "/" + elem.tag
                }
            })
            navigationHeader
            ViewBuilder.buildBlock(
                navState.stack.last != nil
                ? ViewBuilder.buildEither(first: currentNavElement)
                : ViewBuilder.buildEither(second: Text("The navigation stack is empty - this is odd"))
            )
        }
    }
}

struct NavigationDestination<Label: View, Destination: View>: View {

    @ObservedObject var navState: NavigationState
    var tag: String
    var label: () -> Label

    init(_ navState: NavigationState, tag: String, title: String, destination: @escaping () -> Destination, label: @escaping () -> Label) {
        self.navState = navState
        self.tag = tag
        self.label = label
        self.navState.stackElements[tag] = NavigationElement(tag: tag, title: title, viewBuilder: { AnyView(destination()) })
    }

    var body: some View {
        label()
        .onTapGesture {
            self.navState.stackPath.append(self.tag)
        }
    }
}

И немного базового кода использования

struct LandingPageView: View {

    @ObservedObject var navState: NavigationState

    var destinationA: some View {
        List {
            NavigationDestination(self.navState, tag: "aa", title: "AA", destination: { Text("AA") }) {
                Text("Go to AA")
            }
            NavigationDestination(self.navState, tag: "ab", title: "AB", destination: { Text("AB") }) {
                Text("Go to AB")
            }
        }
    }

    var destinationB: some View {
        List {
            NavigationDestination(self.navState, tag: "ba", title: "BA", destination: { Text("BA") }) {
                Text("Go to BA")
            }
            Button(action: { self.navState.stackPath = [ "a", "ab" ] }) {
                Text("Jump to AB")
            }
        }
    }

    var body: some View {
        NavigationStack(navState, title: "Start") {
            List {
                NavigationDestination(self.navState, tag: "a", title: "A", destination: { self.destinationA }) {
                    Text("Go to A")
                }
                NavigationDestination(self.navState, tag: "b", title: "B", destination: { self.destinationB }) {
                    Text("Go to B")
                }
            }
        }
    }
}

Самый простой способ - удалить @Published для последующих уровней.

 class NavState: ObservableObject {
  @Published var firstLevel: String? = nil
    var secondLevel: String? = nil
    var thirdLevel: String? = nil
 }

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

Другие вопросы по тегам