Animating gradient fill in swiftUI - swiftui

Having rectangle with gradient fill and trying to animate color change.
...
Rectangle()
.fill(LinearGradient(
gradient: .init(stops: [Gradient.Stop(color: myColor, location: 0.0), Gradient.Stop(color: .blue, location: 1.0)]),
startPoint: .init(x: 0.5, y: startPoint),
endPoint: .init(x: 0.5, y: 1-startPoint)
))
.animation(Animation.easeIn(duration: 2))
...
While change of starting points is animated nicely; the color change is not animated at all and just "switches"
Any hints?

So if anyone will be struggling with that my current solution is having two gradients in ZStack and animating opacity of the top one
Example gif
Here is rough example, the code sure can be written more nicely:
///helper extension to get some random Color
extension Color {
static func random()->Color {
let r = Double.random(in: 0 ... 1)
let g = Double.random(in: 0 ... 1)
let b = Double.random(in: 0 ... 1)
return Color(red: r, green: g, blue: b)
}
}
struct AnimatableGradientView: View {
#State private var gradientA: [Color] = [.white, .red]
#State private var gradientB: [Color] = [.white, .blue]
#State private var firstPlane: Bool = true
func setGradient(gradient: [Color]) {
if firstPlane {
gradientB = gradient
}
else {
gradientA = gradient
}
firstPlane = !firstPlane
}
var body: some View {
ZStack {
Rectangle()
.fill(LinearGradient(gradient: Gradient(colors: self.gradientA), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1)))
Rectangle()
.fill(LinearGradient(gradient: Gradient(colors: self.gradientB), startPoint: UnitPoint(x: 0, y: 0), endPoint: UnitPoint(x: 1, y: 1)))
.opacity(self.firstPlane ? 0 : 1)
///this button just demonstrates the solution
Button(action:{
withAnimation(.spring()) {
self.setGradient(gradient: [Color.random(), Color.random()])
}
})
{
Text("Change gradient")
}
}
}
}
Update: *In the end I have explored several ways of animating gradient fills and summarized them here: https://izakpavel.github.io/development/2019/09/30/animating-gradients-swiftui.html *

Wanted to share one more simple option.
Two rectangles with animated solid colors on top of each other.
The top ZStack uses a gradient mask to blend them together.
struct ContentView: View {
#State var switchColor = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(switchColor ? Color.purple : Color.green)
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(switchColor ? Color.pink : Color.yellow)
}
.mask(LinearGradient(gradient: Gradient(colors: [.clear,.black]), startPoint: .top, endPoint: .bottom))
}
.padding()
.onTapGesture {
withAnimation(.spring()) {
switchColor.toggle()
}
}
}
}

Related

Position an image randomly on a view in swiftui and pass the position of the image to another fullscreen transparent view

I have a view in SwiftUI. This view has some random images on it in various random positions. Check the code below.
struct ContentView: View {
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
var body: some View {
ZStack {
ForEach(0..<5) { _ in
Image(systemName: "plus")
.frame(width: 30, height: 30)
.background(Color.green)
.position(
x: CGFloat.random(in: 0..<screenWidth),
y: CGFloat.random(in: 0..<screenHeight)
)
}
}
.ignoreSafeArea()
}
}
I need to get the exact position of these random added images and pass the positions to another transparent view that shows up with a ZStack on top of the previous view. In the transparent popup fullscreen ZStack view i need to point to the position of the images i randomly put in the previous view using arrow images. Is this somehow possible in swiftui? I am new in swiftui so any help or suggestion appreciated.
Store the random offsets in a #State var and generate them in .onAppear { }. Then you can use them to position the random images and pass the offsets to the overlay view:
struct ContentView: View {
#State private var imageOffsets: [CGPoint] = Array(repeating: CGPoint.zero, count: 5)
#State private var showingOverlay = true
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
var body: some View {
ZStack {
ForEach(0..<5) { index in
Image(systemName: "plus")
.frame(width: 30, height: 30)
.background(Color.green)
.position(
x: imageOffsets[index].x,
y: imageOffsets[index].y
)
}
}
.ignoresSafeArea()
.onAppear {
for index in 0..<5 {
imageOffsets[index] = CGPoint(x: .random(in: 0..<screenWidth), y: .random(in: 0..<screenHeight))
}
}
.overlay {
if showingOverlay {
OverlayView(imageOffsets: imageOffsets)
}
}
}
}
struct OverlayView: View {
let imageOffsets: [CGPoint]
var body: some View {
ZStack {
Color.clear
ForEach(0..<5) { index in
Circle()
.stroke(.blue)
.frame(width: 40, height: 40)
.position(
x: imageOffsets[index].x,
y: imageOffsets[index].y
)
}
}
.ignoresSafeArea()
}
}

