Prevent SwiftUI animation from overriding nested withAnimation block - ios

I have a screen with a draggable component (the "Springy" text) that springs back when released with a very prominent spring animation.
There is some text that comes in asynchronously (in this example, using a Timer), and I want that to fade in nicely while the Springy label moves up smoothly to make room for it.
I added an animation to the VStack, and it transitions as I want it to when the asynchronously-loaded text fades in. However, that breaks the spring animation on the "Springy" text.
There is an animation that's commented out here, and if switch the 1 second animation to that spot the asynchronously-loaded text fades in, but right at the beginning of the animation the "Springy" text jumps up instead of sliding up.
How can I get both animations to work as I want them to?
import SwiftUI
import Combine
class Store: ObservableObject {
#Published var showSection: Bool = false
private var cancellables: [AnyCancellable] = []
init() {
Timer
.publish(every: 2, on: RunLoop.main, in: .common)
.autoconnect()
.map { _ in true}
.assign(to: \.showSection,
on: self)
.store(in: &cancellables)
}
}
struct ContentView: View {
#ObservedObject var store = Store()
#State var draggedState = CGSize.zero
var body: some View {
VStack {
Spacer()
VStack(alignment: .leading) {
VStack(spacing: 4) {
HStack {
Text("Springy")
.font(.title)
Image(systemName: "hand.point.up.left.fill")
.imageScale(.large)
}
.offset(x: draggedState.width, y: draggedState.height)
.foregroundColor(.secondary)
.gesture(
DragGesture().onChanged { value in
draggedState = value.translation
}
.onEnded { value in
// How can this animation be fast without the other animation slowing it down?
withAnimation(Animation
.interactiveSpring(response: 0.3,
dampingFraction: 0.1,
blendDuration: 0)) {
draggedState = .zero
}
}
)
VStack {
if store.showSection {
Text("Something that animates in after loading asynchrously.")
.font(.body)
.padding()
.transition(.opacity)
}
}
// When the animation is here, it doesn't cancel out the spring animation, but the Springy text jumps up at the beginning of the animation instead of animating.
// .animation(.easeInOut(duration: 1))
}
// Animation here has desired effect for loading, but overrides the Springy release animation.
.animation(.easeInOut(duration: 1))
}
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.preferredColorScheme(.dark)
}
}
Expanded question, based on answer from Asperi.
Now, supposing I have multiple elements in this main VStack that I want to animate when they come in.
Is it a good solution to add a separate .animation modifier with a value for each section I want to animate?
In the expanded example below, that's the part with:
.animation(.easeInOut(duration: 1),
value: store.showSection)
.animation(.easeInOut(duration: 1),
value: store.somePossibleText)
.animation(.easeInOut(duration: 1),
value: store.moreInformation)
I am using 3 approaches here to determine whether to show a section or not. I used Bool in the initial example for simplicity, but in my real scenario I think it will make the most sense to use the String? approach instead of setting a separate Bool or checking for an empty string. Are there drawbacks to using that when it comes to animations, or is that fine? It seems to work well in this example given Asperi's solution.
Here's the full example again with the new modifications:
import SwiftUI
import Combine
class Store: ObservableObject {
#Published var showSection: Bool = false
#Published var somePossibleText: String = ""
#Published var moreInformation: String?
private var cancellables: [AnyCancellable] = []
init() {
Timer
.publish(every: 2, on: RunLoop.main, in: .common)
.autoconnect()
.map { _ in true }
.assign(to: \.showSection,
on: self)
.store(in: &cancellables)
Timer
.publish(every: 3, on: RunLoop.main, in: .common)
.autoconnect()
.map { _ in "Something else loaded later" }
.assign(to: \.somePossibleText,
on: self)
.store(in: &cancellables)
Timer
.publish(every: 4, on: RunLoop.main, in: .common)
.autoconnect()
.map { _ in "More stuff loaded later" }
.assign(to: \.moreInformation,
on: self)
.store(in: &cancellables)
}
}
struct ContentView: View {
#ObservedObject var store = Store()
#State var draggedState = CGSize.zero
var body: some View {
VStack {
Spacer()
VStack(alignment: .leading) {
VStack(spacing: 4) {
HStack {
Text("Springy")
.font(.title)
Image(systemName: "hand.point.up.left.fill")
.imageScale(.large)
}
.offset(x: draggedState.width,
y: draggedState.height)
.foregroundColor(.secondary)
.gesture(
DragGesture().onChanged { value in
draggedState = value.translation
}
.onEnded { value in
// TODO: how can this animation be fast without the other one slowing it down?
withAnimation(Animation
.interactiveSpring(response: 0.3,
dampingFraction: 0.1,
blendDuration: 0)) {
draggedState = .zero
}
}
)
if store.showSection {
Text("Something that animates in after loading asynchrously.")
.font(.body)
.padding()
.transition(.opacity)
}
if store.somePossibleText != "" {
Text(store.somePossibleText)
.font(.footnote)
.padding()
.transition(.opacity)
}
if let moreInformation = store.moreInformation {
Text(moreInformation)
.font(.footnote)
.padding()
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 1),
value: store.showSection)
.animation(.easeInOut(duration: 1),
value: store.somePossibleText)
.animation(.easeInOut(duration: 1),
value: store.moreInformation)
}
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.preferredColorScheme(.dark)
}
}

If I correctly understood your expectation, it is enough to bind last animation to showSection value, like below
}
// Animation here has desired effect for loading, but overrides the Springy release animation.
.animation(.easeInOut(duration: 1), value: store.showSection)
Tested with Xcode 12 / iOS 14.

Related

SwiftUI increasing sheet size after NavigationLink with the "selection" in "presentationDetents" cuts off view inside the sheet

Since iOS 16 there is a new feature for the ".sheet" modifier called ".presentationDetents". ".presentationDetents" has a parameter called "selection" where you can pass a Binding. You can programmatically resize the sheet with the "selection" parameter. As soon as you change the sheet size for example from PresentationDetent.medium to PresentationDetent.large right after changed the page with a "NavigationLink" the View below gets cut off:
But if I slightly move (resize) the sheet afterwards the cut off below is going to disappear:
The view hierarchy is also strange:
If you add a delay by 0.6s for resizing the sheet, the cut off won't happen.
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
}
}
You can find the code below:
import SwiftUI
struct ContentView: View {
#State private var sheetIsOpened = false
#State private var currentSelection = PresentationDetent.medium
var body: some View {
Text("Click to open a sheet")
.padding()
.onTapGesture {
sheetIsOpened = true
}
.sheet(isPresented: $sheetIsOpened) {
NavigationStack {
List {
ScrollView {
ForEach(0..<100) { index in
VStack {
NavigationLink(destination: NavigatedView(currentSelection: $currentSelection)) {
Text("I have the index: \(index)")
.foregroundColor(.green)
}
}
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
.presentationDetents([.medium, .large], selection: $currentSelection)
}
}
}
struct NavigatedView: View {
#Binding fileprivate var currentSelection: PresentationDetent
var body: some View {
ScrollView {
ForEach(0..<100) { index in
VStack {
Text("I'm a child and I have the index: \(index)")
.onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
currentSelection = .large
// }
}
}
.frame(maxWidth: .infinity)
}
}
.background(.red)
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI - update target view during a transition

I want to transition between two views in SwiftUI using a horizontal sliding transition. The problem is, that I also want to update the target view once the data is fetched from the network.
Down below is a minimal example of the transition. When the button on the first view is pressed, the transition and the (placeholder) background work is started. For better visibility, the transition is slowed down. In the second view, I have a ProgressView which should be replaced with the actual view (here a Text view) once the data is available.
#main
struct MyApp: App {
#StateObject private var viewModel = ViewModel()
#State private var push = false
private let transition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let transitionAnimation = Animation.easeOut(duration: 3)
var body: some Scene {
WindowGroup {
if !push {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
Button(action: {
push.toggle()
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.2) {
DispatchQueue.main.sync {
self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
}
}
}){
Text("Go")
}
Spacer()
}
.background(Color.green)
.transition(transition)
.animation(transitionAnimation)
} else {
SecondView()
.transition(transition)
.animation(transitionAnimation)
.environmentObject(viewModel)
}
}
}
}
final class ViewModel: NSObject, ObservableObject {
#Published var someText: String = ""
}
struct SecondView: View {
#EnvironmentObject var viewModel: ViewModel
var body: some View {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
if(viewModel.someText.isEmpty) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Text(viewModel.someText)
}
Spacer()
}.background(Color.red)
}
}
The problem now is that the Text view is not included in the view transition. I would expect that it is moving along with the transition (= the red area), but instead, it just appears at the location where it would be after the transition. The following animation shows this effect.
Is it possible to achieve the animation of the Text view? To be clear: I know that in this case I could just always display the Text view because the string is empty at the beginning. As I stated earlier, this is a massively simplified version of my actual view hierarchy. I don't see a way of leaving out the if-else statement or use the hidden modifier.
Few things have to be done to make it work properly.
First, Text should exist in the views hierarchy even when the someText is empty. You can wrap it and the progress indicator into ZStack and control the text visibility with .opacity instead of the if/else statement.
Second, the animations can be applied conditionally depending on which value has changed. You should apply transitionAnimation only when the push variable is changed:
.animation(transitionAnimation, value: push)
There is a catch though: it won't work from the if/else branches on the same variable, because each of the .animation statements will exist only for one value of the push variable, and the changes in it won't be noticed. To fix that if/else should be wrapped into a Group, and animation should be applied to it.
Here is a full solution:
#main
struct MyApp: App {
#StateObject private var viewModel = ViewModel()
#State private var push = false
private let transition = AnyTransition.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading))
private let transitionAnimation = Animation.easeOut(duration: 3)
var body: some Scene {
WindowGroup {
Group {
if !push {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
Button(action: {
push.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.viewModel.someText = "Test ### Test ### Test ### Test ### Test ### Test ###"
}
}){
Text("Go")
}
Spacer()
}
.background(Color.green)
.transition(transition)
} else {
SecondView()
.transition(transition)
.environmentObject(viewModel)
}
}
.animation(transitionAnimation, value: push)
}
}
}
final class ViewModel: NSObject, ObservableObject {
#Published var someText: String = ""
}
struct SecondView: View {
#EnvironmentObject var viewModel: ViewModel
var body: some View {
VStack(alignment: .center) {
HStack { Spacer() }
Spacer()
ZStack {
if(viewModel.someText.isEmpty) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
Text(viewModel.someText)
.opacity(viewModel.someText.isEmpty ? 0.0 : 1.0)
}
Spacer()
}.background(Color.red)
}
}

