user16961399
user16961399

Reputation: 27

Issue Navigating to View from Side Menu in Swiftui

I am currently making a side menu for an iOS app in SwiftUI and having navigation issues. Navigating to a view from the side menu is fine, but the view will show up inside the side menu itself, rather than it being like Twitter, where the side menu is dismissed and then the view opens up in the app. I have made a reproducible mock of the issue, which can be seen below.

First is the data model for the side menu:

struct SideMenuOBJ: Identifiable, Hashable {
    let id = UUID()
    let image: UIImage
    let label: String
    var vc: UIViewController?
}

struct SideMenuRepository {
    static func profile () -> [SideMenuOBJ] {
        let base = [
            SideMenuOBJ(image: UIImage(systemName: "person.circle")!, label: "Profile"),
            SideMenuOBJ(image: UIImage(systemName: "list.clipboard")!, label: "Account Summary"),
            SideMenuOBJ(image: UIImage(systemName: "fossil.shell")!, label: "History")
        ]
        
        return base
    }
}

The Drawer creates the side menu:

public struct Drawer<Menu: View, Content: View>: View {
    @Binding private var isOpened: Bool
    private let menu: Menu
    private let content: Content
    
    public init(
        isOpened: Binding<Bool>,
        @ViewBuilder menu:  () -> Menu,
        @ViewBuilder content: () -> Content
    ) {
        _isOpened = isOpened
        self.menu = menu()
        self.content = content()
    }
    
    public var body: some View {
        ZStack(alignment: .leading) {
            content
            
            if isOpened {
                Color.clear
                    .contentShape(Rectangle())
                    .onTapGesture {
                        if isOpened {
                            isOpened.toggle()
                        }
                    }
                menu
                    .transition(.move(edge: .leading))
                    .zIndex(1)
            }
        }
        .animation(.spring(), value: isOpened)
        .environment(\.drawerPresentationMode, $isOpened.mappedToDrawerPresentationMode())
    }
}

public struct DrawerPresentationMode {
    @Binding private var _isOpened: Bool
    
    init(isOpened: Binding<Bool>) {
        __isOpened = isOpened
    }
    
    public var isOpened: Bool {
        _isOpened
    }
    
    mutating func open() {
        if !_isOpened {
            _isOpened = true
        }
    }
    
    mutating func close() {
        if _isOpened {
            _isOpened = false
        }
    }
}

extension Binding where Value == Bool {
    func mappedToDrawerPresentationMode() -> Binding<DrawerPresentationMode> {
        Binding<DrawerPresentationMode>(
            get: {
                DrawerPresentationMode(isOpened: self)
            },
            set: { newValue in
                self.wrappedValue = newValue.isOpened
            }
        )
    }
}

extension DrawerPresentationMode {
    static var placeholder: DrawerPresentationMode {
        DrawerPresentationMode(isOpened: .constant(false))
    }
}

private struct DrawerPresentationModeKey: EnvironmentKey {
    static var defaultValue: Binding<DrawerPresentationMode> = .constant(.placeholder)
}

extension EnvironmentValues {
    public var drawerPresentationMode: Binding<DrawerPresentationMode> {
        get { self[DrawerPresentationModeKey.self] }
        set { self[DrawerPresentationModeKey.self] = newValue }
    }
}

The view model is pretty straightforward:

class SideMenuViewModel: ObservableObject {
    @Published var profileOptions: [SideMenuOBJ] = SideMenuRepository.profile()
    
    @ViewBuilder
    func handleProfileOptionTap(option: SideMenuOBJ?) -> some View {
        switch option?.label {
        case "My Profile":
            ProfileView()
        case "Account":
            AccountSummaryView()
        case "My Takes History":
            History()
        default:
            EmptyView()
        }
    }
}

Finally, here is the side menu and the parent view that calls the side menu:

struct SideMenu: View {
    @ObservedObject var viewModel: SideMenuViewModel
    @State private var selectedOption: SideMenuOBJ?
    @State private var isNavigating: Bool = false
    
    var body: some View {
        NavigationStack {
            ZStack {
                ScrollView {
                    VStack(alignment: .leading, spacing: 16) {
                        VStack(alignment: .leading, spacing: 12) {
                            ForEach(viewModel.profileOptions) { option in
                                Button {
                                    selectedOption = option
                                    isNavigating.toggle()
                                } label: {
                                    HStack {
                                        Image(uiImage: option.image)
                                            .renderingMode(.template)
                                            .foregroundColor(.primary)
                                            .frame(width: 24, height: 24)
                                        Text(option.label)
                                            .fontWeight(.bold)
                                            .foregroundStyle(Color.primary)
                                            .font(.body)
                                            .padding(.vertical, 8)
                                            .padding(.horizontal)
                                            .cornerRadius(8)
                                    }
                                    .cornerRadius(8)
                                }
                            }
                        }
                        .padding(.horizontal)
                    }
                }
                .scrollIndicators(.never)
            }
            .navigationDestination(item: $selectedOption) { option in
                viewModel.handleProfileOptionTap(option: option)
            }
        }
    }
}
struct SideMenuIssueApp: App {
    @State private var showSideMenu: Bool = false
    var body: some Scene {
        WindowGroup {
            Drawer(isOpened: $showSideMenu) {
                ZStack {
                    SideMenu(viewModel: SideMenuViewModel())
                        .frame(width: 270)
                }
            } content: {
                contentView
            }
        }
    }
    
