I have a basic SwiftUI view where there is a side menu and main view. Initially the main view is displayed but using a left to right drag gesture the side menu slides into the view and the main view is offset to the right. This simulates a basic settings page. The problem is the drag gesture in the base views gets ignored by the tab view, this is because the tab view itself allows drag gestures to switch tabs.
I would like to allow the user to drag to switch tabs, however, if the user is on the left most tab then another left drag gesture should present the settings. Currently when the left most tab is shown, a left->right drag gesture is picked by the tab view and the settings are not shown. If I include a button in the left most tab and preform a left->right drag gesture then the side menu is shown once the drag is release.
This is much like twitters main page. How can I get a left->right drag gesture in the left tab to show the side menu with the offset transition. Left->right drag gesture in the middle and right tabs should only switch the tab though.
import SwiftUI
struct sideMenu: View {
var body: some View {
ZStack {
Color.gray
Text("Side Menu")
}
}
}
struct MainView: View {
@State var selection: Int = 0
var body: some View {
TabView(selection: $selection, content: {
ZStack {
Color.blue
Text("tab 0")
}.tag(0)
ZStack {
Color.red
Text("tab 1")
}.tag(1)
ZStack {
Color.green
Text("tab 2")
}.tag(2)
})
}
}
struct FeedBaseView: View {
@State var showMenu: Bool = false
@State var offset: CGFloat = 0
@State var lastStoredOffset: CGFloat = 0
@GestureState var gestureOffset: CGFloat = 0
var body: some View {
let sideBarWidth = widthOrHeight(width: true) - 90
VStack {
HStack(spacing: 0) {
sideMenu().frame(width: sideBarWidth)
VStack(spacing: 0) {
MainView()
}
.blur(radius: showMenu ? 10 : 0)
.frame(width: widthOrHeight(width: true))
.overlay(
Rectangle()
.fill(
Color.primary.opacity( (offset / sideBarWidth) / 6.0 )
)
.ignoresSafeArea(.container, edges: .all)
.onTapGesture {
if showMenu {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
showMenu = false
}
}
)
}
.frame(width: sideBarWidth + widthOrHeight(width: true))
.offset(x: -sideBarWidth / 2)
.offset(x: offset)
.gesture(
DragGesture()
.updating($gestureOffset, body: { value, out, _ in
out = value.translation.width
})
.onChanged({ value in
if abs(value.translation.height) > 10 && !showMenu {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
})
.onEnded(onEnd(value:))
)
}
.animation(.linear(duration: 0.15), value: offset == 0)
.onChange(of: showMenu) { newValue in
if showMenu {
if offset == 0 {
offset = sideBarWidth
lastStoredOffset = offset
}
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
if !showMenu && offset == sideBarWidth {
offset = 0
lastStoredOffset = 0
}
}
.onChange(of: gestureOffset) { newValue in
if showMenu {
if newValue < 0 {
if gestureOffset != 0 {
if gestureOffset + lastStoredOffset < sideBarWidth && (gestureOffset + lastStoredOffset) > 0 {
offset = lastStoredOffset + gestureOffset
} else {
if gestureOffset + lastStoredOffset < 0 {
offset = 0
}
}
}
}
} else {
if gestureOffset != 0 {
if gestureOffset + lastStoredOffset < sideBarWidth && (gestureOffset + lastStoredOffset) > 0 {
offset = lastStoredOffset + gestureOffset
} else {
if gestureOffset + lastStoredOffset < 0 {
offset = 0
}
}
}
}
}
}
func onEnd(value: DragGesture.Value) {
let sideBarWidth = widthOrHeight(width: true) - 90
withAnimation(.spring(duration: 0.15)) {
if value.translation.width > 0 {
if value.translation.width > sideBarWidth / 2 {
offset = sideBarWidth
lastStoredOffset = sideBarWidth
if !showMenu {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
showMenu = true
} else {
if value.translation.width > sideBarWidth && showMenu {
offset = 0
if showMenu {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
showMenu = false
} else {
if value.velocity.width > 800 {
offset = sideBarWidth
if !showMenu {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
showMenu = true
} else if showMenu == false {
offset = 0
}
}
}
} else {
if -value.translation.width > sideBarWidth / 2 {
offset = 0
if showMenu {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
showMenu = false
} else {
guard showMenu else { return }
if -value.velocity.width > 800 {
offset = 0
if showMenu {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
showMenu = false
} else {
offset = sideBarWidth
if !showMenu {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
showMenu = true
}
}
}
}
lastStoredOffset = offset
}
}
func widthOrHeight(width: Bool) -> CGFloat {
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
let window = windowScene?.windows.first
if width {
return window?.screen.bounds.width ?? 0
} else {
return window?.screen.bounds.height ?? 0
}
}
Since you only want the side menu to show when a left-to-right drag happens on the first tab view, I would suggest applying the drag gesture to the content of view 1 only. And since we are talking about a left-to-right drag, it is probably reasonable to expect it to start on the left-side of the screen. In fact, I would expect that many users will probably "pull" the menu from near the left edge, if they know it's there.
The example below uses a drag layer on the left-side of view 1. The width of the layer is half the width of the menu.
.allowsHitTesting(false)needs to be applied to inactive content that is shown over it.Here is how the (white) layer would look if used as an overlay with opacity of 0.5 instead of 0.001:
When a drag gesture is detected that starts on the drag layer and continues for a minimum of one-quarter of the menu width, the menu is brought into view. Any drag gesture from right-to-left that begins in the uncovered part of the view (on the right) will be handled by the
TabViewin the usual way.When the menu is showing, the drag layer expands to cover the full width of the menu, so that any drag that starts over the menu can be detected. Here is how it looks when the opacity is 0.5 instead of 0.001:
The menu can be hidden again with a drag gesture from right-to-left that begins over the menu. A small part of view 1 is still visible on the right and the menu can also be hidden by tapping in this area. However, if a right-to-left drag gesture begins in this area then it is handled by the
TabView. This allows view 2 to be brought into view without having to close the menu first.Views 2 and 3 are not impacted by the drag layer on view 1, so drag gestures are handled by the
TabViewin the normal way with these views.The updated example is shown below. Some more notes:
A
GeometryReaderis used to measure the width of the screen, instead of using the first window scene as you were doing before. AGeometryReaderalso works on an iPad when split screen is used.MainViewis now integrated intoFeedBaseView, but view 1 has been factored out as a separate view.To show how it works with some active content, I have added a view of a light bulb on the left and right side of view 1. Each bulb can be turned on and off and dimmed. The toggle and the slider both involve their own drag gestures.
You were applying a blur to view 1 when the menu is showing. This causes the background color of the screen to show through at the edges, so when the background is white, the edges get brighter. To mask this on the left edge where it is connected to the menu, a
zIndexand also a shadow effect are applied to the menu.I found that
.animationmodifiers did not seem to work when applied to the content of view1. I don't know why not, but suspect that it may be because the view is nested inside aTabView. However, animations work fine when changes are appliedwithAnimation.I didn't understand what the first responder calls were doing in your original code so I stripped these out. If you really need them then hopefully it is clear where to put them back in.