swiftuiscrollviewgradient

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

  • Updated with Xcode 13.4 / iOS 15.5

    Now tap through gradient works with .allowsHitTesting(false)

    demo2

    Original

    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 original 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()
                    }
                    .clipped()
    
                    // 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)
    
                        .allowsHitTesting(false)  // << now works !!
    
                }.fixedSize(horizontal: false, vertical: true)
            }
        }
    }