Is there a better way to implement a shake animation in swiftui? - animation

I'm trying to get a button to shake when the user tries to log in without filling all the textfields in, and this is what I've come across so far:
struct Shake: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
y: 0))
}
}
struct Correct: View {
#State var attempts: Int = 0
var body: some View {
VStack {
Rectangle()
.fill(Color.pink)
.frame(width: 200, height: 100)
.modifier(Shake(animatableData: CGFloat(attempts)))
Spacer()
Button(action: {
withAnimation(.default) {
self.attempts += 1
}
}, label: { Text("Login") })
}
}
}
However, this is particularly useless for a button, and even then the animation seems very off in that its pretty robotic. Can someone suggest an improvement so that I can get my button to shake?

try this
struct ContentView: View {
#State var selected = false
var body: some View {
VStack {
Button(action: {
self.selected.toggle()
}) { selected ? Text("Deselect") : Text("Select") }
Rectangle()
.fill(Color.purple)
.frame(width: 200, height: 200)
.offset(x: selected ? -30 : 0)
.animation(Animation.default.repeatCount(5).speed(6))
}
}
}

I do this to make the field shake and then gets back to it's original position:
private func signUp() {
if email.isEmpty {
withAnimation {
emailIsWrong = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation {
emailIsWrong = false
}
}
return
}
}
Where emailIsWrong is a #State variable:
#State private var emailIsWrong = false
Basically after 0.2 sec, I change the emailIsWrong back to false so the view goes back to its position. My text field looks like this:
TextField("Email", text: $email)
.padding()
.frame(height: 45)
.background(Color.white)
.colorScheme(.light)
.offset(x: emailIsWrong ? -8 : 0)
.animation(Animation.default.repeatCount(3, autoreverses: true).speed(6))

A little bit late to the party, but unfortunately the solutions here either finish the animation with a wrong offset or need some hardcoded assumption on the time the animation will finish.
The solution I came up with looks like this:
#State var shake = false
Text("Shake Me")
.font(.title)
.onTapGesture {
shake = true
}
.shake($shake) {
print("Finished")
}
To animate, you just need to set shake to true (it will automatically be set to false once the animation completes).
Here is the implementation:
struct Shake<Content: View>: View {
/// Set to true in order to animate
#Binding var shake: Bool
/// How many times the content will animate back and forth
var repeatCount = 3
/// Duration in seconds
var duration = 0.8
/// Range in pixels to go back and forth
var offsetRange = 10.0
#ViewBuilder let content: Content
var onCompletion: (() -> Void)?
#State private var xOffset = 0.0
var body: some View {
content
.offset(x: xOffset)
.onChange(of: shake) { shouldShake in
guard shouldShake else { return }
Task {
let start = Date()
await animate()
let end = Date()
print(end.timeIntervalSince1970 - start.timeIntervalSince1970)
shake = false
onCompletion?()
}
}
}
// Obs: some of factors must be 1.0.
private func animate() async {
let factor1 = 0.9
let eachDuration = duration * factor1 / CGFloat(repeatCount)
for _ in 0..<repeatCount {
await backAndForthAnimation(duration: eachDuration, offset: offsetRange)
}
let factor2 = 0.1
await animate(duration: duration * factor2) {
xOffset = 0.0
}
}
private func backAndForthAnimation(duration: CGFloat, offset: CGFloat) async {
let halfDuration = duration / 2
await animate(duration: halfDuration) {
self.xOffset = offset
}
await animate(duration: halfDuration) {
self.xOffset = -offset
}
}
}
extension View {
func shake(_ shake: Binding<Bool>,
repeatCount: Int = 3,
duration: CGFloat = 0.8,
offsetRange: CGFloat = 10,
onCompletion: (() -> Void)? = nil) -> some View {
Shake(shake: shake,
repeatCount: repeatCount,
duration: duration,
offsetRange: offsetRange) {
self
} onCompletion: {
onCompletion?()
}
}
func animate(duration: CGFloat, _ execute: #escaping () -> Void) async {
await withCheckedContinuation { continuation in
withAnimation(.linear(duration: duration)) {
execute()
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
continuation.resume()
}
}
}
}

