【问题标题】:SwiftUI - detecting Long Press while keeping TabView swipableSwiftUI - 在保持 TabView 可滑动的同时检测长按
【发布时间】:2021-09-20 19:01:18
【问题描述】:

我正在尝试检测 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 也不起作用。

【问题讨论】:

    标签: swift swiftui gesture tabview swiftui-tabview


    【解决方案1】:

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

    【讨论】:

    • 那是因为正如我所提到的,TimelineView 是全新的,仅在 SwiftUI 3.0 中可用,因此为 IOS 15 构建。如果您不能这样做,请将其放在 ContentView 的顶部:private let timer = Timer.publish(every: 2.75, on: .main, in: .common).autoconnect()这在// When the timer receives an update .onReceive(timer) { _ in update a @State Date variable here } // When the view disappears .onDisappear { self.timer.upstream.connect().cancel() } ContentView 内容的末尾。
    • 知道了。谢谢!
    • 我跳得太快了。我应该更仔细地阅读你的答案。 :)
    • 这将需要您实现一个计时器变量和一个@State 变量来跟踪计时器的变化,这就是我使用 TimeLineView 对其进行简化的原因。你可以传入任何类型的状态变量,只要它被 Timer 的 onReceive 改变并在 CustomTabView 的 onChanged 中监听
    • 实际上 - 计时器需要与 TimeLineView 在同一范围内,因此它应该在 CustomTabView 中,您应该替换 currentPage 中的 .onChange(of: date) {
    【解决方案2】:

    我发现最好和最干净的解决方案就是在幻灯片放映处于活动状态时在 tabView 顶部添加一个清晰的视图,并将手势识别器放在上面。

    我没有展示取决于您的设计的启动停止计时器的实现。

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

    【讨论】:

    • 那你又在哪里把slideShowPlaying设置为true呢?
    猜你喜欢
    • 2020-04-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多