简体   繁体   中英

Button blink animation with SwiftUI

How to make border color changing animation in SwiftUI. Here is the code with UIKit

extension UIButton{
    func blink(setColor: UIColor, repeatCount: Float, duration: Double) {
        self.layer.borderWidth = 1.0
        let animation: CABasicAnimation = CABasicAnimation(keyPath: "borderColor")
        animation.fromValue = UIColor.clear.cgColor
        animation.toValue = setColor.cgColor
        animation.duration = duration
        animation.autoreverses = true
        animation.repeatCount = repeatCount
        self.layer.borderColor = UIColor.clear.cgColor
        self.layer.add(animation, forKey: "")
    }
}

I had a similar problem to implement a repeating text with my SwiftUI project. And the answer looks too advanced for me to implement. After some search and research. I managed to repeatedly blink my text. For someone who sees this post later, you may try this approach using withAnimation{} and .animation() .

Swift 5

@State private var myRed = 0.2
@State private var myGreen = 0.2
@State private var myBlue = 0.2

var body:some View{
    
Button(action:{
 //
}){
 Text("blahblahblah")
}
.border(Color(red: myRed,green: myGreen,blue: myBlue))
.onAppear{
    withAnimation{
       myRed = 0.5
       myGreen = 0.5
       myBlue = 0
    }
}
.animation(Animation.easeInOut(duration:2).repeatForever(autoreverses:true))
}

Update: Xcode 13.4 / iOS 15.5

A proposed solution still works with some minimal tuning.

Updated code and demo is here

Original:

Hope the following approach would be helpful. It is based on ViewModifier and can be controlled by binding. Speed of animation as well as animation kind itself can be easily changed by needs.

Note: Although there are some observed drawbacks: due to no didFinish callback provided by API for Animation it is used some trick to workaround it; also it is observed some strange handling of Animation.repeatCount, but this looks like a SwiftUI issue.

Anyway, here is a demo (screen flash at start is launch of Preview): a) activating blink in onAppear b) force activating by some action, in this case by button

边框闪烁

struct BlinkingBorderModifier: ViewModifier {
    let state: Binding<Bool>
    let color: Color
    let repeatCount: Int
    let duration: Double

    // internal wrapper is needed because there is no didFinish of Animation now
    private var blinking: Binding<Bool> {
        Binding<Bool>(get: {
            DispatchQueue.main.asyncAfter(deadline: .now() + self.duration) {
                self.state.wrappedValue = false
            }
            return self.state.wrappedValue }, set: {
            self.state.wrappedValue = $0
        })
    }
    
    func body(content: Content) -> some View
    {
        content
            .border(self.blinking.wrappedValue ? self.color : Color.clear, width: 1.0)
            .animation( // Kind of animation can be changed per needs
                Animation.linear(duration:self.duration).repeatCount(self.repeatCount)
            )
    }
}

extension View {
    func blinkBorder(on state: Binding<Bool>, color: Color,
                     repeatCount: Int = 1, duration: Double = 0.5) -> some View {
        self.modifier(BlinkingBorderModifier(state: state, color: color,
                                             repeatCount: repeatCount, duration: duration))
    }
}

struct TestBlinkingBorder: View {
    @State  var blink = false
    var body: some View {
        VStack {
            Button(action: { self.blink = true }) {
                Text("Force Blinking")
            }
            Divider()
            Text("Hello, World!").padding()
                .blinkBorder(on: $blink, color: Color.red, repeatCount: 5, duration: 0.5)
        }
        .onAppear {
            self.blink = true
        }
    }
}

This is so much easy. First create a ViewModifier , so that we can use it easily anywhere.

import SwiftUI

struct BlinkViewModifier: ViewModifier {
    
    let duration: Double
    @State private var blinking: Bool = false
    
    func body(content: Content) -> some View {
        content
            .opacity(blinking ? 0 : 1)
            .animation(.easeOut(duration: duration).repeatForever())
            .onAppear {
                withAnimation {
                    blinking = true
                }
            }
    }
}

extension View {
    func blinking(duration: Double = 0.75) -> some View {
        modifier(BlinkViewModifier(duration: duration))
    }
}

Then use this like,

// with duration 

Text("Hello, World!")
    .foregroundColor(.white)
    .padding()
    .background(Color.blue)
    .blinking(duration: 0.75) // here duration is optional. This is blinking time

// or (default is 0.75)

Text("Hello, World!")
    .foregroundColor(.white)
    .padding()
    .background(Color.blue)
    .blinking() 

After a lot of research on this topic, I found two ways to solve this thing. Each has its advantages and disadvantages.

