最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - How can I use animation in a TabView for an onboarding flow? - Stack Overflow

programmeradmin1浏览0评论

I'm trying to create an animated onboarding experience where the content of each tab animates when it appears. My code does work as intended when swiping between tabs:

However, it behaves inconsistently when using a button to switch tabs — sometimes the animations play only partially, and other times they don't play at all:

struct TabDemoView: View {
    var onComplete: (() -> Void)? = nil
    @State private var currentPage = 0
    var body: some View {
        VStack {
            TabView(selection: $currentPage) {
                ForEach(TabDemoPage.allCases.indices, id: \.self) { index in
                    let page = TabDemoPage.allCases[index]
                    getPageView(for: page, shown: currentPage == index).tag(page.rawValue)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .animation(.spring(), value: currentPage)

            Spacer()

            HStack(spacing: 12) {
                ForEach(TabDemoPage.allCases.indices, id: \.self) { index in
                    let circleSize: CGFloat = index == currentPage ? 12 : 8
                    let circleColor: Color = currentPage == index ? .blue : .gray.opacity(0.5)
                    Circle()
                        .fill(circleColor)
                        .frame(width: circleSize, height: circleSize)
                        .animation(.spring(), value: currentPage)
                }
            }

            Group {
                if currentPage < OnboardingPage.allCases.count - 1 {
                    Button("Next") { currentPage += 1 }
                } else {
                    Button("Get Started") { onComplete?() }
                }
            }
            .padding()
            .foregroundColor(.primary)
        }
        .padding(.bottom, 40)
    }

    @ViewBuilder
    private func getPageView(for page: TabDemoPage, shown: Bool) -> some View {
        VStack {
            Spacer()
            Text(page.content)
                .opacity(shown ? 1 : 0)
                .scaleEffect(shown ? 1 : 4)
                .animation(.spring(), value: shown)
            Spacer()
        }

    }
}

enum TabDemoPage: Int, CaseIterable {
    case pageOne
    case pageTwo
    case pageThree
    case pageFour
    case pageFive
    case pageSix

    var content: String {
        switch self {
        case .pageOne:      "Page One"
        case .pageTwo:      "Page Two"
        case .pageThree:    "Page Three"
        case .pageFour:     "Page Four"
        case .pageFive:     "Page Five"
        case .pageSix:      "Page Six"
        }
    }
}

I'm trying to create an animated onboarding experience where the content of each tab animates when it appears. My code does work as intended when swiping between tabs:

However, it behaves inconsistently when using a button to switch tabs — sometimes the animations play only partially, and other times they don't play at all:

struct TabDemoView: View {
    var onComplete: (() -> Void)? = nil
    @State private var currentPage = 0
    var body: some View {
        VStack {
            TabView(selection: $currentPage) {
                ForEach(TabDemoPage.allCases.indices, id: \.self) { index in
                    let page = TabDemoPage.allCases[index]
                    getPageView(for: page, shown: currentPage == index).tag(page.rawValue)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .animation(.spring(), value: currentPage)

            Spacer()

            HStack(spacing: 12) {
                ForEach(TabDemoPage.allCases.indices, id: \.self) { index in
                    let circleSize: CGFloat = index == currentPage ? 12 : 8
                    let circleColor: Color = currentPage == index ? .blue : .gray.opacity(0.5)
                    Circle()
                        .fill(circleColor)
                        .frame(width: circleSize, height: circleSize)
                        .animation(.spring(), value: currentPage)
                }
            }

            Group {
                if currentPage < OnboardingPage.allCases.count - 1 {
                    Button("Next") { currentPage += 1 }
                } else {
                    Button("Get Started") { onComplete?() }
                }
            }
            .padding()
            .foregroundColor(.primary)
        }
        .padding(.bottom, 40)
    }

    @ViewBuilder
    private func getPageView(for page: TabDemoPage, shown: Bool) -> some View {
        VStack {
            Spacer()
            Text(page.content)
                .opacity(shown ? 1 : 0)
                .scaleEffect(shown ? 1 : 4)
                .animation(.spring(), value: shown)
            Spacer()
        }

    }
}

enum TabDemoPage: Int, CaseIterable {
    case pageOne
    case pageTwo
    case pageThree
    case pageFour
    case pageFive
    case pageSix

    var content: String {
        switch self {
        case .pageOne:      "Page One"
        case .pageTwo:      "Page Two"
        case .pageThree:    "Page Three"
        case .pageFour:     "Page Four"
        case .pageFive:     "Page Five"
        case .pageSix:      "Page Six"
        }
    }
}
Share Improve this question edited Mar 30 at 19:51 Benzy Neez 23.2k3 gold badges15 silver badges44 bronze badges asked Mar 30 at 15:52 routernroutern 151 silver badge4 bronze badges
Add a comment  | 

1 Answer 1

Reset to default 0

When you navigate by swiping, the index of the currently selected view probably doesn't change until the new page is partially visible. So in this case, the full animation is seen. However, when you navigate by tapping the button, the index changes before the new page comes into view. This may explain why it seems that there is no animation, because it is mainly happening off-screen and therefore not being seen.

There may also be other reasons why the animation is inconsistent, depending on how the TabView pre-loads the view or keeps it cached.

Since you are using a paged Tabview and also hiding the page indicators, you don't really need to be using a TabView at all. You might have more control of the animation if you use a ScrollView with sticky behavior instead:

  • The scroll view can contain an HStack with .scrollTargetLayout.
  • The current page is tracked using .scrollPosition.
  • Sticky scrolling is achieved by using .scrollTargetBehavior. I would recommend using .viewAligned instead of .paging, to avoid issues with safe area insets on devices with leading or trailing safe area insets (such as an iPad, or an iPhone in landscape orientation).
  • To work around the issue of the index changing before the view appears, I would suggest basing the animation on whether the view is actually near the center of the screen or not. This can be detected using an .onGeometryChange modifier.
  • When a view is going away, I'm guessing you don't want to scale it back up until it has disappeared off screen. This means using a different threshold for the animation when a page is moving into view, compared to when it is moving out of view.
  • It is also a good idea to clip each page view to its own frame, so that the off-screen scaled version doesn't overflow into the current version during animation.

Since the page views will be detecting their own position, they need to be able to update a dedicated state variable. This means factoring the page view out into a separate View.

While we're at it, I would also suggest making the enum Identifiable (and Hashable). You can then avoid all use of array indices.

Here is the fully updated example to show it working:

struct TabDemoView: View {
    var onComplete: (() -> Void)? = nil
    @State private var currentPage: TabDemoPage? = .pageOne

    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                HStack(spacing: 0) {
                    ForEach(TabDemoPage.allCases) { page in
                        PageView(page: page)
                            .containerRelativeFrame(.horizontal)
                            .clipped()
                    }
                }
                .scrollTargetLayout()
            }
            .scrollPosition(id: $currentPage, anchor: .center)
            .scrollTargetBehavior(.viewAligned(limitBehavior: .always))
            .scrollIndicators(.hidden)

            Spacer()

            HStack(spacing: 12) {
                ForEach(TabDemoPage.allCases) { page in
                    let circleSize: CGFloat = currentPage == page ? 12 : 8
                    let circleColor: Color = currentPage == page ? .blue : .gray.opacity(0.5)
                    Circle()
                        .fill(circleColor)
                        .frame(width: circleSize, height: circleSize)
                        .animation(.spring(), value: currentPage)
                }
            }

            Group {
                if currentPage != TabDemoPage.allCases.last {
                    Button("Next") {
                        withAnimation(.spring()) {
                            currentPage = currentPage?.next ?? .pageOne
                        }
                    }
                } else {
                    Button("Get Started") { onComplete?() }
                }
            }
            .padding()
            .foregroundColor(.primary)
        }
        .padding(.bottom, 40)
    }
}

struct PageView: View {
    let page: TabDemoPage
    @State private var shown = false

