SwiftUI - Animating Multiple Parameters with Different Curves - swiftui

I'm exploring a sine wave rendering in a SwiftUI View, and have built my view to have 3 controllable parameters:
phase (to control the offset and allow animating "horizontally")
amplitude (to control how high each peak is)
frequency (how many complete waves there are)
Here's a screenshot:
My SineWave is implemented as a Shape, so it is already Animatable. I made amplitude and frequency an AnimatablePair and expose it as my shape's animatable data like this:
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { .init(frequency, amplitude) }
set {
frequency = newValue.first
amplitude = newValue.second
}
}
This works, and I get a nice animation if I do this in my containing view:
SineWaveShape(phase: phase, amplitude: amplitude, frequency: frequency)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.onAppear {
withAnimation(Animation.spring(response: 0.5, dampingFraction: 0.4, blendDuration: 0.1).repeatForever()) {
amplitude = 0.1
frequency = 4
}
}
Now I want to animate the phase as well. But I don't want this one to auto-reverse, and I want it to be much faster. Unfortunately adding another withAnimation block next to this one has no effect, even if I have it as part of my animatable data. The last animation block always wins.
How should I approach this problem of wanting to animate two properties with two different Animation instances?

Here is possible approach (sketch). Assuming you combine your animatable pair into struct, to work with them as single value, then you can use .animation(_, value:) to specify dedicated animation for each value:
#State private var pair: CGSize = .zero
#State private var phase: CGFloat = .zero
...
SineWaveShape(phase: phase, amplitude: amplitude, frequency: frequency)
.stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundColor(.red)
.animation(Animation.linear(duration: 5).repeatForever(), value: phase)
.animation(Animation.spring(response: 0.5, dampingFraction: 0.4, blendDuration: 0.1).repeatForever(), value: pair)
.onAppear {
self.phase = 90
self.pair = CGSize(width: 0.1, height: 4)
}

Related

Dynamically set frame dimensions in a view extension

