How to create a Scrollable Gradient Background with SwiftUI (and elementary math + 7 lines of UIKit)

Roberto Mattos Mendes Camargo
8 min readNov 30, 2023

--

Hello everyone!

On my first Medium article, I'd like to write on a solution for a very common problem that people face with SwiftUI:

How do I create a Scrollable Gradient Background for my page with SwiftUI?

You can see this effect on some native iOS apps, such as the Health app:

Health app for iOS

So, as a native characteristic, some developers might be interest on replicating this feature on their apps.

Unfortunately, there isn't a native solution (yet of iOS 17) for this issue, so I got into working on a new solution. This so solution is also available as a Swift Package, but in this article, I'll teach you how to implement it by yourself. So, let's get started!

1. Creating our base View

Let's create a basic View for our app, consisting of our NavigationStack, our scrollable view (does not work with List due to it's view structure), and our content (is this case, just simple texts)

NavigationStack {
ScrollView {
VStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
}
.navigationTitle("My Navigation Stack")
}

2. Adding a background

Let's add a background for our view:

NavigationStack {
ScrollView {
VStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
}
.navigationTitle("My Navigation Stack")
.background(Color.red)
}

Let's see the results!

You can see 2 visual issues:

  • Our background only fits the contents
  • The scrollbar shows on the middle of the view

This is due to our content being too small. The best solution in this case is the LazyVStack. So, let's fix it!

NavigationStack {
ScrollView {
LazyVStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
}
.navigationTitle("My Navigation Stack")
.background(Color.red)
}

Excellent! Our view is now on a better state to start implementing our feature!

3. Change to Gradient Background

We can now shift our solid color to a gradient color. We can do this by using a LinearGradient:

NavigationStack {
ScrollView {
LazyVStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
}
.navigationTitle("My Navigation Stack")
.background(LinearGradient(gradient:
Gradient(colors: [Color.red, Color.white]),
startPoint: .top, endPoint: .bottom))
}

Excelent! We have a basic gradient to start building our scrollable gradient!

4a. Understanding the math

We want our scrollable gradient background to meet some requirements:

  • Our gradient starts on top at full color A, and at a point K on our Y axis, it has fully changed to B
  • After we scroll our view after a certain point J, our point K starts shifting up on our view, moving our gradient.
  • We expected that at a certain point L, it has fully transitioned from our color A to the color B filling the page.

Having these requirements, how can we elaborate our scrolling gradient? Using a linear equation.

Let's fill these letters with proper values for our demonstration:

  • A: Color Red
  • B: Color White
  • K: Initial value of 40% of the screen size (0.4)
  • J: 0 (Zero)
  • L: 200

We now have to elaborate our linear equation:

  • y = ax + b
  • y = Gradient position
  • x = Current scroll position

We can establish our solving conditions:

  • For any value of x ≤ 0 (J), y = 0.4 (K). So, let's simplifying by clamping our value, and let's assume that for x = 0, y = 0.4
  • When x = 200 (L), we know that we have fully transitioned from our Red to White, with White filling the whole page. So, for x = 200 (L), y = 0.

We have 2 equations, and 2 variables. This means we have a system of equations!

  • 0.4 = a.0 + b = b, so b = 0.4
  • 0 = a.200 + 0.4, so a = -0.4/200

We have all set up for our gradient view!

4b. Listening to scroll changes

Let's start by obtaining our current scroll position. We start by creating a State variable.

@State private var scrollPosition: Double = 0

We now create a PreferenceKey struct for listening to our scroll changes.

struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

And now we assign our PreferenceKey to our ScrollView

NavigationStack {
ScrollView {
LazyVStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
.coordinateSpace(name: "scroll")
.background(GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scroll")).origin
)
})
}

Now, let's properly listen to those changes and assign it to our variable:

NavigationStack {
ScrollView {
LazyVStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
.coordinateSpace(name: "scroll")
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollPosition = value.y
}
}

4c. Implementing the math

We need to update our background according to the current scroll change. Let's check our current background implementation:

.background(LinearGradient(gradient: 
Gradient(colors: [Color.red, Color.white]),
startPoint: .top, endPoint: .bottom))

We will change our endPoint to move up or down according to our scrollPosition, and the requirements that we imposed:

  • For scrollPosition ≤ 0, we have the default value of 0.4
  • For scrollPosition > 0, we update our view according to our linear equation (-0.4/200).scrollPosition + 0.4
  • We assume the absolute value of our scrollPosition (for simplicity of handling negative values)

We can mix it all up, and end up with:

.background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.white]), 
startPoint: .top,
endPoint: UnitPoint(x: 0.5, y: scrollPosition < 0 ?
(0.4 - (0.4 / 200) * abs(scrollPosition)) : 0.4)))