    var body: some View {
        Text(page.content)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .opacity(shown ? 1 : 0)
            .scaleEffect(shown ? 1 : 4)
            .animation(.spring(), value: shown)
            .onGeometryChange(for: Bool.self) { proxy in
                let midX = proxy.frame(in: .scrollView).midX
                let fullWidth = proxy.size.width
                let halfWidth = fullWidth / 2
                return abs(halfWidth - midX) < (shown ? fullWidth * 9 / 10 : halfWidth)
            } action: { isShown in
                shown = isShown
            }
    }
}

enum TabDemoPage: Identifiable, Hashable, CaseIterable {
    case pageOne
    case pageTwo
    case pageThree
    case pageFour
    case pageFive
    case pageSix

    var id: TabDemoPage {
        self
    }

    var next: TabDemoPage {
        switch self {
        case .pageOne: .pageTwo
        case .pageTwo: .pageThree
        case .pageThree: .pageFour
        case .pageFour: .pageFive
        case .pageFive: .pageSix
        case .pageSix: .pageSix
        }
    }

    var content: String {
        switch self {
        case .pageOne:      "Page One"
        case .pageTwo:      "Page Two"
        case .pageThree:    "Page Three"
        case .pageFour:     "Page Four"
        case .pageFive:     "Page Five"
        case .pageSix:      "Page Six"
        }
    }
}

发布评论

评论列表(0)

  1. 暂无评论