Search code examples
iosuikituilabel

Typewriter Effect UILabel width issue


I want to have a UILabel in the middle of the screen that starts from the left of the screen. The UILabel will get updated every second from a Timer to simulate a typewriter effect. So to display "Album releases", the UILabel will show this series of text:

A Al Alb Albu Album Album Album r Album re Album rel Album rele Album relea Album releas Album release Album releases

The label has a black background. So I want to make sure the width fits the text. But if the text is too long then the width should go up to the screen width and then breaks into a second line. I tried using numberOfLines = 0 and sizeToFit to achieve that. But the issue is that for some reason, one letter is being written on a line. I cannot manage to fix that. Here's some code:

    private let taglinesLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .left
        label.textColor = .white
        label.backgroundColor = .black
        label.numberOfLines = 0
        return label
    }()

    override func layoutSubviews() {
        ...
        taglinesLabel.frame = CGRect(x: 0, y: 200, width: 0, height: 0)
        ...
    }

   public func showText() {
       ...
       startTimer()
       ...
   }

  func startTimer () {
      guard timer == nil else { return }

      timer =  Timer.scheduledTimer(
        timeInterval: TimeInterval(0.1),
          target      : self,
          selector    : #selector(onTimer),
          userInfo    : nil,
          repeats     : true)
    }

   @objc private func onTimer() {
            let tagline = taglines[taglineIndex]
            if(taglineCharacterIndex < tagline.count) {
                let substring = tagline.prefix(taglineCharacterIndex)
                print(substring)
                taglinesLabel.text = String(substring)
                taglinesLabel.sizeToFit()
                taglineCharacterIndex += 1
            } 
    }

enter image description here

How to constraint the UILabel to expand its width and not go to the next line like shown in the picture?


Solution

  • Couple things...

    First, use auto-layout and constraints instead of .sizeToFit().

    Second, let's replace the UILabel with a non-editable, non-scrolling UITextView because:

    • It gives us a visually nice "padding" around the text
    • It keeps the text at the top (if we have set a height for the view)
    • It avoids a weird "line jumping" when the text wraps

    So, start with an example controller that puts an instance of our custom TypingView 40-points from the Top and from each side. We'll also give it four sample "taglines" and we'll start the timer in viewDidAppear:

    class ViewController: UIViewController {
        
        let testView = TypingView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                testView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                testView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                // no bottom or height constraint
                //  we let the TypingView set its own height
            ])
            
            testView.taglines = [
                "First sample string.",
                "This is the Second sample tagline.",
                "This tagline will be long enough that it will wrap onto at least two lines.",
                "Here is the final tagline.",
            ]
            
        }
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            testView.startTimer()
        }
        
    }
    

    Now, our custom UIView subclass:

    class TypingView: UIView {
    
        public var taglines: [String] = []
    
        private var taglineIndex: Int = 0
        private var timer: Timer!
        private var taglineCharacterIndex: Int = 0
        
        private let taglinesLabel: UITextView = {
            let v = UITextView()
            v.font = .systemFont(ofSize: 17.0, weight: .regular)
            v.textColor = .white
            v.backgroundColor = .black
            v.isScrollEnabled = false
            v.isEditable = false
            return v
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        func commonInit() {
            backgroundColor = .black
            taglinesLabel.translatesAutoresizingMaskIntoConstraints = false
            addSubview(taglinesLabel)
            NSLayoutConstraint.activate([
                taglinesLabel.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
                taglinesLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
                taglinesLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
                taglinesLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0),
            ])
        }
    
        func startTimer () {
            guard timer == nil else { return }
            
            timer =  Timer.scheduledTimer(
                timeInterval: TimeInterval(0.1),
                target      : self,
                selector    : #selector(onTimer),
                userInfo    : nil,
                repeats     : true)
        }
        
        @objc private func onTimer() {
            guard taglineIndex < taglines.count else {
                timer.invalidate()
                timer = nil
                return
            }
            let tagline = taglines[taglineIndex]
            if taglineCharacterIndex < tagline.count + 1 {
                let substring = tagline.prefix(taglineCharacterIndex)
                taglinesLabel.text = String(substring)
                taglineCharacterIndex += 1
            } else if taglineCharacterIndex < tagline.count + 6 {
                // this will provide a half-second "pause" before going to the next tagline
                taglineCharacterIndex += 1
            } else {
                taglineIndex += 1
                taglineCharacterIndex = 0
            }
        }
        
    }
    

    It will look like this (adding 1 character every 1/10th second):

    enter image description here

    and with enough text to wrap:

    enter image description here


    Edit - in response to comment...

    To get the view to expand horizontally as the text gets longer, change the trailing constraint (in ViewController) from:

    testView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
    

    to:

    testView.trailingAnchor.constraint(lessThanOrEqualTo: g.trailingAnchor, constant: -40.0),
    

    Now it will grow wider with each character, but only until it reaches 40-points from the right side.

    You could also replace the trailing constraint with a width constraint if that would better suit your needs.

    For example:

    testView.widthAnchor.constraint(lessThanOrEqualToConstant: 240.0),
    

    would limit its width to 240-points.