Search code examples
swiftuiswiftui-navigationlinkswiftui-navigationviewswiftui-tabview

Replace deprecated NavigationView for iPhone and iPad Layout


I'm currently using NavigationView to layout my App for iPhone and iPad depending on the horizontalSizeClass. Since the functions I'm using for NavigationView and NavigationLink are deprecated I wonder if one can achieve something similar with the latest APIs. Here is the simplified version.

ContentView

struct ContentView: View {

    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        if horizontalSizeClass == .compact {
            iPhoneEntry()
        } else {
            iPadEntry()
        }
    }
}

AppItems

indirect enum AppItem: Hashable, Identifiable {
    var id: Int {
        hashValue
    }

    case foobar, profile, welcome, articles, books, websites
    case nested(item: AppItem, nested: [AppItem])

    @ViewBuilder
    var destination: some View {
        switch self {
        case .foobar:
            FoobarOverview()
        case .profile:
            Profile()
        case .welcome:
            Welcome()
        case .articles:
            Overview(navTitle: "Articles")
        case .books:
            Overview(navTitle: "Books")
        case .websites:
            Overview(navTitle: "Websites")
        case .nested:
            EmptyView()
        }
    }

    var name: String {
        switch self {
        case .foobar:
            return "Foobar"
        case .profile:
            return "Profile"
        case .welcome:
            return "Welcome"
        case .articles:
            return "Articles"
        case .books:
            return "Books"
        case .websites:
            return "Websites"
        case .nested(let item, _):
            return item.name
        }
    }

    static var ipad: [AppItem] = [.welcome, .profile, .nested(item: .foobar, nested: overviews)]

    static var iphone: [AppItem] = [.welcome, .profile, .foobar]

    static var overviews: [AppItem] = [.articles, .books, .websites]
}

iPhone Layout

struct iPhoneEntry: View {

    @State var selectedItem: AppItem? = .welcome

    @ViewBuilder
    var body: some View {
        TabView(selection: $selectedItem) {
            ForEach(AppItem.iphone) { item in
                switch item {
                case .foobar:
                    NavigationView {
                        FoobarOverview()
                    }
                    .tag("Overview")
                    .tabItem { Label("Overview", systemImage: "circle") }
                case .profile:
                    Profile()
                        .tag("Profile")
                        .tabItem { Label("Profile", systemImage: "triangle") }
                case .welcome:
                    Welcome()
                        .tag("Welcome")
                        .tabItem { Label("Welcome", systemImage: "square") }
                default:
                    EmptyView()
                }
            }

        }
    }
}

iPad-Layout

struct iPadEntry: View {

    @State var selectedItem: AppItem? = .welcome

    var body: some View {
        NavigationView {
            List {
                ForEach(AppItem.ipad) { item in
                    if case .nested(let item, let subItems) = item {
                        Section {
                            ForEach(subItems, content: ListItem)
                        } header: {
                            Text(item.name)
                        }
                    } else {
                        ListItem(item)
                    }
                }
            }
            .listStyle(.sidebar)
            .modifier(ColumnModifier(appItem: selectedItem))
        }
        .navigationViewStyle(.columns)
    }

    func ListItem(_ appItem: AppItem) -> some View {
        NavigationLink(tag: appItem, selection: $selectedItem) {
            appItem.destination
        } label: {
            Text(appItem.name)
        }
    }

    private struct ColumnModifier: ViewModifier {
        let appItem: AppItem?

        func body(content: Content) -> some View {
            if appItem == .welcome || appItem == .profile {
                Group {
                    content

                    Text("Select something")
                }
            } else {
                Group {
                    content

                    appItem?.destination

                    Text("Select something")
                }
            }
        }
    }
}

Dummy Views

struct Profile: View {

    var body: some View {
        Text("Profile")
    }
}

struct Welcome: View {

    var body: some View {
        Text("Welcome")
    }
}

struct FoobarOverview: View {

