Reputation: 225
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!
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
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:
If in fact the underlying view fits, then it doesn't need to be wrapped in a ScrollView
at all.
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:
isHorizontal
is used to determine the scroll direction (default false, ie vertical)..mask
, as outlined in the answer above.ViewThatFits
requires iOS 16.ScrollView
by using .frame(in: .scrollView)
. This requires iOS 17. For earlier versions, the coordinate space of the ScrollView
can be named instead..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)
}
}
Upvotes: 1
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.
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
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
Reputation: 257729
Now tap through gradient works with .allowsHitTesting(false)
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
(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
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