0

I'm trying to create a profile screen similar to Twitter or Instagram, where there is a header with a floating tab bar around halfway down the page, and selecting different tabs switches the page of content underneath. The tricky part is that the entire view has to scroll vertically together, but the content on one tab is often a completely different height than the content on another tab. On top of this, the content height is dynamic, as it will increase as more content is fetched from a server, independently of other tabs.

I accomplished this in UIKit a while back, but now I'm updating my app to SwiftUI. I'm close to getting the behaviour I want, but as seen in the video below, switching pages is jarring because the moment the page index updates the content heights are resized. Ideally the content should always line up to the top of the page/bottom of the tab bar, and be able to change seamlessly.

enter image description here

Here's my code so far:

import SwiftUI

struct TestProfileView: View {
    let headerHeight: CGFloat = 300
    let tabBarHeight: CGFloat = 50
    
    static let tab1Height: CGFloat = 100
    static let tab2Height: CGFloat = 800
    
    @State var indexHeights: [Int: CGFloat] = [0: Self.tab1Height, 1: Self.tab2Height]
    @State var tabIndex = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                header
                bottom
            }
            .frame(height: headerHeight + tabBarHeight + indexHeights[tabIndex]!)
        }
    }
    
    private var header: some View {
        Text("Header")
            .frame(maxWidth: .infinity)
            .frame(height: headerHeight)
            .background(Color.green)
    }
    
    private var bottom: some View {
        LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
            Section {
                pager
                    .frame(height: indexHeights[tabIndex])
            } header: {
                tabBar
            }
        }
    }
    
    private var tabBar: some View {
        HStack(spacing: 0) {
            tab(title: "Tab 1", at: 0)
            tab(title: "Tab 2", at: 1)
        }
        .frame(maxWidth: .infinity)
        .frame(height: tabBarHeight)
        .background(Color.gray)
    }
    
    private func tab(title: String, at index: Int) -> some View {
        Button {
            withAnimation {
                tabIndex = index
            }
        } label: {
            Text(title)
                .foregroundColor(.black)
                .frame(width: UIScreen.main.bounds.width / 2)
        }
    }
    
    private var pager: some View {
        TabView(selection: $tabIndex) {
            Text("Content 1")
                .frame(height: Self.tab1Height)
                .frame(maxWidth: .infinity)
                .background(Color.yellow)
                .tag(0)
            Text("Content 2")
                .frame(height: Self.tab2Height)
                .frame(maxWidth: .infinity)
                .background(Color.orange)
                .tag(1)
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
    }
}

// MARK: - Previews

struct TestProfileView_Previews: PreviewProvider {
    static var previews: some View {
        TestProfileView()
    }
}
Grambo
  • 887
  • 8
  • 25

2 Answers2

0

Running in a simulator, it works more smoothly than with your animated gif, but the content sections do not "stick" to the tab bar as you are wanting.

It works better if you replace the TabView with a ZStack(alignment: .top). The default transition is opacity (which works very smoothly), but if you only have two tabs then you might like to slide left/right using .move.

EDIT Other changes in response to comments:

  • I don't think you need to set the heights of the VStack or pager, these adjust their heights dynamically without help. This means, the array of heights is not needed.
  • With the frame modifiers removed, it works to apply different animation speeds to the ScrollView and ZStack, to prevent the tab bar from snapping back so fast.
  • The drag gesture added previously has been made more responsive by performing the index change in .onChanged instead of in .onEnded.

Here is the fully updated version:

struct TestProfileView: View {
    let headerHeight: CGFloat = 300
    let tabBarHeight: CGFloat = 50

    static let tab1Height: CGFloat = 100
    static let tab2Height: CGFloat = 800

