Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI - detecting Long Press while keeping TabView swipable

I'm trying to detect a Long Press gesture on TabView that's swipable.

The issue is that it disables TabView's swipable behavior at the moment. Applying the gesture on individual VStacks didn't work either - the long press doesn't get detected if I tap on the background.

Here's a simplified version of my code - it can be copy-pasted into Swift Playground:

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State var currentSlideIndex: Int = 0
    @GestureState var isPaused: Bool = false
    
    var body: some View {
        
        let tap = LongPressGesture(minimumDuration: 0.5,
                                   maximumDistance: 10)
            .updating($isPaused) { value, state, transaction in
                state = value
            }
        
        Text(isPaused ? "Paused" : "Not Paused")
        TabView(selection: $currentSlideIndex) {
            VStack {
                Text("Slide 1")
                Button(action: { print("Slide 1 Button Tapped")}, label: {
                    Text("Button 1")
                })
            }
            VStack {
                Text("Slide 2")
                Button(action: { print("Slide 2 Button Tapped")}, label: {
                    Text("Button 2")
                })
            }
        }
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
        .frame(width: 400, height: 700, alignment: .bottom)
        .simultaneousGesture(tap)
        .onChange(of: isPaused, perform: { value in
            print("isPaused: \(isPaused)")
        })
    }
}

PlaygroundPage.current.setLiveView(ContentView())

The overall idea is that this TabView will be rotating the slides automatically but holding a finger on any of them should pause the rotation (similar to Instagram stories). I removed that logic for simplicity.

Update: using DragGesture didn't work either.

like image 268
Kirill Kudaev Avatar asked Jul 11 '21 05:07

Kirill Kudaev


2 Answers

The issue here is with the precedence of Animations in SwiftUI. Because TabView is a struct we are unable to change, its animation detection precedence cannot really be changed. The solution to this, however clunky, is to write our own custom tab view that has the expected behavior. I apologize for how much code is here, but the behavior you described is surprisingly complex. In essence, we have a TimeLineView that is sending automatic updates to our view, telling it to change the pages, as you would see on Instagram. TimeLineView is a new feature, so if you want this to work old school, you could replace it with a Timer and its onReceive method, but I'm using this for brevity. In the pages themselves, we are listening for this update, but only actually changing the page to the next one if there is room to do so and we are not long pressing the view. We use the .updating modifier on the LongPressGesture to know exactly when our finger is still on the screen or not. This LongPressGesture is combined in a SimultaneousGesture with a DragGesture, so that the drag can also be activated. In the drag gesture, we wait for the user's mouse/finger to traverse a certain percentage of the screen before animating the change in pages. When sliding backwards, we initiate an async request to set the animation direction back to sliding forwards once the animation completes, so that updates received from the TimeLineView still animate in the correct direction, no matter which way we just swiped. Using custom gestures here has the added benefit that if you choose to do so, you can implement some fancy geometry effects to more closely emulate Instagram's animations. At the same time, our CustomPageView is still fully interactable, which means I can still click on button1 and see it's onTapGesture print message! One caveat of passing in Views to a struct as a generic as I am doing in CustomTabView is that all of the views must be of the same type, which is part of the reason the pages are now reusable structs in their own right. If you have any questions about what you can / can't do with this methodology, let me know, but I've just run this in Playground same as you and it works exactly as described.

import SwiftUI
import PlaygroundSupport

// Custom Tab View to handle all the expected behaviors
struct CustomTabView<Page: View>: View {
    @Binding var pageIndex: Int
    var pages: [Page]
    
    /// Primary initializer for a Custom Tab View
    /// - Parameters:
    ///   - pageIndex: The index controlling which page we are viewing
    ///   - pages: The views to display on each Page
    init(_ pageIndex: Binding<Int>, pages: [() -> Page]) {
        self._pageIndex = pageIndex
        self.pages = pages.map { $0() }
    }

    struct currentPage<Page: View>: View {
        @Binding var pageIndex: Int
        @GestureState private var isPressingDown: Bool = false
        @State private var forwards: Bool = true
        private let animationDuration = 0.5
        var pages: [Page]
        var date: Date
        
        /// - Parameters:
        ///   - pageIndex: The index controlling which page we are viewing
        ///   - pages: The views to display on each Page
        ///   - date: The current date
        init(_ pageIndex: Binding<Int>, pages: [Page], date: Date) {
            self._pageIndex = pageIndex
            self.pages = pages
            self.date = date
        }
        