How to animate color change swiftui

I am trying to animate the color transition between green, yellow, and red. How would I go about this? I have tried using animation() on the Circle view, but this created weird bugs in which the StrokeStyle seems to be ignored and the whole circle fills with color during the transition.
import SwiftUI
import UserNotifications
struct CircleTimer_Previews: PreviewProvider {
static var previews: some View {
Group {
CircleTimer() }
}
}
struct CircleTimer : View {
#State var to: CGFloat = 1;
#State var start = false
#State var count = 15
let defaultTime = 15
#State var time = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View{
ZStack{
Color.black.opacity(0.06).edgesIgnoringSafeArea(.all)
VStack{
ZStack{
Circle()
.trim(from: 0, to: 1)
.stroke(Color.black.opacity(0.09), style: StrokeStyle(lineWidth: 35, lineCap: .round))
.frame(width: 280, height: 280)
Circle()
.trim(from: 0, to: self.to)
.stroke(self.count > 10 ? Color.green : self.count > 5 ? Color.yellow : Color.red, style: StrokeStyle(lineWidth: 35, lineCap: .round))
.frame(width: 280, height: 280)
.rotationEffect(.init(degrees: -90))
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
VStack{
Text("\(self.count)")
.font(.system(size: 72))
.fontWeight(.bold)
}
}
TimerButtons(start: $start, to: $to, count: $count)
}
}
.onAppear(perform: {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge,.sound,.alert]) { (_, _) in
}
})
.onReceive(self.time) { (_) in
if self.start{
if self.count > 0 {
self.count -= 1
print(self.count)
withAnimation(.default){
self.to = CGFloat(self.count) / 15
print(self.to)
}
}
else{
self.start.toggle()
}
}
}
}
}
As suggested by #Yrb, you must create three separate views of three different colors.

#State variables across multiple views and inside data arrays - Observables, Environment Published?

