Detect Tab View drag gesture

84 Views Asked by At

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
    }
}
1

There are 1 best solutions below

3
Benzy Neez On BEST ANSWER

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.

  • The layer could be used as an overlay. In this case, the opacity could be reduced to 0.001 to make it effectively invisible while still allowing drag gestures to be captured. However, when used as an overlay, it might block active content.
  • Alternatively, the layer can be used in the background. In this case, .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:

Screenshot

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 TabView in 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:

Screenshot

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 TabView in the normal way with these views.

The updated example is shown below. Some more notes:

  • A GeometryReader is used to measure the width of the screen, instead of using the first window scene as you were doing before. A GeometryReader also works on an iPad when split screen is used.

  • MainView is now integrated into FeedBaseView, 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 zIndex and also a shadow effect are applied to the menu.

  • I found that .animation modifiers 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 a TabView. However, animations work fine when changes are applied withAnimation.

  • 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.

struct SideMenu: View {
    var body: some View {
        ZStack {
            Color.gray
                .allowsHitTesting(false)
            Text("Side Menu")
        }
    }
}

struct LightBulb: View {
    @State private var isOn = false
    @State private var level = Double.zero

    var body: some View {
        VStack(spacing: 40) {
            ZStack {
                Image(systemName: "lightbulb")
                    .resizable()
                    .scaledToFit()
                    .padding(.top, 30)
                    .foregroundStyle(.white)
                    .opacity(1 - level)
                Image(systemName: "lightbulb.min.fill")
                    .resizable()
                    .scaledToFit()
                    .padding(.top, 6)
                    .foregroundStyle(.yellow)
                    .opacity(level * 0.8)
                Image(systemName: "lightbulb.max.fill")
                    .resizable()
                    .scaledToFit()
                    .foregroundStyle(.yellow)
                    .opacity(max(0, (level - 0.5) * 2))
            }
            .frame(height: 150)
            Toggle("Light switch", isOn: $isOn)
            Slider(value: $level, in: 0.0...1.0)
                .disabled(!isOn)
        }
        .frame(width: 150)
        .padding()
        .background(
            Color(white: 0.8)
                .allowsHitTesting(false)
        )
        .clipShape(RoundedRectangle(cornerRadius: 15))
        .onChange(of: isOn) { newVal in
            if !newVal {
                level = 0
            }
        }
    }

}

struct View1WithMenu: View {
    private let screenWidth: CGFloat
    private let sideBarWidth: CGFloat
    @State private var showMenu: Bool = false
    @GestureState private var dragOffset: CGFloat = 0

    init(screenWidth: CGFloat) {
        self.screenWidth = screenWidth
        self.sideBarWidth = screenWidth - 90
    }

    private func revealFraction(sideBarWidth: CGFloat) -> CGFloat {
        showMenu ? 1 : max(0, min(1, dragOffset / (sideBarWidth / 2)))
    }

    private var sideMenu: some View {
        SideMenu()
            .frame(width: sideBarWidth)
            .shadow(
                color: Color(white: 0.2),
                radius: revealFraction(sideBarWidth: sideBarWidth) * 10
            )
            .zIndex(1) // to cover the blur at the edge
    }

    private var mainContent: some View {
        ZStack {
            Color.blue
                .allowsHitTesting(false)
            HStack {
                LightBulb()
                LightBulb()
            }
        }
        .overlay(alignment: .top) {
            Text("tab 0")
        }
        .frame(width: screenWidth)
        .blur(radius: revealFraction(sideBarWidth: sideBarWidth) * 4)
        .overlay {
            Color.black
                .opacity(revealFraction(sideBarWidth: sideBarWidth) * 0.2)
                .onTapGesture {
                    if showMenu {
                        UIImpactFeedbackGenerator(style: .light).impactOccurred()
                        withAnimation { showMenu = false }
                    }
                }
        }
    }

    private var draggableLayer: some View {
        Color.white
            .opacity(0.001) //Change to 0.1 to see it
            .frame(width: sideBarWidth + (showMenu ? sideBarWidth / 2 : 0))
            .gesture(
                DragGesture(minimumDistance: 1)
                    .updating($dragOffset) { value, state, trans in
                        let minOffset = showMenu ? -sideBarWidth : 0
                        let maxOffset = showMenu ? 0 : sideBarWidth
                        state = max(minOffset, min(maxOffset, value.translation.width))
                    }
                    .onEnded { value in
                        withAnimation {
                            showMenu = value.translation.width >= sideBarWidth / 4
                        }
                    }
            )
    }

    var body: some View {
        HStack(spacing: 0) {
            sideMenu
            mainContent
        }
        .offset(x: showMenu ? sideBarWidth / 2 : -sideBarWidth / 2)
        .offset(x: dragOffset)
        .background(alignment: .leading) { draggableLayer }
        .onDisappear { showMenu = false }
    }
}

struct FeedBaseView: View {
    @State private var selection: Int = 0

    var body: some View {
        GeometryReader { proxy in
            TabView(selection: $selection)  {
                View1WithMenu(screenWidth: proxy.size.width)
                    .tag(0)

                ZStack {
                    Color.red
                    Text("tab 1")
                }
                .tag(1)

                ZStack {
                    Color.green
                    Text("tab 2")
                }
                .tag(2)
            }
            .tabViewStyle(.page)
        }
    }
}

Animation