'SwiftUI - fade out a ScrollView

I have a generated, oversized chart, which I put into a ScrollView so that the user can scroll to the right and see all values. I would like to indicate to the user that there's "more to come" on the right by fading the ScrollView out. Something in Swift was easy by applying CAGradientLayer.

My approach was to apply an overlay with a gradient from clear (starting at 80%) to system background color (ending at 100%). The result can be seen the attached screenshot.

Issue no. 1: Does not look like it's supposed to look.

Issue no. 2: Despite applying zIndex of -1 to the overlay, the ScrollView won't scroll any longer as soon as an overlay is applied.

Any idea how to achieve this? Thanks!

The ScrollView with an Rectangle overlay

Here's my code:

struct HealthExportPreview: View {
    @ObservedObject var carbsEntries: CarbsEntries
    
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(0..<self.carbsEntries.carbsRegime.count, id: \.self) { index in
                    ChartBar(carbsEntries: self.carbsEntries, entry: self.carbsEntries.carbsRegime[index], requiresTimeSplitting: index == self.carbsEntries.timeSplittingAfterIndex)
                }
            }
            .padding()
            .animation(.interactiveSpring())
        }
        .overlay(Rectangle()
            .fill(
                LinearGradient(gradient: Gradient(stops: [
                    .init(color: .clear, location: 0.8),
                    .init(color: Color(UIColor.systemBackground), location: 1.0)
                ]), startPoint: .leading, endPoint: .trailing)
            )
            .zIndex(-1)
        )
        .frame(height: CGFloat(carbsEntries.previewHeight + 80))
        .onAppear() {
            self.carbsEntries.fitCarbChartBars()
        }
    }
}

struct ChartBar: View {
    var carbsEntries: CarbsEntries
    var entry: (date: Date, carbs: Double)
    var requiresTimeSplitting: Bool
    
    static var timeStyle: DateFormatter {
        let formatter = DateFormatter()
        formatter.timeStyle = .short
        return formatter
    }
    
    var body: some View {
        VStack {
            Spacer()
            Text(FoodItemViewModel.doubleFormatter(numberOfDigits: entry.carbs >= 100 ? 0 : (entry.carbs >= 10 ? 1 : 2)).string(from: NSNumber(value: entry.carbs))!)
                .font(.footnote)
                .rotationEffect(.degrees(-90))
                .offset(y: self.carbsEntries.appliedMultiplier * entry.carbs <= 40 ? 0 : 40)
                .zIndex(1)
            
            if entry.carbs <= self.carbsEntries.maxCarbsWithoutSplitting {
                Rectangle()
                    .fill(Color.green)
                    .frame(width: 15, height: CGFloat(self.carbsEntries.appliedMultiplier * entry.carbs))
            } else {
                Rectangle()
                    .fill(Color.green)
                    .frame(width: 15, height: CGFloat(self.carbsEntries.getSplitBarHeight(carbs: entry.carbs)))
                    .overlay(Rectangle()
                        .fill(Color(UIColor.systemBackground))
                        .frame(width: 20, height: 5)
                        .padding([.bottom, .top], 1.0)
                        .background(Color.primary)
                        .rotationEffect(.degrees(-10))
                        .offset(y: CGFloat(self.carbsEntries.getSplitBarHeight(carbs: entry.carbs) / 2 - 10))
                )
            }
            
            if self.requiresTimeSplitting {
                Rectangle()
                    .fill(Color(UIColor.systemBackground))
                    .frame(width: 40, height: 0)
                    .padding([.top], 2.0)
                    .background(Color.primary)
                    .overlay(Rectangle()
                        .fill(Color(UIColor.systemBackground))
                        .frame(width: 20, height: 5)
                        .padding([.bottom, .top], 1.0)
                        .background(Color.black)
                        .rotationEffect(.degrees(80))
                        .offset(x: 20)
                        .zIndex(1)
                    )
            } else {
                Rectangle()
                    .fill(Color(UIColor.systemBackground))
                    .frame(width: 40, height: 0)
                    .padding([.top], 2.0)
                    .background(Color.primary)
            }
            
            Text(ChartBar.timeStyle.string(from: entry.date))
                .fixedSize()
                .layoutPriority(1)
                .font(.footnote)
                .rotationEffect(.degrees(-90))
                .offset(y: 10)
                .frame(height: 50)
                .lineLimit(1)
        }.frame(width: 30)
    }
}


Solution 1:[1]

Ok, it is known SwiftUI issue that it does not pass some gestures via overlays even transparent.

Here is possible approach to solve this - the idea is to have gradient to cover only small edge location, so other part of scroll view be accessed directly (yes, under gradient it will be still not draggable, but it is small part).

Demo prepared & tested with Xcode 11.7 / iOS 13.7

demo

(simplified variant of your view)

struct HealthExportPreview: View {
    var body: some View {
        GeometryReader { gp in
            ZStack {
                ScrollView(.horizontal) {
                    HStack {
                       // simplified content
                        ForEach(0..<20, id: \.self) { index in
                            Rectangle().fill(Color.red)
                                .frame(width: 40, height: 80)
                        }
                    }
                    .padding()
                    .animation(.interactiveSpring())
                }

                // inject gradient at right side only
                Rectangle()
                    .fill(
                        LinearGradient(gradient: Gradient(stops: [
                            .init(color: Color(UIColor.systemBackground).opacity(0.01), location: 0),
                            .init(color: Color(UIColor.systemBackground), location: 1)
                        ]), startPoint: .leading, endPoint: .trailing)
                    ).frame(width: 0.2 * gp.size.width)
                    .frame(maxWidth: .infinity, alignment: .trailing)
            }.fixedSize(horizontal: false, vertical: true)
        }
    }
}

Solution 2:[2]

How about using an alpha .mask?

Instead of your .overlay, you can use alpha mask like this:

.mask(
    HStack(spacing: 0) {

        // Left gradient
        LinearGradient(gradient: 
           Gradient(
               colors: [Color.black.opacity(0), Color.black]), 
               startPoint: .leading, endPoint: .trailing
           )
           .frame(width: 50)

        // Middle
        Rectangle().fill(Color.black)

        // Right gradient
        LinearGradient(gradient: 
           Gradient(
               colors: [Color.black, Color.black.opacity(0)]), 
               startPoint: .leading, endPoint: .trailing
           )
           .frame(width: 50)
    }
 )

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Asperi
Solution 2 lmunck