首页 > 解决方案 > SwiftUI - 在保持 TabView 可滑动的同时检测长按

问题描述

我正在尝试检测可滑动的 TabView 上的长按手势。

问题是它目前禁用了 TabView 的可滑动行为。在单个 VStack 上应用手势也不起作用 - 如果我点击背景,则不会检测到长按。

这是我的代码的简化版本 - 它可以复制粘贴到 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())

总体思路是,此 TabView 将自动旋转幻灯片,但将手指放在其中任何一个上都会暂停旋转(类似于 Instagram 故事)。为简单起见,我删除了该逻辑。

更新:使用 DragGesture 也不起作用。

标签: swiftswiftuigesturetabviewswiftui-tabview

解决方案


这里的问题在于 SwiftUI 中动画的优先级。因为TabView是一个我们无法改变的结构体,所以它的动画检测优先级不能真正改变。对此的解决方案,无论多么笨拙,都是编写我们自己的具有预期行为的自定义选项卡视图。对于这里有多少代码,我深表歉意,但是您描述的行为非常复杂。本质上,我们有一个TimeLineView向我们的视图发送自动更新,告诉它更改页面,就像你在 Instagram 上看到的那样。TimeLineView是一个新功能,所以如果你想让它在老学校工作,你可以用 aTimer和它的onReceive方法替换它,但我使用它是为了简洁。在页面本身中,我们正在监听此更新,但只有在有空间的情况下才会将页面实际更改为下一个而且我们并不长按视图。我们使用.updating修饰符LongPressGesture来准确地知道我们的手指何时仍在屏幕上。这与 aLongPressGesture组合在一起,因此也可以激活拖动。在拖动手势中,我们等待用户的鼠标/手指穿过屏幕的一定百分比,然后再对页面的变化进行动画处理。向后滑动时,我们会发起一个异步请求,在动画完成后将动画方向设置回向前滑动,以便从SimultaneousGestureDragGestureTimeLineView无论我们以哪种方式滑动,仍然朝着正确的方向动画。在这里使用自定义手势有一个额外的好处,如果你选择这样做,你可以实现一些花哨的几何效果来更接近地模拟 Instagram 的动画。同时,我们CustomPageView仍然是完全可交互的,这意味着我仍然可以点击button1并查看它的onTapGesture打印信息!Views像我一样将结构作为泛型传入的一个警告CustomTabView是,所有视图都必须属于同一类型,这也是页面现在本身就是可重用结构的部分原因。如果您对使用此方法可以/不能做什么有任何疑问,请告诉我,但我只是在 Playground 中与您一样运行它,它的工作原理与描述的完全一样。

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())

推荐阅读