0

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?

multi fixed gradients, VStack objects masking to specific gradient view

dan1st
  • 12,568
  • 8
  • 34
  • 67
Wallk
  • 1
  • 1

1 Answers1

2

So you probably want either the left column or the right column of this demo:

animated demo of scroll views with gradients spanning vertically

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:

  1. Draw each “bubble” (rounded rectangle) with a clear (transparent) background.
  2. Record the frame (in global coordinates) of each bubble in a “preference”. A preference is SwiftUI's API for passing values from child views to ancestor views.
  3. Add a background to some common ancestor of all the bubble views. The background draws the gradient big enough to cover the entire common ancestor, but masked to only be visible in a rounded rectangle under each bubble.

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 RoundedRectangles:

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 VStacks 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()
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thank you so much! Simple enough lol. I’m learning tons just from your example. Though Xcode has a successful build, it’s giving me an error that it can find the func “bubble” view, therefore cant load ContentView(). “ extension View { func bubble(named name: Name) -> some View { } ” – Wallk Feb 26 '20 at 15:02
  • I have pasted my complete `ContentView.swift` [in this gist](https://gist.github.com/mayoff/481fe99f88e13dafa50d6248efcee00e). If you create a new SwiftUI project in Xcode 11.3.1 and replace the template `ContentView.swift` with the contents of that gist, it should work. (Click the Raw button in the gist for easier copying.) – rob mayoff Feb 26 '20 at 16:56