Trang chủ

NavigationStack với một path có kiểu

NavigationStack đã thay cho NavigationView, và thay đổi quan trọng không nằm ở cái tên. Trạng thái điều hướng trở thành một giá trị bạn sở hữu thay vì một thứ framework giấu đi, và điều đó khiến deep link cùng khôi phục trạng thái trở nên bình thường thay vì là một cuộc vật lộn.

Hình dạng cơ bản

enum Route: Hashable {
    case bookDetail(Book.ID)
    case authorProfile(Author.ID)
    case settings
}

struct RootView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            LibraryView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .bookDetail(let id):    BookDetailView(id: id)
                    case .authorProfile(let id): AuthorProfileView(id: id)
                    case .settings:              SettingsView()
                    }
                }
        }
    }
}

Cái mảng chính là ngăn xếp điều hướng. Thêm vào là đẩy màn hình, bớt đi là quay lại, và gán lại nó là thay cả ngăn xếp.

path.append(.bookDetail(book.id))       // đẩy
path.removeLast()                       // quay lại
path.removeAll()                        // về màn hình gốc
path = [.settings, .bookDetail(id)]     // thay cả ngăn xếp

Cái cuối là năng lực trước đây không hề tồn tại, và nó là thứ khiến deep link chạy được.

Vì sao dùng enum thay vì API dựa trên kiểu

navigationDestination(for: Book.self) cũng chạy được, và tôi đã thôi dùng nó. Hai lý do.

Một đích cho mỗi kiểu. Nếu hai màn hình khác nhau cùng đẩy một Book thì bạn không diễn đạt được — cái đích được khóa theo kiểu. Một enum cho phép .bookDetail(id) và .bookEditor(id) cùng tồn tại.

Dùng ID thay vì mô hình. Đẩy một Book là đặt cả cái mô hình vào path điều hướng. Nếu cuốn sách đó bị sửa ở nơi khác thì bản đã đẩy đã cũ, và nếu path được lưu lại thì bạn đang lưu một bản chụp của mô hình. Đẩy Book.ID rồi lấy dữ liệu về ở màn hình đích tránh được cả hai.

Cảnh báo

Mọi kiểu trong path đều phải Hashable, và nếu bạn lưu path lại thì nó cũng phải Codable. Đó là một lập luận mạnh cho một enum chứa ID — một kiểu mô hình mọc thêm một thuộc tính không Codable sẽ lặng lẽ làm hỏng việc khôi phục, và kiểu hỏng là “ứng dụng mở ra ở màn hình gốc” chứ không phải một lỗi.

Vì path là một giá trị nên một deep link là một phép gán:

.onOpenURL { url in
    guard let route = Route(url: url) else { return }
    path = [route]                     // hoặc append, để giữ lại ngữ cảnh
}

Thay thế hay thêm vào là một quyết định về sản phẩm — một cú chạm thông báo thường là thay thế, một liên kết trong ứng dụng thường là thêm vào. Cả hai đều là một dòng, và đó mới là điểm mấu chốt.

Khôi phục trạng thái

SceneStorage lưu path qua các lần khởi chạy:

struct RootView: View {
    @SceneStorage("navigationPath") private var pathData: Data?
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) { … }
            .task {
                if let pathData, let restored = try? JSONDecoder().decode([Route].self, from: pathData) {
                    path = restored
                }
            }
            .onChange(of: path) {
                pathData = try? JSONEncoder().encode(path)
            }
    }
}

Người dùng đóng ứng dụng khi đang ở sâu ba màn hình rồi quay lại đúng màn hình đó. Với NavigationView thì chuyện này về cơ bản là bất khả thi.

Đáng đặt một lớp bảo vệ cho lượt khôi phục: nếu một route trỏ tới thứ đã bị xóa thì màn hình đích sẽ nạp hỏng. Kiểm tra tính hợp lệ của các route trước khi khôi phục, rồi loại bỏ những cái không hợp lệ, là mười dòng đáng viết.

Đặt path vào một mô hình

Một khi có hơn một view cần điều hướng, hãy giữ path trong một đối tượng @Observable:

@Observable
final class Navigator {
    var path: [Route] = []

    func push(_ route: Route) { path.append(route) }
    func popToRoot() { path.removeAll() }
    func replace(with routes: [Route]) { path = routes }
}

Tiêm qua environment, mọi view đều điều hướng được mà không cần luồn một binding xuống:

@Environment(Navigator.self) private var navigator

Button("Mở cài đặt") { navigator.push(.settings) }

Đây là mảnh khiến việc điều hướng kiểm thử được. navigator.push(.settings) rồi khẳng định navigator.path == [.settings] là một bài unit test bình thường, chẳng dính dáng gì tới giao diện.

Hai thứ vẫn hay bẫy tôi

NavigationLink(value:), không phải NavigationLink(destination:). Dạng có value thì thêm vào path; dạng có destination tạo ra một cú đẩy không được theo dõi mà path chẳng biết gì. Trộn hai cái nghĩa là popToRoot không quay về hết được.

navigationDestination phải nằm bên trong ngăn xếp, gắn vào một view trong phần nội dung của ngăn xếp — không phải gắn vào chính cái NavigationStack. Đặt nó bên ngoài thì biên dịch vẫn ổn và lúc chạy thì chẳng làm gì, và đó là một nửa tiếng đồng hồ rất rối trí.