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!
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!
Share Improve this question asked Jan 20 at 2:15 user16961399user16961399 276 bronze badges 3 |1 Answer
Reset to default 0Your 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)
}
}
}
}
@Binding
is only meaningful in aView
, not inDrawerPresentationMode
. Also, in yourSideMenuIssueApp
you should declare@StateObject private var viewModel = SideMenuViewModel()
so that it is observed by the App/view, then pass it down to yourSideMenu(viewModel: viewModel)
. Note also,TabView
should not be inside aNavigationStack
– workingdog support Ukraine Commented Jan 20 at 2:47NavigationStack
, the side menu button doesn't show up on the view. – user16961399 Commented Jan 20 at 3:20