0

I am trying to animate a multi-line label inside a UIView. In the container view, the width of the label is relative to the bounds. When the container view is animated, the label jumps to the final state and then the container resizes. How can I instead animate the right side of the text to be continuously pinned to the right edge of the container view as it grows larger?

class ViewController: UIViewController {

    var container: ContainerView = ContainerView()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(container)
        container.frame = CGRect(x: 0, y: 0, width: 150, height: 150)
        container.center = view.center
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut) {
                self.container.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
                self.container.center = self.view.center
                self.container.layoutIfNeeded()
            }
        }
    }
}

class ContainerView: UIView {
    let label: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.text = "foo bar foo bar foo bar foo bar foo bar foo bar foo foo bar foo bar foo bar foo bar foo bar foo bar foo"
        return label
    }()


    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .purple
        addSubview(label)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        let size = label.sizeThatFits(CGSize(width: self.bounds.width, height: CGFloat.greatestFiniteMagnitude))
        label.frame = CGRect(x: 0, y: 0, width: self.bounds.width, height: size.height)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

example

Rizwan
  • 3,324
  • 3
  • 17
  • 38
James
  • 291
  • 1
  • 3
  • 13
  • I can sort of replicate the desired effect using a gesture recognizer that increases the width of the container view as I drag – James Oct 11 '22 at 13:49
  • Do you want the **font size** to scale, so you maintain the word-wrapping (line lengths and number of lines)? Or, do you want the font size to stay the same, and have the word-wrapping update as the size is animating? – DonMag Oct 11 '22 at 15:48
  • The latter, the font stays the same but the word-wrapping updates as the container is animating (because there is more horizontal space available) – James Oct 11 '22 at 22:00

1 Answers1

0

As you've seen, when we change the width of a label UIKit re-calculates the word wrapping immediately.

When we do something like this:

UIView.animate(withDuration: 2, delay: 0, options: .curveEaseInOut) {
    self.container.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
    self.container.center = self.view.center
    self.container.layoutIfNeeded()
}

UIKit sets the width and then animates it. So, as soon as the animation starts, the word wrapping gets set to the "destination" width.

One way to animate the word wrap changes would be to create an animation loop, using small point-size changes.

That works-ish, with two problems:

  1. Using a UILabel, we get vertical shifting (because the text is vertically centered in a label), and
  2. If we make the incremental size changes small, it's smooth but slow. If we make the incremental changes large, it's quick but "jerky."

To solve the first problem, we can use a UITextView, subclassed to work like a top-aligned UILabel. Here's an example:

class MyTextViewLabel: UITextView {
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    private func commonInit() -> Void {
        isScrollEnabled = false
        isEditable = false
        isSelectable = false
        textContainerInset = UIEdgeInsets.zero
        textContainer.lineFragmentPadding = 0
    }
    
}

Not much we can do about the second problem, other than experiment with the width-increment value.

Here's a complete example to look at and play with (using the above MyTextViewLabel class). Note that I'm also using auto-layout / constraints instead of explicit frames:

class MyContainerView: UIView {
    
    let label: MyTextViewLabel = {
        let label = MyTextViewLabel()
        label.text = "Let's use some readable text for this example. It will make the wrapping changes look more natural than using a bunch of repeating three-character \"words.\""
        // let's set the font to the default UILabel font
        label.font = .systemFont(ofSize: 17.0)
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        clipsToBounds = true
        backgroundColor = .purple
        addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            // let's inset the "label" by 4-points so we can see the purple view frame
            label.topAnchor.constraint(equalTo: topAnchor, constant: 4.0),
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4.0),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4.0),
            
            // if we want the bottom text to be "clipped"
            //  don't set the bottom anchor
            //label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4.0),
        ])
        label.backgroundColor = .yellow
    }
    
}

class LabelWrapAnimVC: UIViewController {
    
    // for this example
    let startWidth: CGFloat = 150.0
    let targetWidth: CGFloat = 200.0

    // number of points to increment in each loop
    //  play with this value...
    //      1-point produces a very smooth result, but the total animation time will be slow
    //      5-points seems "reasonable" (looks smoother on device than on simulator)
    let loopIncrement: CGFloat = 5.0
    // total amount of time for the animation
    let loopTotalDuration: TimeInterval = 2.0
    // each loop anim duration - will be calculated
    var loopDuration: TimeInterval = 0
    

    let container: MyContainerView = MyContainerView()
    var cWidth: NSLayoutConstraint!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(container)
        
        container.translatesAutoresizingMaskIntoConstraints = false
        
        let g = view.safeAreaLayoutGuide
        
        cWidth = container.widthAnchor.constraint(equalToConstant: startWidth)
        
        NSLayoutConstraint.activate([
            container.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            container.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            container.heightAnchor.constraint(equalTo: container.widthAnchor),
            cWidth,
        ])
        
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        doAnim()
    }

    func animLoop() {

        cWidth.constant += loopIncrement
        // in case we go over the target width
        cWidth.constant = min(cWidth.constant, targetWidth)

        UIView.animate(withDuration: loopDuration, animations: {
            self.view.layoutIfNeeded()
        }, completion: { _ in
            if self.cWidth.constant < self.targetWidth {
                self.animLoop()
            } else {
                // maybe do something when animation is done
            }
        })
        
    }

    func doAnim() {
        // reset width to original
        cWidth.constant = startWidth
        
        // calculate loop duration based on size difference
        let numPoints: CGFloat = targetWidth - startWidth
        let numLoops: CGFloat = numPoints / loopIncrement
        loopDuration = loopTotalDuration / numLoops

        DispatchQueue.main.async {
            self.animLoop()
        }
    }
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        doAnim()
    }

}

I don't know if this will be suitable for your target usage, but it's at least worth a look.

DonMag
  • 69,424
  • 5
  • 50
  • 86