SwiftUI chart animation - ios

I want to do a chart animation of where 2 paths present each other from left to right. I have the following code:
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
path.move(to: CGPoint(x: 0, y: -height * self.ratio(for: 0)))
for index in 1..<dataPoints.count {
path.addLine(to: CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: -height * self.ratio(for: index)))
}
}
.trim(from: 0, to: end)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round))
.animation(.linear(duration: 2))
.onAppear(perform: {
end = 1
})
}
The animation is trimming from left to right which looks good. Problem is the starting point of Path. The chart moves from "behind the scenes" top-left position. I don't know how to set this before the animation starts. If I create a var path: Path how do I initialise with, e.g., CGRect if this is inside a subview and I just the width and height inside the GeometryReader ViewBuilder? Please help. Thank you in advance.
Edit: I was suspicious on the timing of rendering the screen of the .onAppear closure. I got it working using DispatchQueue. Solution:
.onAppear {
DispatchQueue.main.async {
end = 1
}
}

The problem lies in this line:
-height * self.ratio(for: index)))
I don't know what you self.ratio(for:) func does, and I don't understand why you use -height ... this seems to lead to the line starting at the upper left corner of your view. As points are added the view grows larger into negative values and gets pushed down.
You should instead start at y: height (of GeometryReader) and grow smaller but not negative.
struct ContentView: View {
#State private var end = 0.0
let dataPoints: [Double] = [3, 4, 7, 17, 11, 15, 4]
var body: some View {
VStack {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
let maxValue = dataPoints.max(by: <) ?? 1
Path { path in
path.move(to: CGPoint(
x: 0,
y: height - dataPoints[0] / maxValue * height ) )
for index in 1..<dataPoints.count {
path.addLine(to: CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height - dataPoints[index] / maxValue * height ))
}
}
.trim(from: 0, to: end)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round))
.animation(.linear(duration: 2), value: end)
}
Button("Start") { end = 1 }
}
.frame(height: 300)
}
}

Related

Is there a way to create a curved arrow that connects two views in SwiftUI?

I would like to connect two SwiftUI Views with an arrow, one pointing to the other. For example, in this View:
struct ProfileIconWithPointer: View {
var body: some View {
VStack {
Image(systemName: "person.circle.fill")
.padding()
.font(.title)
Text("You")
.offset(x: -20)
}
}
}
I have a Text view and an Image view. I would like to have an arrow pointing from one to the other, like this:
However, all the solutions that I could find rely on knowing the position of each element apriori, which I'm not sure SwiftUI's declarative syntax allows for.
you can use the following code to display an arrow.
struct CurvedLine: Shape {
let from: CGPoint
let to: CGPoint
var control: CGPoint
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(control.x, control.y)
}
set {
control = CGPoint(x: newValue.first, y: newValue.second)
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: from)
path.addQuadCurve(to: to, control: control)
let angle = atan2(to.y - control.y, to.x - control.x)
let arrowLength: CGFloat = 15
let arrowPoint1 = CGPoint(x: to.x - arrowLength * cos(angle - .pi / 6), y: to.y - arrowLength * sin(angle - .pi / 6))
let arrowPoint2 = CGPoint(x: to.x - arrowLength * cos(angle + .pi / 6), y: to.y - arrowLength * sin(angle + .pi / 6))
path.move(to: to)
path.addLine(to: arrowPoint1)
path.move(to: to)
path.addLine(to: arrowPoint2)
return path
}
}
you can use this in your code as follows.
struct ContentView: View {
var body: some View {
VStack {
Text("You")
.alignmentGuide(.top) { d in
d[.bottom] - 30
}
Image(systemName: "person.circle.fill")
.padding()
.font(.title)
}
.overlay(
CurvedLine(from: CGPoint(x: 50, y: 50),
to: CGPoint(x: 300, y: 200),
control: CGPoint(x: 100, y: -300))
.stroke(Color.blue, lineWidth: 4)
)
}
}
This will looks like as follows.
Depend on your scenario modify the code. Hope this will helps you.

Limit drag to rectangle bounds in SwiftUI