here's my one
#State var ringOnFinish: Bool = false
#State var shakeOffset: Double = 0
Button() {
ringOnFinish.toggle()
//give it a little shake animation when off
if !ringOnFinish {
shakeOffset = 5
withAnimation {
shakeOffset = 0
}
} label: {
Image(systemName: "bell\(ringOnFinish ? "" : ".slash")")
.offset(x: ringOnFinish ? 0 : shakeOffset)
.animation(.default.repeatCount(3, autoreverses: true).speed(6), value: ringOnFinish)
}

Related

How to fix Scrolling while pressing an object that shrinks when pressing - SwiftUI

I've been trying to add a shrinking affect on buttons while they're being pressed, and I was able to establish it with with LongPressGesture
But at the same time, I noticed that this also stops the container from being scrolled when the box is pressed to start scrolling, for example, I can scroll when I press an empty space first to start scrolling, but I can't scroll when I press the box first to start scrolling.
I'm using Xcode 14.0.1 on macOS Monterey 12.6 and I tested the code both in virtual and physical phones running on iOS 16.
And here's a simplified version of my code,
struct TestView: View {
#GestureState var isDetectingLongPress = false
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 25) {
RoundedRectangle(cornerRadius: 12.5, style: .continuous)
.fill(Color.blue)
.frame(height: 200)
.padding(25)
.scaleEffect(!isDetectingLongPress ? 1.0 : 0.875)
.brightness(!isDetectingLongPress ? 0.0 : -0.125)
.animation(.easeInOut(duration: 0.125), value: isDetectingLongPress)
.gesture(
LongPressGesture(minimumDuration: 3)
.updating($isDetectingLongPress) { currentState, gestureState,
transaction in
gestureState = currentState
transaction.animation = Animation.easeIn(duration: 2.0)
}
.onEnded { finished in
}
)
}
}
}
}
How can I fix this? Thanks!
Approach 1:
Add a modifier that delays the recognition of the tap gesture. If a user starts their scroll on the button, the user will still be able to scroll. The downside to this is if the user wants to tap the button, it will be slightly delayed.
This approach was found here
One thing you'll notice is that I moved the RoundedRectange you created into its own view. Hope this makes things easier to read!
import SwiftUI
struct ScrollTest: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 25) {
AnimatedButtonView()
}
}
}
}
struct AnimatedButtonView: View {
#GestureState var isDetectingLongPress = false
var body: some View {
RoundedRectangle(cornerRadius: 12.5, style: .continuous)
.fill(Color.blue)
.frame(height: 200)
.padding(25)
.scaleEffect(!isDetectingLongPress ? 1.0 : 0.875)
.brightness(!isDetectingLongPress ? 0.0 : -0.125)
.animation(.easeInOut(duration: 0.125), value: isDetectingLongPress)
.delaysTouches(for: 0.1) {
//some code here, if needed
}
.gesture(
LongPressGesture(minimumDuration: 3)
.updating($isDetectingLongPress) { currentState, gestureState,
transaction in
gestureState = currentState
transaction.animation = Animation.easeIn(duration: 2.0)
}
.onEnded { finished in
print("pooop")
})
}
}
extension View {
func delaysTouches(for duration: TimeInterval = 0.25, onTap action: #escaping () -> Void = {}) -> some View {
modifier(DelaysTouches(duration: duration, action: action))
}
}
fileprivate struct DelaysTouches: ViewModifier {
#State private var disabled = false
#State private var touchDownDate: Date? = nil
var duration: TimeInterval
var action: () -> Void
func body(content: Content) -> some View {
Button(action: action) {
content
}
.buttonStyle(DelaysTouchesButtonStyle(disabled: $disabled, duration: duration, touchDownDate: $touchDownDate))
.disabled(disabled)
}
}
fileprivate struct DelaysTouchesButtonStyle: ButtonStyle {
#Binding var disabled: Bool
var duration: TimeInterval
#Binding var touchDownDate: Date?
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed, perform: handleIsPressed)
}
private func handleIsPressed(isPressed: Bool) {
if isPressed {
let date = Date()
touchDownDate = date
DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) {
if date == touchDownDate {
disabled = true
DispatchQueue.main.async {
disabled = false
}
}
}
} else {
touchDownDate = nil
disabled = false
}
}
}
Approach 2:
Similarly hacky to approach 1. Add a .onTapGesture before .gesture. Downside is that it adds a delay and I am not sure if that is behavior you are looking for:
import SwiftUI
struct ScrollTest: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 25) {
AnimatedButtonView()
}
}
}
}
struct AnimatedButtonView: View {
#GestureState var isDetectingLongPress = false
var body: some View {
RoundedRectangle(cornerRadius: 12.5, style: .continuous)
.fill(Color.blue)
.frame(height: 200)
.padding(25)
.scaleEffect(!isDetectingLongPress ? 1.0 : 0.875)
.brightness(!isDetectingLongPress ? 0.0 : -0.125)
.animation(.easeInOut(duration: 0.125), value: isDetectingLongPress)
.onTapGesture {}
.gesture(
LongPressGesture(minimumDuration: 3)
.updating($isDetectingLongPress) { currentState, gestureState,
transaction in
gestureState = currentState
transaction.animation = Animation.easeIn(duration: 2.0)
}
.onEnded { finished in
print("pooop")
})
}
}

