Details
- Xcode Version 13.2.1 (13C100), Swift 5.5
Solution
Linked list
https://github.com/raywenderlich/swift-algorithm-club/blob/master/Linked%20List/LinkedList.swift
NavigationStack
import SwiftUI import Combine ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MARK: Custom NavigationLink ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// final class CustomNavigationLinkViewModel<CustomViewID>: ObservableObject where CustomViewID: Equatable { private weak var navigationStack: NavigationStack<CustomViewID>? /// `viewId` is used to find a `CustomNavigationLinkViewModel` in the `NavigationStack` let viewId = UUID().uuidString /// `customId` is used to mark a `CustomNavigationLink` in the `NavigationStack`. This is kind of external id. /// In `NavigationStack` we always prefer to use `viewId`. But from time to time we need to implement `pop several views` /// and that is the purpose of the `customId` /// Developer can just create a link with `customId` e.g. `navigationStack.navigationLink(customId: "123") { .. }` /// And to pop directly to view `"123"` should use `navigationStack.popToLast(customId: "123")` let customId: CustomViewID? @Published var isActive = false { didSet { navigationStack?.updated(linkViewModel: self) } } init (navigationStack: NavigationStack<CustomViewID>, customId: CustomViewID? = nil) { self.navigationStack = navigationStack self.customId = customId } } extension CustomNavigationLinkViewModel: Equatable { static func == (lhs: CustomNavigationLinkViewModel, rhs: CustomNavigationLinkViewModel) -> Bool { lhs.viewId == rhs.viewId && lhs.customId == rhs.customId } } struct CustomNavigationLink<Label, Destination, CustomViewID>: View where Label: View, Destination: View, CustomViewID: Equatable { /// Link `ViewModel` where all states are stored @StateObject var viewModel: CustomNavigationLinkViewModel<CustomViewID> let destination: () -> Destination let label: () -> Label var body: some View { NavigationLink(isActive: $viewModel.isActive, destination: destination, label: label) } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MARK: NavigationStack ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class NavigationStack<CustomViewID>: ObservableObject where CustomViewID: Equatable { typealias Link = WeakReference<CustomNavigationLinkViewModel<CustomViewID>> private var linkedList = LinkedList<Link>() func navigationLink<Label, Destination>(customId: CustomViewID? = nil, @ViewBuilder destination: @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) -> some View where Label: View, Destination: View { createNavigationLink(customId: customId, destination: destination, label: label) } private func createNavigationLink<Label, Destination>(customId: CustomViewID? = nil, @ViewBuilder destination: @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) -> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View { .init(viewModel: CustomNavigationLinkViewModel(navigationStack: self, customId: customId), destination: destination, label: label) } } // MARK: Nested Types extension NavigationStack { /// To avoid retain cycle it is important to store weak reference to the `CustomNavigationLinkViewModel` final class WeakReference<T> where T: AnyObject { private(set) weak var weakReference: T? init(value: T) { self.weakReference = value } deinit { print("deinited WeakReference") } } } // MARK: Searching extension NavigationStack { private func last(where condition: (Link) -> Bool) -> LinkedList<Link>.Node? { var node = linkedList.last while(node != nil) { if let node = node, condition(node.value) { return node } node = node?.previous } return nil } } // MARK: Binding extension NavigationStack { fileprivate func updated(linkViewModel: CustomNavigationLinkViewModel<CustomViewID>) { guard linkViewModel.isActive else { switch linkedList.head?.value.weakReference { case nil: break case linkViewModel: linkedList.removeAll() default: last (where: { $0.weakReference === linkViewModel })?.previous?.next = nil } return } linkedList.append(WeakReference(value: linkViewModel)) } } // MARK: pop functionality extension NavigationStack { func popToRoot() { linkedList.head?.value.weakReference?.isActive = false } func pop() { linkedList.last?.value.weakReference?.isActive = false } func popToLast(customId: CustomViewID) { last (where: { $0.weakReference?.customId == customId })?.value.weakReference?.isActive = false } } #if DEBUG extension NavigationStack { var isEmpty: Bool { linkedList.isEmpty } var count: Int { linkedList.count } func testCreateNavigationLink<Label, Destination>(viewModel: CustomNavigationLinkViewModel<CustomViewID>, @ViewBuilder destination: @escaping () -> Destination, @ViewBuilder label: @escaping () -> Label) -> CustomNavigationLink<Label, Destination, CustomViewID> where Label: View, Destination: View { .init(viewModel: viewModel, destination: destination, label: label) } } #endif
Usage (short sample)
Create NavigationLink:
struct Page: View { @EnvironmentObject var navigationStack: NavigationStack<String> var body: some View { navigationStack.navigationLink { NextView(...) } label: { Text("Next page") } } }
Pop functionality
struct Page: View { @EnvironmentObject var navigationStack: NavigationStack<String> var body: some View { Button("Pop") { navigationStack.pop() } Button("Pop to Page 1") { navigationStack.popToLast(customId: "1") } Button("Pop to root") { navigationStack.popToRoot() } } }
Usage (full sample)
import SwiftUI struct ContentView: View { var body: some View { TabView { addTab(title: "Tab 1", systemImageName: "house") addTab(title: "Tab 2", systemImageName: "bookmark") } } func addTab(title: String, systemImageName: String) -> some View { NavigationView { RootPage(title: "\(title) home") .navigationBarTitle(title) } .environmentObject(NavigationStack<String>()) .navigationViewStyle(StackNavigationViewStyle()) .tabItem { Image(systemName: systemImageName) Text(title) } } } struct RootPage: View { let title: String var body: some View { SimplePage(title: title, pageCount: 0) } } struct SimplePage: View { @EnvironmentObject var navigationStack: NavigationStack<String> var title: String var pageCount: Int var body: some View { VStack { navigationStack.navigationLink(customId: "\(pageCount)") { // router.navigationLink { SimplePage(title: "Page: \(pageCount + 1)", pageCount: pageCount + 1) } label: { Text("Next page") } Button("Pop") { navigationStack.pop() } Button("Pop to Page 1") { navigationStack.popToLast(customId: "1") } Button("Pop to root") { navigationStack.popToRoot() } } .navigationTitle(title) } }
Some Unit tests
@testable import SwiftUIPop import XCTest import SwiftUI import Combine class SwiftUIPopTests: XCTestCase { typealias CustomLinkID = String typealias Stack = NavigationStack<CustomLinkID> private let stack = Stack() } // MARK: Empty Navigation Stack extension SwiftUIPopTests { func testNoCrashOnPopToRootOnEmptyStack() { XCTAssertTrue(stack.isEmpty) stack.popToRoot() } func testNoCrashOnPopToLastOnEmptyStack() { XCTAssertTrue(stack.isEmpty) stack.popToLast(customId: "123") } func testNoCrashOnPopOnEmptyStack() { XCTAssertTrue(stack.isEmpty) stack.pop() } } // MARK: expectation functions private extension SwiftUIPopTests { func navigationStackShould(beEmpty: Bool) { if beEmpty { XCTAssertTrue(stack.isEmpty, "Navigation Stack should be empty") } else { XCTAssertFalse(stack.isEmpty, "Navigation Stack should not be empty") } } } // MARK: Data / model generators private extension SwiftUIPopTests { func createNavigationLink(viewModel: CustomNavigationLinkViewModel<CustomLinkID>, stack: Stack) -> CustomNavigationLink<EmptyView, EmptyView, CustomLinkID> { stack.testCreateNavigationLink(viewModel: viewModel) { EmptyView() } label: { EmptyView() } } func createNavigationLinkViewModel(customId: CustomLinkID? = nil) -> CustomNavigationLinkViewModel<CustomLinkID> { .init(navigationStack: stack, customId: customId) } } // MARK: test `isActive` changing from `true` to `false` on `pop` extension SwiftUIPopTests { private func isActiveChangeOnPop(customId: String? = nil, popAction: (Stack) -> Void, file: StaticString = #file, line: UInt = #line) { navigationStackShould(beEmpty: true) let expec = expectation(description: "Wait for viewModel.isActive changing") var canalables = Set<AnyCancellable>() let viewModel = createNavigationLinkViewModel(customId: customId) let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack) navigationLink.viewModel.isActive = true navigationLink.viewModel.$isActive.dropFirst().sink { value in expec.fulfill() }.store(in: &canalables) navigationStackShould(beEmpty: false) popAction(stack) waitForExpectations(timeout: 2) navigationStackShould(beEmpty: true) } func testIsActiveChangeOnPop() { isActiveChangeOnPop { $0.pop() } } func testIsActiveChangeOnPopToRoot() { isActiveChangeOnPop { $0.popToRoot() } } func testIsActiveChangeOnPopToLast() { let customId = "1234" isActiveChangeOnPop(customId: customId) { $0.popToLast(customId: customId) } } func testIsActiveChangeOnPopToLast2() { navigationStackShould(beEmpty: true) let expec = expectation(description: "Wait") var canalables = Set<AnyCancellable>() let viewModel = createNavigationLinkViewModel(customId: "123") let navigationLink = createNavigationLink(viewModel: viewModel, stack: stack) navigationLink.viewModel.isActive = true navigationLink.viewModel.$isActive.dropFirst().sink { value in expec.fulfill() }.store(in: &canalables) navigationStackShould(beEmpty: false) stack.popToLast(customId: "1234") DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { expec.fulfill() } waitForExpectations(timeout: 3) navigationStackShould(beEmpty: false) } } // MARK: Check that changing `CustomNavigationLinkViewModel.isActive` will update `Navigation Stack` extension SwiftUIPopTests { // Add and remove view to the empty stack private func isActiveChangeUpdatesNavigationStack1(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) { navigationStackShould(beEmpty: true) let navigationLink = createLink(stack) navigationStackShould(beEmpty: true) navigationLink.viewModel.isActive = true navigationStackShould(beEmpty: false) navigationLink.viewModel.isActive = false navigationStackShould(beEmpty: true) } func testIsActiveChangeUpdatesNavigationStack1() { isActiveChangeUpdatesNavigationStack1 { stack in let viewModel = createNavigationLinkViewModel() return createNavigationLink(viewModel: viewModel, stack: stack) } } func testIsActiveChangeUpdatesNavigationStack2() { isActiveChangeUpdatesNavigationStack1 { stack in let viewModel = createNavigationLinkViewModel(customId: "123") return createNavigationLink(viewModel: viewModel, stack: stack) } } // Add and remove view to the non-empty stack private func isActiveChangeUpdatesNavigationStack2(createLink: (Stack) -> CustomNavigationLink<EmptyView, EmptyView, String>) { navigationStackShould(beEmpty: true) let viewModel1 = createNavigationLinkViewModel() let navigationLink1 = createNavigationLink(viewModel: viewModel1, stack: stack) navigationLink1.viewModel.isActive = true navigationStackShould(beEmpty: false) XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link") let navigationLink2 = createLink(stack) navigationLink2.viewModel.isActive = true navigationStackShould(beEmpty: false) navigationLink2.viewModel.isActive = false XCTAssertEqual(stack.count, 1, "Navigation Stack Should contains only one link") } func testIsActiveChangeUpdatesNavigationStack3() { isActiveChangeUpdatesNavigationStack2 { stack in let viewModel = createNavigationLinkViewModel() return createNavigationLink(viewModel: viewModel, stack: stack) } } func testIsActiveChangeUpdatesNavigationStack4() { isActiveChangeUpdatesNavigationStack2 { stack in let viewModel = createNavigationLinkViewModel(customId: "123") return createNavigationLink(viewModel: viewModel, stack: stack) } } }