skip to Main Content

Hello 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. Ive been working at this for hours and have had no luck Id appreciate any help, thanks.

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. The relevant UICollectionViewFlowLayout instance is and it is attached to <SwiftUIPagingCollectionView: 0x12d0f6a00; baseClass = UICollectionView; frame = (0 0; 390 844); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x600000888a20>; backgroundColor = UIExtendedGrayColorSpace 0 0; layer = <CALayer: 0x600000670580>; contentOffset: {0.33333333333333331, 0}; contentSize: {1170, 763}; adjustedContentInset: {47, 0, 34, 0}; layout: Make a symbolic breakpoint at UICollectionViewFlowLayoutBreakForInvalidSizes to catch this in the debugger.

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 {
        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))
        }
        .edgesIgnoringSafeArea(.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
}

2

Answers


  1. Tou have an error from the UICollectionViewFlowLayout which is related to the height of the items in the collection view.
    However, you are using SwiftUI for your interface and not UIKit‘s UICollectionView. The error should mean that the item height in a collection view is too tall for the available space. It could be related to how SwiftUI is being bridged to UIKit under the hood, or how the UIKit’s UIScrollView is being managed within SwiftUI’s ScrollView.

    You are trying to manage the header’s visibility and the tab selection with quite a complex setup using offsets, preferences, and geometry readers. That setup might be making it hard to debug or identify the actual issue.

    I would recommend first to simplify the layout: Temporarily remove the header and see if the error persists. That can help isolate whether the header is the cause of the issue. You might want to simplify the layout to the bare minimum to see if the error still occurs, and then gradually reintroduce elements to identify the cause.

    Then check the layout calculations: Verify all the offset and height calculations, especially where you are manipulating headerOffset and shiftOffset, to ensure they are producing the intended values.

    You might want to add some print statements or use a debugger to check the values of your offsets and other variables at runtime to see if anything unexpected is happening. The error message suggests setting a symbolic breakpoint at UICollectionViewFlowLayoutBreakForInvalidSizes to catch this in the debugger. That might provide more insight into the issue.

    Login or Signup to reply.
  2. There might be a simpler way to achieve the effect you’re after. In the revised example below, the following techniques are used:

    • Each ScrollView has a GeometryReader in the background and this is used to monitor for a change of scroll position.
    • Instead of attempting to set a precise offset on the header, the header is either fully visible or fully hidden, with changes animated.
    • The height of the header is read using a GeometryReader in .onAppear. By placing the .ignoresSafeArea more selectively, it is not necessary to add padding to compensate for the safe area insets.
    • Whenever the tab selection changes, the new tab is automatically scrolled to the top using a ScrollViewPoxy. The header is also brought into view, if it was previously hidden.
    • I found that the bounce effect of the ScrollView was causing the header to be hidden or to become visible when it was not supposed to. To prevent this from happening, a change of scroll direction is only accepted if there has been a delay of at least 0.5s since the last scroll.
    • This is a pure SwiftUI solution, no UI classes are used, nor are any values written to the preferences.

    Here you go:

    struct FeedViewSec: View {
        @Environment(.colorScheme) var colorScheme
        @State private var selection = 0
        @State var isHeaderHidden = false
        @State var headerHeight: CGFloat = 0
        @State var referenceScrollOffset: CGFloat = 0
        @State var direction: SwipeDirection = .none
        @State var timeOfLastScroll = Date.now
    
        var body: some View {
            ZStack(alignment: .bottomTrailing){
                TabView(selection: $selection) {
                    scrollBody().tag(0)
                    scrollBody().tag(1)
                    scrollBody().tag(2)
                }
                .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
                .ignoresSafeArea(edges: .top)
            }
            .overlay(alignment: .top) {
                headerView
                    .offset(y: isHeaderHidden ? -headerHeight : 0)
                    .animation(.easeInOut, value: isHeaderHidden)
            }
        }
    
        func scrollBody() -> some View {
            ScrollViewReader { proxy in
                ScrollView {
                    Color.clear
                        .frame(height: 130)
                        .id("top")
                    LazyVStack {
                        ForEach(0..<30){ i in
                            RoundedRectangle(cornerRadius: 15).frame(width: 100, height: 100)
                        }
                    }
                    .padding(.top)
                    .background(alignment: .top) {
                        GeometryReader { proxy in
                            Color.clear
    
                                // pre iOS 17:
                                //.onChange(of: proxy.frame(in: .global).minY) { [oldMinY = proxy.frame(in: .global).minY] newMinY in
                                .onChange(of: proxy.frame(in: .global).minY) { oldMinY, newMinY in
                                    if newMinY < oldMinY {
    
                                        // Scrolling up
                                        if direction != .up && timeOfLastScroll.timeIntervalSinceNow < -0.5 {
                                            referenceScrollOffset = oldMinY
                                            direction = .up
                                        }
                                        if referenceScrollOffset - newMinY > headerHeight / 2 {
                                            isHeaderHidden = true
                                        }
                                    } else if newMinY > oldMinY {
    
                                        // Scrolling down
                                        if direction != .down && timeOfLastScroll.timeIntervalSinceNow < -0.5 {
                                            referenceScrollOffset = oldMinY
                                            direction = .down
                                        }
                                        if newMinY - referenceScrollOffset > headerHeight / 2 {
                                            isHeaderHidden = false
                                        }
                                    }
                                    timeOfLastScroll = Date.now
                                }
                        }
                        .frame(height: 1)
                    }
                    .onAppear {
                        proxy.scrollTo("top", anchor: .top)
                        timeOfLastScroll = Date.now
                        isHeaderHidden = false
                        direction = .none
                    }
                }
            }
            .ignoresSafeArea()
        }
    
        var headerView: some View {
            VStack(spacing: 0){
                // content as before
            }
    //        .padding(.top, top_Inset())
            .background(
                GeometryReader { proxy in
                    Color.clear
                        .background(.ultraThinMaterial)
                        .onAppear {
                            if headerHeight == 0 {
                                headerHeight = proxy.frame(in: .global).maxY
                            }
                        }
                }
            )
        }
    }
    

    Animation

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search