swiftuiscrollviewgeometryreader

How to hide an item in the ScrollView when it is visible on the screen?


I'm trying to hide BottomRoundedRectangle() when I see the 2nd LazyVStack {…} on the screen, but it doesn't work.

value in .onPreferenceChange(OffsetKey.self) doesn't change as well.

struct OffsetKey: PreferenceKey {
    typealias Value = CGFloat

    static let defaultValue: Value = .zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = nextValue()
    }
}

struct OffsetKeyBackground: ViewModifier {
    func body(content: Content) -> some View {
        GeometryReader { reader in
            content
                .preference(
                    key: OffsetKey.self,
                    value: -reader.frame(in: .named("secondLazyStack")).origin.y
                )
        }
    }
}

struct ScrollQuestion: View {
    @State
    private var elementValue: CGFloat = .zero
    
    var body: some View {
        ScrollView(.vertical) {
            // Dynamic long text there
            LazyVStack {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 920) // dynamic text, 920 for example, may be bigger
            }
            
            // 2nd LazyVStack
            LazyVStack {
                RoundedRectangle(cornerRadius: 12)
                    .frame(height: 820)
            }
            .coordinateSpace(name: "secondLazyStack")
        }
        .safeAreaInset(edge: .bottom) {
            // How to hide this correctly when we see second LazyVStack ?
            BottomRoundedRectangle()
//          .opacity(elementValue < 50 ? 1 : 0)
        }
        .modifier(OffsetKeyBackground())
        .onPreferenceChange(OffsetKey.self) { value in
            debugPrint(value)
            
            elementValue = value
        }
    }
}

struct BottomRoundedRectangle: View {
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 8)
                .frame(height: 40)
                .foregroundStyle(.indigo)
                .padding(.horizontal)
            
            Text("Visible only in first LazyVStack")
                .foregroundStyle(.white)
        }
    }
}

#Preview {
    ScrollQuestion()
}

What do I need to do to hide BottomRoundedRectangle() correctly ?


Solution

  • You can detect this by wrapping the second LazyVStack in a GeometryReader.

    If the scroll view's frame intersects the frame of the LazyVStack, that means at least some part of the LazyVStack is on screen.

    struct IsOnScreenKey: PreferenceKey {
        
        static let defaultValue: Bool? = nil
        static func reduce(value: inout Value, nextValue: () -> Value) {
            if let next = nextValue() {
                value = next
            }
        }
    }
    
    struct DetectIsOnScreen: ViewModifier {
        func body(content: Content) -> some View {
            GeometryReader { reader in
                content
                    .preference(
                        key: IsOnScreenKey.self,
                        // bounds(of: .scrollView) gives us the scroll view's frame
                        // frame(in: .local) gives us the LazyVStack's frame
                        // both are in the local coordinate space
                        value: reader.bounds(of: .scrollView)?.intersects(reader.frame(in: .local)) ?? false
                    )
            }
        }
    }
    

    Then you can do:

    @State
    private var isSecondVStackVisible = false
    
    var body: some View {
        ScrollView(.vertical) {
            LazyVStack {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 920)
            }
            LazyVStack {
                RoundedRectangle(cornerRadius: 12)
                    .modifier(DetectIsOnScreen())
                    .frame(height: 820)
                    .onPreferenceChange(IsOnScreenKey.self) { value in
                        isSecondVStackVisible = value ?? false
                    }
            }
        }
        .safeAreaInset(edge: .bottom) {
            BottomRoundedRectangle()
                      .opacity(isSecondVStackVisible ? 0 : 1)
        }
    }
    

    Note that in this particular case, it is also possible to just set the preference to

    reader.bounds(of: .scrollView) != nil
    

    This is because LazyVStacks are lazy - if they are not on screen, it literally doesn't exist. The geometry reader can't convert the scroll view's frame to a coordinate space that doesn't exist.