Let's break down our result:

  • On our X axis, it's always in the middle (0.5 or 50% of the screen width)
  • We use absolute scroll position values on our linear equation
  • We have a default value in case that we haven't surpassed our scrolling point

Let's see what we've got:

Ok… Not the result that we were expecting. Why is this happening? Because our gradient point is assuming negative values. Let's clamp it by creating an extension:

extension Comparable {
func clamped(to r: ClosedRange<Self>) -> Self {
let min = r.lowerBound, max = r.upperBound
return self < min ? min : (max < self ? max : self)
}
}

Now, we add a clamp modifier:

.background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.white]), 
startPoint: .top,
endPoint: UnitPoint(x: 0.5, y: scrollPosition < 0 ?
(0.4 - (0.4 / 200) * abs(scrollPosition)).clamped(to: 0 ... 0.4) : 0.4)))

Ok, it seems that we half fixed it (pun intended). How to completely fix it? Our current issue is that our background is related to our view, and we need to completely ignore the safe area. So let's do it!

.background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.white])
, startPoint: .top,
endPoint: UnitPoint(x: 0.5, y: scrollPosition < 0 ?
(0.4 - (0.4 / 200) * abs(scrollPosition)).clamped(to: 0 ... 0.4) : 0.4))
.ignoresSafeArea())

Excellent! We have got a very satisfying result! But there a last step to finish our page!

5. NavigationBar style

If we want to mimic Apple native style, we need to change how the NavigationBar shows up to us. This is where UIKit comes in, since it's a part where SwiftUI falls behind.

Currently, our NavigationBar obstructs our background, taking away it's visibility. We can add a blur effect, or, in UIKit, a systemUltraThinMaterial blur effect, more specifically. When we have it compact, we add a blur effect. When exposed, we make it clear and shadowless.

So, let's add this change to our page. Add this modifier to our NavigationStack:

.onAppear {

let appearance = UINavigationBarAppearance()
appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterial)
UINavigationBar.appearance().standardAppearance = appearance

let exposedAppearance = UINavigationBarAppearance()
exposedAppearance.backgroundEffect = .none
exposedAppearance.shadowColor = .clear
UINavigationBar.appearance().scrollEdgeAppearance = exposedAppearance
}

Excelent! We have our finished result!

6. Improvements and final result

We can put the behavior into functions, values within variables, and make our code prettier and more maintainable. A final result should look like this:

struct ContentView: View {
@State private var gradientEndPoint: Double = 0
private let heightPercentage = 0.4
private let maxHeight = 200.0
private let minHeight = 0.0
private let percentage = 0.4 - (0.4 / 200.0)

private func calculateEndPointForScrollPosition(scrollPosition: Double) -> Double {
let absoluteScrollPosition = abs(scrollPosition)
let endPoint = heightPercentage - (heightPercentage / maxHeight) * absoluteScrollPosition

return endPoint.clamped(to: 0 ... heightPercentage)
}

private func checkScrollPositionAndGetEndPoint(scrollPosition: Double) -> Double {
let isScrollPositionLowerThanMinHeight = scrollPosition < minHeight

return isScrollPositionLowerThanMinHeight ? calculateEndPointForScrollPosition(scrollPosition: scrollPosition) : heightPercentage
}

private func onScrollPositionChange(scrollPosition: Double) {
gradientEndPoint = checkScrollPositionAndGetEndPoint(
scrollPosition: scrollPosition
)
}

var body: some View {
NavigationStack {
ScrollView {
LazyVStack {
ForEach(0 ..< 120) { index in
Text("Row \(index)")
}
}
.padding()
.coordinateSpace(name: "scroll")
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(
in: .named("scroll")
).origin
)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) {
value in
onScrollPositionChange(scrollPosition: value.y)
}
}
.navigationTitle("My Navigation Stack")
.background(
LinearGradient(
gradient:
Gradient(
colors: [Color.red, Color.white]
), startPoint: .top,
endPoint: UnitPoint(
x: 0.5,
y: gradientEndPoint
)
)
.ignoresSafeArea()
)
}
.onAppear {
let appearance = UINavigationBarAppearance()
appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterial)
UINavigationBar.appearance().standardAppearance = appearance

let exposedAppearance = UINavigationBarAppearance()
exposedAppearance.backgroundEffect = .none
exposedAppearance.shadowColor = .clear
UINavigationBar.appearance().scrollEdgeAppearance = exposedAppearance
}
}
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero

static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

extension Comparable {
func clamped(to r: ClosedRange<Self>) -> Self {
let min = r.lowerBound, max = r.upperBound
return self < min ? min : (max < self ? max : self)
}
}

I hope that this tutorial helped you reach your goal of making your app even more beautiful! You can see the ending result on my GitHub repo!

--

--