I want to be able to drag an arrow shape around on top of an image in swiftUI. I have tried to simplify it and limit it to a rectangle here in the beginning. I have taken some inspiration from this post: Limit drag to circular bounds in SwiftUI
And this is my take on it ATM. I hope someone can point me in the right direction :)
So imagine that the gray rectangle is the image and the blue one is the arrow, then I only want to be able to drag the blue rectangle inside the gray rectangle.
struct ImageView: View {
#State private var position = CGPoint(x: 100, y: 100)
private var rectSize: CGFloat = 600
var body: some View {
VStack {
Text("Current postion = (x: \(Int(position.x)), y: \(Int(position.y))")
Rectangle()
.fill(.gray)
.frame(width: rectSize, height: rectSize)
.overlay(
Rectangle()
.fill(.blue)
.frame(width: rectSize / 4, height: rectSize / 4)
.position(x: position.x, y: position.y)
.gesture(
DragGesture()
.onChanged({ value in
let currentLocation = value.location
let center = CGPoint(x: self.rectSize/2, y: self.rectSize/2)
let distance = center.distance(to: currentLocation)
if distance > self.rectSize / 2 {
let const = (self.rectSize / 2) / distance
let newLocX = (currentLocation.x - center.x) * const+center.x
let newLocY = (currentLocation.y - center.y) * const+center.y
self.position = CGPoint(x: newLocX, y: newLocY)
} else {
self.position = value.location
}
})
)
)
}
}
}
You're almost there. As you're outer shape is a rect, you don't need to calculate complicated distances. It's enough to check whether the position is still in the outer rect, and limit drag to its size values (0...rectSize). If you don't want your selection frame to move over the borders, you have to correct by 1/2 of its size (rectSize / 4 / 2)
struct ContentView: View {
#State private var position = CGPoint(x: 100, y: 100)
private var rectSize: CGFloat = 350
var body: some View {
VStack {
Text("Current postion = (x: \(Int(position.x)), y: \(Int(position.y))")
Rectangle()
.fill(.gray)
.frame(width: rectSize, height: rectSize)
.overlay(
Rectangle()
.fill(.clear)
.border(.blue, width: 2.0)
.contentShape(Rectangle())
.frame(width: rectSize / 4, height: rectSize / 4)
.position(position)
.gesture(
DragGesture()
.onChanged { value in
// limit movement to min and max value
let limitedX = max(min(value.location.x, rectSize - rectSize / 8), rectSize / 8)
let limitedY = max(min(value.location.y, rectSize - rectSize / 8), rectSize / 8)
self.position = CGPoint(x: limitedX,
y: limitedY)
}
)
)
}
}
}

How can I animate my capsule's progress when a button is clicked?

