7

SwiftUI animations are typically driven by state, which is great, but sometimes you really want to trigger a temporary (often reversible) animation in response to some event. For example, I want to temporarily increase the size of a button when a it is tapped (both the increase and decrease in size should happen as a single animation when the button is released), but I haven't been able to figure this out.

It can sort of be hacked together with transitions I think, but not very nicely. Also, if I make an animation that uses autoreverse, it will increase the size, decrease it and then jump back to the increased state.

Gusutafu
  • 745
  • 6
  • 17
  • Can you use something like this? (It's from beta 1 or 2, so it may not work anymore.) https://alejandromp.com/blog/2019/06/22/swiftui-reusable-button-style/ –  Jul 29 '19 at 13:55
  • Thank you for the interesting link, but unfortunately this suffers from the same problem as kontiki’s solution: it relies on state, in the pressed state the button has one size, in the non-pressed state it has the normal size. I would need the animation to play itself forwards and then backwards automatically when i trigger it. – Gusutafu Jul 29 '19 at 20:14

4 Answers4

3

That is something I have been into as well.

So far my solution depends on applying GeometryEffect modifier and misusing the fact that its method effectValue is called continuously during some animation. So the desired effect is actually a transformation of interpolated values from 0..1 that has the main effect in 0.5 and no effect at 0 or 1

It works great, it is applicable to all views not just buttons, no need to depend on touch events or button styles, but still sort of seems to me as a hack.

Example with random rotation and scale effect:

enter image description here

Code sample:

struct ButtonEffect: GeometryEffect {

    var offset: Double // 0...1

    var animatableData: Double {
        get { offset }
        set { offset = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {

        let effectValue = abs(sin(offset*Double.pi))
        let scaleFactor = 1+0.2*effectValue

        let affineTransform = CGAffineTransform(rotationAngle: CGFloat(effectValue)).translatedBy(x: -size.width/2, y: -size.height/2).scaledBy(x: CGFloat(scaleFactor), y: CGFloat(scaleFactor))

        return ProjectionTransform(affineTransform)
    }
}

struct ButtonActionView: View {
    @State var animOffset: Double = 0
    var body: some View {
        Button(action:{
            withAnimation(.spring()) {
                self.animOffset += 1
            }
        })
        {
            Text("Press ME")
                .padding()
        }
        .background(Color.yellow)
        .modifier(ButtonEffect(offset: animOffset))
    }
}
Pavel Zak
  • 479
  • 3
  • 11
  • Thanks a lot for this, I will test it out. I had never heard of GeometryEffect before! – Gusutafu Aug 27 '19 at 08:09
  • 1
    you are welcome, I have just published a short [blogpost](https://izakpavel.github.io/development/2019/08/29/tweaking-animations-with-GeometryEffect.html) about GeometryEffect, if you are interested – Pavel Zak Aug 29 '19 at 17:27
  • @PavelZak how would yo fix the issue of centering the view? let's say that you only want the view to scale. Btw, great work! – Aнгел Sep 04 '21 at 02:23
2

You can use a @State variable tied to a longPressAction():

enter image description here

Code updated for Beta 5:

struct ContentView: View {
    var body: some View {
        HStack {
            Spacer()
            MyButton(label: "Button 1")
            Spacer()
            MyButton(label: "Button 2")
            Spacer()
            MyButton(label: "Button 3")
            Spacer()
        }
    }
}

struct MyButton: View {
    let label: String
    @State private var pressed = false


    var body: some View {

        return Text(label)
            .font(.title)
            .foregroundColor(.white)
            .padding(10)
            .background(RoundedRectangle(cornerRadius: 10).foregroundColor(.green))
            .scaleEffect(self.pressed ? 1.2 : 1.0)
            .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { pressing in
                withAnimation(.easeInOut(duration: 0.2)) {
                    self.pressed = pressing
                }
            }, perform: { })
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • 1
    Thanks a lot for the answer my friend, this does what it says it does, but unfortunately in my case i only have a single event to trigger the scale-up and scale-down, So I don’t think I can use a pure state approach. In this example, I would need both parts of the animations to run when you release the finger! – Gusutafu Jul 29 '19 at 13:11
2

I believe this is what you're after. (this is how I solved this problem)

Based on dfd's link in i came up with this, which is not dependent on any @State variable. You simply just implement your own button style. No need for Timers, @Binding, @State or other complex workarounds.

enter image description here

import SwiftUI

struct MyCustomPressButton: ButtonStyle {  
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .padding(10)
            .cornerRadius(10)
            .scaleEffect(configuration.isPressed ? 0.8 : 1.0)
    }
}

struct Play: View {
    var body: some View {
            Button("Tap") {
         }.buttonStyle(MyCustomPressButton())
            .animation(.easeIn(duration: 0.2))
    }
}

struct Play_Previews: PreviewProvider {
    static var previews: some View {
        Play()
    }
}
Joakim Poromaa Helger
  • 1,261
  • 10
  • 17
0

There is no getting around the need to update via state in SwiftUI. You need to have some property that is only true for a short time that then toggles back.

The following animates from small to large and back.


struct ViewPlayground: View {

    @State var enlargeIt = false
    var body: some View {
        Button("Event!") {
            withAnimation {
                self.enlargeIt = true
            }
        }
        .background(Momentary(doIt: self.$enlargeIt))
        .scaleEffect(self.enlargeIt ? 2.0 : 1.0)
    }
}

struct Momentary: View {
    @Binding var doIt: Bool
    var delay: TimeInterval = 0.35
    var body: some View {
        Group {
            if self.doIt {
                ZStack { Spacer() }
                    .onAppear {
                        DispatchQueue.main.asyncAfter(deadline: .now() + self.delay) {
                            withAnimation {
                                self.doIt = false
                            }
                        }
                }
            }
        }
    }
}

Unfortunately delay was necessary to get the animation to occur when setting self.enlargeIt = true. Without that it only animates back down. Not sure if that's a bug in Beta 4 or not.

arsenius
  • 12,090
  • 7
  • 58
  • 76
  • Yeah I understand, there needs to be some state change. But I’m thinking it should be possible to trigger a more complex animation, like a scaleup followed by a scaledown, whenever the state changes. – Gusutafu Aug 01 '19 at 12:11