Below is my code, the problem im trying to solve is the image in the circle shape appears like a tiled background that is fixed in position as the shape moves around it, whereas the result I want is the shape itself is effectively a profile picture and the image moves inline with the shape’s movements, not resembling a background fixed in position.
The same issue happens if I try to use clipShape(CustomCircleShape…
I am puzzled how to essentially turn the circle with its movements into an image.
struct WaveAnimationView: View {
@State private var screenCoveragePercent = 45.0
@State private var waveOffset = Angle(degrees: 0)
var body: some View {
ZStack {
let shape = CustomCircleShape(offSet: Angle(degrees: waveOffset.degrees), percent: screenCoveragePercent)
shape.fill(ImagePaint(image: Image("ryan"), sourceRect: CGRect(x: 0, y: 0, width: 1, height: 1), scale: 0.1)) .ignoresSafeArea(.all)
}
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.waveOffset = Angle(degrees: 360)
}
}
}
}
struct CustomCircleShape: Shape {
var offSet: Angle
var percent: Double
var animatableData: Double {
get { offSet.degrees }
set { offSet = Angle(degrees: newValue) }
}
func path(in rect: CGRect) -> Path {
var p = Path()
let lowestWave = 0.02
let highestWave = 1.00
let newPercent = lowestWave + (highestWave - lowestWave) * (percent / 100)
let waveHeight = 0.0020 * rect.height
let yOffSet = CGFloat(1 - newPercent) * (rect.height - 4 * waveHeight) + 2 * waveHeight
let startAngle = offSet
let endAngle = offSet + Angle(degrees: 360 + 10)
var circleCenter = CGPoint.zero
for angle in stride(from: startAngle.degrees, through: endAngle.degrees, by: 5) {
let x = CGFloat((angle - startAngle.degrees) / 360) * rect.width
let y = yOffSet + waveHeight * CGFloat(sin(Angle(degrees: angle).radians))
if x == rect.midX {
circleCenter = CGPoint(x: x, y: y)
}
}
if circleCenter == .zero {
circleCenter = CGPoint(x: rect.midX, y: yOffSet + waveHeight * CGFloat(sin(Angle(degrees: offSet.degrees + 180).radians)))
}
let circleRadius: CGFloat = 50
p.addEllipse(in: CGRect(x: circleCenter.x - circleRadius, y: circleCenter.y - circleRadius, width: 2 * circleRadius, height: 2 * circleRadius))
return p
}
}
EDIT: Below is code showing the wave animation which the circle (which I’d like to be an image) is mimicking the movements of the wave.
struct WaveAnimation: View {
@State private var screenCoveragePercent = 25.0
@State private var waveOffset = Angle(degrees: 0)
var body: some View {
ZStack {
let opacityOfWater = 0.25
Wave(offSet: Angle(degrees: waveOffset.degrees), percent: screenCoveragePercent)
.fill(Color.blue).opacity(opacityOfWater)
.ignoresSafeArea(.all)
CustomCircle(offSet: Angle(degrees: waveOffset.degrees), percent: screenCoveragePercent)
.fill(Color.blue).opacity(1.0)
.ignoresSafeArea(.all)
InvisibleSlider(percent: $screenCoveragePercent)
}
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.waveOffset = Angle(degrees: 360)
}
}
}
}
struct CustomCircle: Shape {
var offSet: Angle
var percent: Double
var animatableData: Double {
get { offSet.degrees }
set { offSet = Angle(degrees: newValue) }
}
func path(in rect: CGRect) -> Path {
var p = Path()
let lowestWave = 0.02
let highestWave = 1.00
let newPercent = lowestWave + (highestWave - lowestWave) * (percent / 100)
let waveHeight = 0.0020 * rect.height
let yOffSet = CGFloat(1 - newPercent) * (rect.height - 4 * waveHeight) + 2 * waveHeight
let startAngle = offSet
let endAngle = offSet + Angle(degrees: 360 + 10)
var circleCenter = CGPoint.zero
for angle in stride(from: startAngle.degrees, through: endAngle.degrees, by: 5) {
let x = CGFloat((angle - startAngle.degrees) / 360) * rect.width
let y = yOffSet + waveHeight * CGFloat(sin(Angle(degrees: angle).radians))
if x == rect.midX {
circleCenter = CGPoint(x: x, y: y)
}
}
if circleCenter == .zero {
circleCenter = CGPoint(x: rect.midX, y: yOffSet + waveHeight * CGFloat(sin(Angle(degrees: offSet.degrees + 180).radians)))
}
let circleRadius: CGFloat = 50
p.addEllipse(in: CGRect(x: circleCenter.x - circleRadius, y: circleCenter.y - circleRadius, width: 2 * circleRadius, height: 2 * circleRadius))
return p
}
}
struct Wave: Shape {
var offSet: Angle
var percent: Double
var animatableData: Double {
get { offSet.degrees }
set { offSet = Angle(degrees: newValue) }
}
func path(in rect: CGRect) -> Path {
var p = Path()
let lowestWave = 0.02
let highestWave = 1.00
let newPercent = lowestWave + (highestWave - lowestWave) * (percent / 100)
let waveHeight = 0.0020 * rect.height
let yOffSet = CGFloat(1 - newPercent) * (rect.height - 4 * waveHeight) + 2 * waveHeight
let startAngle = offSet
let endAngle = offSet + Angle(degrees: 360 + 10)
// Draw wave
p.move(to: CGPoint(x: 0, y: yOffSet + waveHeight * CGFloat(sin(offSet.radians))))
for angle in stride(from: startAngle.degrees, through: endAngle.degrees, by: 5) {
let x = CGFloat((angle - startAngle.degrees) / 360) * rect.width
let y = yOffSet + waveHeight * CGFloat(sin(Angle(degrees: angle).radians))
p.addLine(to: CGPoint(x: x, y: y))
}
p.addLine(to: CGPoint(x: rect.width, y: rect.height))
p.addLine(to: CGPoint(x: 0, y: rect.height))
p.closeSubpath()
return p
}
}
2
Your code is animating the path of the shape, relative to its bounds (the rect
parameter).
You should instead create an Image(...).clipShape(Circle())
, and add an .offset(y: ...)
to it. Then you can animate the y offset of this image as a whole.
I would design Wave
so that it fills the entirety of the given rect
. That is, remove percent
. How much space the wave covers can be adjusted using a Spacer
in a VStack
.
struct Wave: Shape {
var offset: Angle
let maxHeight: CGFloat
var animatableData: Double {
get { offset.degrees }
set { offset = Angle(degrees: newValue) }
}
func path(in rect: CGRect) -> Path {
Path { p in
p.move(to: rect.origin)
for deg in stride(from: 0, through: 360, by: 5) {
let angle = Angle.degrees(Double(deg)) + offset
let height = sin(angle.radians) * maxHeight
p.addLine(to: .init(x: Double(deg) / 360 * rect.size.width + rect.minX, y: height + rect.minY))
}
p.addLine(to: .init(x: rect.maxX, y: rect.maxY))
p.addLine(to: .init(x: rect.minX, y: rect.maxY))
p.closeSubpath()
}
}
}
Now we put the image on top of it. The y offset of the image is calculated mathematically. See how the implementation of yOffset
corresponds to the implementation of Wave.path(in:)
.
struct WaveAndCircle: View, Animatable {
var waveOffset: Angle
let maxWaveHeight: CGFloat = 20
var animatableData: Double {
get { waveOffset.degrees }
set { waveOffset = Angle(degrees: newValue) }
}
var body: some View {
ZStack(alignment: .top) {
Wave(offset: waveOffset, maxHeight: maxWaveHeight)
.fill(.blue.opacity(0.25))
Image("my_image")
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
// change the alignment guide of the image, so that its center
// is aligned to the top of the wave
.alignmentGuide(.top, computeValue: { dimension in
dimension[VerticalAlignment.center]
})
.offset(y: yOffset)
}
}
var yOffset: CGFloat {
let offsetAngle = waveOffset + .degrees(180)
let sinResult = sin(offsetAngle.radians)
return sinResult * maxWaveHeight
}
}
And finally, putting everything together.
struct WaveAnimation: View {
@State private var screenCoveragePercent = 25.0
@State private var waveOffset = Angle(degrees: 0)
var body: some View {
GeometryReader { geo in
VStack {
Spacer().frame(height: geo.size.height * (1 - screenCoveragePercent))
WaveAndCircle(waveOffset: waveOffset)
}
}
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.waveOffset = Angle(degrees: 360)
}
}
}
}
5