I want to animate the trim attribute of a circle. However, if the animation is running and I change the line width, the animation finishes immediately (and unexpectedly).
Does somebody have an idea what I am doing wrong? I'm sure it's a very simple thing.
Thanks for answering!
import SwiftUI
struct ContentView: View {
#State var progress: Double = 0.01
#State var lineWidth: CGFloat = 20
var body: some View {
VStack{
CircleView(progress: progress, lineWidth: lineWidth)
.foregroundColor(.orange)
.onAppear{
withAnimation(.linear(duration: 20)){
progress = 1
}
}
.padding()
Button(action: {
withAnimation{
lineWidth = 40
}
}, label: {Text("Change Line Width")})
}
}
}
struct CircleView: View {
var progress: Double
var lineWidth: CGFloat
var body: some View {
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
.padding()
}
}
I'm not sure you can directly do what you hope to do. The stroking of the line uses the lineWidth, so I don't believe you can animate it with a separate time interval.
What you can do is change the lineWidth over time so that while the circle animation is running and redrawing the circle, it will use the new values.
With that in mind, I created the function changeValueOverTime(value:newValue:duration) to do this:
struct ContentView: View {
#State var progress: Double = 0.01
#State var lineWidth: CGFloat = 20
var body: some View {
VStack{
CircleView(progress: progress, lineWidth: $lineWidth)
.foregroundColor(.orange)
.onAppear{
withAnimation(.linear(duration: 20)){
progress = 1
}
}
.padding()
Button(action: {
changeValueOverTime(value: $lineWidth, newValue: 40, duration: 0.5)
}, label: {Text("Change Line Width")})
}
}
func changeValueOverTime(value: Binding<CGFloat>, newValue: CGFloat, duration: Double) {
let timeIncrements = 0.02
let steps = Int(duration / timeIncrements)
var count = 0
let increment = (newValue - value.wrappedValue) / CGFloat(steps)
Timer.scheduledTimer(withTimeInterval: timeIncrements, repeats: true) { timer in
value.wrappedValue += increment
count += 1
if count == steps {
timer.invalidate()
}
}
}
}
Just remove second animation and all will work. Tested with Xcode 12 / iOS 14
Button(action: {
lineWidth = 40 // << no animation here !!
}, label: {Text("Change Line Width")})
Related
I'm trying to make an idle animation in SwiftUI that gets triggered if there's no touch in the screen for 3 seconds. I made a little animation that goes up and down (y offset 15) when there's no touch for 3 seconds and goes back to its original position when a touch occurs. But the thing is, when it goes to its original positon, autoreverses doesn't get triggered. Here's how it looks like:
Go Live button:
struct GoLiveButton: View {
#State private var animationOffset: CGFloat = 0
#Binding var isIdle: Bool
var body: some View {
ZStack {
Button(action: {} ) {
Text("Go Live")
.frame(width: 120, height: 40)
.background(Color.black)
.foregroundColor(.white)
.clipShape(Capsule())
.font(.system(size: 20))
.shadow(color: .black, radius: 4, x: 4, y: 4)
}
.offset(y: animationOffset)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2).repeatForever(autoreverses: true), value: isIdle)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: !isIdle)
}
.onAppear {
self.isIdle = true
self.animationOffset = 15
}
.onChange(of: isIdle) { newValue in
if newValue {
self.animationOffset = 15
}
else {
self.animationOffset = 0
}
}
}
}
Here is the idle view:
struct StackOverflowView: View {
#State private var timer: Timer?
#State private var isIdle = false
var body: some View {
GeometryReader { geo in
GoLiveButton(isIdle: $isIdle)
}
.onTapGesture {
print("DEBUG: CustomTabView OnTapGesture Triggered")
self.isIdle = false
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
}
}
.gesture(
DragGesture().onEnded { _ in
self.isIdle = false
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
}
}
)
}
}
Here is an approach without AnimatableData:
I added a second timer that just triggers the animations by offset (0, -15, 0, -15 ...) every 2 seconds, repeating forever.
If isIdle changes to false, we just set offset to 0, and this will be animated too. We reset all timers. And again set the idle timer (3 secs) which when fires will start the animation timer (2 secs). voila.
(I also restructured the GoLiveButton a little bit so it holds all relevant states in itself, and the parent view only has to control isIdle)
struct GoLiveButton: View {
#Binding var isIdle: Bool
#State private var timer: Timer?
#State private var animationTimer: Timer?
#State private var animationOffset: CGFloat = 0
var body: some View {
ZStack {
Button(action: {} ) {
Text("Go Live")
.frame(width: 120, height: 40)
.background(Color.black)
.foregroundColor(.white)
.clipShape(Capsule())
.font(.system(size: 20))
.shadow(color: .black, radius: 4, x: 4, y: 4)
}
.offset(y: animationOffset)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: animationOffset)
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
animationOffset = (animationOffset == 15) ? 0 : 15
}
}
}
.onChange(of: isIdle) { newValue in
if newValue == false {
// reset all
self.animationTimer?.invalidate()
self.timer?.invalidate()
animationOffset = 0
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
animationOffset = (animationOffset == 15) ? 0 : 15
}
}
}
}
}
}
struct ContentView: View {
#State private var isIdle = false
var body: some View {
ZStack {
// for background tap only
Color.gray.opacity(0.2)
.onTapGesture {
print("tap")
self.isIdle = false
}
GoLiveButton(isIdle: $isIdle)
}
}
}
When I press the button, I want to make the blue rectangle smaller and then move it to the right.
In the current code, the animation to make it smaller and the animation to move it to the right happen at the same time.
How do you start an animation after the previous one has finished?
sample code:
import SwiftUI
struct ContentView: View {
#State private var scale: CGFloat = 1
#State private var offsetX: CGFloat = 1
var body: some View {
VStack {
Button(
action: {
withAnimation {
scale = scale - 0.1
}
withAnimation {
offsetX = offsetX + 25
}
},
label: {
Text("Tap Me")
}
)
RectangleView().scaleEffect(
scale
)
.offset(
x: offsetX,
y: 0
)
}
}
}
struct RectangleView: View {
var body: some View {
Rectangle().fill(
Color.blue
)
.frame(
width: 200,
height: 150
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can adjust by duration and delay.
struct ContentView: View {
#State private var scale: CGFloat = 1
#State private var offsetX: CGFloat = 1
var body: some View {
VStack {
Button(
action: {
withAnimation(.linear(duration: 0.2)) { //<-- Here
scale = scale - 0.1
}
withAnimation(Animation.default.delay(0.2)) { //<-- Here
offsetX = offsetX + 25
}
},
label: {
Text("Tap Me")
}
)
RectangleView().scaleEffect(
scale
)
.offset(
x: offsetX,
y: 0
)
}
}
}
So I have a circular progress bar that is declared like this
struct ProgressBar: View {
var progress: CGFloat
var body: some View {
let gradient = LinearGradient(...)
ZStack {
Circle()
.stroke(lineWidth: 25)
.opacity(0.3)
.foregroundColor(Color.secondary)
Circle()
.trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
.stroke(gradient ,style: StrokeStyle(lineWidth: 25.0, lineCap: .round, lineJoin: .round))
.rotationEffect(Angle(degrees: 270.0))
.animation(.linear)
}
}
}
The variable progress is passed into the view and is just simple division of value/total where I have two buttons to reduce or increase value and I use the animation to update the progress cleanly.
I call this view in another view called DetailView
However for some reason the second secondView floats in from the top when I navigate to DetailView from another view. What is happening?
Im not sure if this is a common issue but I can share a video if that might help you.
As requested here is an example.
import SwiftUI
struct ContentView: View {
#State var bookData: [Book] = load("list")
#State private var selectedBook: Book? = nil
#State var showOnboarding = false
var body: some View {
NavigationView {
Form {
Section{
ForEach(bookData){ bookDetail in
NavigationLink(
destination: PageDetail(bookData: $bookData, book: bookDetail),
label: {
BookView(book: bookDetail)
})
}
}
}
}
}
import SwiftUI
struct PageDetail: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#Binding var bookData: [Book]
#State var count = 0
var progress: CGFloat{
let page = value
let total = Int(book.total) ?? 1
let answer = CGFloat(page)/CGFloat(total)
return answer
}
var value: Int{
let page = Int(book.page) ?? 1
let cnt = count
let calc = page + cnt
return calc
}
var book: Book{
var body: some View {
ZStack{
LinearGradient(...)
VStack {
VStack(spacing: -25){
ProgressBar(page: value,total: book.total ,progress: progress)
.frame(width: 250, height: 300)
.padding(.horizontal, 20)
HStack {
Button(action: {
self.count -= 1
}, label: {
Image(systemName: "minus.circle")
})
Button(action: {
self.count += 1
}, label: {
Image(systemName: "plus.circle")
})
}
}
}
}
}
struct ProgressBar: View {
var page: Int
var total: String
var progress: CGFloat
var body: some View {
let gradient = LinearGradient(...)
ZStack {
Circle()
.stroke(lineWidth: 25)
.opacity(0.3)
.foregroundColor(Color.secondary)
Circle()
.trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
.stroke(gradient ,style: StrokeStyle(lineWidth: 25.0, lineCap: .round, lineJoin: .round))
.rotationEffect(Angle(degrees: 270.0))
.animation(.linear)
}
}
}
struct PageDetail_Previews: PreviewProvider {
#State static var previewed = testData
static var previews: some View {
PageDetail(bookData: $previewed, book: previewed[0])
}
}
Only idea - try to make animation value dependent, like below
Circle()
.trim(from: 0.0, to: CGFloat(min(self.progress, 1.0)))
.stroke(gradient ,style: StrokeStyle(lineWidth: 25.0, lineCap: .round, lineJoin: .round))
.rotationEffect(Angle(degrees: 270.0))
.animation(.linear, value: progress) // << here !!
I'm having some trouble layering iOS animations for my video recoding button. I've modeled it after other standard recording buttons and I'd like for it to begin as a solid circle and then on press, transform to a rounded rectangle where its opacity pulses.
The issue is that I've ordered the animations in a way such that their all applied at the same time.
https://imgur.com/a/xi7PxtH
I've tried reordering these animations but can't figure out which ordering with achieve the scope I want.
import SwiftUI
struct ContentView: View {
#State var recordComplete = false
#State private var rCorner: CGFloat = 100
#State private var rWidth: CGFloat = 70
#State private var rHeight: CGFloat = 70
#State private var opacityVal = 1.0
var body: some View {
HStack{
Button(action: {
self.rCorner = self.rCorner == 100 ? 12 : 100
self.rWidth = self.rWidth == 70 ? 45 : 70
self.rHeight = self.rHeight == 70 ? 45 : 70
self.recordComplete.toggle()
}) {
ZStack{
Circle()
.stroke(Color.red, lineWidth: 6)
.frame(width:85, height: 85)
RoundedRectangle(cornerRadius: rCorner)
.fill(Color.red)
.frame(width: rWidth, height: rHeight)
.opacity(opacityVal)
.animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)).onAppear{ self.opacityVal = 0.3 }
}
.padding(.vertical, 25)
}
}.animation(.spring(response: 0.5, dampingFraction: 2, blendDuration: 0.5))
}
}
Thank you as always for the help with this issue!
Here is a possible solution - the main idea is to separate animations by value and make them serial. Tested with Xcode 12 / iOS 14
struct ContentView: View {
#State var recordComplete = false
#State private var rCorner: CGFloat = 100
#State private var rWidth: CGFloat = 70
#State private var rHeight: CGFloat = 70
#State private var opacityVal = 1.0
var body: some View {
HStack{
Button(action: {
self.rCorner = self.rCorner == 100 ? 12 : 100
self.rWidth = self.rWidth == 70 ? 45 : 70
self.rHeight = self.rHeight == 70 ? 45 : 70
self.recordComplete.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.opacityVal = self.recordComplete ? 0.3 : 1.0
}
}) {
ZStack{
Circle()
.stroke(Color.red, lineWidth: 6)
.frame(width:85, height: 85)
RoundedRectangle(cornerRadius: rCorner)
.fill(Color.red)
.frame(width: rWidth, height: rHeight)
.opacity(opacityVal)
.animation(
self.recordComplete ?
Animation.easeInOut(duration: 1).repeatForever(autoreverses: true) :
Animation.default,
value: opacityVal)
}
.padding(.vertical, 25)
}
}.animation(.spring(response: 0.5, dampingFraction: 2, blendDuration: 0.5), value: rCorner)
}
}
I created a custom button in SwiftUi(ios) like this that has its own interaction
but when I use it with two other animations, the interaction of the button is replaced by the new ones
Here is the custom button code
import SwiftUI
public struct fillCircular: ButtonStyle {
var width: CGFloat
var height: CGFloat = 50
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(.horizontal)
.frame(width: width, height: height)
.background(Capsule(style: .circular).fill(Color.primary))
.font(.titleChanga3)
.foregroundColor(Color.white)
.shadow(color: Color.primary
.opacity(configuration.isPressed ? 0.55 : 0.34),
radius: configuration.isPressed ? 1 : 4,
x: 0, y: configuration.isPressed ? 1 : 3)
.scaleEffect(CGSize(width: configuration.isPressed ? 0.99 : 1,
height: configuration.isPressed ? 0.99 : 1))
.animation(.spring())
}
}
Here lies the problem
struct Welcome: View {
#State var start: Bool = false
var body: some View {
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
.animation(Animation.easeOut(duration: 0.5).delay(0.4))
}
}
When this method is added, the animation changes in the custom button (fillCircular)
.animation(Animation.easeOut(duration: 0.5).delay(0.4))
How do I enter Animation and at the same time maintain the interaction of the custom button
Updated answer
Basically, your second .animation() call is overriding the first. Instead of applying the second animation, wrap your start = true in a withAnimation() call, like so:
struct WelcomeView: View {
#State var start: Bool = false
var body: some View {
VStack {
// This is just here to toggle start variable
Button("Toggle", action: {
withAnimation(Animation.easeOut(duration: 0.5).delay(0.4)) {
self.start.toggle()
}
})
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
}
}
}
}
Then the easeOut animation will only apply when the button's action closure is called.
This answer is cleaner, but the original also works as of Swift 5.1 / Xcode 11.0
Original answer
Although it's a little hacky, you could have whatever sets start = true also set the animation to .spring() after a delay.
struct WelcomeView: View {
#State var start: Bool = false
#State var animation: Animation = Animation.easeOut(duration: 0.5).delay(0.4)
var body: some View {
VStack {
// This is just here to toggle start variable
Button("Toggle", action: {
if !self.start {
// Show button, and after combined delay + duration, set animation to .spring()
self.start.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
self.animation = .spring()
}
} else {
// Set animation to .easeOut(), then hide button
self.animation = Animation.easeOut(duration: 0.5).delay(0.4)
self.start.toggle()
}
})
GeometryReader { g in
Button(action: {}) {
Text("New Account")
}
.buttonStyle(fillCircular(width: g.size.width - (24 * 2)))
.offset(y: self.start ? 0 : 20)
.opacity(self.start ? 1 : 0)
.animation(self.animation)
}
}
}
}