2

I'm trying to set different gradient to the each section for swiftui list section but its not working and showing some weird gradient on individual cell!

I'm expecting single background for whole section not individual cell.

What i tried:

import SwiftUI

struct GradientListSectionView: View {
    var body: some View {
        List {
            Section {
                ForEach(0..<5) { index in
                    Text("Item \(index)")
                }
            } header: {
                Text("Gradient Section")
            }
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .fill(
                        LinearGradient(
                            colors: [.blue.opacity(0.6), .purple.opacity(0.6)],
                            startPoint: .bottom,
                            endPoint: .top
                        )
                    )
            )
        }
        .listStyle(.plain)
    }
}
#Preview {
    GradientListSectionView()
}


What result i got:

Broken background

What I'm expecting:

expected background

3 Answers 3

2

SwiftUI List is lazy, but to display a vertical gradient correctly, the list rows must be eagerly computed, so that the height of the section is known. So in general this is not possible. I would recommend your own container. Here is a simple example that you can use as a starting point to make it look more like a List:

extension ContainerValues {
    @Entry var sectionBackground: AnyShapeStyle = .init(.background)
}

extension View {
    func sectionBackground(_ style: some ShapeStyle) -> some View {
        containerValue(\.sectionBackground, AnyShapeStyle(style))
    }
}

struct CustomContainer<Content: View>: View {
    let content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                ForEach(sections: content) { section in
                    VStack {
                        section.header.font(.body.bold())
                        ForEach(section.content) { row in
                            Divider()
                            row
                        }
                        if !section.footer.isEmpty {
                            Divider()
                            section.footer
                        }
                    }
                    .background(section.containerValues.sectionBackground, in: .rect(cornerRadius: 12))
                }
            }
        }
    }
}

// Usage:

CustomContainer {
    Section {
        ForEach(0..<5) { index in
            Text("Item \(index)")
        }
    } header: {
        Text("Gradient Section")
    }
    .sectionBackground(
        LinearGradient(
            colors: [.blue.opacity(0.6), .purple.opacity(0.6)],
            startPoint: .bottom,
            endPoint: .top
        )
    )
}

If you are fine with the section background covering only the visible rows, you can do something hacky like this:

struct RowBounds {
    let bounds: CGRect
    let background: AnyShapeStyle
}

struct RowBoundsKey: PreferenceKey {
    static let defaultValue: [String: RowBounds] = [:]
    
    static func reduce(value: inout [String: RowBounds], nextValue: () -> [String: RowBounds]) {
        value.merge(nextValue()) { a, b in
            RowBounds(bounds: a.bounds.union(b.bounds), background: a.background)
        }
    }
}

extension View {
    func sectionBackground(_ style: some ShapeStyle, for section: String, geometry: GeometryProxy) -> some View {
        self.listRowBackground(
            Color.clear
                .anchorPreference(key: RowBoundsKey.self, value: .bounds) {
                    [section: RowBounds(bounds: geometry[$0], background: AnyShapeStyle(style))]
                }
        )
    }
}

struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            List {
                Section {
                    ForEach(0..<5) { index in
                        Text("Item \(index)")
                    }
                } header: {
                    Text("Gradient Section")
                }
                // sectionBackground is designed to be put on Sections only
                .sectionBackground(
                    LinearGradient(
                        colors: [.blue.opacity(0.6), .purple.opacity(0.6)],
                        startPoint: .bottom,
                        endPoint: .top
                    ),
                    for: "gradient section 1", // please use a different identifier for each section
                    geometry: geo
                )

                Section {
                    ForEach(0..<5) { index in
                        Text("Item \(index)")
                    }
                } header: {
                    Text("Gradient Section")
                }
                .sectionBackground(
                    LinearGradient(
                        colors: [.red.opacity(0.6), .yellow.opacity(0.6)],
                        startPoint: .bottom,
                        endPoint: .top
                    ),
                    for: "gradient section 2",
                    geometry: geo
                )
            }
            .listStyle(.plain)
            .scrollContentBackground(.hidden)
            .backgroundPreferenceValue(RowBoundsKey.self) { key in
                ForEach(key.sorted { $0.key < $1.key }, id: \.key) { (_, value) in
                    RoundedRectangle(cornerRadius: 12)
                        .fill(value.background)
                        .frame(width: value.bounds.width, height: value.bounds.height)
                        .position(x: value.bounds.midX, y: value.bounds.midY)
                }
            }
        }
    }
}

The idea is that each row of a section sends its own bounds and its background up the view hierarchy via a preference key. When preference keys are merged in reduce, row bounds that belong in the same section (identified by a string) are merged using union. This is so that in backgroundPreferenceValue, the preference value will contain all the total bounds for each section. Now you have all the necessary information to position a rounded rectangle for that section.

Output:

enter image description here

Sign up to request clarification or add additional context in comments.

Comments

2