I've been playing around with giving views a gradient shadow (taken from here and here) and while these achieve most of what I need, they seem to have a flaw: the extension requires you to set a .frame height, otherwise the gradient looks really desaturated (as it's taking up the entire height of the device screen). It's a little hard to describe, so here's the code:
struct RainbowShadowCard: View {
#State private var cardGeometryHeight: CGFloat = 0.0
#State private var cardGeometryWidth: CGFloat = 0.0
var body: some View {
VStack {
Text("This is a card, it's pretty nice. It has a couple of lines of text inside it. Here are some more lines to see how it scales.")
.font(.system(.body, design: .rounded).weight(.medium))
}
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background {
GeometryReader { geo in
Color.black
.onAppear {
cardGeometryHeight = geo.size.height
cardGeometryWidth = geo.size.width
print("H: \(cardGeometryHeight), W: \(cardGeometryWidth)")
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
.multicolorGlow(cardHeight: cardGeometryHeight, cardWidth: cardGeometryWidth)
}
}
extension View {
func multicolorGlow(cardHeight: CGFloat, cardWidth: CGFloat) -> some View {
ZStack {
ForEach(0..<2) { i in
Rectangle()
.fill(
LinearGradient(colors: [
.red,
.green
], startPoint: .topLeading, endPoint: .bottomTrailing)
)
// The height of the frame dictates the saturation of
// the linear gradient. Without it, the gradient takes
// up the full width and height of the screen, resulting in
// a washed out / desaturated gradient around the card.
.frame(height: 300)
// My attempt at making the height and width of this view
// be based off the parent view
/*
.frame(width: cardWidth, height: cardHeight)
*/
.mask(self.blur(radius: 10))
.overlay(self.blur(radius: 0))
}
}
}
}
struct RainbowShadowCard_Previews: PreviewProvider {
static var previews: some View {
RainbowShadowCard()
}
}
I've managed to successfully store the VStack height and width in cardGeometryHeight and cardGeometryWidth states respectfully, but I can't figure out how to correctly pass that into the extension.
In the extension, if I uncomment:
.frame(width: cardWidth, height: cardHeight)
The VStack goes to a square of 32x32.
Edit
For the sake of clarity, the above solution "works" if you don't use a frame height value for the extension, but it doesn't work very nicely. Compare the saturation of the shadow in this image to the original, and you'll see a big difference between a non framed approach and a framed approach. The reason for this muddier gradient is the extension is using the screen bounds for the linear gradient, so our shadow gradient isn't getting the benefit of the "start" and "end" saturation of the red and green, but the middle blending of the two.

Reset Path stroke animation

I have an array with the path of some different shapes. Let's say five rectangles.
Now I would like to animate one path until the user double tap on this shape. After double tapping the next shape of the array should be display and animated again.
struct TestView: View {
#State var percentage: CGFloat = .zero
#State var index: Int = 0
#State var pathArr = Array<Path>(repeating: Rectangle().path(in: CGRect(x: 0, y: 0, width: 100, height: 100)), count: 5)
var body: some View {
pathArr[index]
.trim(from: 0, to: percentage)
.stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round))
.onAppear {
withAnimation(Animation.easeInOut(duration: 2.0).repeatForever(autoreverses: false)){
percentage = 1.0
}
}
.onTapGesture(count: 2, perform: {index += 1; percentage = .zero})
}
}
The problem with my current approach is that the first shape will be displayed and animated correctly. But after double tapping the next paths won't be displayed and animated. Probably because of the percentage = .zero. When I delete this line the other paths are displayed and the animated. But now I have the problem that the animation state won't be resetted. That leads to the situation that when the animation is at 50% and I double tap the next shape starts with the animation at 50%. I would like to start each new displayed path at "0% animation".
Use the following gesture handler instead. Tested and worked with Xcode 12.4 / iOS 14.4.
.onTapGesture(count: 2, perform: {
percentage = .zero // >> stops current
DispatchQueue.main.async {
index += 1; percentage = 1.0 // << starts new
}
})

Is it possible to flip a SwiftUI View vertically?

Looks like you can rotate a view upside down with UIView, but I can't find anything saying it's possible to do the same thing with a SwiftUI View.
Any help will be appreciated :)
Actually approach is the same to referenced post
Text("Test").font(.largeTitle)
.scaleEffect(CGSize(width: 1.0, height: -1.0)) // << here !!
Here is a convenience extension:
extension View {
func flipped(_ axis: Axis = .horizontal, anchor: UnitPoint = .center) -> some View {
switch axis {
case .horizontal:
return scaleEffect(CGSize(width: -1, height: 1), anchor: anchor)
case .vertical:
return scaleEffect(CGSize(width: 1, height: -1), anchor: anchor)
}
}
}
Use it:
Text("Flip me")
.flipped(.vertical)
Rotate Image As Mirror image by SwiftUI:
struct Rotate3DView: View {
#State var imagename: String = "exImage"
var body: some View {
VStack {
ZStack{
Image(imagename).resizable().opacity(0.3)
.rotation3DEffect(.degrees(180), axis: (x: -10, y: 0, z: 0))
.rotationEffect(.radians(.pi))
.padding(.top,60)
.cornerRadius(5)
.frame(width: 180, height: 280)
Image(imagename).resizable().padding(.bottom,45).frame(width: 180, height: 280)
}
}
}
}
This is very easy for native Users.
Turns out I can just apply this to the surrounding Stack:
.rotationEffect(.degrees(-180))
To flip it vertically
SwiftUI’s rotationEffect() modifier lets us rotate views freely, using either degrees or radians.
For example, if you wanted to rotate some text by -90 degrees so that it reads upwards, you would use this:
Text("Up we go")
.rotationEffect(.degrees(-90))
If you prefer using radians, just pass in .radians() as your parameter, like this:
Text("Up we go")
.rotationEffect(.radians(.pi))
View rotation is so fast that it’s effectively free, so you could even make it interactive using a slider if you wanted:
struct ContentView: View {
#State private var rotation = 0.0
var body: some View {
VStack {
Slider(value: $rotation, in: 0...360)
Text("Up we go")
.rotationEffect(.degrees(rotation))
}
}
}
enter link description here
By default views rotate around their center, but if you want to pin the rotation from a particular point you can add an extra parameter for that. For example if you wanted to make the slider above pivoting the rotation around the view’s top-left corner you’d write this:
struct ContentView: View {
#State private var rotation = 0.0
var body: some View {
VStack {
Slider(value: $rotation, in: 0...360)
Text("Up we go")
.rotationEffect(.degrees(rotation), anchor: .topLeading)
}
}
}
enter link description here

Positioning View using anchor point

