Search code examples
iosswiftswiftuiviewheader

Simple SwiftUI View bug when using header UICollectionViewFlowLayout


I am making a simple view with content in a scroll view and a top header. When the user scrolls down I want to hide the header and when the user scrolls up I want to show it. I have three different tabs and if I manually swipe between them everything works fine. If I try and click the buttons in the header to switch tabs the scroll view adjusts the scroll view a bit and does nothing then I get a ton of the error below.

The code works when (either or)

  1. removing .ignoresSafeArea(edges: .top) fixes the problem
  2. Removing the TabView and using conditional rendering to show the tabs.

I want to retain the functionality of both the ignore safe are and tabview, how can I workaround this?

The behavior of the UICollectionViewFlowLayout is not defined because: the item height must be less than the height of the UICollectionView minus the section insets top and bottom values, minus the content insets top and bottom values

import SwiftUI

struct FeedViewSec: View {
    @Environment(\.colorScheme) var colorScheme
    @State private var selection = 0
    @State var headerHeight: CGFloat = 130
    @State var headerOffset: CGFloat = 0
    @State var lastHeaderOffset: CGFloat = 0
    @State var direction: SwipeDirection = .none
    @State var shiftOffset: CGFloat = 0

    var body: some View {
        NavigationStack {
            ZStack(alignment: .bottomTrailing){
                TabView(selection: $selection) {
                    scrollBody().tag(0)
                    scrollBody().tag(1)
                    scrollBody().tag(2)
                }.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            }
            .overlay(alignment: .top) {
                headerView().offset(y: -headerOffset < headerHeight ? headerOffset : (headerOffset < 0 ? headerOffset : 0))
            }
            .ignoresSafeArea(edges: .top)
        }
    }
    func scrollBody() -> some View {
        ScrollView {
            LazyVStack {
                Color.clear.frame(height: 130)
                ForEach(0..<30){ i in
                    RoundedRectangle(cornerRadius: 15).frame(width: 100, height: 100)
                }
            }
            .offsetY { previous, current in
                if previous > current {
                    if direction != .up && current < 0{
                        shiftOffset = current - headerOffset
                        direction = .up
                        lastHeaderOffset = headerOffset
                    }
                    
                    let offset = current < 0 ? (current - shiftOffset) : 0

                    headerOffset = (-offset < headerHeight ? (offset < 0 ? offset : 0) : -headerHeight * 2.0)
                } else {
                    if direction != .down{
                        shiftOffset = current
                        direction = .down
                        lastHeaderOffset = headerOffset
                    }
                    
                    let offset = lastHeaderOffset + (current - shiftOffset)
                    headerOffset = (offset > 0 ? 0 : offset)
                }
            }
        }.coordinateSpace(name: "SCROLL")
    }
    func headerView() -> some View {
        VStack(spacing: 0){
            HStack {
                HStack(spacing: 1){
                    Text("Explore").font(.title).bold()
                    Image(systemName: "chevron.down").font(.body).bold()
                }
                Spacer()
            }
            .padding(.leading)
            HStack(alignment: .center, spacing: 0) {
                Button {
                    withAnimation(.easeInOut){
                        selection = 0
                    }
                } label: {
                    Text("New")
                        .foregroundColor(.black).bold()
                        .frame(width: 80, height: 25)
                }
                .background((selection == 0) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
                Button {
                    withAnimation(.easeInOut){
                        selection = 1
                    }
                } label: {
                    Text("LeaderBoard")
                        .foregroundColor(.black).bold()
                        .frame(width: 120, height: 25)
                }
                .background((selection == 1) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
                Button {
                    withAnimation(.easeInOut){
                        selection = 2
                    }
                } label: {
                    Text("Hot")
                        .foregroundColor(.black).bold()
                        .frame(width: 80, height: 25)
                }
                .background((selection == 2) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
            }
            .mask {
                RoundedRectangle(cornerRadius: 5)
            }
            .padding(.top, 8)
            Color.clear.frame(height: 13)
        }
        .padding(.top, top_Inset())
        .background(.ultraThinMaterial)
    }
}

func top_Inset() -> CGFloat {
    let scenes = UIApplication.shared.connectedScenes
    let windowScene = scenes.first as? UIWindowScene
    let window = windowScene?.windows.first
    
    return window?.safeAreaInsets.top ?? 0
}
extension View{
    @ViewBuilder
    func offsetY(completion: @escaping (CGFloat,CGFloat)->())->some View{
        self.modifier(OffsetHelper(onChange: completion))
    }
    func safeArea()->UIEdgeInsets{
        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else{return .zero}
        guard let safeArea = scene.windows.first?.safeAreaInsets else{return .zero}
        return safeArea
    }
}
struct OffsetHelper: ViewModifier{
    var onChange: (CGFloat,CGFloat)->()
    @State var currentOffset: CGFloat = 0
    @State var previousOffset: CGFloat = 0
    
    func body(content: Content) -> some View {
        content
            .overlay {
                GeometryReader{proxy in
                    let minY = proxy.frame(in: .named("SCROLL")).minY
                    Color.clear
                        .preference(key: OffsetKeyNew.self, value: minY)
                        .onPreferenceChange(OffsetKeyNew.self) { value in
                            previousOffset = currentOffset
                            currentOffset = value
                            onChange(previousOffset,currentOffset)
                        }
                }
            }
    }
}
struct OffsetKeyNew: PreferenceKey{
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
struct HeaderBoundsKey: PreferenceKey{
    static var defaultValue: Anchor<CGRect>?
    
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue()
    }
}
enum SwipeDirection{
    case up
    case down
    case none
}

Solution

  • Here is the complete working solution done by KavSoft. I recommend everyone checking out his YouTube, it's great.

    import SwiftUI
    
    struct FeedViewSec: View {
        @Environment(\.colorScheme) var colorScheme
        @State private var selection = 0
        @State var headerHeight: CGFloat = 130
        @State var headerOffset: CGFloat = 0
        @State var lastHeaderOffset: CGFloat = 0
        @State var direction: SwipeDirection = .none
        @State var shiftOffset: CGFloat = 0
    
        var body: some View {
            NavigationStack {
                ScrollView(.init()){
                    TabView(selection: $selection) {
                        scrollBody().tag(0)
                        scrollBody().tag(1)
                        scrollBody().tag(2)
                    }
                    .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                    .overlay(alignment: .top) {
                        headerView()
                            .offset(y: -headerOffset < headerHeight ? headerOffset : (headerOffset < 0 ? headerOffset : 0))
                    }
                }
                .ignoresSafeArea()
            }
        }
        
        @ViewBuilder
        func scrollBody() -> some View {
            ScrollView {
                Rectangle()
                    .fill(.clear)
                    .frame(height: 90 + top_Inset())
                    .offsetY { previous, current in
                        if previous > current {
                            if direction != .up && current < 0{
                                shiftOffset = current - headerOffset
                                direction = .up
                                lastHeaderOffset = headerOffset
                            }
                            
                            let offset = current < 0 ? (current - shiftOffset) : 0
    
                            headerOffset = (-offset < headerHeight ? (offset < 0 ? offset : 0) : -headerHeight * 2.0)
                        } else {
                            if direction != .down{
                                shiftOffset = current
                                direction = .down
                                lastHeaderOffset = headerOffset
                            }
                            
                            let offset = lastHeaderOffset + (current - shiftOffset)
                            headerOffset = (offset > 0 ? 0 : offset)
                        }
                    }
                
                LazyVStack {
                    ForEach(0..<30){ i in
                        RoundedRectangle(cornerRadius: 15).frame(width: 100, height: 100)
                    }
                }
            }
            .coordinateSpace(name: "SCROLL")
        }
        
        @ViewBuilder
        func headerView() -> some View {
            VStack(spacing: 0){
                HStack {
                    HStack(spacing: 1){
                        Text("Explore").font(.title).bold()
                        Image(systemName: "chevron.down").font(.body).bold()
                    }
                    Spacer()
                }
                .padding(.leading)
                HStack(alignment: .center, spacing: 0) {
                    Button {
                        withAnimation(.easeInOut){
                            selection = 0
                        }
                    } label: {
                        Text("New")
                            .foregroundColor(.black).bold()
                            .frame(width: 80, height: 25)
                    }
                    .background((selection == 0) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
                    Button {
                        withAnimation(.easeInOut){
                            selection = 1
                        }
                    } label: {
                        Text("LeaderBoard")
                            .foregroundColor(.black).bold()
                            .frame(width: 120, height: 25)
                    }
                    .background((selection == 1) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
                    Button {
                        withAnimation(.easeInOut){
                            selection = 2
                        }
                    } label: {
                        Text("Hot")
                            .foregroundColor(.black).bold()
                            .frame(width: 80, height: 25)
                    }
                    .background((selection == 2) ? colorScheme == .dark ? .gray.opacity(0.3) : .gray : colorScheme == .dark ? .gray : .gray.opacity(0.3))
                }
                .mask {
                    RoundedRectangle(cornerRadius: 5)
                }
                .padding(.top, 8)
            }
            .frame(height: 90)
            .padding(.top, top_Inset())
            .background(.ultraThinMaterial)
        }
    }
    
    
    extension View{
        @ViewBuilder
        func offsetY(completion: @escaping (CGFloat,CGFloat)->())->some View{
            self.modifier(OffsetHelper(onChange: completion))
        }
        func safeArea()->UIEdgeInsets{
            guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else{return .zero}
            guard let safeArea = scene.windows.first?.safeAreaInsets else{return .zero}
            return safeArea
        }
    }
    struct OffsetHelper: ViewModifier{
        var onChange: (CGFloat,CGFloat)->()
        @State var currentOffset: CGFloat = 0
        @State var previousOffset: CGFloat = 0
        
        func body(content: Content) -> some View {
            content
                .overlay {
                    GeometryReader{proxy in
                        let minY = proxy.frame(in: .named("SCROLL")).minY
                        Color.clear
                            .preference(key: OffsetKeyNew.self, value: minY)
                            .onPreferenceChange(OffsetKeyNew.self) { value in
                                previousOffset = currentOffset
                                currentOffset = value
                                onChange(previousOffset,currentOffset)
                            }
                    }
                }
        }
    }
    struct OffsetKeyNew: PreferenceKey{
        static var defaultValue: CGFloat = 0
        
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value = nextValue()
        }
    }
    struct HeaderBoundsKey: PreferenceKey{
        static var defaultValue: Anchor<CGRect>?
        
        static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
            value = nextValue()
        }
    }
    enum SwipeDirection{
        case up
        case down
        case none
    }