SwiftUI: How do you dismiss a sheet and launch a different sheet from ContentView?

For my app, I have a welcome screen that intro's what the app does and allows the user to create their first item. When the user clicks the button I'd like to dismiss the 'welcomeScreen' sheet and and then launch the 'newRemindr' sheet.
I tried to achieve this by creating an observable object with an 'addNewTrigger' boolean set to false. When I click the Add New Reminder button on the welcomeScreen, the button's action causes the welcomeScreen to dismiss and toggles the 'addNewTrigger' boolean to True. (I've verified this is working with Print Statements). However content view is listening to that same observed object to launch the 'newRemindr' sheet but that action doesn't seem to be working.
Can somebody please take a look at the code and see where I am going wrong? Or suggest an alternative that can provide the type of functionality.
I really appreciate all the help. Thanks!
Code Below...
welcomeScreen:
import SwiftUI
import Combine
struct welcomeScreen: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
#ObservedObject var addNewReminder = showAddScreen()
var body: some View {
NavigationView {
ZStack (alignment: .center) {
LinearGradient(gradient: Gradient(colors: [Color.white, Color.white, Color.gray]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
Image("Ellipse2")
.offset(y: -475)
VStack {
Spacer()
Text("Welcome to")
.foregroundColor(.white)
.fontWeight(.bold)
Image("RemindrLogoWhite")
Spacer()
Text("What is remindr?")
.font(.title)
.fontWeight(.bold)
.padding(.bottom, 25)
Text("Remindr is a simple app designed to help you schedule random reminders with the goal of clearing your mind.\n\nRemind yourself to check in with your body, set up positive affirmations, set your intentions; Whatever it is, the power is up to you.")
.padding(.horizontal, 25)
.padding(.bottom, 25)
Text("Click below to get started:")
.fontWeight(.bold)
// Add New Reminder Button
Button(action: {
self.mode.wrappedValue.dismiss()
print("Add Reminder Button from Welcome Screen is Tapped")
self.addNewReminder.addNewTrigger.toggle()
print("var addNewTrigger has been changed to \(self.addNewReminder.addNewTrigger)")
}) {
Image("addButton")
.renderingMode(.original)
}.padding(.bottom, 25)
Spacer()
} .frame(maxWidth: UIScreen.main.bounds.width,
maxHeight: UIScreen.main.bounds.height)
}
.navigationBarTitle(Text(""), displayMode: .automatic)
.navigationBarItems(trailing: Button(action: {
self.mode.wrappedValue.dismiss()
}, label: {
Image(systemName: "xmark")
.foregroundColor(.white)
}))
}
}
}
ContentView:
import SwiftUI
import CoreData
class showAddScreen: ObservableObject {
#Published var addNewTrigger = false
}
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: ReminderEntity.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \ReminderEntity.dateCreated, ascending: false)])
var reminder: FetchedResults<ReminderEntity>
// Sheet Control
#ObservedObject var addNewReminder = showAddScreen()
//#State private var showingAddScreen = false
#State var showWelcomeScreen = false
let emojiList = EmojiList()
//Toggle Control
#State var notifyOn = true
// Save Items Function
func saveItems() {
do {
try moc.save()
} catch {
print(error)
}
}
// Delete Item Function
func deleteItem(indexSet: IndexSet) {
let source = indexSet.first!
let listItem = reminder[source]
moc.delete(listItem)
}
// View Controller
var body: some View {
VStack {
NavigationView {
ZStack (alignment: .top) {
// List View
List {
ForEach(reminder, id: \.self) { notification in
NavigationLink(destination: editRemindr(reminder: notification,
notifyOn: notification.notifyOn,
emojiChoice: Int(notification.emojiChoice),
notification: notification.notification ?? "unknown",
notes: notification.notes ?? "unknown")) {
// Text within List View
HStack {
// MARK: TODO
//Toggle("NotifyOn", isOn: self.$notifyOn)
// .labelsHidden() // Hides the label/title
Text("\(self.emojiList.emojis[Int(notification.emojiChoice)]) \(notification.notification!)")
}
}
}
.onDelete(perform: deleteItem)
}.lineLimit(1)
// Navigation Items
.navigationBarTitle("", displayMode: .inline)
.navigationBarItems(
leading:
HStack {
Button(action: {
self.showWelcomeScreen.toggle()
}) {
Image(systemName: "info.circle.fill")
.font(.system(size: 24, weight: .regular))
}.foregroundColor(.gray)
// Positioning Remindr Logo on Navigation
Image("remindrLogoSmall")
.resizable()
.aspectRatio(contentMode: .fit)
//.frame(width: 60, height: 60, alignment: .center)
.padding(.leading, 83)
.padding(.top, -10)
},
// Global Settings Navigation Item
trailing: NavigationLink(destination: globalSettings()){
Image("settings")
.font(Font.title.weight(.ultraLight))
}.foregroundColor(.gray)
)
// Add New Reminder Button
VStack {
Spacer()
Button(action: { self.addNewReminder.addNewTrigger.toggle()
}) {
Image("addButton")
.renderingMode(.original)
}
.sheet(isPresented: $addNewReminder.addNewTrigger) {
newRemindr().environment(\.managedObjectContext, self.moc)
}
}
}
} .sheet(isPresented: $showWelcomeScreen) {
welcomeScreen()
}
}
}
}
First what I see is you use different observable objects in both views, but should use same, so changes made in one view be available for second view as well.
Se here is a way to solve this
struct welcomeScreen: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
#ObservedObject var addNewReminder: showAddScreen // << declare to be injected
// ... other code
and in ContentView
} .sheet(isPresented: $showWelcomeScreen) {
welcomeScreen(addNewReminder: self.addNewReminder) // << inject !!
}
Alternate: you can remove addNewReminder from welcomeScreen and work with it only in ContentView by activating on welcome sheet dismiss, like
} .sheet(isPresented: $showWelcomeScreen, onDismiss: {
// it is better to show second sheet with delay to give chance
// for first one to animate closing to the end
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.addNewReminder.addNewTrigger.toggle()
}
}
) {
welcomeScreen()
}