I've got myself in a bit of a pickle. I'm learning swift via a variety of online tutorials and at the same time coding a side project as further self learning. The side project will be a VERY basic football manager style app which I figure will give me a bit of UI, data handling, and algorithm refactoring practice. It is this side project where I have hit a blocker. I understand the principle of state variables to maintain a permanent link between an app state and the UI and I also understand the general principle of using observable objects / environment and and #Published to allow those variables to be available in multiple "views" however the implementation / syntax is eluding me.
The (lengthy) code below basically sets up a home screen on the UI where two teams abilities are compared. This is done via ability bars and a radar chart. Both the ability bar and the radar chart work correctly but I can't seem to link them to the same variable. The ability bar uses a series of #State variables called in the main content.view and then passed via $bindings from a child view to its parent but the radar plot is populated by local variables within a data array. I can't work out how to put the state variables into the array, either as is, or by using observable or environmental variables. Basically I want to declare the 6 variables in one location and then use them inside the array and also inside the various views.
I'd be really appreciative if someone could offer some suggestions or indeed to point me in a different direction if I am completely on a tangent with this.
By way of a quick summary...
First I have an ability bar view which creates something similar to a progress bar...
//Ability Bar View
struct abilityBar: View {
#Binding var value: Double
var color1: Color
var color2: Color
var title: String
var barHeight: Float
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading, spacing: -3) {
HStack {
Text("\(title)")
.modifier(lightFont())
Spacer()
Text("\(self.value, specifier: "%.1f")")
.modifier(heavyFont())
}
Spacer()
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.frame(width: geometry.size.width, height: CGFloat(barHeight))
.foregroundColor(.white)
.opacity(0.075)
RoundedRectangle(cornerRadius: 4)
.fill(LinearGradient(
gradient: Gradient(colors: [color1, color2]),
startPoint: .leading,
endPoint: .trailing))
.frame(width: ((CGFloat(self.value)/10) * geometry.size.width), height: CGFloat(barHeight))
}
}
}
}
}
Second I have a team widget view that nests these ability bars and is passed variables by way of $Bindings
//Team Widget View
struct teamWidget: View {
#Binding var defence: Double
#Binding var midfield: Double
#Binding var attack: Double
var color1: Color
var color2: Color
var location: String
var teamName: String
var position: String
var body: some View {
ZStack {
Rectangle()
.frame(height: 185.0)
.foregroundColor(Color(red: 0.23921568627450981, green: 0.24313725490196078, blue: 0.3215686274509804))
.shadow(color: .black, radius: 4, x: 4, y: 4)
HStack {
Rectangle()
.fill(LinearGradient(
gradient: Gradient(colors: [color1, color2]),
startPoint: .top,
endPoint: .bottom))
.frame(height: 185.0)
.frame(width: 8)
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 0.0) {
Text("\(location)")
.modifier(lightFont())
Text("\(teamName)")
.modifier(heavyFont())
Text("League Position")
.modifier(lightFont())
Text("\(position)")
.modifier(heavyFont())
}
VStack(spacing: 4.0) {
abilityBar(value: $defence, color1: color1, color2: color2, title: "Defence", barHeight: 9.0)
.frame(height: 28)
abilityBar(value: $midfield, color1: color1, color2: color2, title: "Midfield", barHeight: 9.0)
.frame(height: 28)
abilityBar(value: $attack, color1: color1, color2: color2, title: "Attack", barHeight: 9.0)
.frame(height: 28)
}
}
Spacer()
}
}
}
}
These are populated in the main content view via #State variables. Also in the main content view is a data array that is used to draw a radar chart (main code in a separate .swift file. What I cant work out how to do is call the same #State variables in this array. Or whether I need to define the state variables differently (observable / environment etc). For reference in this large body of code I ahve commented 3 lines (1) - where the state variables are declared (2) where the data array for the radar chart is declared and where I currently cant get state variables or equivalent to work and (3) where the ability bars are declared (work with state variables)
Apologies for the long main / lots of code. I couldn't think of a way to cut this down further.
enum RayCase:String, CaseIterable {
case defence = "Def"
case midfield = "Mid"
case attack = "Att"
}
struct DataPoint:Identifiable {
var id = UUID()
var entrys:[RayEntry]
var color:Color
init (defence:Double, midfield:Double, attack:Double, color:Color) {
self.entrys = [
RayEntry(rayCase: .defence, value: defence),
RayEntry(rayCase: .attack, value: attack),
RayEntry(rayCase: .midfield, value: midfield),
]
self.color = color
}
}
struct ContentView: View {
// 1 THIS IS WHERE STATE VARIABLES ARE DECLARED
#State var team1Defence: Double = Double.random(in: 1.0...9.9)
#State var team1Midfield: Double = Double.random(in: 1.0...9.9)
#State var team1Attack: Double = Double.random(in: 1.0...9.9)
#State var team2Defence: Double = Double.random(in: 1.0...9.9)
#State var team2Midfield: Double = Double.random(in: 1.0...9.9)
#State var team2Attack: Double = Double.random(in: 1.0...9.9)
#State var playMatchIsVisible: Bool = false
let mainRed = Color(red: 245/255, green: 39/255, blue: 85/255)
let highlightRed = Color(red: 245/255, green: 85/255, blue: 141/255)
let mainBlue = Color(red: 62/255, green: 219/255, blue: 208/255)
let highlightBlue = Color(red: 119/255, green: 237/255, blue: 194/255)
let dimensions = [
Ray(maxVal: 10, rayCase: .attack),
Ray(maxVal: 10, rayCase: .midfield),
Ray(maxVal: 10, rayCase: .defence),
]
// 2 THIS IS THE DATA ARRAY WHERE I CANT GET STATE VARIABLES TO WORK, THE SAME 6 DECLARED VARIABLES NEED TO POPULATE THIS ARRAY
let data = [
DataPoint(defence: 5, midfield: 8, attack: 7, color: .red),
DataPoint(defence: 5, midfield: 2, attack: 9, color: .blue),
]
var body: some View {
//Background Z-Stack Container
ZStack {
LinearGradient(gradient: Gradient(colors: [(Color(red: 0.23529411764705882, green: 0.23529411764705882, blue: 0.32941176470588235)), (Color(red: 0.058823529411764705, green: 0.06666666666666667, blue: 0.12941176470588237))]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(/*#START_MENU_TOKEN#*/.all/*#END_MENU_TOKEN#*/)
//Main VStack
VStack {
//Row 1 Main HStack
HStack {
//Date / Fixture ZStack
ZStack {
Rectangle()
.frame(height: 70.0)
.foregroundColor(Color(red: 0.23921568627450981, green: 0.24313725490196078, blue: 0.3215686274509804))
.shadow(color: /*#START_MENU_TOKEN#*/.black/*#END_MENU_TOKEN#*/, radius: 4, x: 4, y: 4)
//Date Fixture HStack to ensure left alignment
HStack {
// Date / Fixture VStack to put text on top of each other (6)
VStack(alignment: .leading, spacing: 4.0) {
Text("Date")
.modifier(heavyFont())
Text("Saturday 23rd October")
.modifier(lightFont())
Text("League 2 Fixture")
.modifier(heavyFont())
}
Spacer() //Forces text to LHS of widget
}
.padding(.leading, 7)
}
.padding(.leading, 15)
.padding(.trailing, 6)
// Play Match Stack (7)
ZStack {
Rectangle()
.frame(height: 70.0)
.foregroundColor(Color(red: 0.23921568627450981, green: 0.24313725490196078, blue: 0.3215686274509804))
.shadow(color: /*#START_MENU_TOKEN#*/.black/*#END_MENU_TOKEN#*/, radius: 4, x: 4, y: 4)
// Play Match HStack to ensure correct left alignment (8)
HStack {
Button(action: {
self.playMatchIsVisible = true
}) {
Image("playFull")
Text("Play Match")
.modifier(heavyFont())
}
.alert(isPresented: $playMatchIsVisible, content: {
return Alert(title: Text("Are you sure you want to kick off?"), primaryButton: .default(Text("Yes, Let's play!")), secondaryButton: .default(Text("No, not yet.")))
})
}
}
.padding(.leading, 6)
.padding(.trailing, 15)
}
.padding(.top, 15)
//Row 2 HStack
HStack {
// 3 THIS IS WHERE STATE VARIABLES ARE CALLED TO POPULATE THE ABILITY BARS AND WORK IN THIS CONTEXT AND WHEEN PASSED AS BINDINGS INTERNALLY
teamWidget(defence: $team1Defence, midfield: $team1Midfield, attack: $team1Attack, color1: highlightRed, color2: mainRed, location: "Home", teamName: "Oxford United", position: "14")
.padding(.leading, 15)
.padding(.trailing, 6)
teamWidget(defence: $team2Defence, midfield: $team2Midfield, attack: $team2Attack, color1: highlightBlue, color2: mainBlue, location: "Away", teamName: "Luton Town", position: "3")
.padding(.leading, 6)
.padding(.trailing, 15)
}
.padding(.top, 15)
VStack {
RadarChartView(width: 370, MainColor: Color.init(white: 0.8), SubtleColor: Color.init(white: 0.6), quantity_incrementalDividers: 4, dimensions: dimensions, data: data)
}
.frame(height: 275)
.padding(.top, 15)
Spacer() //Forces all to top in main stack
HStack {
Button(action: {}) {
Image("home")
}
Spacer()
Button(action: {}) {
Image("barcode")
}
Spacer()
Button(action: {}) {
Image("squad")
}
Spacer()
Button(action: {}) {
Image("fixtures")
}
Spacer()
Button(action: {}) {
Image("settings")
}
}
.padding(.leading, 20)
.padding(.trailing, 20)
.padding(.bottom, 15)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The net result of this looks something like this... but currently the ability bars and radar charts dont have the same single truth data set.
content view screen shot

Placing a rectangle in front of ScrollView affects scrolling

I need to place a translucent rectangle on front of ScrollView but when i put everything (Rectangle & ScrollView) inside of a ZStack, scroll & touch events stop working within this rectangle.
Atm I'm using .background modifier as it doesn't affect scrolling but I am still looking for way to make it work properly with rectangle placed over (in front of) my ScrollView.
Is there any way to put a View over ScrollView so it wouldn't affect it's functionality?
Here's the code i'm using now (i changed the colors and removed opacity to make the objects visible as my original rectangle is translucent & contains barely visible gradient)
struct ContentView: View {
var body: some View {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.blue)
} // ForEach
}
}
.background(RadialGradient(gradient: Gradient(stops: [
.init(color: Color.blue, location: 0),
.init(color: Color.red, location: 1)]), center: .top, startRadius: 1, endRadius: 200)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
))
}
}
}
Here is a possible approach to start with - use UIVisualEffectView. And Blur view is taken from How do I pass a View into a struct while getting its height also? topic.
struct ScrollContentView: View {
var body: some View {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.blue)
} // ForEach
}
}
Blur(style: .systemThinMaterialLight)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
)
.allowsHitTesting(false)
}
}
}
I decided to post a solution here.. it's based on an approach suggested by Asperi.
2Asperi: Thank you, i appreciate your help, as always.
I played a little bit with applying .opacity & mask to Blur but it didn't work.
So i applied mask to the .layer property inside makeUIView and it worked fine
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
ZStack {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0...100, id:\.self) { val in
ZStack {
Text("test")
.font(.system(size: 128))
} // ZStack
.background(Color.white)
} // ForEach
}
}
Blur(style: .systemThinMaterial)
.mask(
VStack(spacing: 0) {
Rectangle()
.frame(width: 347, height: 139)
.padding(.top, 0)
Spacer()
}
)
.allowsHitTesting(false)
}
}
}
}
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemMaterial
func makeUIView(context: Context) -> UIVisualEffectView {
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: style))
let gradientMaskLayer = CAGradientLayer()
gradientMaskLayer.type = .radial
gradientMaskLayer.frame = CGRect(x: 0, y: 0, width: 347, height: 256)
gradientMaskLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor]
gradientMaskLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientMaskLayer.endPoint = CGPoint(x: 1, y: 1)
gradientMaskLayer.locations = [0 , 0.6]
blurEffectView.layer.mask = gradientMaskLayer
return blurEffectView
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
The only thing i don't understand is why startPoint and endPoint work only when i set them to [0.5,0] & [1,1] but not [0.5,0] & [0.5,1] - i expected that it should determine the direction of radial gradient and in my case it should go from .topCenter to .topBottom (which it does but i don't understand the nature of endPoint)..

How to make my objects in a VStack mask to different full screen size gradients in swiftUI?

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:
Draw each “bubble” (rounded rectangle) with a clear (transparent) background.
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.
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()
}
}