Button blink animation with SwiftUI - ios

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))
}

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()

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
}
}
}

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.
Animations are especially designed to get from one View to another look of the same View.(same = same struct, different instance)
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")
}
}
}

Related

SwiftUI Drag to Dismiss ScrollView

I'd like to have a drag to dismiss scroll view in SwiftUI, where if you keep dragging when it's at the top of the content (offset 0), it will instead dismiss the view.
I'm working to implement this in SwiftUI and finding it to be rather difficult. It seems like I can either recognize the DragGesture or allowing scrolling, but not both.
I need to avoid using UIViewRepresentable and solve this using pure SwiftUI or get as close as possible. Otherwise it can make developing other parts of my app difficult.
Here's an example of the problem I'm running into:
import SwiftUI
struct DragToDismissScrollView: View {
enum SeenState {
case collapsed
case fullscreen
}
#GestureState var dragYOffset: CGFloat = 0
#State var scrollYOffset: CGFloat = 0
#State var seenState: SeenState = .collapsed
var body: some View {
GeometryReader { proxy in
ZStack {
Button {
seenState = .fullscreen
} label: {
Text("Show ScrollView")
}
/*
* Works like a regular ScrollView but provides updates on the current yOffset of the content.
* Can find code for OffsetAwareScrollView in link below.
* Left out of question for brevity.
* https://gist.github.com/robhasacamera/9b0f3e06dcf27b54962ff0e077249e0d
*/
OffsetAwareScrollView { offset in
self.scrollYOffset = offset
} content: {
ForEach(0 ... 100, id: \.self) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
}
}
.background(Color.white)
// If left at the default minimumDistance gesture isn't recognized
.gesture(DragGesture(minimumDistance: 0)
.updating($dragYOffset) { value, gestureState, _ in
// Only want to start dismissing if at the top of the scrollview
guard scrollYOffset >= 0 else {
return
}
gestureState = value.translation.height
}
.onEnded { value in
if value.translation.height > proxy.frame(in: .local).size.height / 4 {
seenState = .collapsed
} else {
seenState = .fullscreen
}
})
.offset(y: offsetForProxy(proxy))
.animation(.spring())
}
}
}
func offsetForProxy(_ proxy: GeometryProxy) -> CGFloat {
switch seenState {
case .collapsed:
return proxy.frame(in: .local).size.height
case .fullscreen:
return max(dragYOffset, 0)
}
}
}
Note: I've tried a lot solutions for the past few days (none that have worked), including:
Adding a delay to the DragGesture using the method mentioned here: https://stackoverflow.com/a/59961959/898984
Adding an empty onTapGesture {} call before the DragGesture as mentioned here: https://stackoverflow.com/a/60015111/898984
Removing the gesture and using the offset provided from the OffsetAwareScrollView when it's > 0. This doesn't work because as the ScrollView is moving down the offset decreases as the OffsetAwareScrollView catches up to the content.

How to make a reusable modifier for view lifecycle events (onAppear, onDisappear)?

I have a common fadeIn and fadeOut animation that I use for when views appear and disappear:
struct ActiveView: View {
#State var showCode: Bool = false
#State var opacity = 0.0
var body: some View {
if self.showCode {
Color.black.opacity(0.7)
.onAppear{
let animation = Animation.easeIn(duration: 0.5)
return withAnimation(animation) {
self.opacity = 1
}
}
.onDisappear{
let animation = Animation.easeOut(duration: 0.5)
return withAnimation(animation) {
self.opacity = 0
}
}
}
}
}
However, I want to use these same animations on other views, so I want it to be simple and reusable, like this:
if self.showCode {
Color.black.opacity(0.7)
.fadeAnimation()
}
How can I achieve this?
EDIT:
Trying to implement a View extension:
extension View {
func fadeAnimation(opacity: Binding<Double>) -> some View {
self.onAppear{
let animation = Animation.easeIn(duration: 0.5)
return withAnimation(animation) {
opacity = 1
}
}
.onDisappear{
let animation = Animation.easeOut(duration: 0.5)
return withAnimation(animation) {
opacity = 0
}
}
}
}
What you try to do is already present and named opacity transition, which is written in one modifier.
Here is a demo:
struct ActiveView: View {
#State var showCode: Bool = false
var body: some View {
ZStack {
if self.showCode {
Color.black.opacity(0.7)
.transition(AnyTransition.opacity.animation(.default))
}
Button("Demo") { self.showCode.toggle() }
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
The functionality that you are trying to implement is already part of the Animation and Transition modifiers from SwiftUI.
Therefore, you can add .transition modifier to any of your Views and it will animate its insertion and removal.
if self.showCode {
Color.black.opacity(0.7)
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.5)))
}
You can use a multitude of different transitions like .slide, .scale, .offset, etc. More information about transitions here.
You can even create custom transitions with different actions for insertion and removal. In your case, different animation curves.
extension AnyTransition {
static var fadeTransition: AnyTransition {
.asymmetric(
insertion: AnyTransition.opacity.animation(.easeIn(duration: 0.5)),
removal: AnyTransition.opacity.animation(.easeOut(duration: 0.5))
)
}
}
And use it like this:
if self.showCode {
Color.black.opacity(0.7)
.transition(.fadeTransition)
}
Hope this helps 😉!

How to Animate Binding of Child View

A bit confused as to why this code does not work as expected.
Some generic animatable view:
struct SomeAnimatableView: View, Animatable {
var percent: Double
var body: some View {
GeometryReader { geo in
ZStack {
Rectangle()
.fill(Color.blue)
.frame(height: 120)
Rectangle()
.fill(Color.red)
.frame(width: geo.size.width * CGFloat(self.percent), height: 116)
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
var animatableData: Double {
set { percent = newValue }
get { percent }
}
}
A view which wraps said animatable view:
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
}
}
Finally another view, consider it parent:
struct ContentView: View {
#State var percent: Double
var body: some View {
VStack {
ProgressRectangle(percent: $percent.animation())
Text("Percent: \(percent * 100)")
}
.onTapGesture {
self.percent = 1
}
}
}
Issue ⚠️ why doesn't the progress animate? $percent.animation() isn't that supposed cause animations? Lots of questions...
A fix 💡:
.onTapGesture {
withAnimation {
self.percent = 1
}
}
However, this doesn't actually fix the issue or answer the question to why that binding doesn't animate. Because this also seems to animate the entire view, notice the button animating its width in this example:
Thoughts, ideas?
Update: additional clarification
Animation to Binding is a mechanism to transfer animation inside child so when bound variable is changed internally it would be changed with animation.
Thus for considered scenario ProgressRectangle would look like
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
.onTapGesture {
withAnimation(_percent.transaction.animation) { // << here !!
self.percent = 0 == self.percent ? 0.8 : 0
}
}
}
}
without onTapGesture in ContentView (similarly as would you have Toggle which changes value internally).
Test module on GitHub
Original (still valid and works)
Here is working solution. Tested with Xcode 11.4 / iOS 13.4
#State var percent: Double = .zero
var body: some View {
VStack {
ProgressRectangle(percent: $percent)
.animation(.default) // << place here !!
.onTapGesture {
self.percent = self.percent == .zero ? 1 : .zero
}
Text("Percent: \(percent * 100)")
}
}

Combining an animated view with another view using ZStack

I'm working on a WatchOS-app that needs to display an animation on top of another view while waiting for a task to finish.
My approach is the following (ConnectionView):
struct ConnectionView: View{
#EnvironmentObject var isConnected : Bool
var body: some View {
return VStack(alignment: .trailing){
ZStack{
ScrollView{
.....
}
if(!isConnected){
ConnectionLoadingView()
}
}
}
}
}
And for the ConnectionLoadingView:
struct ConnectionLoadView: View {
#State var isSpinning = false
#EnvironmentObject var isConnected : Bool
var body: some View {
var animation : Animation
///This is needed in order to make the animation stop when isConnected is true
if(!isConnected){
animation = Animation.linear(duration: 4.0)
}else{
animation = Animation.linear(duration: 0)
}
return Image(systemName: "arrowtriangle.left.fill")
.resizable()
.frame(width: 100, height: 100)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.animation(animation)
.foregroundColor(.green)
.onAppear(){
self.isSpinning = true
}
.onDisappear(){
self.isSpinning = false
}
}
}
The real problem consists of two parts:
On the very first ConnectionView that is displayed after the app is started, the ConnectionLoadView is displayed properly. On the subsequent runs the ConnectionLoadView has a weird "fade in" effect where it changes it's opacity throughout the animation (doesn't matter if I set the opacity for the view to 1, 0 or anything inbetween).
If I don't have the following code snippet in ConnectionLoadView:
if(!isConnected){
animation = Animation.linear(duration: 4.0)
}else{
animation = Animation.linear(duration: 0)
}
Without this the ConnectionView will continue to play the animation but move it from the foreground to background of the ZStack, behind the ScrollView, when it should just disappear straight away? Without this code snippet, the animation will only disappear as it should if the animation has stopped before the task has finished.
Is there any reason why the ConnectionLoadView is pushed to the background of the ZStack instead of just being removed from the view altogether when I clearly state that it should only be displayed if and only if !isConnected in ConnectionView?
I also can't quite figure out why there is a difference between the animation behaviour of the initial ConnectionView and the subsequent ones regarding the the opacity behaviour. Is the opacity changing part of the linear-animation?
Thanks!
You are approaching the animation wrong. You shouldn't use implicit animations for this. Explicit animations are better suited.
Implicit animations are the ones you apply with .animation(). This affect any animatable parameter that changes on a view.
Explicit animations are the ones you trigger with withAnimation { ... }. Only parameters affected by the variables modified inside the closure, are the ones that will animate. The rest will not.
The new code looks like this:
import SwiftUI
class Model: ObservableObject {
#Published var isConnected = false
}
struct Progress: View{
#EnvironmentObject var model: Model
var body: some View {
return VStack(alignment: .trailing){
ZStack{
ScrollView{
ForEach(0..<3) { idx in
Text("Some line of text in row # \(idx)")
}
Button("connect") {
self.model.isConnected = true
}
Button("disconect") {
self.model.isConnected = false
}
}
if !self.model.isConnected {
ConnectionLoadView()
}
}
}
}
}
struct ConnectionLoadView: View {
#State var isSpinning = false
#EnvironmentObject var model: Model
var body: some View {
return Image(systemName: "arrowtriangle.left.fill")
.resizable()
.frame(width: 100, height: 100)
.rotationEffect(.degrees(isSpinning ? 360 : 0))
.foregroundColor(.green)
.onAppear(){
withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
self.isSpinning = true
}
}
}
}