I hope you are all doing good.
I'm very new to this community and I hope we can help each other grow.
I'm currently building a MacOS App with swiftUI and I have this capsule shape, that has a water animation inside. The water animation are just waves that move horizontally created with animatable data and path. My problem is, I have two other buttons on the screen, a play button and a stop button. They are supposed to start filling the capsule with the water and stop doing it respectively, which they do, but they are supposed to do it with an animation, and it's not.
Below is my code for further details. Thanks in advance.
GeometryReader { geometry in
VStack {
Spacer()
BreathingWave(progress: $progressValue, phase: phase)
.fill(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.clipShape(Capsule())
.border(Color.gray, width: 3)
.frame(width: geometry.size.width / 12)
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
self.phase = .pi * 2
}
}
HStack {
Spacer()
Image(systemName: "play.circle.fill")
.resizable()
.renderingMode(.template)
.frame(width: 50, height: 50)
.foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.scaleEffect(scalePlay ? 1.25 : 1)
.onHover(perform: { scalPlay in
withAnimation(.linear(duration: 0.1)) {
scalePlay = scalPlay
}
})
.onTapGesture {
withAnimation(
.easeInOut(duration: duration)
.repeatForever(autoreverses: true)) {
if(progressValue < 1) {
progressValue += 0.1
}
else {
progressValue = progressValue
}
}
}
Spacer()
Image(systemName: "stop.circle.fill")
.resizable()
.renderingMode(.template)
.frame(width: 50, height: 50)
.foregroundColor(Color(#colorLiteral(red: 0.14848575, green: 0.6356075406, blue: 0.5744615197, alpha: 1)))
.scaleEffect(scaleStop ? 1.25 : 1)
.onHover(perform: { scalStop in
withAnimation(.linear(duration: 0.1)) {
scaleStop = scalStop
}
})
.onTapGesture {
withAnimation(.linear) {
progressValue = 0.0
}
}
Spacer()
}
.padding(.bottom, 50)
}
}
And this is the code of the BreathingWave
struct BreathingWave: Shape {
#Binding var progress: Float
var applitude: CGFloat = 10
var waveLength: CGFloat = 20
var phase: CGFloat
var animatableData: CGFloat {
get { phase }
set { phase = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
let minWidth = width / 2
let progressHeight = height * (1 - CGFloat(progress))
path.move(to: CGPoint(x: 0, y: progressHeight))
for x in stride(from: 0, to: width + 5, by: 5) {
let relativeX = x / waveLength
let normalizedLength = (x - minWidth) / minWidth
let y = progressHeight + sin(phase + relativeX) * applitude * normalizedLength
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: width, y: progressHeight))
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.addLine(to: CGPoint(x: 0, y: progressHeight))
return path
}
}
The problem you are running into is simply that you never told BreathingWave how to animate for progress. You set a single dimension animation, when you really want it to animate in two dimensions. The fix is straightforward: use AnimatablePair to supply the variables to animatableData.
struct BreathingWave: Shape {
var applitude: CGFloat = 10
var waveLength: CGFloat = 20
// progress is no longer a Binding. Using #Binding never did anything
// as you were not changing the value to be used in the parent view.
var progress: Float
var phase: CGFloat
var animatableData: AnimatablePair<Float, CGFloat> { // <- AnimatablePair here
get { AnimatablePair(progress, phase) } // <- the getter returns the pair
set { progress = newValue.first // <- the setter sets each individually from
phase = newValue.second // the pair.
}
}
func path(in rect: CGRect) -> Path {
...
}
}
Lastly, you now call it like this:
BreathingWave(progress: progressValue, phase: phase)
One final thing. Please provide a Minimal Reproducible Example (MRE) in the future. There was a lot of code that was unnecessary to debug this, and I had to implementment the struct header to run it. Strip as much out as possible, but give us something that we can copy, paste and run.

Circle with an evenly dotted border in SwiftUI?

I'm trying to create a resizable button that has an evenly dotted circle border around it. If you simply put:
Circle()
.stroke(color, style: StrokeStyle(lineWidth: 3, lineCap: .butt, dash: [3, radius / 3.82]))
.frame(width: radius * 2, height: radius * 2)
There might be an uneven distribution of dots as you can see in the below image:
Here's a related question with a solution which I tried adapting from UIKit into a SwiftUI struct but also failed.
Can someone please help me either find a way to adapt that "dash" value to create an evenly dashed stroked border with dependence on the radius OR create a custom shape instead?
I have an answer in pure SwiftUI. I think the issue you are running into is simply that you are drawing for part and skipping for part, and you have to take both into account.
Therefore, you need to come up with the circumference, divide it into the segments of 1 drawn part + 1 undrawn part. The drawn part is simply what looks good to you, so the undrawn part is the segment less the drawn part. You then plug both of those values into the StrokeStyle.dash and you get evenly spaced dots.
import SwiftUI
struct ContentView: View {
let radius: CGFloat = 100
let pi = Double.pi
let dotCount = 10
let dotLength: CGFloat = 3
let spaceLength: CGFloat
init() {
let circumerence: CGFloat = CGFloat(2.0 * pi) * radius
spaceLength = circumerence / CGFloat(dotCount) - dotLength
}
var body: some View {
Circle()
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .butt, lineJoin: .miter, miterLimit: 0, dash: [dotLength, spaceLength], dashPhase: 0))
.frame(width: radius * 2, height: radius * 2)
}
}
using some of the code in circle with dash lines uiview
I got this test code working for Swiftui:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var color = Color.blue
#State var radius = CGFloat(128)
#State var painted = CGFloat(6)
#State var unpainted = CGFloat(6)
let count: CGFloat = 30
let relativeDashLength: CGFloat = 0.25
var body: some View {
Circle()
.stroke(color, style: StrokeStyle(lineWidth: 3, lineCap: .butt, dash: [painted, unpainted]))
.frame(width: radius * 2, height: radius * 2)
.onAppear {
let dashLength = CGFloat(2 * .pi * radius) / count
painted = dashLength * relativeDashLength
unpainted = dashLength * (1 - relativeDashLength)
}
}
}