I have several dozen Texts that I would like to position such that their leading baseline (lastTextBaseline) is at a specific coordinate. position can only set the center. For example:
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
This locates the strings such that their center is at the desired point (marked as a red circle):
I would like to adjust this so that the leading baseline is at this red dot. In this example, a correct layout would move the glyphs up and to the right.
I have tried adding .topLeading alignment to the ZStack, and then using offset rather than position. This will let me align based on the top-leading corner, but that's not the corner I want to layout. For example:
ZStack(alignment: .topLeading) { // add alignment
Rectangle().foregroundColor(.clear) // to force ZStack to full size
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle), anchor: .topLeading) // rotate on top-leading
.offset(x: run.point.x, y: run.point.y)
}
}
I've also tried changing the "top" alignment guide for the Texts:
.alignmentGuide(.top) { d in d[.lastTextBaseline]}
This moves the red dots rather than the text, so I don't believe this is on the right path.
I am considering trying to adjust the locations themselves to take into account the size of the Text (which I can predict using Core Text), but I am hoping to avoid calculating a lot of extra bounding boxes.
So, as far as I can tell, alignment guides can't be used in this way – yet. Hopefully this will be coming soon, but in the meantime we can do a little padding and overlay trickery to get the desired effect.
Caveats
You will need to have some way of retrieving the font metrics – I'm using CTFont to initialise my Font instances and retrieving metrics that way.
As far as I can tell, Playgrounds aren't always representative of how a SwiftUI layout will be laid out on the device, and certain inconsistencies arise. One that I've identified is that the displayScale environment value (and the derived pixelLength value) is not set correctly by default in playgrounds and even previews. Therefore, you have to set this manually in these environments if you want a representative layout (FB7280058).
Overview
We're going to combine a number of SwiftUI features to get the outcome we want here. Specifically, transforms, overlays and the GeometryReader view.
First, we'll align the baseline of our glyph to the baseline of our view. If we have the font's metrics we can use the font's 'descent' to shift our glyph down a little so it sits flush with the baseline – we can use the padding view modifier to help us with this.
Next, we're going to overlay our glyph view with a duplicate view. Why? Because within an overlay we're able to grab the exact metrics of the view underneath. In fact, our overlay will be the only view the user sees, the original view will only be utilised for its metrics.
A couple of simple transforms will position our overlay where we want it, and we'll then hide the view that sits underneath to complete the effect.
Step 1: Set up
First, we're going to need some additional properties to help with our calculations. In a proper project you could organise this into a view modifier or similar, but for conciseness we'll add them to our existing view.
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
We'll also need a our font initialised as a CTFont so we can grab its metrics:
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
Then some calculations. This calculates some EdgeInsets for a text view that will have the effect of moving the text view's baseline to the bottom edge of the enclosing padding view:
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
We'll also add a couple of helper properties to CTFont:
extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
And finally we create a new helper function to generate our Text views that uses the CTFont we defined above:
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
}
Step 2: Adopt our glyphView(_:) in our main body call
This step is simple and has us adopt the glyphView(_:) helper function we define above:
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
This gets us here:
Step 3: Baseline shift
Next we shift the baseline of our text view so that it sits flush with the bottom of our enclosing padding view. This is just a case of adding a padding modifier to our new glyphView(_:)function that utilises the padding calculation we define above.
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding) // Added padding modifier
}
Notice how the glyphs are now sitting flush with the bottom of their enclosing views.
Step 4: Add an overlay
We need to get the metrics of our glyph so that we are able to accurately place it. However, we can't get those metrics until we've laid out our view. One way around this is to duplicate our view and use one view as a source of metrics that is otherwise hidden, and then present a duplicate view that we position using the metrics we've gathered.
We can do this with the overlay modifier together with a GeometryReader view. And we'll also add a purple border and make our overlay text blue to differentiate it from the previous step.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
})
.position(run.point)
Step 5: Translate
Making use of the metrics we now have available for us to use, we can shift our overlay up and to the right so that the bottom left corner of the glyph view sits on our red positioning spot.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
})
.position(run.point)
Step 6: Rotate
Now we have our view in position we can finally rotate.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Step 7: Hide our workings out
Last step is to hide our source view and set our overlay glyph to its proper colour:
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
The final code
//: A Cocoa based Playground to present user interface
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding)
}
}
private extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
PlaygroundPage.current.setLiveView(
ContentView()
.environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
.frame(width: 640, height: 480)
.background(Color.white)
)
And that's it. It's not perfect, but until SwiftUI gives us an API that allows us to use alignment anchors to anchor our transforms, it might get us by!
this code takes care of the font metrics, and position text as you asked
(If I properly understood your requirements :-))
import SwiftUI
import PlaygroundSupport
struct BaseLine: ViewModifier {
let alignment: HorizontalAlignment
#State private var ref = CGSize.zero
private var align: CGFloat {
switch alignment {
case .leading:
return 1
case .center:
return 0
case .trailing:
return -1
default:
return 0
}
}
func body(content: Content) -> some View {
ZStack {
Circle().frame(width: 0, height: 0, alignment: .center)
content.alignmentGuide(VerticalAlignment.center) { (d) -> CGFloat in
DispatchQueue.main.async {
self.ref.height = d[VerticalAlignment.center] - d[.lastTextBaseline]
self.ref.width = d.width / 2
}
return d[VerticalAlignment.center]
}
.offset(x: align * ref.width, y: ref.height)
}
}
}
struct ContentView: View {
var body: some View {
ZStack {
Cross(size: 20, color: Color.red).position(x: 200, y: 200)
Cross(size: 20, color: Color.red).position(x: 200, y: 250)
Cross(size: 20, color: Color.red).position(x: 200, y: 300)
Cross(size: 20, color: Color.red).position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .trailing))
.rotationEffect(.degrees(45))
.position(x: 200, y: 200)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .center))
.rotationEffect(.degrees(45))
.position(x: 200, y: 250)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(45))
.position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(225))
.position(x: 200, y: 300)
}
}
}
struct Cross: View {
let size: CGFloat
var color = Color.clear
var body: some View {
Path { p in
p.move(to: CGPoint(x: size / 2, y: 0))
p.addLine(to: CGPoint(x: size / 2, y: size))
p.move(to: CGPoint(x: 0, y: size / 2))
p.addLine(to: CGPoint(x: size, y: size / 2))
}
.stroke().foregroundColor(color)
.frame(width: size, height: size, alignment: .center)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Updated: you could try the following variants
let font = UIFont.systemFont(ofSize: 48)
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.font))
.border(Color.green)
.offset(x: 0, y: -self.font.lineHeight / 2.0)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
there is also next interesting variant, use ascender instead of above lineHeight
.offset(x: 0, y: -self.font.ascender / 2.0)

