Inside of a ZStack:
I know you can set up a VStack inside a .mask()
, but then I have to add offsets to each object so they don't overlap.
Is there a way to modify your object to mask a specific view inside a single VStack?
So you probably want either the left column or the right column of this demo:
In the left column, the gradient spans all of the bubbles, including the bubbles off the screen. So the gradient appears to scroll with the bubbles.
In the right column, the gradient spans just the visible frame of the scroll view, so the bubble backgrounds appear to change as the bubbles move up and down.
Either way, this is a tricky problem! I've solved it before for UIKit. Here's a solution for SwiftUI.
Here's what we'll do:
We'll collect the frames of the bubbles in this data structure:
struct BubbleFramesValue {
var framesForKey: [AnyHashable: [CGRect]] = [:]
var gradientFrame: CGRect? = nil
}
We'll collect the frames of the bubbles in the framesForKey
property. Since we want to draw two gradients (gold and teal), we need to keep separate collections of bubble frames. So framesForKey[gold]
collects the frames of the gold bubbles, and framesForKey[teal]
collects the frames of the teal bubbles.
We'll also need the frame of the common ancestor, so we'll store that in the gradientFrame
property.
We'll collect these frames using the preference API, which means we need to define a type that conforms to the PreferenceKey
protocol:
struct BubbleFramesKey { }
extension BubbleFramesKey: PreferenceKey {
static let defaultValue: BubbleFramesValue = .init()
static func reduce(value: inout BubbleFramesValue, nextValue: () -> BubbleFramesValue) {
let next = nextValue()
switch (value.gradientFrame, next.gradientFrame) {
case (nil, .some(let frame)): value.gradientFrame = frame
case (_, nil): break
case (.some(_), .some(_)): fatalError("Two gradient frames defined!")
}
value.framesForKey.merge(next.framesForKey) { $0 + $1 }
}
}
Now we can define two new methods on View
. The first method declares that the view should be a bubble, meaning it should have a rounded rect background that shows the gradient. This method uses the preference
modifier and a GeometryReader
to supply its own frame (in global coordinates) as a BubbleFramesValue
:
extension View {
func bubble<Name: Hashable>(named name: Name) -> some View {
return self
.background(GeometryReader { proxy in
Color.clear
.preference(
key: BubbleFramesKey.self,
value: BubbleFramesValue(
framesForKey: [name: [proxy.frame(in: .global)]],
gradientFrame: nil))
})
}
}
The other method declares that the view is the common ancestor of bubbles, and so it should define the gradientFrame
property. It also inserts the background gradient behind itself, with the appropriate mask made of RoundedRectangle
s:
extension View {
func bubbleFrame(
withGradientForKeyMap gradientForKey: [AnyHashable: LinearGradient]
) -> some View {
return self
.background(GeometryReader { proxy in
Color.clear
.preference(
key: BubbleFramesKey.self,
value: BubbleFramesValue(
framesForKey: [:],
gradientFrame: proxy.frame(in: .global)))
} //
.edgesIgnoringSafeArea(.all))
.backgroundPreferenceValue(BubbleFramesKey.self) {
self.backgroundView(for: $0, gradientForKey: gradientForKey) }
}
private func backgroundView(
for bubbleDefs: BubbleFramesKey.Value,
gradientForKey: [AnyHashable: LinearGradient]
) -> some View {
return bubbleDefs.gradientFrame.map { gradientFrame in
GeometryReader { proxy in
ForEach(Array(gradientForKey.keys), id: \.self) { key in
bubbleDefs.framesForKey[key].map { bubbleFrames in
gradientForKey[key]!.masked(
toBubbleFrames: bubbleFrames, inGradientFrame: gradientFrame,
readerFrame: proxy.frame(in: .global))
}
}
}
}
}
}
We set up the gradient to have the correct size, position, and mask in the masked(toBubbleFrames:inGradientFrame:readerFrame:)
method:
extension LinearGradient {
fileprivate func masked(
toBubbleFrames bubbleFrames: [CGRect],
inGradientFrame gradientFrame: CGRect,
readerFrame: CGRect
) -> some View {
let offset = CGSize(
width: gradientFrame.origin.x - readerFrame.origin.x,
height: gradientFrame.origin.y - readerFrame.origin.y)
let transform = CGAffineTransform.identity
.translatedBy(x: -readerFrame.origin.x, y: -readerFrame.origin.y)
var mask = Path()
for bubble in bubbleFrames {
mask.addRoundedRect(
in: bubble,
cornerSize: CGSize(width: 10, height: 10),
transform: transform)
}
return self
.frame(
width: gradientFrame.size.width,
height: gradientFrame.size.height)
.offset(offset)
.mask(mask)
}
}
Whew! Now we're ready to try it out. Let's write the ContentView
I used for the demo at the top of this answer. We'll start by defining a gold gradient and a teal gradient:
struct ContentView {
init() {
self.gold = "gold"
self.teal = "teal"
gradientForKey = [
gold: LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(#colorLiteral(red: 0.9823742509, green: 0.8662455082, blue: 0.4398147464, alpha: 1)), location: 0),
.init(color: Color(#colorLiteral(red: 0.3251565695, green: 0.2370383441, blue: 0.07140993327, alpha: 1)), location: 1),
]),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 0, y: 1)),
teal: LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(#colorLiteral(red: 0, green: 0.8077999949, blue: 0.8187007308, alpha: 1)), location: 0),
.init(color: Color(#colorLiteral(red: 0.08204867691, green: 0.2874087095, blue: 0.4644176364, alpha: 1)), location: 1),
]),
startPoint: UnitPoint(x: 0, y: 0),
endPoint: UnitPoint(x: 0, y: 1)),
]
}
private let gold: String
private let teal: String
private let gradientForKey: [AnyHashable: LinearGradient]
}
We'll want some content to show in the bubbles:
extension ContentView {
private func bubbledItem(_ i: Int) -> some View {
Text("Bubble number \(i)")
.frame(height: 60 + CGFloat((i * 19) % 60))
.frame(maxWidth: .infinity)
.bubble(named: i.isMultiple(of: 2) ? gold : teal)
.padding([.leading, .trailing], 20)
}
}
Now we can define the body
of ContentView
. We draw VStack
s of bubbles, each inside a scroll view. On the left side, I put the bubbleFrame
modifier on the VStack
. On the right side, I put the bubbleFrame
modifier on the ScrollView
.
extension ContentView: View {
var body: some View {
HStack(spacing: 4) {
ScrollView {
VStack(spacing: 8) {
ForEach(Array(0 ..< 20), id: \.self) { i in
self.bubbledItem(i)
}
} //
.bubbleFrame(withGradientForKeyMap: gradientForKey)
} //
ScrollView {
VStack(spacing: 8) {
ForEach(Array(0 ..< 20), id: \.self) { i in
self.bubbledItem(i)
}
}
} //
.bubbleFrame(withGradientForKeyMap: gradientForKey)
}
}
}
And here's the PreviewProvider
so we can see it in Xcode's canvas:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}