LinearGradient with mask does not fill the whole area

I'm creating a line chart view, inspired by this blog post.
The lines are drawn with Path and the gradient with a LinearGradient and a mask, and they are in overlays on top of each other.
My main issue is that, as you can see in the screenshot, the start of all the gradients have a slope, they seem to close too soon if the value is zero (also the ends seem to be cut too early but it's not my main isssue). The starts also seem to begin too late if the value is > 0 (like the orange one).
What I want is for the gradient to fully fill the parts where the red arrows are pointing (removing the slopes and the abrupt cuts).
But I can't find a solution by just shuffling up the magic numbers. Besides, I'd prefer to understand what's happening rather than just poking around.
What am I doing wrong?
I've made a reproducible example, paste this is a ContentView:
struct ContentView: View {
#State var values: [Double] = [0, 0, 1, 2, 0, 6, 4, 0, 1, 1, 0, 0]
#State var totalMaxnum: Double = 6
var body: some View {
GeometryReader { proxy in
ScrollView {
VStack {
// some other view
VStack {
VStack {
RoundedRectangle(cornerRadius: 5)
.overlay(
ZStack {
Color.white.opacity(0.95)
drawValuesLine(values: values, color: .blue)
}
)
.overlay(
ZStack {
Color.clear
drawValuesGradient(values: values, start: .orange, end: .red)
}
)
.frame(width: proxy.size.width - 40, height: proxy.size.height / 4)
// some other view
}
// some other view
}
}
}
}
}
func drawValuesLine(values: [Double], color: Color) -> some View {
GeometryReader { geo in
Path { p in
let scale = (geo.size.height - 40) / CGFloat(totalMaxnum)
var index: CGFloat = 0
let y1: CGFloat = geo.size.height - 10 - (CGFloat(values[0]) * scale)
let y = y1.isNaN ? 0 : y1
p.move(to: CGPoint(x: 8, y: y))
for _ in values {
if index != 0 {
let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * index
let x = x1.isNaN ? 0 : x1
let yy: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)
let y = yy.isNaN ? 0 : yy
p.addLine(to: CGPoint(x: x, y: y))
}
index += 1
}
}
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round, miterLimit: 80, dash: [], dashPhase: 0))
.foregroundColor(color)
}
}
func drawValuesGradient(values: [Double], start: Color, end: Color) -> some View {
LinearGradient(gradient: Gradient(colors: [start, end]), startPoint: .top, endPoint: .bottom)
.padding(.bottom, 1)
.opacity(0.8)
.mask(
GeometryReader { geo in
Path { p in
let scale = (geo.size.height - 40) / CGFloat(totalMaxnum)
var index: CGFloat = 0
// Move to the starting point
let y1: CGFloat = geo.size.height - (CGFloat(values[Int(index)]) * scale)
let y = y1.isNaN ? 0 : y1
p.move(to: CGPoint(x: 8, y: y))
// Draw the lines
for _ in values {
if index != 0 {
let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * index
let x = x1.isNaN ? 0 : x1
let yy: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)
let y = yy.isNaN ? 0 : yy
p.addLine(to: CGPoint(x: x, y: y))
}
index += 1
}
// Close the subpath
let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * (index - 1)
let x = x1.isNaN ? 0 : x1
p.addLine(to: CGPoint(x: x, y: geo.size.height))
p.addLine(to: CGPoint(x: 8, y: geo.size.height))
p.closeSubpath()
}
}
)
}
}
You can see in the screenshot how the orange gradient doesn't properly fill all the area below the line:
The problem is in this line:
let y1: CGFloat = geo.size.height - (CGFloat(values[Int(index)]) * scale)
y1 becomes the height of the entire graph.
You need to subtract 10, because that's what you've been doing for your blue line and all the gradient's other points.
/// subtract 10
let y1: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)

Resources