Why does this swiftui view not update?

Consider the following code block:
import SwiftUI
struct MeasurementReading: View, Equatable {
#ObservedObject var ble: BluetoothConnectionmanager
#GestureState var isDetectTap = false
#State var MyText:String = "Wait"
static func == (lhs: MeasurementReading, rhs: MeasurementReading)->Bool{
return lhs.MyText == rhs.MyText
}
var body: some View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
return HStack {
Spacer()
VStack{
Button(action:{
self.MyText = "\(self.ble.getValue()!) mV"
print("Text is \(self.MyText as NSString)")
}, label: {
Text(MyText)
.font(.system(size: 40))
.bold()
.foregroundColor(Color.black)
.padding(.trailing, 15)
.frame(height: 100)
})
Button(action: {
self.MyText = "\(self.ble.getValue()!) mV"
print("Text is \(self.MyText as NSString)")
}, label: {
Text(MyText)
.font(.system(size: 25))
.padding(.top, -20)
.padding(.bottom, 20)
.foregroundColor(Color.black)
})
}
}.onReceive(timer)
{ _ in // TIMER FUNCTIONALITY HERE
self.MyText = "\(self.ble.getValue()!) mV"
print("Text is \(self.MyText)")
}
}
}
struct MeasurementReading_Previews: PreviewProvider {
static var previews: some View {
MeasurementReading(ble: BluetoothConnectionmanager())
}
}
Every 1 second the correct value read from the BLE system is assigned to MyText and then MyText is printed to the debug output properly with the updated value.
The problem here is that view MeasurementReading does not update. Also, using a closure on any item also has the same behavior (variable is updated, it is output properly but no view update) ex .onTap{....} will have the same behavior or any other .onXXXX closure. The only way I could get the view to update at all with new values for the MyText state is to put the behavior in a Button.
My question is this: Why does the view not update even when the state variable changes via Timer or .onXXXX closure?
You need to be setting the ble value to the updated timer value:
Without testing this properly. I also think your BluetoothConnectionmanager needs to be a #State property for this to work.
#State var ble: BluetoothConnectionmanager
.onReceive(timer) { value in // value is the updated value
self.ble.value = value
self.MyText = "\(self.ble.getValue()!) mV"
print("Text is \(self.MyText)")
}
Take a look at this example to see how a timer works with a Date() object.

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

Resources