    var body: some View {
        List {
            ForEach(AppItem.overviews) { item in
                NavigationLink(item.name, destination: item.destination)
            }
        }
    }
}

struct Overview: View {

    var navTitle: String

    var body: some View {
        List {
            ForEach(0 ... 10, id: \.self) { num in
                NavigationLink("\(navTitle) number: \(num)") {
                    Detail(title: "\(navTitle) number: \(num)")
                }
            }
        }
    }
}

struct Detail: View {

    var title: String

    var body: some View {
        Text("foobar")
    }
}

EDIT: I want to know how I can navigate 6 levels deep for iPad and iPhone without implementing two layouts.


Solution

  • NavigationSplitView cannot have a dynamic number of columns, and I don't think NavigationView officially supported this anyway.

    That said, you can achieve a similar kind of behaviour by wrapping two NavigationSplitViews with an if. i.e. show the three-column split view if the selected item is one of the ones in AppItem.overviews, otherwise show the two-column split view.

    struct iPadEntry: View {
    
        @State var selectedItem: AppItem? = .welcome
    
        var body: some View {
            if let selectedItem, AppItem.overviews.contains(selectedItem) {
                NavigationSplitView {
                    SplitSidebar(selectedItem: $selectedItem)
                } content: {
                    SplitDetail(selectedItem: selectedItem)
                } detail: {
                    NavigationStack {
                        Text("Select something")
                    }
                }
            } else {
                NavigationSplitView {
                    SplitSidebar(selectedItem: $selectedItem)
                } detail: {
                    NavigationStack {
                        SplitDetail(selectedItem: selectedItem)
                    }
                }
            }
        }
    }
    
    struct SplitSidebar: View {
        @Binding var selectedItem: AppItem?
        var body: some View {
            List(selection: $selectedItem) {
                ForEach(AppItem.ipad) { item in
                    if case .nested(let item, let subItems) = item {
                        Section {
                            ForEach(subItems) { subItem in
                                NavigationLink(value: subItem) {
                                    Text(subItem.name)
                                }
                            }
                        } header: {
                            Text(item.name)
                        }
                    } else {
                        NavigationLink(value: item) {
                            Text(item.name)
                        }
                    }
                }
            }
            .listStyle(.sidebar)
        }
    }
    
    struct SplitDetail: View {
        let selectedItem: AppItem?
        var body: some View {
            if let selectedItem {
                selectedItem.destination
            } else {
                Text("Select something")
            }
        }
    }
    

    That said, I would recommend redesigning this to not use a dynamic number of columns. Consider navigating to the overview view in the same column, or nest another two-column split view in the detail column.

    Here are the other views:

    struct iPhoneEntry: View {
    
        @State var selectedItem: AppItem = .welcome
    
        @ViewBuilder
        var body: some View {
            TabView(selection: $selectedItem) {
                ForEach(AppItem.iphone) { item in
                    switch item {
                    case .foobar:
                        NavigationStack {
                            FoobarOverview()
                        }
                        .tabItem { Label("Overview", systemImage: "circle") }
                    case .profile:
                        Profile()
                            .tabItem { Label("Profile", systemImage: "triangle") }
                    case .welcome:
                        Welcome()
                            .tabItem { Label("Welcome", systemImage: "square") }
                    default:
                        EmptyView()
                    }
                }
    
            }
        }
    }
    
    struct FoobarOverview: View {
    
        var body: some View {
            List {
                ForEach(AppItem.overviews) { item in
                    NavigationLink(item.name) { item.destination }
                }
            }
        }
    }
    
    struct Overview: View {
    
        var navTitle: String
    
        var body: some View {
            List {
                ForEach(0 ... 10, id: \.self) { num in
                    NavigationLink("\(navTitle) number: \(num)") {
                        Detail(title: "\(navTitle) number: \(num)")
                    }
                }
            }
        }
    }
    

    Side note: you should declare AppItem.id as:

    var id: AppItem { self }
    

    Do not use the hash value as an identifier (for anything), because hash collisions exist.