Esera
Esera

Reputation: 277

SwiftUI TabView with PageTabViewStyle dynamic height based on Content

How do I make my SwiftUI TabView with a PageTabViewStyle adjust its height to the height of the content?

I have a SwiftUI view like follows:

struct TabViewDynamicHeight: View {
    var body: some View {
        VStack {
            TabView {
                ForEach(0..<5, id: \.self) { index in
                    VStack {
                        Text("Text \(index)")
                        Text("Text \(index)")
                        Text("Text \(index)")
                    }
                }
            }
            .tabViewStyle(PageTabViewStyle())
            .background(Color.red)
            .fixedSize(horizontal: false, vertical: true)
        }
        .background(Color.blue)
    }
}

This produces an output like this:

enter image description here

You can see, that the content of the TabView is cut off. I'm aware, that I can remove .fixedSize, but than the view looks like this:

enter image description here

I would like the TabView to respond to the height of the content. Any ideas on how to achieve that?

Upvotes: 14

Views: 4384

Answers (3)

Florent Morin
Florent Morin

Reputation: 912

I found a solution which works fine with accessibility.

  1. Statically define a font for your texts

  2. define height for each ContentSizeCategory (if you are using system fonts, you can use Typography specifications from Apple HIG.

  3. Dynamically generate a UIFont using UIFont.withSize(_) from ContentSizeCategory retrieved using @Environment(\.sizeCategory)

  4. Display text using with a SwiftUI Font based on UIFont

  5. Use GeometryProxy to retrieve width of your screen.

  6. From screen width, you can calculate remaining width for your text based on margins and components size

  7. You can calculate minimum height with margins and components around your text

  8. With CoreText and ContentSizeCategory, you can calculate height of each text by using NSAttributedString, CTFramesetterCreateWithAttributedString() and CTFramesetterSuggestFrameSizeWithConstraints()

  9. For each item that will be displayed on your TabView, there is an height that can be calculated from @Environment(\.sizeCategory) and GeometryProxy. And you can also calculate the maximum height of all items.

  10. Apply the maximum height for all views inside TabView

  11. Enjoy 😉

Upvotes: 0

Jaafar Mahdi
Jaafar Mahdi

Reputation: 713

I took inspiration from Asperi' answer, however I had to modify some things to make it work in my case.

On the 'Content' of the TabView, I added these:

.overlay(GeometryReader { proxy in
   Color.clear.preference(key: ViewRectKey.self, value: proxy.size)
 })
 .onPreferenceChange(ViewRectKey.self) { size in
    if self.viewPagerSize.height == .zero {
      self.viewPagerSize = size
    }
 }

Where viewPagerSize is initially:

@State var viewPagerSize: CGSize = .zero

My PreferenceKey looks like this:

struct ViewRectKey: PreferenceKey {
   static let defaultValue: CGSize = .zero
   static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
      value = nextValue()
   }
}

In total the code looks like this:

@ViewBuilder
func createViewPager() -> some View {
    TabView {
        ForEach(views(), id: \.id) { _ in
            createInformationSquareView()
                .background(Color(.systemGray6))
                .overlay(GeometryReader { proxy in
                    Color.clear.preference(key: ViewRectKey.self, value: proxy.size)
                })
                .onPreferenceChange(ViewRectKey.self) { size in
                    if self.viewPagerSize.height == .zero {
                        self.viewPagerSize = size
                    }
                }
        }
    }
    .cornerRadius(10)
    .frame(height: self.viewPagerSize.height + 60)
    .tabViewStyle(.page)
    .indexViewStyle(.page(backgroundDisplayMode: .always))
    .padding()
}

Upvotes: 0

Asperi
Asperi

Reputation: 257663

A possible approach is to fetch content rect dynamically in run-time and transfer to parent via view prefs, so parent can set it as frame to fit content.

Tested with Xcode 13.3 / iOS 15.4

demo

Here is main part:

VStack {
    Text("Text \(index)")
    Text("Text \(index)")
    Text("Text \(index)")
}
.frame(maxWidth: .infinity)
.background(GeometryReader {
    Color.clear.preference(key: ViewRectKey.self,
                                  value: [$0.frame(in: .local)])
})

// ...

.frame(height: rect.size.height
         + 60 /* just to avoid page indicator overlap */)
.onPreferenceChange(ViewRectKey.self) { rects in
    self.rect = rects.first ?? .zero
}

Complete test code in project is here

Upvotes: 4

Related Questions