        var body: some View {
            // Ensure that the Page fills the screen
            GeometryReader { bounds in
                ZStack {
                    // You can obviously change this to whatever you like, but it's here right now because SwiftUI will not look for gestures on a clear background, and the CustomPageView I implemented is extremely bare
                    Color.red
                    
                    // Space the Page horizontally to keep it centered
                    HStack {
                        Spacer()
                        pages[pageIndex]
                        Spacer()
                    }
                }
                // Frame this ZStack with the GeometryReader's bounds to include the full width in gesturable bounds
                .frame(width: bounds.size.width, height: bounds.size.height)
                // Identify this page by its index so SwiftUI knows our views are not identical
                .id("page\(pageIndex)")
                // Specify the transition type
                .transition(getTransition())
                .gesture(
                    // Either of these Gestures are allowed
                    SimultaneousGesture(
                        // Case 1, we perform a Long Press
                        LongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity)
                            // Sequence this Gesture before an infinitely long press that will never trigger
                            .sequenced(before: LongPressGesture(minimumDuration: .infinity))
                            // Update the isPressingDown value
                            .updating($isPressingDown) { value, state, _ in
                                switch value {
                                    // This means the first Gesture completed
                                    case .second(true, nil):
                                        // Update the GestureState
                                        state = true
                                    // We don't need to handle any other case
                                    default: break
                                }
                            },
                        // Case 2, we perform a Drag Gesture
                        DragGesture(minimumDistance: 10)
                            .onChanged { onDragChange($0, bounds.size) }
                    )
                )
            }
            // If the user releases their finger, set the slide animation direction back to forwards
            .onChange(of: isPressingDown) { newValue in
                if !newValue { forwards = true }
            }
            // When we receive a signal from the TimeLineView
            .onChange(of: date) { _ in
                // If the animation is not pause and there are still pages left to show
                if !isPressingDown && pageIndex < pages.count - 1{
                    // This should always say sliding forwards, because this will only be triggered automatically
                    print("changing pages by sliding \(forwards ? "forwards" : "backwards")")
                    // Animate the change in pages
                    withAnimation(.easeIn(duration: animationDuration)) {
                        pageIndex += 1
                    }
                }
            }
        }
        
        /// Called when the Drag Gesture occurs
        private func onDragChange(_ drag: DragGesture.Value, _ frame: CGSize) {
            // If we've dragged across at least 15% of the screen, change the Page Index
            if abs(drag.translation.width) / frame.width > 0.15 {
                // If we're moving forwards and there is room
                if drag.translation.width < 0 && pageIndex < pages.count - 1 {
                    forwards = true
                    withAnimation(.easeInOut(duration: animationDuration)) {
                        pageIndex += 1
                    }
                }
                // If we're moving backwards and there is room
                else if drag.translation.width > 0 && pageIndex > 0 {
                    forwards = false
                    withAnimation(.easeInOut(duration: animationDuration)) {
                        pageIndex -= 1
                    }
                    DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
                        forwards = true
                    }
                }
            }
        }
        
        // Tell the view which direction to slide
        private func getTransition() -> AnyTransition {
            // If we are swiping left / moving forwards
            if forwards {
                return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
            }
            // If we are swiping right / moving backwards
            else {
                return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
            }
        }
    }
    
    var body: some View {
        ZStack {
            // Create a TimeLine that updates every five seconds automatically
            TimelineView(.periodic(from: Date(), by: 5)) { timeLine in
                // Create a current page struct, as we cant react to timeLine.date changes in this view
                currentPage($pageIndex, pages: pages, date: timeLine.date)
            }
        }
    }
}

// This is the view that becomes the Page in our Custom Tab View, you can make it whatever you want as long as it is reusable
struct CustomPageView: View {
    var title: String
    var buttonTitle: String
    var buttonAction: () -> ()
    
    var body: some View {
        VStack {
            Text("\(title)")
            Button(action: { buttonAction() }, label: { Text("\(buttonTitle)") })
        }
    }
}

struct ContentView: View {
    @State var currentSlideIndex: Int = 0
    @GestureState var isPaused: Bool = false
    
    var body: some View {
        CustomTabView($currentSlideIndex, pages: [
            {
                CustomPageView(title: "slide 1", buttonTitle: "button 1", buttonAction: { print("slide 1 button tapped") })
                
            },
            {
                CustomPageView(title: "slide 2", buttonTitle: "button 2", buttonAction: { print("slide 2 button tapped") })
            }]
        )
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
        .frame(width: 400, height: 700, alignment: .bottom)
    }
}

PlaygroundPage.current.setLiveView(ContentView())
like image 149
Vera Gonzalez Avatar answered Nov 16 '22 03:11

Vera Gonzalez


I found the best and cleanest solution to this is just to add a clear view on top of your tabView when the slide show is active and put the gesture recognizer on that.

I haven't shown the implementation of the start stop timer which depends on your design.

private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
@State var slideshowPlaying = false
@State var selection = 0

var body: some View {
    ZStack {
        
        TabView(selection: $selection) {
            ForEach(modelArray.indices, id: \.self) { index  in
                SomeView()
                    .tag(index)
                
            }
        }
        .tabViewStyle(PageTabViewStyle())
        .background(Color(.systemGroupedBackground))
        .onReceive(self.timer) { _ in
            if selection < modelArray.count + 1 {
                selection += 1
            } else {
                selection = 0
            }
        }

        if slideshowPlaying {
            Color.clear
                .contentShape(Rectangle())
                .gesture(DragGesture(minimumDistance: 0).onChanged { _ in
                    slideshowPlaying = false
                })
        }
    }
}
like image 35
alionthego Avatar answered Nov 16 '22 03:11

alionthego