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))
}
}
}
Related
TextEditor seems to have a default white background. So the following is not working and it displayed as white instead of defined red:
var body: some View {
TextEditor(text: .constant("Placeholder"))
.background(Color.red)
}
Is it possible to change the color to a custom one?
iOS 16
You should hide the default background to see your desired one:
TextEditor(text: .constant("Placeholder"))
.scrollContentBackground(.hidden) // <- Hide it
.background(.red) // To see this
iOS 15 and below
TextEditor is backed by UITextView. So you need to get rid of the UITextView's backgroundColor first and then you can set any View to the background.
struct ContentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
List {
TextEditor(text: .constant("Placeholder"))
.background(.red)
}
}
}
Demo
You can find my simple trick for growing TextEditor here in this answer
Pure SwiftUI solution on iOS and macOS
colorMultiply is your friend.
struct ContentView: View {
#State private var editingText: String = ""
var body: some View {
TextEditor(text: $editingText)
.frame(width: 400, height: 100, alignment: .center)
.cornerRadius(3.0)
.colorMultiply(.gray)
}
}
Update iOS 16 / SwiftUI 4.0
You need to use .scrollContentBackground(.hidden) instead of UITextView.appearance().backgroundColor = .clear
https://twitter.com/StuFFmc/status/1556561422431174656
Warning: This is an iOS 16 only so you'll probably need some if #available and potentially two different TextEditor component.
extension View {
/// Layers the given views behind this ``TextEditor``.
func textEditorBackground<V>(#ViewBuilder _ content: () -> V) -> some View where V : View {
self
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
.background(content())
}
}
Custom Background color with SwiftUI on macOS
On macOS, unfortunately, you have to fallback to AppKit and wrap NSTextView.
You need to declare a view that conforms to NSViewRepresentable
This should give you pretty much the same behaviour as SwiftUI's TextEditor-View and since the wrapped NSTextView does not draw its background, you can use the .background-ViewModifier to change the background
struct CustomizableTextEditor: View {
#Binding var text: String
var body: some View {
GeometryReader { geometry in
NSScrollableTextViewRepresentable(text: $text, size: geometry.size)
}
}
}
struct NSScrollableTextViewRepresentable: NSViewRepresentable {
typealias Representable = Self
// Hook this binding up with the parent View
#Binding var text: String
var size: CGSize
// Get the UndoManager
#Environment(\.undoManager) var undoManger
// create an NSTextView
func makeNSView(context: Context) -> NSScrollView {
// create NSTextView inside NSScrollView
let scrollView = NSTextView.scrollableTextView()
let nsTextView = scrollView.documentView as! NSTextView
// use SwiftUI Coordinator as the delegate
nsTextView.delegate = context.coordinator
// set drawsBackground to false (=> clear Background)
// use .background-modifier later with SwiftUI-View
nsTextView.drawsBackground = false
// allow undo/redo
nsTextView.allowsUndo = true
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
// get wrapped nsTextView
guard let nsTextView = scrollView.documentView as? NSTextView else {
return
}
// fill entire given size
nsTextView.minSize = size
// set NSTextView string from SwiftUI-Binding
nsTextView.string = text
}
// Create Coordinator for this View
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// Declare nested Coordinator class which conforms to NSTextViewDelegate
class Coordinator: NSObject, NSTextViewDelegate {
var parent: Representable // store reference to parent
init(_ textEditor: Representable) {
self.parent = textEditor
}
// delegate method to retrieve changed text
func textDidChange(_ notification: Notification) {
// check that Notification.name is of expected notification
// cast Notification.object as NSTextView
guard notification.name == NSText.didChangeNotification,
let nsTextView = notification.object as? NSTextView else {
return
}
// set SwiftUI-Binding
parent.text = nsTextView.string
}
// Pass SwiftUI UndoManager to NSTextView
func undoManager(for view: NSTextView) -> UndoManager? {
parent.undoManger
}
// feel free to implement more delegate methods...
}
}
Usage
ContenView: View {
#State private var text: String
var body: some View {
VStack {
Text("Enter your text here:")
CustomizableTextEditor(text: $text)
.background(Color.red)
}
.frame(minWidth: 600, minHeight: 400)
}
}
Edit:
Pass reference to SwiftUI UndoManager so that default undo/redo actions are available.
Wrap NSTextView in NSScrollView so that it is scrollable. Set minSize property of NSTextView to enclosing SwiftUIView-Size so that it fills the entire allowed space.
Caveat: Only first line of this custom TextEditor is clickable to enable text editing.
This works for me on macOS
extension NSTextView {
open override var frame: CGRect {
didSet {
backgroundColor = .clear
drawsBackground = true
}
}
}
struct ContentView: View {
#State var text = ""
var body: some View {
TextEditor(text: $text)
.background(Color.red)
}
Reference this answer
To achieve this visual design here is the code I used.
iOS 16
TextField(
"free_form",
text: $comment,
prompt: Text("Type your feedback..."),
axis: .vertical
)
.lineSpacing(10.0)
.lineLimit(10...)
.padding(16)
.background(Color.themeSeashell)
.cornerRadius(16)
iOS 15
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 16)
.foregroundColor(.gray)
TextEditor(text: $comment)
.padding()
.focused($isFocused)
if !isFocused {
Text("Type your feedback...")
.padding()
}
}
.frame(height: 132)
.onAppear() {
UITextView.appearance().backgroundColor = .clear
}
You can use Mojtaba's answer (the approved answer). It works in most cases. However, if you run into this error:
"Return from initializer without initializing all stored properties"
when trying to use the init{ ... } method, try adding UITextView.appearance().backgroundColor = .clear to .onAppear{ ... } instead.
Example:
var body: some View {
VStack(alignment: .leading) {
...
}
.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
Using the Introspect library, you can use .introspectTextView for changing the background color.
TextEditor(text: .constant("Placeholder"))
.cornerRadius(8)
.frame(height: 100)
.introspectTextView { textView in
textView.backgroundColor = UIColor(Color.red)
}
Result
import SwiftUI
struct AddCommentView: View {
init() {
UITextView.appearance().backgroundColor = .clear
}
var body: some View {
VStack {
if #available(iOS 16.0, *) {
TextEditor(text: $viewModel.commentText)
.scrollContentBackground(.hidden)
} else {
TextEditor(text: $viewModel.commentText)
}
}
.background(Color.blue)
.frame(height: UIScreen.main.bounds.width / 2)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.red, lineWidth: 1)
)
}
}
It appears the UITextView.appearance().backgroundColor = .clear trick in IOS 16,
only works for the first time you open the view and the effect disappear when the second time it loads.
So we need to provide both ways in the app. Answer from StuFF mc works.
var body: some View {
if #available(iOS 16.0, *) {
mainView.scrollContentBackground(.hidden)
} else {
mainView.onAppear {
UITextView.appearance().backgroundColor = .clear
}
}
}
// rename body to mainView
var mainView: some View {
TextEditor(text: $notes).background(Color.red)
}
SwiftUI promise is to call View’s body only when needed to avoid invalidating views whose State has not changed.
However, there are some cases when this promise is not kept and the View is updated even though its state has not changed.
Example:
struct InsideView: View {
#Binding var value: Int
// …
}
Looking at that view, we’d expect that its body is called when the value changes. However, this is not always true and it depends on how that binding is passed to the view.
When the view is created this way, everything works as expected and InsideView is not updated when value hasn’t changed.
#State private var value: Int = 0
InsideView(value: $value)
In the example below, InsideView will be incorrectly updated even when value has not changed. It will be updated whenever its container is updated too.
var customBinding: Binding<Int> {
Binding<Int> { 100 } set: { _ in }
}
InsideView(value: customBinding)
Can anyone explain this and say whether it's expected? Is there any way to avoid this behaviour that can ultimately lead to performance issues?
Here's a sample project if anyone wants to play with it.
And here's a full code if you just want to paste it to your project:
import SwiftUI
struct ContentView: View {
#State private var tab = 0
#State private var count = 0
#State private var someValue: Int = 100
var customBinding: Binding<Int> {
Binding<Int> { 100 } set: { _ in }
}
var body: some View {
VStack {
Picker("Tab", selection: $tab) {
Text("#Binding from #State").tag(0)
Text("Custom #Binding").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
VStack(spacing: 10) {
if tab == 0 {
Text("When you tap a button, a view below should not be updated. That's a desired behaviour.")
InsideView(value: $someValue)
} else if tab == 1 {
Text("When you tap a button, a view below will be updated (its background color will be set to random value to indicate this). This is unexpected because the view State has not changed.")
InsideView(value: customBinding)
}
}
.frame(width: 250, height: 150)
Button("Tap! Count: \(count)") {
count += 1
}
}
.frame(width: 300, height: 350)
.padding()
}
}
struct InsideView: View {
#Binding var value: Int
var body: some View {
print("[⚠️] InsideView body.")
return VStack {
Text("I'm a child view. My body should be called only once.")
.multilineTextAlignment(.center)
Text("Value: \(value)")
}
.background(Color.random)
}
}
extension ShapeStyle where Self == Color {
static var random: Color {
Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
Hmm i think the reason is being updated is because you are using a computed property in the ContentView view. and even if it's not tagged with a state annotation like #State, #Binding,#Stateobject... its a view state regardless, and swiftui use that to infer the difference between a view with an old state and a new state. And you're getting a new binding object at every contentview body update.
you can try change the init from what you have to something like this
let customBinding: Binding<Int>
init() {
self.customBinding = Binding<Int> { 99 } set: { _ in }
}
but i would advise against this approach because simply is not useful to create a binding like this in the view, because you can't change anything inside the set because it's the init of a struct.
Instead you can pass in the init an ObservableObject where you moved the state logic to an ObservableObject and use that.
something like this
class ContentViewState: ObservableObject {
#Published var someValue: Int = 100
var customBinding: Binding<Int> = .constant(0)
init() {
customBinding = Binding<Int> { [weak self] in
self?.someValue ?? 0
}
set: { [weak self] in
self?.someValue = $0
}
}
}
// and change the InsideView like
struct InsideView: View {
#ObservedObject private var state: ContentViewState
#Binding var value: Int
init(state: ContentViewState) {
self.state = state
_value = state.customBinding
}
...
}
I would still use the $ with simple #state notation most of the time where i don't have complicated states to handle, but this can be another approach i guess.
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 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")
}
}
}
I have a swiftUI animation based on some state:
withAnimation(.linear(duration: 0.1)) {
self.someState = newState
}
Is there any callback which is triggered when the above animation completes?
If there are any suggestions on how to accomplish an animation with a completion block in SwiftUI which are not withAnimation, I'm open to those as well.
I would like to know when the animation completes so I can do something else, for the purpose of this example, I just want to print to console when the animation completes.
Unfortunately there's no good solution to this problem (yet).
However, if you can specify the duration of an Animation, you can use DispatchQueue.main.asyncAfter to trigger an action exactly when the animation finishes:
withAnimation(.linear(duration: 0.1)) {
self.someState = newState
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
print("Animation finished")
}
Here's a bit simplified and generalized version that could be used for any single value animations. This is based on some other examples I was able to find on the internet while waiting for Apple to provide a more convenient way:
struct AnimatableModifierDouble: AnimatableModifier {
var targetValue: Double
// SwiftUI gradually varies it from old value to the new value
var animatableData: Double {
didSet {
checkIfFinished()
}
}
var completion: () -> ()
// Re-created every time the control argument changes
init(bindedValue: Double, completion: #escaping () -> ()) {
self.completion = completion
// Set animatableData to the new value. But SwiftUI again directly
// and gradually varies the value while the body
// is being called to animate. Following line serves the purpose of
// associating the extenal argument with the animatableData.
self.animatableData = bindedValue
targetValue = bindedValue
}
func checkIfFinished() -> () {
//print("Current value: \(animatableData)")
if (animatableData == targetValue) {
//if animatableData.isEqual(to: targetValue) {
DispatchQueue.main.async {
self.completion()
}
}
}
// Called after each gradual change in animatableData to allow the
// modifier to animate
func body(content: Content) -> some View {
// content is the view on which .modifier is applied
content
// We don't want the system also to
// implicitly animate default system animatons it each time we set it. It will also cancel
// out other implicit animations now present on the content.
.animation(nil)
}
}
And here's an example on how to use it with text opacity animation:
import SwiftUI
struct ContentView: View {
// Need to create state property
#State var textOpacity: Double = 0.0
var body: some View {
VStack {
Text("Hello world!")
.font(.largeTitle)
// Pass generic animatable modifier for animating double values
.modifier(AnimatableModifierDouble(bindedValue: textOpacity) {
// Finished, hurray!
print("finished")
// Reset opacity so that you could tap the button and animate again
self.textOpacity = 0.0
}).opacity(textOpacity) // bind text opacity to your state property
Button(action: {
withAnimation(.easeInOut(duration: 1.0)) {
self.textOpacity = 1.0 // Change your state property and trigger animation to start
}
}) {
Text("Animate")
}
}
}
}
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
On this blog this Guy Javier describes how to use GeometryEffect in order to have animation feedback, in his example he detects when the animation is at 50% so he can flip the view and make it looks like the view has 2 sides
here is the link to the full article with a lot of explanations: https://swiftui-lab.com/swiftui-animations-part2/
I will copy the relevant snippets here so the answer can still be relevant even if the link is not valid no more:
In this example #Binding var flipped: Bool becomes true when the angle is between 90 and 270 and then false.
struct FlipEffect: GeometryEffect {
var animatableData: Double {
get { angle }
set { angle = newValue }
}
#Binding var flipped: Bool
var angle: Double
let axis: (x: CGFloat, y: CGFloat)
func effectValue(size: CGSize) -> ProjectionTransform {
// We schedule the change to be done after the view has finished drawing,
// otherwise, we would receive a runtime error, indicating we are changing
// the state while the view is being drawn.
DispatchQueue.main.async {
self.flipped = self.angle >= 90 && self.angle < 270
}
let a = CGFloat(Angle(degrees: angle).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
return ProjectionTransform(transform3d).concatenating(affineTransform)
}
}
You should be able to change the animation to whatever you want to achieve and then get the binding to change the state of the parent once it is done.
You need to use a custom modifier.
I have done an example to animate the offset in the X-axis with a completion block.
struct OffsetXEffectModifier: AnimatableModifier {
var initialOffsetX: CGFloat
var offsetX: CGFloat
var onCompletion: (() -> Void)?
init(offsetX: CGFloat, onCompletion: (() -> Void)? = nil) {
self.initialOffsetX = offsetX
self.offsetX = offsetX
self.onCompletion = onCompletion
}
var animatableData: CGFloat {
get { offsetX }
set {
offsetX = newValue
checkIfFinished()
}
}
func checkIfFinished() -> () {
if let onCompletion = onCompletion, offsetX == initialOffsetX {
DispatchQueue.main.async {
onCompletion()
}
}
}
func body(content: Content) -> some View {
content.offset(x: offsetX)
}
}
struct OffsetXEffectModifier_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Text("Hello")
.modifier(
OffsetXEffectModifier(offsetX: 10, onCompletion: {
print("Completed")
})
)
}
.frame(width: 100, height: 100, alignment: .bottomLeading)
.previewLayout(.sizeThatFits)
}
}
You can try VDAnimation library
Animate(animationStore) {
self.someState =~ newState
}
.duration(0.1)
.curve(.linear)
.start {
...
}