0

I tried to make an editable uitextview with centered text that can have a maximum of 2 lines and that automatically adjusts its font size in order to fit its text within a fixed width and height.

My solution: Type some text in a UITextView, automatically copy that text and paste it in a uilabel that itself automatically adjusts its font size perfectly, and then retrieve the newly adjusted font size of the uilabel and set that size on the UITextView text.

I have spent about a month on this and repeatedly failed. I can't find a way to make it work. My attempted textview below glitches some letters out and hides large portions of out-of-bounds text instead of resizing everything. Please help me stack overflow Gods.

My attempt:

import UIKit

class TextViewViewController: UIViewController{
    
    private let editableUITextView: UITextView = {
        let tv = UITextView()
        tv.font = UIFont.systemFont(ofSize: 20)
        tv.text = "Delete red text, and type here"
        tv.backgroundColor = .clear
        tv.textAlignment = .center
        tv.textContainer.maximumNumberOfLines = 2
        tv.textContainer.lineBreakMode = .byWordWrapping
        tv.textColor = .red
        return tv
    }()
    private let correctTextSizeLabel: UILabel = {
        let tv = UILabel()
        tv.font = UIFont.systemFont(ofSize: 20)
        tv.backgroundColor = .clear
        tv.text = "This is properly resized"
        tv.adjustsFontSizeToFitWidth = true
        tv.lineBreakMode = .byTruncatingTail
        tv.numberOfLines = 2
        tv.textAlignment = .center
        tv.textColor = .green

        return tv
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
     
        view.backgroundColor = UIColor.white
        view.addSubview(correctTextSizeLabel)

        view.addSubview(editableUITextView)
        editableUITextView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        editableUITextView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        editableUITextView.heightAnchor.constraint(equalToConstant: 150).isActive = true
        editableUITextView.widthAnchor.constraint(equalToConstant: 150).isActive = true
        editableUITextView.translatesAutoresizingMaskIntoConstraints = false
        editableUITextView.delegate = self
        
        correctTextSizeLabel.leftAnchor.constraint(equalTo: editableUITextView.leftAnchor).isActive = true
        correctTextSizeLabel.rightAnchor.constraint(equalTo: editableUITextView.rightAnchor).isActive = true
        correctTextSizeLabel.topAnchor.constraint(equalTo: editableUITextView.topAnchor).isActive = true
        correctTextSizeLabel.bottomAnchor.constraint(equalTo: editableUITextView.bottomAnchor).isActive = true
        correctTextSizeLabel.translatesAutoresizingMaskIntoConstraints = false
        editableUITextView.isScrollEnabled = false

        
    }
    func getApproximateAdjustedFontSizeOfLabel(label: UILabel) -> CGFloat {
        if label.adjustsFontSizeToFitWidth == true {
            var currentFont: UIFont = label.font
            let originalFontSize = currentFont.pointSize
            var currentSize: CGSize = (label.text! as NSString).size(withAttributes: [NSAttributedString.Key.font: currentFont])
            while currentSize.width > label.frame.size.width * 2 && currentFont.pointSize > (originalFontSize * label.minimumScaleFactor) {
                currentFont = currentFont.withSize(currentFont.pointSize - 1)
                currentSize = (label.text! as NSString).size(withAttributes: [NSAttributedString.Key.font: currentFont])
            }
            return currentFont.pointSize
        } else {
            return label.font.pointSize
        }
    }
}

//MARK: - UITextViewDelegate
extension TextViewViewController : UITextViewDelegate {
    
    private func textViewShouldBeginEditing(_ textView: UITextView) {
        textView.becomeFirstResponder()
    }

    func textViewDidBeginEditing(_ textView: UITextView) {
    }

    func textViewDidEndEditing(_ textView: UITextView) {
    }

    func textViewDidChange(_ textView: UITextView) {
        textView.becomeFirstResponder()
        self.correctTextSizeLabel.text = textView.text
        self.correctTextSizeLabel.isHidden = false
        let estimatedTextSize = self.getApproximateAdjustedFontSizeOfLabel(label: self.correctTextSizeLabel)
    print("estimatedTextSize: ",estimatedTextSize)
        self.editableUITextView.font = UIFont.systemFont(ofSize: estimatedTextSize)
    }
}

UITextField's have the option to automatically adjust font size to fit a fixed width but they only allow 1 line of text, I need it to have a maximum of 2. UILabel's solve this problem perfectly but they aren't editable.

newswiftcoder
  • 111
  • 1
  • 11
  • 1
    Difficult task... maybe consider a different approach. Display a 2-line label instead of a text view. When the user wants to edit the label, show a normal text view for the editing, and update the text of the label as the user edits the text if the text view? – DonMag Oct 11 '20 at 13:03
  • @DonMag that is a good idea but I want users to see the final, properly centered and formatted, product as they type. It is difficult but not impossible, I think I just lack the experience needed to create the right solution. Do you have any other ideas or know anyone who can help? I will keep trying, thank you very much – newswiftcoder Oct 11 '20 at 19:15
  • @DonMag Going off of this SO question it seems like Apple has their own proprietary way of adjusting fonts to fit widths: https://stackoverflow.com/questions/48028047/uilabel-and-uitextview-line-breaks-dont-match/48029014. That formula could possibly be found by reverse engineering their uilabel's `adjustsFontSizeToFitWidth` feature. The solution is to then manually apply that same formula to uitextviews. – newswiftcoder Oct 12 '20 at 00:43