SwiftUI: Animate Text color - foregroundColor()

I've been trying to work on animating various parts of the UI, but it seems as though you can't animate a SwiftUI Text's foregroundColor? I want to switch the color of some text smoothly when a state changes. This works fine if I animate the background color of the Text's surrounding view, but foreground color does not work. Has anyone had any luck animating a change like this? Unsure if this is an Xcode beta bug or it's intended functionality...
Text(highlightedText)
.foregroundColor(color.wrappedValue)
.animation(.easeInOut)
// Error: Cannot convert value of type 'Text' to closure result type '_'
There is a much easier way, borrowing from Apple's "Animating Views and Transitions" tutorial code. The instantiation of GraphCapsule in HikeGraph demonstrates this.
While foregroundColor cannot be animated, colorMultiply can. Set the foreground color to white and use colorMultiply to set the actual color you want. To animate from red to blue:
struct AnimateDemo: View {
#State private var color = Color.red
var body: some View {
Text("Animate Me!")
.foregroundColor(Color.white)
.colorMultiply(self.color)
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
self.color = Color.blue
}
}
}
}
struct AnimateDemo_Previews: PreviewProvider {
static var previews: some View {
AnimateDemo()
}
}
Color property of Text is not animatable in SwiftUI or UIKit. BUT YOU CAN achieve the result you need like this:
struct ContentView: View {
#State var highlighted = false
var body: some View {
VStack {
ZStack {
// Highlighted State
Text("Text To Change Color")
.foregroundColor(.red)
.opacity(highlighted ? 1 : 0)
// Normal State
Text("Text To Change Color")
.foregroundColor(.blue)
.opacity(highlighted ? 0 : 1)
}
Button("Change") {
withAnimation(.easeIn) {
self.highlighted.toggle()
}
}
}
}
}
You can encapsulate this functionality in a custom View and use it anywhere you like.
There is a nice protocol in SwiftUI that let you animate anything. Even things that are not animatable! (such as the text color). The protocol is called AnimatableModifier.
If you would like to learn more about it, I wrote a full article explaining how this works: https://swiftui-lab.com/swiftui-animations-part3/
Here's an example on how you can accomplish such a view:
AnimatableColorText(from: UIColor.systemRed, to: UIColor.systemGreen, pct: flag ? 1 : 0) {
Text("Hello World").font(.largeTitle)
}.onTapGesture {
withAnimation(.easeInOut(duration: 2.0)) {
self.flag.toggle()
}
}
And the implementation:
struct AnimatableColorText: View {
let from: UIColor
let to: UIColor
let pct: CGFloat
let text: () -> Text
var body: some View {
let textView = text()
// This should be enough, but there is a bug, so we implement a workaround
// AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)
// This is the workaround
return textView.foregroundColor(Color.clear)
.overlay(Color.clear.modifier(AnimatableColorTextModifier(from: from, to: to, pct: pct, text: textView)))
}
struct AnimatableColorTextModifier: AnimatableModifier {
let from: UIColor
let to: UIColor
var pct: CGFloat
let text: Text
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
return text.foregroundColor(colorMixer(c1: from, c2: to, pct: pct))
}
// This is a very basic implementation of a color interpolation
// between two values.
func colorMixer(c1: UIColor, c2: UIColor, pct: CGFloat) -> Color {
guard let cc1 = c1.cgColor.components else { return Color(c1) }
guard let cc2 = c2.cgColor.components else { return Color(c1) }
let r = (cc1[0] + (cc2[0] - cc1[0]) * pct)
let g = (cc1[1] + (cc2[1] - cc1[1]) * pct)
let b = (cc1[2] + (cc2[2] - cc1[2]) * pct)
return Color(red: Double(r), green: Double(g), blue: Double(b))
}
}
}

Resources