Framming trimmed circle view - ios

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
}
}

Related

iOS SwiftUI - How to stick a label to a rotating view without the label rotating too?

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)
}
}
}
}

How to animate a circle to fill instead of using .trim?

I have this ring style view below, this animation starts at a filled ring of 100% and "unfills" to the trim amount, I'd like to reverse the animation so it starts at 0 and fills to the trim amount.
var progressAnimation: Animation {
Animation
.linear
.speed(0.5)
.delay(0.2)
}
var body: some View {
VStack {
ZStack {
Circle()
.stroke(Color(.systemFill).opacity(0.5), style: StrokeStyle(lineWidth: lineWidth))
.frame(height: 125)
Circle()
.trim(from: CGFloat(getTrimLevelForRingFromExertionLevel()), to: 1)
.stroke(ColorManager.getColorsForMetric(metricType: .Exertion(value: mostRecentEffortLevelObject?.effortLevel ?? 0)),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round, miterLimit: .infinity, dash: [20, 0], dashPhase: 0))
.animation(self.progressAnimation, value: getTrimLevelForRingFromExertionLevel())
.rotationEffect(Angle(degrees: 90))
.rotation3DEffect(Angle(degrees: 180), axis: (x: 1, y: 0, z: 0))
.frame(height: 125)
Probably you have issue in different code or model, because approach in referenced answer works fine.
Here is a complete demo. Tested with Xcode 12.4 / iOS 14.4
struct DemoView: View {
#State private var progress = 0.0
var body: some View {
VStack {
Circle()
.trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
.stroke(Color.blue ,style: StrokeStyle(lineWidth: 25.0, lineCap: .round, lineJoin: .round))
.animation(.linear, value: progress)
.rotationEffect(Angle(degrees: 270.0))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
progress = 0.73
}
}
}.padding()
}
}

Make two buttons acting as one SwiftUI

I want to make two buttons acting as one, or have a single button but with the touch area only the zone that is red like in the picture. A normal button would also have on the touch area that white space on which I put the x-es, which I don't want.
struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Spacer()
Button(action: {}) {
Text("This is button 1")
.frame(width: 100, height: 200)
.background(Color.red)
}
Button(action: {}) {
Text("This is button 2")
.frame(height: 100)
.background(Color.red)
}
Spacer()
}
}
}
Here's one way it could be done. Have a single button with two overlaid white rectangles. Only the red area can be clicked:
struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Spacer()
Button(action: {}) {
Text("This is one big button")
.frame(width: 200, height: 200)
.background(Color.red)
}
.overlay(
HStack {
Spacer()
VStack {
Rectangle()
.frame(width: 100, height: 50)
.foregroundColor(.white)
Spacer()
Rectangle()
.frame(width: 100, height: 50)
.foregroundColor(.white)
}
}
)
Spacer()
}
}
}
Solution using a custom Shape
Another way to do it would be to create a custom Shape for the Button, and then use it to draw and set its contentShape():
struct ButtonShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: width/2, y: 0))
path.addLine(to: CGPoint(x: width/2, y: height/4))
path.addLine(to: CGPoint(x: width, y: height/4))
path.addLine(to: CGPoint(x: width, y: 3 * height/4))
path.addLine(to: CGPoint(x: width/2, y: 3 * height/4))
path.addLine(to: CGPoint(x: width/2, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.closeSubpath()
return path
}
}
struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
Spacer()
Button(action: {}) {
Color.red
.clipShape(ButtonShape())
.overlay(
Text("This is one big button")
)
}
.contentShape(ButtonShape())
.frame(width: 200, height: 200)
Spacer()
}
}
}
This solution overall works better because the clipped areas aren't drawn making it easier to put this button on a colored background for instance.

Using SwiftUI, how do I animate pie progress bar

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.

Shadow offset with SwiftUI Shape

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
}
}

Resources