13

How would it be possible to animate Text or TextField views from Swift UI?

By animation I mean, that when the text changes it will "count up".

For example given some label, how can an animation be created that when I set the labels text to "100" it goes up from 0 to 100. I know this was possible in UIKit using layers and CAAnimations, but using the .animation() function in Swift UI and changing the text of a Text or TextField does not seem to do anything in terms of animation.

I've taken a look at Animatable protocol and its related animatableData property but it doesn't seem like Text nor TextField conform to this. I'm trying to create a label that counts up, so given some value, say a Double the changes to that value would be tracked, either using @State or @Binding and then the Text or TextField would animate its content (the actual string text) from what the value was at to what it was set to.

Edit:

To make it clearer, I'd like to recreate a label that looks like this when animated:

enter image description here

Luis
  • 951
  • 2
  • 12
  • 27
  • AFAIK, animatableData is for animating paths only. Also, when you say "go up", what do you mean? move physically up? or increase its number? – kontiki Jul 10 '19 at 21:21
  • @kontiki Ah gotcha, and I'm looking to create a label that animates by increasing the number. I've updated my question with an example gif – Luis Jul 10 '19 at 21:24
  • 1
    Thanks for the clarification, I'll think about it ;-) From the top of my head, I think you may need to have a @State variable with a counter, and a timer that increases that number. Simultaneously, you would have a view that receives the percentage number as a parameter. Technically is not a SwiftUI animation, but it could work. – kontiki Jul 10 '19 at 21:33
  • What you're describing is not an "animation" in the UIKit sense. You want to create a Timer that fires repeatedly and changes the text in your label. (I don't know what's driving the completion percentage, but if you have a progress block you can update it in there too) – Wernzy Jul 10 '19 at 23:53
  • I see what you mean but animating a label is possible using UIKit layers and @NSManaged properties without a need for a timer. I’m just not familiar enough with Swift UI. – Luis Jul 11 '19 at 02:02
  • 1
    With time I learnt that animatableData can be used to animate Text after all. I posted a new answer to your question. – kontiki Sep 09 '19 at 19:44

2 Answers2

21

There is a pure way of animating text in SwiftUI. Here's an implementation of your progress indicator using the AnimatableModifier protocol in SwiftUI:

enter image description here

I've written an extensive article documenting the use of AnimatableModifier (and its bugs). It includes the progress indicator too. You can read it here: https://swiftui-lab.com/swiftui-animations-part3/

struct ContentView: View {
    @State private var percent: CGFloat = 0

    var body: some View {
        VStack {
            Spacer()
            Color.clear.overlay(Indicator(pct: self.percent))

            Spacer()
            HStack(spacing: 10) {
                MyButton(label: "0%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 0 } }

                MyButton(label: "27%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 0.27 } }

                MyButton(label: "100%", font: .headline) { withAnimation(.easeInOut(duration: 1.0)) { self.percent = 1.0 } }
            }
        }.navigationBarTitle("Example 10")
    }
}

struct Indicator: View {
    var pct: CGFloat

    var body: some View {
        return Circle()
            .fill(LinearGradient(gradient: Gradient(colors: [.blue, .purple]), startPoint: .topLeading, endPoint: .bottomTrailing))
            .frame(width: 150, height: 150)
            .modifier(PercentageIndicator(pct: self.pct))
    }
}

struct PercentageIndicator: AnimatableModifier {
    var pct: CGFloat = 0

    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue }
    }

    func body(content: Content) -> some View {
        content
            .overlay(ArcShape(pct: pct).foregroundColor(.red))
            .overlay(LabelView(pct: pct))
    }

    struct ArcShape: Shape {
        let pct: CGFloat

        func path(in rect: CGRect) -> Path {

            var p = Path()

            p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
                     radius: rect.height / 2.0 + 5.0,
                     startAngle: .degrees(0),
                     endAngle: .degrees(360.0 * Double(pct)), clockwise: false)

            return p.strokedPath(.init(lineWidth: 10, dash: [6, 3], dashPhase: 10))
        }
    }

    struct LabelView: View {
        let pct: CGFloat

        var body: some View {
            Text("\(Int(pct * 100)) %")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.white)
        }
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
5

You can use a CADisplayLink in a BindableObject to create a timer that updates your text during the animation. Gist


class CADisplayLinkBinding: NSObject, BindableObject {

    let didChange = PassthroughSubject<CADisplayLinkBinding, Never>()
    private(set) var progress: Double = 0.0

    private(set) var startTime: CFTimeInterval = 0.0
    private(set) var duration: CFTimeInterval = 0.0
    private(set) lazy var displayLink: CADisplayLink = {
        let link = CADisplayLink(target: self, selector: #selector(tick))
        link.add(to: .main, forMode: .common)
        link.isPaused = true
        return link
    }()

    func run(for duration: CFTimeInterval) {
        let now = CACurrentMediaTime()
        self.progress = 0.0
        self.startTime = now
        self.duration = duration
        self.displayLink.isPaused = false
    }

    @objc private func tick() {
        let elapsed = CACurrentMediaTime() - self.startTime
        self.progress = min(1.0, elapsed / self.duration)
        self.displayLink.isPaused = self.progress >= 1.0
            self.didChange.send(self)
    }

    deinit {
        self.displayLink.invalidate()
    }

}

And then to use it:

@ObjectBinding var displayLink = CADisplayLinkBinding()

var body: some View {
    Text("\(Int(self.displayLink.progress*100))")
        .onAppear {
            self.displayLink.run(for: 10.0)
    }
}
arsenius
  • 12,090
  • 7
  • 58
  • 76
  • I was hoping this would be possible without using UIKit / Objective-C since I'm trying to do this purely using Swift UI. Thanks for the answer, I didn't know about `CADisplayLink` – Luis Jul 11 '19 at 23:39
  • CADisplayLink isn't actually UIKit, it's Core Animation. What you're looking to do is not possible without using a percentage to drive your animation (not to be confused with `UIPercentDrivenInteractiveTransition`). In your comment above you mention that it's possible in UIKit. I don't believe that's correct without using a timer like I outlined. Do you have a link? – arsenius Jul 12 '19 at 00:07
  • 1
    I suggest checking out [SwiftUI-Processing](https://github.com/anandabits/SwiftUI-Processing/tree/master/Processing), in particular `RenderClock.swift`. Under the hood this is still using `CADisplayLink`. – arsenius Jul 12 '19 at 00:39
  • Sure, [here is a link](https://github.com/luispadron/UICircularProgressRing), this is a project I maintain but was written with UIKit. It uses a custom layer and @NSManaged to manipulate the label text, etc (take a look at `UICircularRingLayer.swift`). It's possible. As to whether or not this is possible in SwiftUI without using CoreAnimation or some equivalent was the purpose of this question, since I've been trying to rewrite the library I linked in Swift UI. I would have hoped that something like `animatableData` for `Path` and `Shape` could be used for `Text` but I guess not. – Luis Jul 12 '19 at 16:12
  • I'll however, mark this is as the answer since as you mention it doesn't seem like there is another way. At least not with the current API's/knowledge the community has. – Luis Jul 12 '19 at 16:12
  • I see. I thought you were trying to say there was some automatic support for animated text on UILabel or something. You're just using the built in CALayer animation stack to drive your percentage instead of CADisplayLink. It would still be possible to set up a custom layer as you've done there, wrap it in UIViewRepresentable, and update a binding with the new value instead of calling your delegate method. – arsenius Jul 15 '19 at 01:10