SwiftUI animation not continuous after view change

Here is the testing code:
import SwiftUI
struct ContentView: View {
#State private var pad: Bool = false
#State private var showDot: Bool = true
var body: some View {
VStack {
Button {showDot.toggle()} label: {Text("Toggle Show Dot")}
Spacer().frame(height: pad ? 100 : 10)
Circ(showDot: showDot)
Spacer()
}.onAppear {
withAnimation(.linear(duration: 3).repeatForever()) {pad = true}
}
}
}
struct Circ: View {
let showDot: Bool
var body: some View {
Circle().stroke().frame(height: 50).overlay {if showDot {Circle().frame(height: 20)}}
}
}
It happens that after I toggle showDot, the dot circle is not on the center of the stroke circle again! How can I fix this?
The Circ View is given, I can't change that view!
Edit
If you can just hide the view, see solution 1, which is preferred. If you need to re-build the view, see solution 2.
Solution 1
Replace the if condition with a .opacity() modifier that reads 1 when showDot is true.
This way, the dot does not disappear completely, it is there but you just can't see it. You will be toggling the visibility, not the view itself.
Like this:
#State private var pad: Bool = false
#State private var showDot: Bool = true
var body: some View {
VStack {
Button {
showDot.toggle()
} label: {
Text("Toggle Show Dot")
}
Spacer()
.frame(height: pad ? 100 : 10)
Circle().stroke()
.frame(height: 50)
.overlay {
Circle()
.frame(height: 20)
.opacity(showDot ? 1 : 0) // <- Here
}
Spacer()
}
.onAppear {
withAnimation(.linear(duration: 3).repeatForever()) {pad = true}
}
}
Solution 2
You can replace the animation with a timer. Every time it triggers, it will move the whole view by changing the height of the Spacer().
// These variables will track the position and moving direction of the dot
#State private var pos: CGFloat = 0
#State private var movingUp = false
#State private var showDot: Bool = true
// This variable will change the position
// This is a dummy iniatialization, the .onAppear modifier sets the real timer
#State private var timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in }
var body: some View {
VStack {
Button {
showDot.toggle()
} label: {
Text("Toggle Show Dot")
}
Spacer()
.frame(height: pos)
Circle().stroke()
.frame(height: 50)
.overlay {
if showDot {
Circle().frame(height: 20)
}
}
Spacer()
}
.onAppear {
// The timer interval will define the speed
timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
moveCircle()
}
}
}
private func moveCircle() {
if movingUp {
if pos <= 0 {
pos = 0
movingUp = false
} else {
pos -= 1
}
} else {
if pos >= 100 {
pos = 100
movingUp = true
} else {
pos += 1
}
}
}

Swift UI - animation - terrible performances

I'm trying to display an automatic scrolling text (marquee?) with an animation using Swift UI.
When the mouse is over the text, the animation stops (that's why I store the current state of the animation).
Using one of the latest M1 MBP, this simple animation is using up to 10% of CPU and I'm trying to understand why. Is Swift UI not made for animations like this one or am I doing something wrong? At the end, it's just an animation moving the x offset.
Here is the code of my Marquee.
import SwiftUI
private enum MarqueeState {
case idle
case animating
}
struct GeometryBackground: View {
var body: some View {
GeometryReader { geometry in
Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
}
}
}
struct WidthKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
extension View {
func myOffset(x: CGFloat, y: CGFloat) -> some View {
return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
}
func myOffset(_ offset: CGSize) -> some View {
return modifier(_OffsetEffect(offset: offset))
}
}
struct PausableOffsetX: GeometryEffect {
#Binding var currentOffset: CGFloat
#Binding var contentWidth: CGFloat
private var targetOffset: CGFloat = 0.0;
var animatableData: CGFloat {
get { targetOffset }
set { targetOffset = newValue }
}
init(targetOffset: CGFloat, currentOffset: Binding<CGFloat>, contentWidth: Binding<CGFloat>) {
self.targetOffset = targetOffset
self._currentOffset = currentOffset
self._contentWidth = contentWidth
}
public func effectValue(size: CGSize) -> ProjectionTransform {
DispatchQueue.main.async {
self.currentOffset = targetOffset
}
let relativeOffset = targetOffset.truncatingRemainder(dividingBy: contentWidth)
let transform = CGAffineTransform(translationX: relativeOffset, y: 0)
return ProjectionTransform(transform)
}
}
struct Marquee<Content: View> : View {
#State private var isOver: Bool = false
private var content: () -> Content
#State private var state: MarqueeState = .idle
#State private var contentWidth: CGFloat = 0
#State private var isAppear = false
#State private var targetOffsetX: CGFloat = 0
#State private var currentOffsetX: CGFloat
public init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
self.currentOffsetX = 0
}
private func getAnimation() -> Animation {
let duration = contentWidth / 30
print("animation with duration of ", duration)
return Animation.linear(duration: duration).repeatForever(autoreverses: false);
}
public var body : some View {
GeometryReader { proxy in
HStack(alignment: .center, spacing: 0) {
if isAppear {
content()
.overlay(GeometryBackground())
.fixedSize()
content()
.overlay(GeometryBackground())
.fixedSize()
}
}
.modifier(PausableOffsetX(targetOffset: targetOffsetX, currentOffset: $currentOffsetX, contentWidth: $contentWidth))
.onPreferenceChange(WidthKey.self, perform: { value in
if value != self.contentWidth {
self.contentWidth = value
print("Content width = \(value)")
resetAnimation()
}
})
.onAppear {
self.isAppear = true
resetAnimation()
}
.onDisappear {
self.isAppear = false
}
.onHover(perform: { isOver in
self.isOver = isOver
checkAnimation()
})
}
.frame(width: 400)
.clipped()
}
private func getOffsetX() -> CGFloat {
switch self.state {
case .idle:
return self.currentOffsetX
case .animating:
return -self.contentWidth + currentOffsetX
}
}
private func checkAnimation() {
if isOver{
if self.state != .idle {
pauseAnimation()
}
} else {
if self.state != .animating {
resumeAnimation()
}
}
}
private func pauseAnimation() {
withAnimation(.linear(duration: 0)) {
self.state = .idle
self.targetOffsetX = getOffsetX()
}
}
private func resumeAnimation() {
print("Resume animation");
withAnimation(getAnimation()) {
self.state = .animating
self.targetOffsetX = getOffsetX()
}
}
private func resetAnimation() {
withAnimation(.linear(duration: 0)) {
self.currentOffsetX = 0
self.targetOffsetX = 0
self.state = .idle
}
resumeAnimation()
}
}
And we can use it as follow:
Marquee {
Text("Hello, world! Hello, world! Hello, world! Hello, world!").padding().fixedSize()
}.frame(width: 300)
EDIT
I ended up using Core Animation instead of the one built in Swift UI. The cpu / Energy impact is an absolute zero. So I wouldn't recommend using Swift UI animation for long lasting or persistant animations.

swiftui, animation applied to parent effect child animation

RectangleView has a slide animation, his child TextView has a rotation animation. I suppose that RectangleView with his child(TextView) as a whole slide(easeInOut) into screen when Go! pressed, and TextView rotate(linear) forever. But in fact, the child TextView separates from his parent, rotating(linear) and sliding(linear), and repeats forever.
why animation applied to parent effect child animation?
struct AnimationTestView: View {
#State private var go = false
var body: some View {
VStack {
Button("Go!") {
go.toggle()
}
if go {
RectangleView()
.transition(.slide)
.animation(.easeInOut)
}
}.navigationTitle("Animation Test")
}
}
struct RectangleView: View {
var body: some View {
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.pink)
.overlay(TextView())
}
}
struct TextView: View {
#State private var animationRotating: Bool = false
let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
var body: some View {
Text("Test")
.foregroundColor(.blue)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(animation)
.onAppear { animationRotating = true }
.onDisappear { animationRotating = false }
}
}
If there are several simultaneous animations the generic solution (in majority of cases) is to use explicit state value for each.
So here is a corrected code (tested with Xcode 12.1 / iOS 14.1, use Simulator or Device, Preview renders some transitions incorrectly)
struct AnimationTestView: View {
#State private var go = false
var body: some View {
VStack {
Button("Go!") {
go.toggle()
}
VStack { // container needed for correct transition !!
if go {
RectangleView()
.transition(.slide)
}
}.animation(.easeInOut, value: go) // << here !!
}.navigationTitle("Animation Test")
}
}
struct RectangleView: View {
var body: some View {
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.pink)
.overlay(TextView())
}
}
struct TextView: View {
#State private var animationRotating: Bool = false
let animation = Animation.linear(duration: 3.0).repeatForever(autoreverses: false)
var body: some View {
Text("Test")
.foregroundColor(.blue)
.rotationEffect(.degrees(animationRotating ? 360 : 0))
.animation(animation, value: animationRotating) // << here !!
.onAppear { animationRotating = true }
.onDisappear { animationRotating = false }
}
}