    @State var tabIndex = 0

    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                header
                bottom
            }
        }
        .animation(.easeOut(duration: 1), value: tabIndex)
    }

    private var header: some View {
        Text("Header")
            .frame(maxWidth: .infinity)
            .frame(height: headerHeight)
            .background(Color.green)
    }

    private var bottom: some View {
        LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
            Section {
                pager
            } header: {
                tabBar
            }
        }
    }

    private var tabBar: some View {
        HStack(spacing: 0) {
            tab(title: "Tab 1", at: 0)
            tab(title: "Tab 2", at: 1)
        }
        .frame(maxWidth: .infinity)
        .frame(height: tabBarHeight)
        .background(Color.gray)
    }

    private func tab(title: String, at index: Int) -> some View {
        Button {
            withAnimation {
                tabIndex = index
            }
        } label: {
            Text(title)
                .foregroundColor(.black)
                .frame(width: UIScreen.main.bounds.width / 2)
        }
    }

    @State private var appliedIndex = -1
    var selectByDrag: some Gesture {
        DragGesture()
            .onChanged() { value in
                if appliedIndex < 0 &&
                    abs(value.translation.width) > abs(value.translation.height) {
                    appliedIndex = tabIndex
                    withAnimation {
                        tabIndex = value.translation.width > 0 ? 0 : 1
                    }
                }
            }
            .onEnded { value in
                appliedIndex = -1
            }
    }

    private var pager: some View {
        ZStack(alignment: .top) {
            if tabIndex == 0 {
                Text("Content 1")
                    .frame(height: Self.tab1Height)
                    .frame(maxWidth: .infinity)
                    .background(Color.yellow)
                    .transition(.move(edge: .leading))
            } else  {
                Text("Content 2")
                    .frame(height: Self.tab2Height)
                    .frame(maxWidth: .infinity)
                    .background(Color.orange)
                    .transition(.move(edge: .trailing))
            }
        }
        .animation(.easeOut, value: tabIndex)
        .gesture(selectByDrag)
    }
}

If you have more than two tabs then you may want to check out my answer to SwiftUI bi-directional move transition moving the wrong way in certain cases.

Benzy Neez
  • 1,546
  • 2
  • 3
  • 10
  • Thanks for the input. This solution definitely make for a better transition, but it doesn't allow for swiping/dragging between pages. Also if one were to scroll down on a long page, and then switch tabs to a shorter page, the animation is still jumpy. – Grambo Jul 13 '23 at 16:13
  • OK, didn't notice any mention of swiping in the question! But all it needs is a drag gesture on the ```ZStack```, answer updated. Regarding the still-jumpy animation, I guess you're talking about the way the tab bar snaps back into position. This is actually driven by the ```ScrollView``` contracting, so it can be slowed down by adding a different animation to the ScrollView. However, when you do this, you see a delay while the non-visible part contracts. I'll give this some more thought. – Benzy Neez Jul 13 '23 at 17:04
  • Updated again, the tab bar doesn't snap back so quickly now and the drag gesture is also more responsive. See change notes in answer. – Benzy Neez Jul 13 '23 at 18:31
  • Yes you're right, I didn't mention swiping initially. I assumed it was a given as that's how this type of screen works in Instagram, Twitter, Pinterest, etc. Your updated solution is impressive, but it automatically triggers the tab index to change when swiping. I need to be able to slowly drag over and view the next page, like any of the apps mentioned above do. I don't think this will be possible by using `if tabIndex == 0 { ... } else { ... }`, as both pages need to be visible at the same time when being dragged. – Grambo Jul 13 '23 at 21:47
0

The swipe behavior that was explained in the comments to my first answer will require quite a different layout. This brings its own issues, so I'm adding as a separate answer.

Instead of using a ZStack for the sub-views, I would now suggest using an HStack, where each sub-view is set to the full screen width. A GeometryReader is used to find this width, instead of the deprecated UIScreen.main. An x-offset is applied to the HStack to bring the selected sub-view into full view.

The drag gesture now allows a glimpse of the next view. When drag is released, it snaps to the full view. You might want to make the logic more sophisticated where it decides whether to stay on the same tab or move to the neighbouring tab.

Here's the new version, more comments below:

struct TestProfileView: View {
    let headerHeight: CGFloat = 300
    let tabBarHeight: CGFloat = 50

