Search code examples
iosswiftanimationtextviewcalayer

TextView with fading text animation


I am trying to implement a Textview in Swift like: https://github.com/chtgupta/FadeInTextView-Android

I have tried to set a CALayer for opacity animation when the text changes, but after the animation and the new text are set, the new text will flash once.

UPDATE

class AnimatedTextView: UITextView {

private var animatingTextLayer: CATextLayer?
private var aniStatus: Bool = false

override func layoutSublayers(of layer: CALayer) {
    super.layoutSublayers(of: layer)
    if !aniStatus {
        animatingTextLayer?.removeFromSuperlayer()
    }
}

func appendTextAndAnimate(with newText: String) {
    guard let font = self.font else { return }
    
    animatingTextLayer = CATextLayer()
    guard let animatingTextLayer = animatingTextLayer else { return }
    
    animatingTextLayer.string = newText
    animatingTextLayer.foregroundColor = UIColor.black.cgColor
    animatingTextLayer.alignmentMode = .left
    animatingTextLayer.frame = calculateTextLayerFrame(for: newText, in: self)
    animatingTextLayer.opacity = 0
    self.layer.addSublayer(animatingTextLayer)

    let fadeAnimation = CABasicAnimation(keyPath: "opacity")
    fadeAnimation.fromValue = 0
    fadeAnimation.toValue = 1
    fadeAnimation.duration = 0.5
    
    CATransaction.begin()
    CATransaction.setCompletionBlock {
        animatingTextLayer.opacity = 1
        self.text.append(newText)
        self.aniStatus = false
    }
    animatingTextLayer.add(fadeAnimation, forKey: "fadeIn")
    aniStatus = true
    CATransaction.commit()
}

func calculateTextLayerFrame(for newCharacter: String, in textView: UITextView) -> CGRect {
    guard let font = textView.font else { return .zero }
    
    let textLength = textView.text.count
    
    let layoutManager = textView.layoutManager
    let textContainer = textView.textContainer
    let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textLength, length: 0), actualCharacterRange: nil)
    let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    let xOffset = glyphRect.origin.x + textView.textContainerInset.left
    let yOffset = glyphRect.origin.y + textView.textContainerInset.top

    let newTextSize = (newCharacter as NSString).size(withAttributes: [.font: font])
    
    return CGRect(x: xOffset, y: yOffset, width: newTextSize.width, height: newTextSize.height)
}
}

To use this view, I implement a UIViewRepresentable:

struct TextViewRepresentable: UIViewRepresentable {
var content: String

func updateUIView(_ uiView: AnimatedTextView, context: Context) {
    let currentText = uiView.text ?? ""
    let newText = String(content.dropFirst(currentText.count))
    
    if !newText.isEmpty {
        uiView.appendTextAndAnimate(with: newText)
    }
}
}

The layer animation works fine, but after the animation, it seems that Textview.text = newText leads to a refresh layouts, and the textview flashes once.

I am looking for a proper way to remove the animation layer and text update time.


Solution

  • One approach is to use 2 text views and alternate adding 1 character to each while using a cross-dissolve transition.

    enter image description here

    Sample code...


    UIView subclass:

    class TextAnimView: UIView {
    
        public var theText: String = "Test"
    
        public var font: UIFont = .systemFont(ofSize: 16.0) {
            didSet {
                textViews[0].font = font
                textViews[1].font = font
            }
        }
        public var textColor: UIColor = .black {
            didSet {
                textViews[0].textColor = textColor
                textViews[1].textColor = textColor
            }
        }
        override var backgroundColor: UIColor? {
            didSet {
                super.backgroundColor = backgroundColor
                textViews[0].backgroundColor = backgroundColor
                textViews[1].backgroundColor = backgroundColor
            }
        }
        
        // two text views
        private let textViews: [UITextView] = [
            UITextView(), UITextView(),
        ]
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            for v in textViews {
                v.translatesAutoresizingMaskIntoConstraints = false
                addSubview(v)
                NSLayoutConstraint.activate([
                    v.topAnchor.constraint(equalTo: topAnchor),
                    v.leadingAnchor.constraint(equalTo: leadingAnchor),
                    v.trailingAnchor.constraint(equalTo: trailingAnchor),
                    v.bottomAnchor.constraint(equalTo: bottomAnchor),
                ])
                v.isScrollEnabled = false
                v.isUserInteractionEnabled = false
                v.backgroundColor = .white
            }
        }
        
        private var cCounter: Int = 0
        
        public func doAnim() -> Void {
            cCounter = 1
            textViews[0].text = ""
            textViews[1].text = ""
            textViews[0].isHidden = false
            textViews[1].isHidden = true
            nextChar()
        }
        private func nextChar() -> Void {
            // fromView is the one that is NOT hidden
            let fromView: UITextView = textViews[0].isHidden ? textViews[1] : textViews[0]
            
            // toView is the one that IS hidden
            let toView: UITextView = textViews[0].isHidden ? textViews[0] : textViews[1]
            
            // set the text of the "view to show" to one character longer
            toView.text = String(theText.prefix(cCounter))
            
            UIView.transition(from: fromView,
                              to: toView,
                              duration: 0.15,
                              options: [.transitionCrossDissolve, .showHideTransitionViews],
                              completion: { b in
                self.cCounter += 1
                if self.cCounter <= self.theText.count {
                    self.nextChar()
                } else {
                    // if we want to do something when the text has been fully shown
                }
            })
        }
        
    }
    

    Sample view controller class: - tap anywhere to cycle to the next sample string...

    class TextAnimVC: UIViewController {
        
        let testView = TextAnimView()
        
        var sampleStrings: [String] = [
            "Sup!\nI'm a Fade-In TextView.",
            "This is another\nSample String!",
            "When using text wrapping, though, this animation may not be suitable.",
        ]
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .systemBackground
            
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                testView.heightAnchor.constraint(equalToConstant: 200.0),
            ])
            
            testView.font = .systemFont(ofSize: 32.0, weight: .regular)
            testView.textColor = .white
            testView.backgroundColor = .blue
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            let s = sampleStrings.removeFirst()
            sampleStrings.append(s)
            testView.theText = s
            testView.doAnim()
        }
    
    }