The Animation way

There is a direct answer to your question. It's not elegant as it relies on you putting in the timing in a redundant way.

Add a reverse function to Animation like this:

extension Animation {
    func reverse(on: Binding<Bool>, delay: Double) -> Self {
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            on.wrappedValue = false /// Switch off after `delay` time
        }
        return self
    }
}

With this extension, you can create a text, that scales up and back again after a button was pressed like this:

struct BlinkingText: View {
    @State private var isBlinking: Bool = false

    var body: some View {
        VStack {
            Button {
                isBlinking = true
            } label: {
                Text("Let it blink")
            }
            .padding()

            Text("Blink!")
                .font(.largeTitle)
                .foregroundColor(.red)
                .scaleEffect(isBlinking ? 2.0 : 1.0)
                .animation(Animation.easeInOut(duration: 0.5).reverse(on: $isBlinking, delay: 0.5))
        }
    }
}

It's not perfect so I did more research.

The Transition way

Actually, SwiftUI provides two ways to get from one look (visual representation, ... you name it) to another smoothly.

  1. Animations are especially designed to get from one View to another look of the same View.(same = same struct, different instance)
  2. Transitions are made to get from one view to another view by transitioning out the old View and transition in another one.

So, here's another code snippet using transitions. The hacky part is the if-else which ensures, that one View disappears and another one appears.

struct LetItBlink: View {
    @State var count: Int

    var body: some View {
        VStack {
            Button {
                count += 1
            } label: {
                Text("Let it blink: \(count)")
            }
            .padding()

            if count % 2 == 0 {
                BlinkingText(text: "Blink Blink 1!")
            } else {
                BlinkingText(text: "Blink Blink 2!")
            }
        }
        .animation(.default)
    }
}

private struct BlinkingText: View {
    let text: String

    var body: some View {
        Text(text)
            .foregroundColor(.red)
            .font(.largeTitle)
            .padding()
            .transition(AnyTransition.scale(scale: 1.5).combined(with: .opacity))
    }
}

You can create nice and interesting "animations" by combining transitions.

What's my personal opinion?

  • Both are not perfectly elegant and look somehow "hacky". Either because of the delay management or because of the if-else . Adding the possibility to SwiftUI to chain Animations would help.
  • Transitions look more customisable, but this depends on the actual requirement.
  • Both are necessary. Adding a default animation is one of the first things I do, because they make the app look and feel smooth.

This is some code I came up with for a blinking button in SwiftUI 2, it might help someone. It's a toggle button that blinks a capsule shaped overlay around the button. It works but personally, I don't like my function blink() that calls itself.

    struct BlinkingButton:View{
    @Binding var val:Bool
    var label:String
    @State private var blinkState:Bool = false
    
    var body: some View{
        Button(label){
            val.toggle()
            if val{
                blink()
            }
        }
        .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
        .foregroundColor(.white)
        .background(val ? Color.blue:Color.gray)
        .clipShape(Capsule())
        .padding(.all,8)
        .overlay(Capsule().stroke( blinkState && val ? Color.red:Color.clear,lineWidth: 3))

    }
     func blink(){
        blinkState.toggle()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5){
            if val{
                blink()
            }
        }
    }
   
}

In use it looks like this:

    struct ContentView: View {
    @State private var togVal:Bool = false
    var body: some View {
        VStack{
            Text("\(togVal ? "ON":"OFF")")
            BlinkingButton(val: $togVal, label: "tap me")
        }
    }
}

Please use below SwiftUI code to implement border blinking animation.

extension AnyTransition {
    static func repeating<T: ViewModifier>(from: T, to: T, duration: Double = 1) -> AnyTransition {
       .asymmetric(
            insertion: AnyTransition
                .modifier(active: from, identity: to)
                .animation(Animation.easeInOut(duration: duration).repeatForever())
                .combined(with: .opacity),
            removal: .opacity
        )
    }
}

struct BorderColor: ViewModifier {
    private let borderColor: Color
    init(_ borderColor: Color) {
        self.borderColor = borderColor
    }

    func body(content: Content) -> some View {
        content.border(borderColor, width: 2.0)
    }
}

struct ContentView: View {
    @State var showBlinkingView: Bool = false

    var body: some View {
        VStack {
            if showBlinkingView {

                Text("I am blinking")
                    .transition(.repeating(from: BorderColor(.red), to: BorderColor(.green)))
            }
            Spacer()
            Button(action: {
                self.showBlinkingView.toggle()
            }, label: {
                Text("Toggle blinking view")
            })
        }.padding(.vertical, 50)
    }
}

Please have a look reference video

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM