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.
One approach is to use 2 text views and alternate adding 1 character to each while using a cross-dissolve transition.
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()
}
}