SwiftUI - 在保持 TabView 可滑动的同时检测长按

Kir*_*aev 7 gesture tabview swift swiftui 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())
Run Code Online (Sandbox Code Playgroud)

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

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

Ver*_*lez 7

这里的问题在于 SwiftUI 中动画的优先级。因为TabView它是一个我们无法更改的结构,所以它的动画检测优先级实际上无法更改。解决这个问题的方法,无论多么笨拙,都是编写我们自己的具有预期行为的自定义选项卡视图。

对于这里有多少代码,我深表歉意,但您所描述的行为非常复杂。本质上,我们有一个TimeLineView向我们的视图发送自动更新,告诉它更改页面,就像您在 Instagram 上看到的那样。TimeLineView是一个新功能,所以如果您希望它能像旧式那样工作,您可以将其替换为 aTimer及其onReceive方法,但为了简洁起见,我使用它。

在页面本身中,我们正在侦听此更新,但只有在有空间并且我们没有长时间按下视图的情况下才实际将页面更改为下一页。我们使用.updating上的修饰符来LongPressGesture确切地知道我们的手指何时仍在屏幕上。这与 aLongPressGesture结合在一起,这样也可以激活拖动。在拖动手势中,我们等待用户的鼠标/手指遍历屏幕的一定百分比,然后再以动画方式显示页面的更改。SimultaneousGestureDragGesture

向后滑动时,我们启动一个异步请求,以便在动画完成后将动画方向设置回向前滑动,以便从TimeLineView静止动画接收到的更新方向正确,无论我们以哪种方式滑动。在这里使用自定义手势还有一个额外的好处,如果您选择这样做,您可以实现一些奇特的几何效果,以更紧密地模拟 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())
Run Code Online (Sandbox Code Playgroud)

  • 这是因为正如我所提到的,TimelineView 是全新的,仅在 SwiftUI 3.0 中可用,因此是为 IOS 15 构建的。如果你做不到这一点,请将其放在 ContentView 的顶部: `private let timer = Timer.publish(every: 2.75, on: .main, in: .common).autoconnect()` and this in `// 当计时器收到更新时 .onReceive(timer) { _ in update a @State Date variable here } // 当视图消失 .onDisappear { self.timer.upstream.connect().cancel() }` ContentView 内容的末尾。 (2认同)
  • 知道了。谢谢你! (2认同)
  • 我跳得太快了。我应该更仔细地阅读你的答案。:) (2认同)
  • 这需要您实现一个计时器变量和一个 @State 变量来跟踪计时器的更改,这就是我使用 TimeLineView 简化它的原因。您可以传入任何类型的状态变量,只要它被 Timer 的 onReceive 更改并在 CustomTabView 的 onChanged 中侦听即可 (2认同)
  • 实际上 - 计时器需要与 TimeLineView 处于相同的范围内,因此它应该位于“CustomTabView”中,并且您应该替换“currentPage”中的“.onChange(of: date) {” (2认同)
  • 这是修改后的代码的粘贴箱,不使用“TimeLineView”:https://pastebin.com/uvSFVZ39 (2认同)