Spring Animation: What does the blendDuration parameter do?

In Apple's definition file for the spring animation is says:
blendDuration: The duration in seconds over which to interpolate changes to the response value of the spring.
Code Example
struct Spring_BlendDuration: View {
#State private var change = false
#State private var blendDuration = 100.0
var body: some View {
VStack(spacing: 20) {
Circle()
.foregroundColor(.green)
.scaleEffect(change ? 0.2 : 1)
.animation(.spring(response: 1, dampingFraction: 0.5, blendDuration: blendDuration))
HStack {
Image(systemName: "hare")
Slider(value: $blendDuration, in: 0...200)
Image(systemName: "tortoise")
}.foregroundColor(.green).padding()
Button("Change") {
self.change.toggle()
}.font(.title)
}
}
}
What it looks like
Comparison with fast Response parameter and slow Response parameter
I'm just not seeing any difference.
Note
I submitted feedback to Apple to get clarification on this. If I hear back from them, I'll update this question.
Here is an example to apply multiple spring() animations to the same property.
struct Spring_BlendDuration: View {
#State private var change = false
#State private var secondChange = false
#State private var blendDuration = 1.0
var body: some View {
VStack(spacing: 20) {
Circle()
.foregroundColor(.green)
.scaleEffect(change ? secondChange ? 0.1 : 0.3 : secondChange ? 0.5 : 1.0)
.animation(.spring(response: 1, dampingFraction: 0.1, blendDuration: blendDuration), value: change)
.animation(.spring(response: 10, dampingFraction: 1, blendDuration: blendDuration), value: secondChange)
HStack {
Image(systemName: "hare")
Slider(value: $blendDuration, in: 0...2)
Image(systemName: "tortoise")
}.foregroundColor(.green).padding()
Text("\(self.blendDuration)")
Button("Change") {
withAnimation{
self.change.toggle()}
}.font(.title)
Button("SecondChange") {
withAnimation{
self.secondChange.toggle()}
}.font(.title)
}
}
}
If you clicked the other button before a spring animation is over, you may notice some differences when blendDuration is applied. For example, the circle can expand very large easily compared to the situation without blendDuration.
According to the document:
A persistent spring animation. When mixed with other spring()
or interactiveSpring() animations on the same property, each
animation will be replaced by their successor, preserving
velocity from one animation to the next. Optionally blends the
response values between springs over a time period.
It seems that currently blendDuration makes no visual change according to this spring animations sample project.
Changing the blend duration of any of the examples in this repository, does not produce any visual change.
blend duration has no visible effect on the spring, at the time of writing this article.
https://github.com/GetStream/swiftui-spring-animations