TabView content jitters on animated frame change

In my app I have TabView PageTabViewStyle(indexDisplayMode: .never) style and I want to display banner view on the top of the screen which, when displayed, pushes all other content down. Basic view setup is:

struct ContentView: View {
  @StateObject private var viewModel = ContentViewModel()
  @State var selectedTab = 0
  var body: some View {
      return VStack(spacing: 0) {
        if viewModel.showingBanner {
          bannerView
        }
        TabView(selection: $selectedTab, content: {
          ForEach(0..<3) { index in
            tabContents(index)
              .tag(index)
          }
        })
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
      }
      .ignoresSafeArea(edges: [.bottom])

  }

  func tabContents(_ index: Int) -> some View {
    VStack {
      Text("Hello, \(index)!")
        .font(.system(size: 48, weight: .bold))
    }
  }

  var bannerView: some View {
    VStack {
      Label("Banner", systemImage: "exclamationmark.triangle")
        .font(.largeTitle)
        .foregroundStyle(.white)
        .padding(4)
    }
    .frame(maxWidth: .infinity)
    .background(.red)
    .onTapGesture {
      withAnimation {
        viewModel.showingBanner = false
      }
      DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        withAnimation {
          viewModel.showingBanner = true
        }
      }
    }
  }
}

The problem is that when banner view is added, tab view content jitters during animation. There's several workarounds that make jittering go away, but by using them I lose some functionality:

  • remove .ignoresSafeArea(edges: [.bottom]) - I need content to extend into safe area
  • don't use page tabViewStyle - I lose scrolling between tabs
  • Use plain ScrollView - I lose automatic binding to the selected tab

Am I missing something in my TabView or content setup that causes this issue or my only recourse is to switch to using ScrollView instead of TabView for my use case?

Unfortunately I cannot embed video in the post, here's link to the screen recording showing the issue: https://youtube.com/shorts/_wUWgUWJ-Bk

I don't recommend combining GCD/DispatchQueue with SwiftUI’s state management or async/await. You should consider moving the showBanner logic to your model and using Task.Sleep for the delay.

Also, TabViews are navigation containers, so it's unclear why they are embedded in a VStack in your example. Could you clarify this? A better way to display the banner is to add it to the safe area. For example

import SwiftUI

struct TabItem: Identifiable, Hashable {
    let id: Int
    let title: String
}

@Observable
class ContentViewModel {
    var showingBanner: Bool = true
    var tabItems: [TabItem] = [
        TabItem(id: 0, title: "First"),
        TabItem(id: 1, title: "Second"),
        TabItem(id: 2, title: "Third")
    ]
    
    func temporarilyHideBanner(for duration: TimeInterval = 2.0) {
        Task {
            self.showingBanner = false
            try await Task.sleep(for: .seconds(duration))
            self.showingBanner = true

        }
    }
}

struct ContentView: View {
    @State private var viewModel = ContentViewModel()
    @State var selectedTab: Int = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(viewModel.tabItems) { tab in
                tabContents(tab)
                    .tag(tab.id)
            }
        }
        .tabViewStyle(.page)
        .safeAreaInset(edge: .top) {
            if viewModel.showingBanner {
                bannerView
            }
        }
        .ignoresSafeArea(edges: [.bottom])
    }

    func tabContents(_ tab: TabItem) -> some View {
        VStack {
            Text("Hello, \(tab.title)!")
                .font(.system(size: 48, weight: .bold))
        }
    }

    var bannerView: some View {
        VStack {
            Label("Banner", systemImage: "exclamationmark.triangle")
                .font(.largeTitle)
                .foregroundStyle(.white)
                .padding(4)
        }
        .frame(maxWidth: .infinity)
        .background(.red)
        .onTapGesture {
            viewModel.temporarilyHideBanner()
        }
    }
}

Thanks for the feedback!

I don't recommend combining GCD/DispatchQueue with SwiftUI’s state management or async/await. You should consider moving the showBanner logic to your model and using Task.Sleep for the delay.

Sure, I did this just for this demo example

Also, TabViews are navigation containers, so it's unclear why they are embedded in a VStack in your example.

In real app content is more complex, think a horizontal carousel embedded inside parent view controller. I guess we used it because it provided binding to selected view out of the box and worked for older iOS versions.

A better way to display the banner is to add it to the safe area.

This makes view code a bit cleaner indeed, thanks. Unfortunately it did not solve animation issue though.

I ended up just using paged ScrollView and scrollPosition provided binding to selected view, it worked like a charm although it's limited to iOS 17+

TabView content jitters on animated frame change
 
 
Q