    static let tab1Height: CGFloat = 100
    static let tab2Height: CGFloat = 800

    @State var tabIndex = 0
    @GestureState var dragOffset = CGSize.zero

    var body: some View {

        // The GeometryReader must contain the ScrollReader, not
        // the other way around, otherwise scrolling doesn't work
        GeometryReader { geometryProxy in
            ScrollViewReader { scrollViewProxy in
                ScrollView {
                    VStack(spacing: 0) {
                        header.id(0)
                        bottom(viewWidth: geometryProxy.size.width)
                    }
                }
                // Scroll back to the header when the tab changes
                .onChange(of: tabIndex) { newValue in
                    withAnimation(.easeInOut(duration: 1)) {
                        scrollViewProxy.scrollTo(0)
                    }
                }
            }
        }
    }

    private var header: some View {
        Text("Header")
            .frame(maxWidth: .infinity)
            .frame(height: headerHeight)
            .background(Color.green)
    }

    private func bottom(viewWidth: CGFloat) -> some View {
        LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
            Section {
                pager(viewWidth: viewWidth)
            } header: {
                tabBar(viewWidth: viewWidth)
            }
        }
    }

    private func tabBar(viewWidth: CGFloat) -> some View {
        HStack(spacing: 0) {
            tab(title: "Tab 1", at: 0, viewWidth: viewWidth)
            tab(title: "Tab 2", at: 1, viewWidth: viewWidth)
        }
        .frame(maxWidth: .infinity)
        .frame(height: tabBarHeight)
        .background(Color.gray)
    }

    private func tab(title: String, at index: Int, viewWidth: CGFloat) -> some View {
        Button {
            withAnimation {
                tabIndex = index
            }
        } label: {
            Text(title)
                .foregroundColor(.black)
                .frame(width: viewWidth / 2)
        }
    }

    func selectByDrag(viewWidth: CGFloat) -> some Gesture {
        DragGesture()
            .updating($dragOffset) { value, state, transaction in
                let translation = value.translation

                // Only interested in horizontal drag
                if abs(translation.width) > abs(translation.height) {
                    state = translation
                }
            }
            .onEnded { value in
                let translation = value.translation

                // Switch view if the translation is more than a
                // threshold (half the view width)
                if abs(translation.width) > abs(translation.height) &&
                    abs(translation.width) > viewWidth / 2 {
                    tabIndex = translation.width > 0 ? 0 : 1
                }
            }
    }

    private func pager(viewWidth: CGFloat) -> some View {
        HStack(alignment: .top, spacing: 0) {
            Text("Content 1")
                .frame(width: viewWidth, height: Self.tab1Height)
                .background(Color.yellow)
            Text("Content 2")
                .frame(width: viewWidth, height: Self.tab2Height)
                .background(Color.orange)
        }
        .fixedSize()
        .offset(x: (CGFloat(-tabIndex) * viewWidth) + dragOffset.width)
        .animation(.easeInOut, value: tabIndex)
        .animation(.easeInOut, value: dragOffset)
        .gesture(selectByDrag(viewWidth: viewWidth))
    }
}

The main complication is that there is only one ScrollView. This is so that the header and tab bar scroll together with the pager content. So this has some unfortunate consequences:

  • A short sub-view can be scrolled away.
  • The empty area below a short sub-view responds to scrolling.
  • After scrolling down on a large sub-view, swiping to a neighbouring short view displays an empty space.

These issues could be avoided if each sub-view would be nested in its own ScrollView, instead of one outer ScrollView. But then the header and tab bar will not scroll in sync. You could maybe solve by building your own scroll view based on a VStack with y-offset (which is effectively how the pager view is working in the lower region, using an HStack and x-offset) but it won't be simple.

In the end, you may have to compromise on your preferred layout and behavior. This is why I left the previous answer in place - perhaps it's a preferable solution after all.

Benzy Neez
  • 1,546
  • 2
  • 3
  • 10