AFAIK there is no way of adding a single gradient to a section background but you can simply calculate the color for each row and make it look like a single gradient as follow:

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            Section {
                ForEach(0..<5) { index in
                    let isFirst = index == 0
                    let isLast = index == 4
                    Text("Item \(index)")
                        .listRowBackground(
                            LinearGradient(
                                gradient: .init(
                                    colors: getColorsForIndex(
                                        start: .blue.opacity(0.6),
                                        end: .purple.opacity(0.6),
                                        index: index,
                                        totalItems: 5
                                    )
                                ),
                                startPoint: .top,
                                endPoint: .bottom
                            ).clipShape(RoundedCorner(radius: 12, corners: roundedCorners(isFirst: isFirst, isLast: isLast)))
                            
                        )
                }
            } header: {
                Text("Gradient Section")
            }
        }
        .listStyle(.plain)
        .padding(.horizontal, 20)
        .padding(.vertical, 4)
    }

    func getColorsForIndex(
        start: Color,
        end: Color,
        index: Int,
        totalItems: Int
    ) -> [Color] {
        guard totalItems > 1 else { return [start, end]}

        let lower = CGFloat(index) / CGFloat(totalItems)
        let upper = CGFloat(index + 1) / CGFloat(totalItems)

        let top = Color.interpolate(
            from: start,
            to: end,
            fraction: lower
        )
        let bottom = Color.interpolate(
            from: start,
            to: end,
            fraction: upper
        )
        return [top, bottom]
    }

    func roundedCorners(isFirst: Bool, isLast: Bool) -> UIRectCorner {
        switch (isFirst, isLast) {
        case (true, true): return [.allCorners]
        case (true, false): return [.topLeft, .topRight]
        case (false, true): return [.bottomLeft, .bottomRight]
        default: return []
        }
    }
}

#Preview {
    ContentView()
}


extension Color {
    static func interpolate(
        from: Color,
        to: Color,
        fraction: CGFloat
    ) -> Color {

        var (red1, green1, blue1, alpha1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0,0,0,0)
        UIColor(from).getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1)

        var (red2, green2, blue2, alpha2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0,0,0,0)
        UIColor(to).getRed(&red2, green: &green2, blue: &blue2, alpha: &alpha2)

        return .init(
            red: Double(red1 + (red2 - red1) * fraction),
            green: Double(green1 + (green2 - green1) * fraction),
            blue: Double(blue1 + (blue2 - blue1) * fraction),
            opacity: Double(alpha1 + (alpha2 - alpha1) * fraction)
        )
    }
}


struct RoundedCorner: Shape {
    var radius: CGFloat = 12
    var corners: UIRectCorner = .allCorners

    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(
            roundedRect: rect,
            byRoundingCorners: corners,
            cornerRadii: CGSize(width: radius, height: radius)
        )
        return Path(path.cgPath)
    }
}

enter image description here

5 Comments

This only works if all items are the same size. Also, what is the purpose of fraction argument here?
Well most list items are the same size. fraction is how many items will compose the whole gradient, usually the array count. Btw even if they are not the same height it is possible to adapt this approach to calculate the row colors to make it compensate.
fraction argument is not used in the function's body or I'm missing something?
Yes you are missing the * T ate the end of each channel when returning the color initializer
Oh that little internal name got even smaller on my phone! Thanks for mentioning. I like your method by the way +1
1

A list Section is like a Group, it is not like a container. So it is not trivial to set the background behind it.

One technique is to hide the background to the list and also to the rows. Then show the gradient behind the list. However, this will show a gradient that extends from above the header to the bottom of the screen. So:

  • .matchedGeometryEffect can be used to position the gradient below the header
  • .onGeometryChange can be used to determine the positions of the header and the footer in the global coordinate space
  • the height of the section can be computed from the difference in the two positions.

Here is an example to show it working for a single section. The same technique could also be used for multiple sections (each section would need its own pair of state variables):

struct GradientListSectionView: View {
    @Namespace private var ns
    @State private var maxYHeader: CGFloat?
    @State private var minYFooter: CGFloat?

    private var sectionHeight: CGFloat? {
        if let maxYHeader, let minYFooter {
            minYFooter - maxYHeader
        } else {
            nil
        }
    }

    var body: some View {
        List {
            Section {
                ForEach(0..<5) { index in
                    Text("Item \(index)")
                }
            } header: {
                Text("Gradient Section")
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .matchedGeometryEffect(id: 0, in: ns, anchor: .bottom)
                    .onGeometryChange(for: CGFloat.self) { proxy in
                        proxy.frame(in: .global).maxY
                    } action: { maxY in
                        maxYHeader = maxY
                    }
            } footer: {
                Spacer()
                    .onGeometryChange(for: CGFloat.self) { proxy in
                        proxy.frame(in: .global).minY
                    } action: { minY in
                        minYFooter = minY
                    }
            }
            .listRowBackground(Color.clear)
        }
        .listStyle(.insetGrouped)
        .scrollContentBackground(.hidden)
        .background {
            RoundedRectangle(cornerRadius: 12)
                .fill(
                    LinearGradient(
                        colors: [.blue.opacity(0.6), .purple.opacity(0.6)],
                        startPoint: .bottom,
                        endPoint: .top
                    )
                )
                .padding(.horizontal, 20)
                .padding(.vertical, 4)
                .frame(maxHeight: sectionHeight)
                .matchedGeometryEffect(id: 0, in: ns, properties: .position, anchor: .top, isSource: false)
        }
    }
}

Screenshot

2 Comments

This has some weird behaviour when the section is long and the header/footer isn't visible, which is why I used a row-based approach in my answer. But this answer is less CPU-intensive, without all those CGRect.unions.
@Sweeper Yes, if the list is long then the computed height might need some tweaks. The gradient itself will also need to ignore safe area insets. It might help to measure the safe area insets and the screen size, to determine if either the header or the footer are off-screen.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.