swiftui

Gradient in navigation bar together with scrollable content


I discovered that one can put gradient backgrounds in the navigation bar in SwiftUI - if you put a background below a scrollable View, this gradient also expands over into the navigation bar.

I am using this to create a cover-like View on top of scrollable content together with the standard navigation bar:

struct ContentView: View {

    static let gradient = RadialGradient(gradient: Gradient(stops: [.init(color: .red, location: 0), .init(color: .yellow, location: 1)]), center: .bottom, startRadius: 0, endRadius: 300)

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                Image(systemName: "lasso.badge.sparkles")
                    .padding()
                    .foregroundStyle(.white)
                    .font(.system(size: 120))
                    .frame(maxWidth: .infinity)
                    .background(Self.gradient)
            
                Form {
                    Image(systemName: "globe")
                        .imageScale(.large)
                        .foregroundStyle(.tint)
                    ForEach(1...100, id: \.self) { idx in
                        Text("Hello, world! \(idx)")
                    }
                }
            }
            .navigationBarTitle("Foo", displayMode: .inline)
        }
    }
}

Is there a way to move the image (the lasso.badge.sparkles in the example) into the scrollable content (into the Form / could also be a ScrollView) so that it scrolls under the standard navigation bar (which then should do its default-material-background-blur thing), but keep the continuous gradient in the initial non-scrolled state? Something like this when scrolled down:


Solution

  • I found another version on how to solve this, inspired by Apple's TestFlight app that also has a gradient that starts at the top edge of the navigation bar and expands into the content area. There it grows when dragged by scrolling / scrolls away in the other direction.

    I found I could get the same effect using a GeometryReader + Preference to pass up the correct height for the background.

    Then I tinkered around with it more and it turns out you can get this effect simply by putting the background behind the header in the ScrollView and make it expand to the top like this:

    public struct StickyHeaderView<Header: View, Gradient: View, Content: View>: View {
        @ViewBuilder let header: () -> Header
        @ViewBuilder let gradient: () -> Gradient
        @ViewBuilder let content: () -> Content
    
        public init(header: @escaping () -> Header, gradient: @escaping () -> Gradient, content: @escaping () -> Content) {
            self.header = header
            self.gradient = gradient
            self.content = content
        }
    
        public var body: some View {
            ScrollView(.vertical) {
                VStack(spacing: 0) {
                    header()
                        .frame(maxWidth: .infinity)
                        .background(alignment: .bottom) {
                            gradient()
                                .offset(y: -500)
                                .padding(.bottom, -500)
                        }
    
                    content()
                }
            }
            .frame(maxWidth: .infinity)
        }
    }
    
    #if DEBUG
        struct StickyHeaderExampleView: View {
            var body: some View {
                StickyHeaderView(
                    header: {
                        Image(systemName: "lasso.badge.sparkles")
                            .font(.system(size: 120))
                            .padding()
                            .foregroundStyle(.white)
                    },
                    gradient: {
                        RadialGradient(gradient: Gradient(stops: [.init(color: .red, location: 0), .init(color: .yellow, location: 1)]), center: .bottom, startRadius: 0, endRadius: 300)
                    },
                    content: {
                        VStack {
                            ForEach(1 ... 30, id: \.self) { idx in
                                Text("Hello, world! \(idx)")
                            }
                        }
                    }
                )
                .navigationBarTitle("Example", displayMode: .inline)
            }
        }
    
        #Preview("In a sheet") {
            Color.yellow
                .sheet(isPresented: .constant(true)) {
                    NavigationStack {
                        StickyHeaderExampleView()
                    }
                }
        }
    
        #Preview("Standalone") {
            NavigationStack {
                StickyHeaderExampleView()
            }
        }
    #endif