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
1 Answer
Reset to default 0When 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"
}
}
}