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.
Deep link
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í.