I'm trying to animate a line graph using SwiftUI, Path and Shape. I can't get each path.addLine to appear individually as each line is added
This is what I've tried
import SwiftUI
struct LineChartShape: Shape{
var chartItems: [ChartItem]
var animatableData: [ChartItem] {
get { chartItems }
set {
self.chartItems = newValue
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let height = rect.height
path.move(to: CGPoint(x: width * 0.05, y: height * 0.5))
chartItems.forEach { chartItem in
path.addLine(to: CGPoint(x: width * CGFloat(chartItem.x), y: height * CGFloat(chartItem.y)))
}
return path
}
}
struct LineChartShape_Previews: PreviewProvider {
static var previews: some View {
GeometryReader { geometry in
LineChartShape(chartItems: [
ChartItem(y: 0.5, x: 0.05),
ChartItem(y: 0.4, x: 0.1),
ChartItem(y: 0.2, x: 0.15),
ChartItem(y: 0.3, x: 0.2),
ChartItem(y: 0.3, x: 0.25),
ChartItem(y: 0.4, x: 0.3),
ChartItem(y: 0.5, x: 0.35),
ChartItem(y: 0.3, x: 0.4),
ChartItem(y: 0.6, x: 0.45),
ChartItem(y: 0.65, x: 0.5),
ChartItem(y: 0.5, x: 0.55),
ChartItem(y: 0.5, x: 0.6),
ChartItem(y: 0.4, x: 0.65),
ChartItem(y: 0.45, x: 0.7),
ChartItem(y: 0.3, x: 0.75),
ChartItem(y: 0.3, x: 0.8),
ChartItem(y: 0.2, x: 0.85),
ChartItem(y: 0.3, x: 0.9)
])
.stroke(Color.blue, lineWidth: 5)
.animation(.easeInOut(duration: 10))
}
}
}
struct ChartItem: Identifiable{
let id = UUID()
var y: Float
var x: Float
}
I'm new to Swift so I'm sure I'm missing something obvious but I can't figure it out. Thanks for your help
Here is a demo of possible solution. Tested with Xcode 12.
struct TestAnimateAddShape: View {
#State private var end = CGFloat.zero
var body: some View {
GeometryReader { geometry in
LineChartShape(chartItems: [
ChartItem(y: 0.5, x: 0.05),
ChartItem(y: 0.4, x: 0.1),
ChartItem(y: 0.2, x: 0.15),
ChartItem(y: 0.3, x: 0.2),
ChartItem(y: 0.3, x: 0.25),
ChartItem(y: 0.4, x: 0.3),
ChartItem(y: 0.5, x: 0.35),
ChartItem(y: 0.3, x: 0.4),
ChartItem(y: 0.6, x: 0.45),
ChartItem(y: 0.65, x: 0.5),
ChartItem(y: 0.5, x: 0.55),
ChartItem(y: 0.5, x: 0.6),
ChartItem(y: 0.4, x: 0.65),
ChartItem(y: 0.45, x: 0.7),
ChartItem(y: 0.3, x: 0.75),
ChartItem(y: 0.3, x: 0.8),
ChartItem(y: 0.2, x: 0.85),
ChartItem(y: 0.3, x: 0.9)
])
.trim(from: 0, to: end) // << here !!
.stroke(Color.blue, lineWidth: 5)
.animation(.easeInOut(duration: 10))
}.onAppear { self.end = 1 } // << activate !!
}
}
Great solution! I recommend however to remove the animation modifier and use the WithAnimation function instead:
withAnimation(.easeIn(duration: x) {
self.end = 1
}
The reason is that if you change the screen orientation, I found that the line moves weirdly over the screen... This can be prevented, if you only start the animation from onAppear.
Related
I want an NSWindow with fullSizeContentView to take the exact size of a SwiftUI view that has an intrinsic content size. I saw similar posts like this one but they were different in that it was fine to provide a fixed frame at a top level. I don’t want to do that, I want the window size to be exactly the size of the view. How can I do that?
This is a Playground snippet that runs in Xcode 14.1.
import AppKit
import SwiftUI
class MyWindow: NSWindow {
override func setFrame(_ frameRect: NSRect, display flag: Bool) {
print("\(Date().timeIntervalSince1970) setFrame called \(frameRect)")
super.setFrame(frameRect, display: flag)
}
}
let window = MyWindow()
window.styleMask = [
.titled,
.closable,
.resizable,
.fullSizeContentView
]
window.toolbar = nil
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovable = true
window.isMovableByWindowBackground = true
window.standardWindowButton(.closeButton)?.isHidden = false
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
print("\(Date().timeIntervalSince1970) Before content \(window.frame)")
window.contentView = NSHostingView(rootView: ContentView())
print("\(Date().timeIntervalSince1970) After setting content \(window.frame)")
window.makeKeyAndOrderFront(nil)
print("\(Date().timeIntervalSince1970) After makeKeyAndOrderFront \(window.frame)")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print("\(Date().timeIntervalSince1970) After 1 second \(window.frame)")
}
struct ContentView: View {
var body: some View {
Text("Hello")
.font(.system(size: 200))
.background(.blue)
.fixedSize()
.ignoresSafeArea()
}
}
The problem is that it leaves some space at the end. Why is this code behaving like that?
It prints this:
1674086812.362426 setFrame called (100.0, 100.0, 100.0, 100.0)
1674086812.363435 Before content (100.0, 100.0, 100.0, 100.0)
1674086812.373186 setFrame called (100.0, -63.0, 431.0, 263.0)
1674086812.3741732 After setting content (100.0, -63.0, 431.0, 263.0)
1674086812.374618 setFrame called (100.0, 85.0, 431.0, 263.0)
1674086812.375651 After makeKeyAndOrderFront (100.0, 85.0, 431.0, 263.0)
1674086812.4359 setFrame called (100.0, 57.0, 431.0, 291.0)
1674086813.41998 After 1 second (198.0, 99.0, 431.0, 291.0)
Why is SwiftUI setting the frame with a different size after showing it?
The problem is your ordering of modifiers. You've put .background before .ignoresSafeArea(), so the background takes the safe area into account, If you re-order as follows, it works as required:
struct ContentView: View {
var body: some View {
Text("Hello")
.font(.system(size: 200))
.fixedSize()
.ignoresSafeArea()
.background(.blue)
}
}
Second attempt
Adding some borders to the views after modifiers, it seems that the text is overlapping the title bar, and is inset that amount from the bottom.
struct ContentView: View {
var body: some View {
Text("Hello")
.font(.system(size: 200))
.fixedSize()
.border(.pink, width: 2)
.ignoresSafeArea()
.border(.yellow, width: 1)
.background(.blue.opacity(0.5))
}
}
Applying a small offset to the View after .ignoresSafeArea()
.ignoresSafeArea()
.offset(y: 0.1)
.background(.blue)
gives:
If you do the same after the .background modifier, the title bar is shown:
.ignoresSafeArea()
.offset(y: 0.1)
.background(.blue)
I don't know why an offset seems to fix the problem.
Ok, after spending a lot of time struggling with this, I think I found a workaround. The idea is to avoid completely ignoreSafeArea which seems buggy. In order to do that, I suppressed safe area behavior on the AppKit side by extending NSHostingView and overriding safe area related behavior. This is the code.
import AppKit
import SwiftUI
class MyWindow: NSWindow {
override func setFrame(_ frameRect: NSRect, display flag: Bool) {
print("\(Date().timeIntervalSince1970) setFrame called \(frameRect)")
super.setFrame(frameRect, display: flag)
}
}
let window = MyWindow()
window.styleMask = [
.titled,
.closable,
.resizable,
.fullSizeContentView
]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovable = true
window.isMovableByWindowBackground = true
window.standardWindowButton(.closeButton)?.isHidden = false
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
class NSHostingViewSuppressingSafeArea<T : View>: NSHostingView<T> {
required init(rootView: T) {
super.init(rootView: rootView)
addLayoutGuide(layoutGuide)
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
topAnchor.constraint(equalTo: layoutGuide.topAnchor),
trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor)
])
}
private lazy var layoutGuide = NSLayoutGuide()
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var safeAreaRect: NSRect {
print ("super.safeAreaRect \(super.safeAreaRect)")
return frame
}
override var safeAreaInsets: NSEdgeInsets {
print ("super.safeAreaInsets \(super.safeAreaInsets)")
return NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
override var safeAreaLayoutGuide: NSLayoutGuide {
print ("super.safeAreaLayoutGuide \(super.safeAreaLayoutGuide)")
return layoutGuide
}
override var additionalSafeAreaInsets: NSEdgeInsets {
get {
print ("super.additionalSafeAreaInsets \(super.additionalSafeAreaInsets)")
return NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
set {
print("additionalSafeAreaInsets.set \(newValue)")
}
}
}
print("\(Date().timeIntervalSince1970) Before content \(window.frame)")
window.contentView = NSHostingViewSuppressingSafeArea(rootView: ContentView())
print("\(Date().timeIntervalSince1970) After setting content \(window.frame)")
window.makeKeyAndOrderFront(nil)
print("\(Date().timeIntervalSince1970) After makeKeyAndOrderFront \(window.frame)")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
print("\(Date().timeIntervalSince1970) After 1 second \(window.frame)")
}
struct ContentView: View {
var body: some View {
Text("Hello")
.font(.system(size: 200))
.fixedSize()
.background(.blue)
}
}
The result is:
And it prints this:
1675110465.322774 setFrame called (100.0, 100.0, 100.0, 100.0)
1675110465.332483 Before content (100.0, 100.0, 100.0, 100.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
1675110465.3494139 setFrame called (100.0, -35.0, 431.0, 235.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
super.safeAreaInsets NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
1675110465.3513222 After setting content (100.0, -35.0, 431.0, 235.0)
1675110465.352477 setFrame called (100.0, 85.0, 431.0, 235.0)
1675110465.3534908 After makeKeyAndOrderFront (100.0, 85.0, 431.0, 235.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
super.additionalSafeAreaInsets NSEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)
additionalSafeAreaInsets.set NSEdgeInsets(top: 28.0, left: 0.0, bottom: 0.0, right: 0.0)
1675110466.401649 After 1 second (636.0, 490.0, 431.0, 235.0)
I'm converting my project to SwiftUI and I use a background with gradient. I can't figure out how to convert the below to a SwiftUI view:
let layer = CAGradientLayer()
layer.colors = [
UIColor(red: 0.165, green: 0.224, blue: 0.314, alpha: 1).cgColor,
UIColor(red: 0.112, green: 0.17, blue: 0.26, alpha: 1).cgColor]
layer.locations = [0.45, 1]
layer.startPoint = CGPoint(x: 0.25, y: 0.5)
layer.endPoint = CGPoint(x: 0.75, y: 0.5)
layer.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: 0, b: 0.46, c: -0.46, d: 0, tx: 0.73, ty: 0.68))
layer.bounds = view.bounds.insetBy(dx: -0.5*view.bounds.size.width, dy: -0.5*view.bounds.size.height)
layer.position = view.center
view.layer.addSublayer(layer)
I tried the below, but the results are not the same:
LinearGradient(gradient: Gradient(colors: [Color("BackgroundNEWTop"), Color("BackgroundNEWBottom")]), startPoint: .top, endPoint: .bottom))
After I tested your UIKit code, this is the closest one I replicated in SwiftUI. I included the result image here, but you should try this code yourself first because the online image may look slightly different.
Sample Code and Image are below:
struct SampleView: View {
let color1 = Color.init(red: 0.112, green: 0.17, blue: 0.26)
let color2 = Color.init(red: 0.165, green: 0.224, blue: 0.314)
var body: some View {
ZStack {
LinearGradient(colors: [color1, color2],
startPoint: UnitPoint(x: 0.75, y: 0.5),
endPoint: UnitPoint(x: 0.25, y: 0.5))
}
}
}
I'm trying to create a capsule style progress view in SwiftUI, basing my code on the popular circle progress style.
Here is the code:
struct PSOCapsuleProgressView: View{
var body: some View{
ZStack{
Text("20/99")
.font(.system(size: 12, weight: .light, design: .rounded))
Capsule(style: .circular)
.stroke(lineWidth: 3)
.foregroundColor(.red)
.padding(4)
.opacity(0.3)
Capsule(style: .circular)
.trim(from: 0.0, to: 0.5)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.padding(4)
}
}
}
This is how it looks:
My only issue is how to make the progress start from the middle top? The circle progress view's that I've seen use a rotation effect but in this case it won't work because its not circular.
Any help is greatly appreciated.
To solve this you need to construct your own Shape. The trim will start from where the shape starts so you have to start drawing your Shape from the top middle. This gives us something like this:
struct MyCapsule: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let halfHeight = rect.height / 2
let halfWidth = rect.width / 2
path.move(to: CGPoint(x: halfWidth, y: 0))
path.addLine(to: CGPoint(x: rect.width - halfHeight, y: 0))
path.addCurve(to: CGPoint(x: rect.width, y: halfHeight),
control1: CGPoint(x: rect.width, y: 0),
control2: CGPoint(x: rect.width, y: halfHeight))
path.addCurve(to: CGPoint(x: rect.width - halfHeight, y: rect.height),
control1: CGPoint(x: rect.width, y: halfHeight),
control2: CGPoint(x: rect.width, y: rect.height))
path.addLine(to: CGPoint(x: halfHeight, y: rect.height))
path.addCurve(to: CGPoint(x: 0, y: halfHeight),
control1: CGPoint(x: 0, y: rect.height),
control2: CGPoint(x: 0, y: halfHeight))
path.addCurve(to: CGPoint(x: halfHeight, y: 0),
control1: CGPoint(x: 0, y: 0),
control2: CGPoint(x: halfHeight, y: 0))
path.addLine(to: CGPoint(x: halfWidth, y: 0))
return path
}
}
Then we can set up our ContentView like this:
struct ContentView: View {
#State private var trim: CGFloat = 0
var body: some View {
ZStack {
MyCapsule()
.stroke(Color.red)
.opacity(0.6)
MyCapsule()
.trim(from: 0, to: trim)
.stroke(Color.red,
style: StrokeStyle(lineWidth: 4,
lineCap: .round,
lineJoin: .round))
Button(action: {
withAnimation(Animation.easeIn(duration: 2)) {
trim = trim == 0 ? 1 : 0
}
}, label: {
Text("Animate in")
})
}
.frame(width: 200, height: 100)
}
}
This gives the following result:
Note because it is a Shape it will be greedy and fill up as much of the View as it can, so make sure you set a frame around it.
It is a bit kludgy, but it appears the only way to do this is to add another Capsule and have the second Capsule run from 0.75 to 1.0, and the other to run from 0.0 to 0.25:
struct PSOCapsuleProgressView: View{
var body: some View{
ZStack{
Text("20/99")
.font(.system(size: 12, weight: .light, design: .rounded))
Capsule(style: .circular)
.stroke(lineWidth: 3)
.foregroundColor(.red)
.padding(4)
.opacity(0.3)
Capsule(style: .circular)
.trim(from: 0.75, to: 1.0)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.padding(4)
Capsule(style: .circular)
.trim(from: 0.0, to: 0.25)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.padding(4)
}
}
}
You will have to manage which capsule responds to the data. It may be possible, depending upon how your updates work to send the data into a ForEach as an array and create as many Capsules as you need in smaller increments.
var body: some View {
let zstack = ZStack {
Frontside(id: $tangoID, sheet: $showingSheet, rotate: $fullRotation)
.rotation3DEffect(.degrees(self.showResults ? 180.0 : 0.0), axis: (x: 0.0, y: 1.0, z: 0.0))
.rotation3DEffect(.degrees(self.fullRotation ? 360.0 : 0.0), axis: (x: 0.0, y: 1.0, z: 0.0))
.zIndex(self.showResults ? 0 : 1)
Backside(id: $tangoID, sheet: $showingSheet, bookmark: $bookmarked, results: $showResults, rotate: $fullRotation)
.rotation3DEffect(.degrees(self.showResults ? 0.0 : 180.0), axis: (x: 0.0, y: -1.0, z: 0.0))
.rotation3DEffect(.degrees(self.fullRotation ? 360.0 : 0.0), axis: (x: 0.0, y: 1.0, z: 0.0))
.zIndex(self.showResults ? 1 : 0)
}
.contextMenu(menuItems: {Button(action: {
tangoArray[randomNum].bookmark.toggle()
database.updateUserData(tango: tangoArray[randomNum])
}, label: {
VStack{
Image(systemName: tangoArray[randomNum].bookmark ? "bookmark" : "bookmark.fill")
.font(.title)
Text(tangoArray[randomNum].bookmark ? "Remove bookmark" : "Bookmark")
}
})
})
I'm trying to use a contextMenu to bookmark flashcard items. What I have found though is that even after randonNum has changed, so a different flashcard is presented, the bookmark in the context menu is still showing the previous card's status. For example, it's a filled bookmark if I just tagged the previous card as a bookmark.
How can I create an animation like this one?
override func draw(_ rect: CGRect)
{
let xPointRect = viewWithTag(1)?.alignmentRect(forFrame: rect).midX
let yPointRect = viewWithTag(1)?.alignmentRect(forFrame: rect).midY
let widthSize = self.viewWithTag(1)?.frame.size.width
let heightSize = self.viewWithTag(1)?.frame.size.height
let sizeRect = CGSize(width: widthSize! * 0.8, height: heightSize! * 0.4)
let rectPlacementX = CGFloat(sizeRect.width/2)
let rectPlacementY = CGFloat(sizeRect.height/2)
let context2 = UIGraphicsGetCurrentContext()
context2?.setLineWidth(2.0)
context2?.setStrokeColor(UIColor.red.cgColor)
context2?.move(to: CGPoint(x: (xPointRect! - rectPlacementX), y: (yPointRect! - rectPlacementY)))
context2?.addLine(to: CGPoint(x: (xPointRect! + rectPlacementX), y: (yPointRect! + rectPlacementY)))
context2?.setLineDash(phase: 3, lengths: dashArray)
context2?.strokePath()
context2.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
UIView.animate(withDuration: 1.0,
delay: 0,
usingSpringWithDamping: 0.15,
initialSpringVelocity: 6.0,
options: .allowUserInteraction,
animations: { context2.transform = CGAffineTransform.identity },
completion: nil)
}
My current code is not working because context2 doesn't contain a member .transfer since it is a CGRect.
How can I make this line animate? Any idea?
A CGRect is not visible, so I do not see why you want to animate it.
I assume what you want to animate the frame of a UIView.
You can change many properties of a UIView in an animation block. Also it's frame:
let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
someParentView.addSubview(view)
UIView.animate(withDuration: 2.5) {
someUIView.frame = CGRect(x: 100, y: 100, width: 100, height: 100)
}