Can't set Variable Interval for SwiftUI Timer

I'm trying to automatically display a sequence of images in a SwiftUI view. I can create a fixed
timer with a predetermined "every" value and trigger the view update in an .onReceive
closure but I have not been able to construct a timer with a property for the interval
that can be adjusted by the user with a slider. The code below works for the fixed
timer. The subscription fires appropriately but I cannot seem to find an equivalent
to .onReceive for a subscription to update the image. I also tried creating my own
TimerPublisher but I had the same issue - I could not initialize it without specifying
a fixed value.
My view:
struct ImageStream2: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: DermPhoto.getAllDermPhotos()) var dermPhotos: FetchedResults<DermPhoto>
#State private var activeImageIndex = 0
#State private var animateSpeed: Double = 1.0
#State private var startTimer = false
let timer = Timer.publish(every: 1.0, on: .main, in: .default).autoconnect()
#State private var mst: MyTimerSubscription!
var body: some View {
GeometryReader { geo in
VStack {
Text("Time Sequence")
.foregroundColor(.blue)
.font(.system(size: 25))
Image(uiImage: UIImage(data: self.dermPhotos[self.activeImageIndex].myImage!)!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width - 20, height: geo.size.width - 20, alignment: .center)
.cornerRadius(20)
.shadow(radius: 10, x: 10, y: 10)
.onReceive(self.timer) { t in
print("in fixed timer")
if self.startTimer {
self.activeImageIndex = (self.activeImageIndex + 1) % self.dermPhotos.count
}
}
Group {
HStack {
Text("0.5").foregroundColor(.blue)
Slider(value: self.$animateSpeed, in: 0.5...5.0, step: 0.5)
Text("5.0").foregroundColor(.blue)
}
.padding()
Text("Replay at: " + "\(self.animateSpeed) " + (self.animateSpeed == 1.0 ? "second" : "seconds") + " per frame")
.foregroundColor(.blue)
HStack {
Button(action: {
self.startTimer.toggle()
if self.startTimer {
self.mst = MyTimerSubscription(interval: 5.0)
} else {
self.mst.subscription.cancel()
}
}) {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.yellow)
.frame(width: 200, height: 40)
Text(self.startTimer ? "Stop" : "Start").font(.headline)
}
}
}
.padding()
}//group
}//top VStack
}.onDisappear{ self.startTimer = false }
}
}
And the subscription file:
class MyTimerSubscription {
let subscription: AnyCancellable
let interval: Double
init(interval: Double) {
subscription =
Timer.publish(every:interval, on:.main, in:.default)
.autoconnect()
.sink { _ in
print("in MyTimerSubscription print")
}
self.interval = interval
}
deinit {
subscription.cancel()
}
}
Any guidance would be appreciated. Xcode 11.3 (11C29)
Create an Observable Timer:
class TimerWrapper : ObservableObject {
let willChange = PassthroughSubject<TimerWrapper, Never>()
#Published var timer : Timer!
func start(withTimeInterval interval: Double) {
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.willChange.send(self)
}
}
}
Then with the view, add:
#State var timer = Timer.publish(every: 1.0, on: .main, in: .default).autoconnect()
#State private var animateSpeed: Double = 1.0
Then with the Image:
.onReceive(self.timer) { t in
if self.startTimer {
if self.timerToggle {
self.activeImageIndex = (self.activeImageIndex + 1) % self.dermPhotos.count
}
self.goTimerAnimation.toggle() //changes the shape of the frame
self.timerToggle.toggle()
self.pct.toggle() //changes the size of the frame
}
}//onReceive
And in the button action:
self.timer = Timer.publish(every: self.animateSpeed, on: .main, in: .default).autoconnect()

Resources