Ulrich
Ulrich

Reputation: 225

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)
    }
}

Upvotes: 12

Views: 8477

Answers (5)

Benzy Neez
Benzy Neez

Reputation: 20804

The basic techniques of how to add a fade-out zone are covered by the other answers. However, an ideal solution would go a bit further:

  1. If in fact the underlying view fits, then it doesn't need to be wrapped in a ScrollView at all.

  2. The fade-out zone should only be visible if there is more content to be seen by scrolling. To put it another way, if the scroll view is fully scrolled to one end, there should be no fading at this end.

To address the first point, ViewThatFits can be used. This is supplied with the content as-is (not wrapped in a ScrollView) and then with the same content, wrapped in a ScrollView. If the first version fits then this is the version that gets used.

To address the second point, the size of the fade-out zone can depend on the scroll offset. This can be measured using a GeometryReader in the background.

Here is a general-purpose implementation of a scroll container that has this functionality built-in. Some notes:

  • The same implementation can be used for both horizontal and vertical scroll views. The init parameter isHorizontal is used to determine the scroll direction (default false, ie vertical).
  • The fade-out zones are implemented using the technique of a .mask, as outlined in the answer above.
  • ViewThatFits requires iOS 16.
  • The frame of the content is found in the coordinate space of the ScrollView by using .frame(in: .scrollView). This requires iOS 17. For earlier versions, the coordinate space of the ScrollView can be named instead.
  • The modifier .onGeometryChange requires a relatively new version of Xcode (Xcode 16?), but it is backwards compatible with iOS 16.
struct ScrollViewWithFadeZones<Content: View>: View {
    private let isHorizontal: Bool
    private let content: () -> Content
    private let maxZoneSize: CGFloat = 50
    @State private var firstZoneSize = CGFloat.zero
    @State private var lastZoneSize = CGFloat.zero

    init(isHorizontal: Bool = false, content: @escaping () -> Content) {
        self.isHorizontal = isHorizontal
        self.content = content
    }

    var body: some View {
        ViewThatFits {
            content()
            scrollableContent
        }
    }

    private var scrollableContent: some View {
        GeometryReader { outer in
            let outerSize = outer.size
            ScrollView(isHorizontal ? .horizontal : .vertical) {
                content()
                    .onGeometryChange(for: CGRect.self) { proxy in
                        proxy.frame(in: .scrollView)
                    } action: { frame in
                        if isHorizontal {
                            let leadingZoneWidth = min(-frame.minX, maxZoneSize)
                            let trailingZoneWidth = min(frame.maxX - outerSize.width, maxZoneSize)
                            if firstZoneSize != leadingZoneWidth {
                                firstZoneSize = leadingZoneWidth
                            }
                            if lastZoneSize != trailingZoneWidth {
                                lastZoneSize = trailingZoneWidth
                            }
                        } else {
                            let topZoneHeight = min(-frame.minY, maxZoneSize)
                            let bottomZoneHeight = min(frame.maxY - outerSize.height, maxZoneSize)
                            if firstZoneSize != topZoneHeight {
                                firstZoneSize = topZoneHeight
                            }
                            if lastZoneSize != bottomZoneHeight {
                                lastZoneSize = bottomZoneHeight
                            }
                        }
                    }
            }
            .mask {
                if isHorizontal {
                    HStack(spacing: 0) {
                        LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing)
                            .frame(width: max(1, firstZoneSize))
                        Color.black
                        LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing)
                            .frame(width: max(1, lastZoneSize))
                    }
                } else {
                    VStack(spacing: 0) {
                        LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom)
                            .frame(height: max(1, firstZoneSize))
                        Color.black
                        LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom)
                            .frame(height: max(1, lastZoneSize))
                    }
                }
            }
        }
    }
}

Example use:

struct ContentView: View {
    var body: some View {
        ScrollViewWithFadeZones {
            VStack {
                ForEach(0..<20) { i in
                    Color.red
                        .frame(height: 50)
                }
            }
        }
        .frame(width: 100)
    }
}

Animation

Upvotes: 1

Nikolay Suvandzhiev
Nikolay Suvandzhiev

Reputation: 9055

In my case, I wanted a simple fade - which extends past the frame of the scroll view, but does not affect the scroll view's layout.

Here is an image with colors to show what I mean - the yellow border is the scroll view's layout, the red-blue bars are the fade, and they don't affect any layout.

Image showing the code in practice

This way the fade is only visible once the content goes out of the bounds of the scroll view. No geometry readers or modern scroll view offset observation just a simple mask with some strategic padding.

Here is an abstracted code which is a drop-in replacement of ScrollView, but of course you might need to adapt the code for horizontal scroll view or other parameters:

struct ScrollViewWithFade<Content: View>: View {
    private let content: Content
    private let fadeSize: Double
    
    init(content: @escaping () -> Content, fadeSize: Double = 10) {
        self.content = content()
        self.fadeSize = fadeSize
    }
    
    var body: some View {
        ScrollView(.vertical){
            content.padding(.vertical, fadeSize)
        }
        .mask {
            VStack(spacing: 0) {
                makeGradient(reversed: false)
                Color.black
                makeGradient(reversed: false)
            }
        }
        .padding(.vertical, -fadeSize)
    }
    
    private func makeGradient(reversed: Bool) -> some View {
        LinearGradient(
            colors: [.black.opacity(0), .black],
            startPoint: reversed ? .bottom : .top,
            endPoint: reversed ? .top : .bottom
        )
        .frame(height: fadeSize)
    }
}

Upvotes: 0

Thyselius
Thyselius

Reputation: 954

Just made lmunck's great answer into a View extension for better readability, and added a variable for the size (width) of the fade

Usage:

ScrollView {
    // Content
}
    .fadeOutSides() // fade out with default mask
    //.fadeOutSides(fadeLength:200) // specified fade gradient size

And here is the extension

extension View {
    func fadeOutSides(fadeLength:CGFloat=50) -> some View {
        return mask(
            HStack(spacing: 0) {
                
                // Left gradient
                LinearGradient(gradient: Gradient(
                    colors: [Color.black.opacity(0), Color.black]),
                    startPoint: .leading, endPoint: .trailing
                )
                .frame(height: fadeLength)
                
                // Middle
                Rectangle().fill(Color.black)
                
                // Right gradient
                LinearGradient(gradient: Gradient(
                    colors: [Color.black, Color.black.opacity(0)]),
                    startPoint: .leading, endPoint: .trailing
                )
                .frame(width: fadeLength)
            }
        )
    }
}

While I was at it I also made one to fade out the top of a ScrollView

Usage:

ScrollView {
    // Content
}
    .fadeOutTop() // fade out with default mask
    //.fadeOutTop(fadeLength:200) // specified fade gradient size

And the extension

extension View {
    func fadeOutTop(fadeLength:CGFloat=50) -> some View {
        return mask(
            VStack(spacing: 0) {

                // Top gradient
                LinearGradient(gradient:
                   Gradient(
                       colors: [Color.black.opacity(0), Color.black]),
                       startPoint: .top, endPoint: .bottom
                   )
                   .frame(height: fadeLength)
                
                Rectangle().fill(Color.black)
            }
        )
    }
}

Upvotes: 6

Asperi
Asperi

Reputation: 257729

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)
        }
    }
}

Upvotes: 8

lmunck
lmunck

Reputation: 665

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)
    }
 )

Upvotes: 22

Related Questions