1 Answers1

1

After some searching, this looks like it will be very difficult to get working as desired.

This doesn't directly answer your question, but it may be an option:

enter image description here

Here's the example code:

class TestInputViewController: UIViewController {
    
    let testLabel: InputLabel = InputLabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let instructionLabel = UILabel()
        instructionLabel.textAlignment = .center
        instructionLabel.text = "Tap yellow label to edit..."
        
        let centeringFrameView = UIView()
        
        // label properties
        let fnt: UIFont = .systemFont(ofSize: 32.0)
        testLabel.isUserInteractionEnabled = true
        testLabel.font = fnt
        testLabel.adjustsFontSizeToFitWidth = true
        testLabel.minimumScaleFactor = 0.25
        testLabel.numberOfLines = 2
        testLabel.setContentHuggingPriority(.required, for: .vertical)
        let minLabelHeight = ceil(fnt.lineHeight)
        
        // so we can see the frames
        centeringFrameView.backgroundColor = .red
        testLabel.backgroundColor = .yellow
        
        [centeringFrameView, instructionLabel, testLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        
        view.addSubview(instructionLabel)
        view.addSubview(centeringFrameView)
        centeringFrameView.addSubview(testLabel)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            // instruction label centered at top
            instructionLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            instructionLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
            // centeringFrameView 20-pts from instructionLabel bottom
            centeringFrameView.topAnchor.constraint(equalTo: instructionLabel.bottomAnchor, constant: 20.0),
            // Leading / Trailing with 20-pts "padding"
            centeringFrameView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            centeringFrameView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            // test label centered vertically in centeringFrameView
            testLabel.centerYAnchor.constraint(equalTo: centeringFrameView.centerYAnchor, constant: 0.0),
            // Leading / Trailing with 20-pts "padding"
            testLabel.leadingAnchor.constraint(equalTo: centeringFrameView.leadingAnchor, constant: 20.0),
            testLabel.trailingAnchor.constraint(equalTo: centeringFrameView.trailingAnchor, constant: -20.0),
            
            // height will be zero if label has no text,
            //  so give it a min height of one line
            testLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: minLabelHeight),
            
            // centeringFrameView height = 3 * minLabelHeight
            centeringFrameView.heightAnchor.constraint(equalToConstant: minLabelHeight * 3.0)
        ])
        
        // to handle user input
        testLabel.editCallBack = { [weak self] str in
            guard let self = self else { return }
            self.testLabel.text = str
        }
        testLabel.doneCallBack = { [weak self] in
            guard let self = self else { return }
            // do something when user taps done / enter
        }
        
        let t = UITapGestureRecognizer(target: self, action: #selector(self.labelTapped(_:)))
        testLabel.addGestureRecognizer(t)
        
    }
    
    @objc func labelTapped(_ g: UITapGestureRecognizer) -> Void {
        testLabel.becomeFirstResponder()
        testLabel.inputContainerView.theTextView.text = testLabel.text
        testLabel.inputContainerView.theTextView.becomeFirstResponder()
    }
    
}

class InputLabel: UILabel {
    
    var editCallBack: ((String) -> ())?
    var doneCallBack: (() -> ())?
    
    override var canBecomeFirstResponder: Bool {
        return true
    }
    override var canResignFirstResponder: Bool {
        return true
    }
    override var inputAccessoryView: UIView? {
        get { return inputContainerView }
    }
    
    lazy var inputContainerView: CustomInputAccessoryView = {
        let customInputAccessoryView = CustomInputAccessoryView(frame: .zero)
        customInputAccessoryView.backgroundColor = .blue
        customInputAccessoryView.editCallBack = { [weak self] str in
            guard let self = self else { return }
            self.editCallBack?(str)
        }
        customInputAccessoryView.doneCallBack = { [weak self] in
            guard let self = self else { return }
            self.resignFirstResponder()
        }
        return customInputAccessoryView
    }()
    
    
}

class CustomInputAccessoryView: UIView, UITextViewDelegate {
    
    var editCallBack: ((String) -> ())?
    var doneCallBack: (() -> ())?
    
    let theTextView: UITextView = {
        let tv = UITextView()
        tv.isScrollEnabled = false
        tv.font = .systemFont(ofSize: 16)
        tv.autocorrectionType = .no
        tv.returnKeyType = .done
        return tv
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.white
        autoresizingMask = [.flexibleHeight, .flexibleWidth]
        
        addSubview(theTextView)
        theTextView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            // constraint text view with 8-pts "padding" on all 4 sides
            theTextView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
            theTextView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
            theTextView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
            theTextView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
        ])
        
        theTextView.delegate = self
    }
    
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if (text == "\n") {
            textView.resignFirstResponder()
            doneCallBack?()
        }
        return true
    }
    func textViewDidChange(_ textView: UITextView) {
        editCallBack?(textView.text ?? "")
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override var intrinsicContentSize: CGSize {
        return .zero
    }
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • @newswiftcoder - for your 2nd question, search for `find where user taps in uilabel` and `uitextview set cursor position` ... for your 1st question, you'd sort of "reverse" that process. Personally, I think it would be more effort than it would be worth... but I'm not writing your app :) – DonMag Oct 14 '20 at 16:37