SwiftUI and TabView height

Today, a SwiftUI recipe.

Problem

In SwiftUI, the TabView component doesn’t report how much vertical size it needs. Here’s how it manifests. This layout kind of works:

VStack {
  TabView(selection: $selection) {
    HorizontallyScrollingContent()
  }.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
  ScrollView {
    VerticallyScrollingContent()
  }
}

Because the TabView doesn’t say how much space it needs, it’ll get half the available vertical space in the VStack, and the scroll view with its VerticallyScrollingContent() will get the other half. However, I was running into cases where “half the space” wasn’t enough, so I tried this:

ScrollView {
  TabView(selection: $selectedPage) {
    HorizontallyScrollingContent()
  }.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
  VerticallyScrollingContent()
}

I expected the contents of the HorizontallyScrollingContent() on top of the VerticallyScrollingContent(), and the whole thing scrolls vertically. (In other words, the HorizontallyScrollingContent() scrolls away, like a header.)

What actually happened? HorizontallyScrollingContent() didn’t show up at all, because TabView doesn’t tell the ScrollView how much space it needs.

The solution

Write a component that uses GeometryReader and preferences to report its size. (Hat tip: this project — I wouldn’t have figured this out on my own. My only improvement is putting it into an easy-to-reuse component.)

/// A variant of `TabView` that sets an appropriate `minHeight` on its frame.
struct HeightPreservingTabView<SelectionValue: Hashable, Content: View>: View {
  var selection: Binding<SelectionValue>?
  @ViewBuilder var content: () -> Content

  // `minHeight` needs to start as something non-zero or we won't measure the interior content height
  @State private var minHeight: CGFloat = 1

  var body: some View {
    TabView(selection: selection) {
      content()
        .background {
          GeometryReader { geometry in
            Color.clear.preference(
              key: TabViewMinHeightPreference.self,
              value: geometry.frame(in: .local).height
            )
          }
        }
    }
    .frame(minHeight: minHeight)
    .onPreferenceChange(TabViewMinHeightPreference.self) { minHeight in
      self.minHeight = minHeight
    }
  }
}

private struct TabViewMinHeightPreference: PreferenceKey {
  static var defaultValue: CGFloat = 0

  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    // It took me so long to debug this line
    value = max(value, nextValue())
  }
}

Gotchas

I spent a lot of time frustrated because I didn’t understand SwiftUI preferences and wrote the preference key wrong. I started with this:

private struct TabViewMinHeightPreference: PreferenceKey {
  static var defaultValue: CGFloat = 0

  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    value = nextValue()
  }
}

My thinking was there was no need to really “reduce” the min height preference, because only one thing would report its height.

However! My mental model of preferences was wrong, and this article helped straighten me out. It turns out every view will have the value you are looking for with the preference key. Views get the defaultValue if you don’t specify something else. So my original code worked only if the last child inside the TabView reported its height. As soon as I changed things even a little, things stopped working. (I still find it really hard to debug SwiftUI because I don’t know where to put breakpoints or print statements.) Once I understood that every view gets a preference value, I realized I had to change my reduce logic to value = max(value, nextValue()), and things started working reliably.