    var contentView: some View {
        NavigationStack {
            TabView {
                ContentView()
                    .tabItem {
                        Label("Content View", systemImage: "tray")
                    }
            }
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        showSideMenu.toggle()
                    } label: {
                        Image(systemName: "menucard")
                    }
                }
            }
        }
    }
}

ProfileView, AccountSummaryView, and HistoryView are all default views for navigation testing. What am I doing wrong in regards to the navigation that's making the selected view show up in the Side Menu instead of on the app itself? All help is greatly appreciated!

Upvotes: 0

Views: 107

Answers (1)

saucym
saucym

Reputation: 61

Your view hierarchy needs to be adjusted. This is the code I debugged.

struct SideMenuIssueApp: App {
    @State private var showSideMenu: Bool = false
    @State private var selectedOption: SideMenuOBJ?
    var body: some Scene {
        WindowGroup {
            Drawer(isOpened: $showSideMenu) {
                ZStack {
                    SideMenu(viewModel: SideMenuViewModel()) {
                        selectedOption = $0
                        showSideMenu.toggle()
                    }
                    .frame(width: 270)
                }
            } content: {
                contentView
            }
        }
    }
    
    var contentView: some View {
        NavigationStack {
            TabView {
                ContentView()
                    .tabItem {
                        Label("Content View", systemImage: "tray")
                    }
            }
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        showSideMenu.toggle()
                    } label: {
                        Image(systemName: "menucard")
                    }
                }
            }
            .navigationDestination(item: $selectedOption) { option in
                Text(option.label)
            }
        }
    }
}

struct SideMenuOBJ: Identifiable, Hashable {
    let id = UUID()
    let image: UIImage
    let label: String
    var vc: UIViewController?
}

struct SideMenuRepository {
    static func profile () -> [SideMenuOBJ] {
        let base = [
            SideMenuOBJ(image: UIImage(systemName: "person.circle")!, label: "Profile"),
            SideMenuOBJ(image: UIImage(systemName: "list.clipboard")!, label: "Account Summary"),
            SideMenuOBJ(image: UIImage(systemName: "fossil.shell")!, label: "History")
        ]
        
        return base
    }
}

public struct Drawer<Menu: View, Content: View>: View {
    @Binding private var isOpened: Bool
    private let menu: Menu
    private let content: Content
    
    public init(
        isOpened: Binding<Bool>,
        @ViewBuilder menu:  () -> Menu,
        @ViewBuilder content: () -> Content
    ) {
        _isOpened = isOpened
        self.menu = menu()
        self.content = content()
    }
    
    public var body: some View {
        ZStack(alignment: .leading) {
            content
            
            if isOpened {
                Color.clear
                    .contentShape(Rectangle())
                    .onTapGesture {
                        if isOpened {
                            isOpened.toggle()
                        }
                    }
                menu
                    .transition(.move(edge: .leading))
                    .zIndex(1)
            }
        }
        .animation(.spring(), value: isOpened)
        .environment(\.drawerPresentationMode, $isOpened.mappedToDrawerPresentationMode())
    }
}

public struct DrawerPresentationMode {
    @Binding private var _isOpened: Bool
    
    init(isOpened: Binding<Bool>) {
        __isOpened = isOpened
    }
    
    public var isOpened: Bool {
        _isOpened
    }
    
    mutating func open() {
        if !_isOpened {
            _isOpened = true
        }
    }
    
    mutating func close() {
        if _isOpened {
            _isOpened = false
        }
    }
}

extension Binding where Value == Bool {
    func mappedToDrawerPresentationMode() -> Binding<DrawerPresentationMode> {
        Binding<DrawerPresentationMode>(
            get: {
                DrawerPresentationMode(isOpened: self)
            },
            set: { newValue in
                self.wrappedValue = newValue.isOpened
            }
        )
    }
}

extension DrawerPresentationMode {
    static var placeholder: DrawerPresentationMode {
        DrawerPresentationMode(isOpened: .constant(false))
    }
}

private struct DrawerPresentationModeKey: EnvironmentKey {
    static var defaultValue: Binding<DrawerPresentationMode> = .constant(.placeholder)
}

extension EnvironmentValues {
    public var drawerPresentationMode: Binding<DrawerPresentationMode> {
        get { self[DrawerPresentationModeKey.self] }
        set { self[DrawerPresentationModeKey.self] = newValue }
    }
}

class SideMenuViewModel: ObservableObject {
    @Published var profileOptions: [SideMenuOBJ] = SideMenuRepository.profile()
}

struct SideMenu: View {
    @ObservedObject var viewModel: SideMenuViewModel
    let action: (SideMenuOBJ) -> Void
    @State private var isNavigating: Bool = false
    
    var body: some View {
        NavigationStack {
            ZStack {
                ScrollView {
                    VStack(alignment: .leading, spacing: 16) {
                        VStack(alignment: .leading, spacing: 12) {
                            ForEach(viewModel.profileOptions) { option in
                                Button {
                                    action(option)
                                    isNavigating.toggle()
                                } label: {
                                    HStack {
                                        Image(uiImage: option.image)
                                            .renderingMode(.template)
                                            .foregroundColor(.primary)
                                            .frame(width: 24, height: 24)
                                        Text(option.label)
                                            .fontWeight(.bold)
                                            .foregroundStyle(Color.primary)
                                            .font(.body)
                                            .padding(.vertical, 8)
                                            .padding(.horizontal)
                                            .cornerRadius(8)
                                    }
                                    .cornerRadius(8)
                                }
                            }
                        }
                        .padding(.horizontal)
                    }
                }
                .scrollIndicators(.never)
            }
        }
    }
}

Upvotes: 0

Related Questions