I am trying to draw a circle in SwiftUI using a Shape, Geometry reader and Path, I want a shadow to sit right underneath the circle but the shadow is coming out offset and I can't seem to get it to draw where it should:
struct ContentView: View {
var body: some View {
return GeometryReader { geometry in
VStack(alignment: .center) {
BackgroundRing()
.stroke(Color.red, style: StrokeStyle(lineWidth: geometry.size.width < geometry.size.height ? geometry.size.width / 12.0 : geometry.size.height / 12))
.padding()
.shadow(color: .gray, radius: 1.0, x: 0.0, y: 0.0)
}
}
}
}
struct BackgroundRing : Shape {
func path(in rect: CGRect) -> Path {
var path: Path = Path()
let radiusOfRing: CGFloat = (rect.width < rect.height ? rect.width/2 - rect.width / 12 : rect.height/2 - rect.height / 12)
path.addRelativeArc(center: CGPoint(x: rect.width/2, y: rect.height/2),
radius: radiusOfRing,
startAngle: Angle(radians: 0.0),
delta: Angle(radians: Double.pi * 2.0 ))
return path
}
}
OK So I seem to have managed to fix the problem. There is something going on with the width / heigh that interacts with the code to calculate the location of the shadow - the shadow position seems to come from the frame dimensions rather than the Shape.
Adding
.aspectRatio(contentMode: .fit)
fixes the problem
Additionally, it seems that .shadow automatically sets the default offset to the same value as the radius, so to get a real offset of 0.0 you have to set it relative to the radius, like this:
.shadow(radius: 10.0, x: -10.0, y: -10.0)
Looks to me like a bug, but this work around solves it:
import SwiftUI
struct ContentView: View {
var body: some View {
return GeometryReader { geometry in
VStack(alignment: .center) {
BackgroundRing()
.stroke(Color.red,
style: StrokeStyle(lineWidth: geometry.size.width < geometry.size.height ? geometry.size.width / 12.0 : geometry.size.height / 12))
.shadow(radius: 30.0, x: -30.0, y: -30.0)
.aspectRatio(contentMode: .fit)
}
}
}
}
struct BackgroundRing : Shape {
func path(in rect: CGRect) -> Path {
var path: Path = Path()
let radiusOfRing: CGFloat = (rect.width < rect.height ? rect.width/2 - rect.width / 12 : rect.height/2 - rect.height / 12)
path.addRelativeArc(center: CGPoint(x: rect.width/2, y: rect.height/2), // <- this change solved the problem
radius: radiusOfRing,
startAngle: Angle(radians: 0.0),
delta: Angle(radians: Double.pi * 2.0 ))
return path
}
}
Related
I have the following simple SwiftUI Path of an arrow which I wish to rotate around it's center:
#State var heading: Double = 0.0
#State var xOffset = 0.0
#State var yOffset = 0.0
var body: some View {
TabView {
NavigationStack {
Path { path in
path.move(to: CGPoint(x: xOffset, y: yOffset))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 20))
path.addLine(to: CGPoint(x: xOffset + 50, y: yOffset + 0))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset - 20))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
.rotationEffect(Angle(degrees: heading), anchor: .center)
.transformEffect(CGAffineTransform(translationX: 80, y: 80))
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
It does however not rotate around its center:
How can I rotate a Path around its center (or around any other relative point on its bounds)?
The Path view greedily takes up all of the space available to it, so the view is much larger than you think, and its center is far away. So when you rotate the path, it moves off the screen. You can see this by adding .background(Color.yellow) to the Path view.
It's easier to manage the rotation of the arrow if you make it an .overlay of another View (Color.clear) and then rotate that View. You can make the view visible by using Color.yellow while tuning, and then position the arrow relative to its parent view. The nice thing about this is that when you rotate the parent view, the arrow will stick to it and rotate predictably.
struct ContentView: View {
#State var heading: Double = 0.0
var body: some View {
TabView {
NavigationStack {
Color.clear
.frame(width: 60, height: 60)
.overlay (
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: 50, y: 0))
path.addLine(to: CGPoint(x: 0, y: -20))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
.offset(x: 5, y: 30)
)
.rotationEffect(Angle(degrees: heading), anchor: .center)
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
}
How do I make my code work?
You need to give your Path view a reasonable sized frame. Here I added .frame(width: 60, height: 60) which is big enough to hold your arrow path.
Since you have defined xOffset and yOffset, use them to move the path drawing within its view. Temporarily add a background to your path view with .background(Color.yellow) and then adjust xOffset and yOffset until your arrow is drawing inside of that view. You can then remove this .background.
This has the same effect as the overlay method presented above:
struct ContentView: View {
#State var heading: Double = 0.0
#State var xOffset = 5.0 // here
#State var yOffset = 30.0 // here
var body: some View {
TabView {
NavigationStack {
Path { path in
path.move(to: CGPoint(x: xOffset, y: yOffset))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 20))
path.addLine(to: CGPoint(x: xOffset + 50, y: yOffset + 0))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset - 20))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
// .background(Color.yellow)
.frame(width: 60, height: 60) // here
.rotationEffect(Angle(degrees: heading), anchor: .center)
//.transformEffect(CGAffineTransform(translationX: 80, y: 80))
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
}
This is the desired outcome
This is what I have now
Can anyone help? I'm new to SwiftUI and I've been struggling for two days
The thin line and the rotation works well, but how can I keep the label horizontal at any rotation?
I have tried using a VSTack and that causes undesired behavior. And when I set the rotation only to the rectangle (thin line) I can't figure out how to correctly postion the label dynamically.
This is my code so far, and the piece at TodayLabel is where this is done
struct SingleRingProgressView: View {
let startAngle: Double = 270
let progress: Float // 0 - 1
let ringWidth: CGFloat
let size: CGFloat
let trackColor: Color
let ringColor: Color
let centerText: AttributedText?
let centerTextSubtitle: AttributedText?
let todayLabel: CircleGraph.Label?
private let maxProgress: Float = 2 // allows the ring show a progress up to 200%
private let shadowOffsetMultiplier: CGFloat = 4
private var absolutePercentageAngle: Float {
percentToAngle(percent: (progress * 100), startAngle: 0)
}
private var relativePercentageAngle: Float {
// Take into account the startAngle
absolutePercentageAngle + Float(startAngle)
}
#State var position: (x: CGFloat, y: CGFloat) = (x: 0, y: 0)
var body: some View {
GeometryReader { proxy in
HStack {
Spacer()
VStack {
Spacer()
ZStack {
Circle()
.stroke(lineWidth: ringWidth)
.foregroundColor(trackColor)
.frame(width: size, height: size)
Circle()
.trim(from: 0.0, to: CGFloat(min(progress, maxProgress)))
.stroke(style: StrokeStyle(lineWidth: ringWidth, lineCap: .round, lineJoin: .round))
.foregroundColor(ringColor)
.rotationEffect(Angle(degrees: startAngle))
.frame(width: size, height: size)
if shouldShowShadow(frame: proxy.size) {
Circle()
.fill(ringColor)
.frame(width: ringWidth, height: ringWidth, alignment: .center)
.offset(y: -(size/2))
.rotationEffect(Angle.degrees(360 * Double(progress)))
.shadow(
color: Color.white,
radius: 2,
x: endCircleShadowOffset().0,
y: endCircleShadowOffset().1)
.shadow(
color: Color.black.opacity(0.5),
radius: 1,
x: endCircleShadowOffset().0,
y: endCircleShadowOffset().1)
}
// Today label
if let todayLabel = self.todayLabel {
ZStack {
StyledText(todayLabel.label)
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.color(token: .hint))
.cornerRadius(2)
.offset(y: -(size/1.5))
Rectangle()
.frame(width: 2, height: ringWidth + 2, alignment: .center)
.offset(y: -(size/2))
}.rotationEffect(Angle.degrees(Double(todayLabel.degrees)))
}
VStack(spacing: 4) {
if let text = centerText {
StyledText(text)
}
if let subtitle = centerTextSubtitle {
StyledText(subtitle)
.frame(maxWidth: 120)
.multilineTextAlignment(.center)
}
}
}
Spacer()
}
Spacer()
}
}
}
private func percentToAngle(percent: Float, startAngle: Float) -> Float {
(percent / 100 * 360) + startAngle
}
private func endCircleShadowOffset() -> (CGFloat, CGFloat) {
let angleForOffset = absolutePercentageAngle + Float(startAngle + 90)
let angleForOffsetInRadians = angleForOffset.toRadians()
let relativeXOffset = cos(angleForOffsetInRadians)
let relativeYOffset = sin(angleForOffsetInRadians)
let xOffset = CGFloat(relativeXOffset) * shadowOffsetMultiplier
let yOffset = CGFloat(relativeYOffset) * shadowOffsetMultiplier
return (xOffset, yOffset)
}
private func shouldShowShadow(frame: CGSize) -> Bool {
let circleRadius = min(frame.width, frame.height) / 2
let remainingAngleInRadians = CGFloat((360 - absolutePercentageAngle).toRadians())
if (progress * 100) >= 100 {
return true
} else if circleRadius * remainingAngleInRadians <= ringWidth {
return true
}
return false
}
}
just turn the inner text label back by -angle:
struct ContentView: View {
let startAngle: Double = 270
let progress: Float = 0.2 // 0 - 1
let ringWidth: CGFloat = 30
let size: CGFloat = 200
let trackColor: Color = .gray
let ringColor: Color = .blue
let todayLabeldegrees = 120.0
#State var position: (x: CGFloat, y: CGFloat) = (x: 0, y: 0)
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: ringWidth)
.foregroundColor(trackColor)
.frame(width: size, height: size)
Circle()
.trim(from: 0.0, to: CGFloat(progress))
.stroke(style: StrokeStyle(lineWidth: ringWidth, lineCap: .round, lineJoin: .round))
.foregroundColor(ringColor)
.rotationEffect(Angle(degrees: startAngle))
.frame(width: size, height: size)
// Today label
ZStack {
Text("todayLabel")
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.white)
.cornerRadius(5)
.shadow(radius: 2)
.rotationEffect(Angle.degrees(-todayLabeldegrees)) // << turn back
.offset(y: -(size/1.5))
Rectangle()
.frame(width: 2, height: ringWidth + 2, alignment: .center)
.offset(y: -(size/2))
}
.rotationEffect(Angle.degrees(todayLabeldegrees))
VStack(spacing: 4) {
Text("Test").font(.title)
Text("subtitle")
.frame(maxWidth: 120)
.multilineTextAlignment(.center)
}
}
}
}
I'm fairly new to SwiftUI (worked with UIKit before), and I got this internal, learning/testing project to do, and I ran into this problem.
I have two trimmed circles in a ZStack (and those two texts), in a HStack so I can add that little icon on its right side. How can I trim that bottom, invisible part of the circles so it fits my needs because it pushes everything down?
Edit: I tried clipping and padding with EdgeInsets, but still, changing the frame size pushes everything around. I'm looking for a proper way of doing this, and not some "hack".
screenshot
private struct ActivityCircleComponent: View {
private let widthOfCircle: CGFloat = 15
private let headerIconSize = UIScreen.main.bounds.height * 0.03
private let rotationAmount: Double = 152.5
private let progress: Double = 0.4
private let trimAmount: CGFloat = 40
var body: some View {
GeometryReader { g in
HStack(alignment: .bottom) {
ZStack {
ZStack(alignment: .center) {
Circle()
.trim(from: 0.0, to: 0.65)
.stroke(style: StrokeStyle(lineWidth: self.widthOfCircle, lineCap: .round, lineJoin: .round))
.foregroundColor(.black)
.rotationEffect(Angle(degrees: self.rotationAmount))
Circle()
.trim(from: 0.0, to: self.progress)
.stroke(style: StrokeStyle(lineWidth: self.widthOfCircle, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.rotationEffect(Angle(degrees: self.rotationAmount))
}
.padding([.leading, .top, .trailing], self.widthOfCircle / 2)
VStack {
Text("10.000")
.font(Fonts.Roboto.bold.of(size: 30))
Text("meters")
.font(Fonts.Roboto.light.of(size: 20))
}
}
Image("Goal")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: self.headerIconSize)
}
.frame(width: g.size.width, height: g.size.height)
}
}
}
PS Don't mind the state of the code, I'm just playing around
Thanks a bunch!
Here is how you would use the link I provided in my comment. I used .overlay instead of ZStack as you get one view that you don't have to worry about tweaking things when your screen size changes. Easy peasy:
struct ActivityCircleComponent: View {
private let widthOfCircle: CGFloat = 15
private let headerIconSize = UIScreen.main.bounds.height * 0.03
private let progress: Double = 0.4
private let trimAmount: CGFloat = 40
var body: some View {
HStack(alignment: .bottom) {
Arc(startAngle: .degrees(0), endAngle: .degrees(220), clockwise: true)
.stroke(style: StrokeStyle(lineWidth: widthOfCircle, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 120)
.fixedSize()
.overlay(
Arc(startAngle: .degrees(0), endAngle: .degrees(progress * 220), clockwise: true)
.stroke(style: StrokeStyle(lineWidth: widthOfCircle, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.frame(width: 300, height: 120)
.fixedSize()
)
Image(systemName: "rosette")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: self.headerIconSize)
}
.overlay(
VStack {
Text("10.000")
Text("meters")
}
)
}
}
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(200)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
return path
}
}
How to make the lower right corner sharp? the result is shown in the screenshot
here is my code now:
struct Test123: View {
var body: some View {
Text("Hello, World!")
.foregroundColor(.white)
.frame(width: 200, height: 50)
.background(Color.black)
}
}
The possible solution is by using .clipShape by preparing any custom shape you want.
Here a demo (prepared with Xcode 13 / iOS 15)
struct SharpShape: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
let chunk = rect.height * 0.5
path.move(to: .zero)
path.addLine(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: chunk))
path.addLine(to: CGPoint(x: max(rect.width - chunk, rect.width / 2), y: rect.height))
path.addLine(to: CGPoint(x: 0, y: rect.height))
}
}
}
struct Test123: View {
var body: some View {
Text("Hello, World!")
.foregroundColor(.white)
.frame(width: 200, height: 50)
.background(Color.black)
.clipShape(SharpShape())
}
}
I've created the code for displaying the pie progress bar. I need to add an animation to this progress bar. I tried linear animation but it didn't help. I'm stuck and don't know how to get it to animate. Can someone help? Here's the code.
import SwiftUI
struct PieProgress: View {
#State var progress: Float
var body: some View {
GeometryReader { geometry in
VStack(spacing:20) {
HStack{
Text("0%")
Slider(value: self.$progress)
Text("100%")
}.padding()
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: geometry.size.width, height: geometry.size.width, alignment: .center)
.padding()
.overlay(
PieShape(progress: Double(self.progress))
.frame(width: geometry.size.width - 10, height: geometry.size.width - 10 , alignment: .center)
.foregroundColor(.blue)
)
}
}
}
}
struct PieShape: Shape {
var progress: Double = 0.0
private let startAngle: Double = (Double.pi) * 1.5
private var endAngle: Double {
get {
return self.startAngle + Double.pi * 2 * self.progress
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let arcCenter = CGPoint(x: rect.size.width / 2, y: rect.size.width / 2)
let radius = rect.size.width / 2
path.move(to: arcCenter)
path.addArc(center: arcCenter, radius: radius, startAngle: Angle(radians: startAngle), endAngle: Angle(radians: endAngle), clockwise: false)
path.closeSubpath()
return path
}
}
Xcode 13.4 / iOS 15.5
With updated deprecated methods and now w/o hard-codes
Circle()
.stroke(Color.gray, lineWidth: 1)
.overlay(
PieShape(progress: Double(self.progress))
.padding(4)
.foregroundColor(.blue)
)
.frame(maxWidth: .infinity)
.animation(Animation.linear, value: progress) // << here !!
.aspectRatio(contentMode: .fit)
Test code is here
Original
Here is possible approach (tested & works with Xcode 11.2 / iOS 13.2):
You need to specify animatableData for your shape as below
struct PieShape: Shape {
var progress: Double = 0.0
var animatableData: Double {
get {
self.progress
}
set {
self.progress = newValue
}
}
...
then add animation to Circle
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: geometry.size.width, height: geometry.size.width, alignment: .center)
.padding()
.overlay(
PieShape(progress: Double(self.progress))
.frame(width: geometry.size.width - 10, height: geometry.size.width - 10 , alignment: .center)
.foregroundColor(.blue)
)
.animation(Animation.linear) // << here !!
and that's it. For testing (only!) you can add the following in PieProgress
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.progress = 0.72
}
}
Note: to use PieProgress as reused component it would be reasonable to make progress as #Binding, just in case.