Search code examples
swiftuikituitextview

UITextView doesn't display all of the text on small screens when using exclusions


I have a UITextView:

var textView: UITextView = {
    let textView = UITextView()
    textView.textColor = .orange
    textView.isScrollEnabled = false
    textView.isEditable = false
    return textView
}()

I have it constrained to another view, which is in turn part of a UIStackView

    mainStack.axis = .vertical
    mainStack.addArrangedSubview(titleView)
    mainStack.addArrangedSubview(bodyView)
    
    titleView.addSubview(textView)
    textView.translatesAutoresizingMaskIntoConstraints = false
    
    let padding: CGFloat = 5
    
    NSLayoutConstraint.activate([
        textView.leadingAnchor.constraint(equalTo: titleView.leadingAnchor, constant: padding),
        textView.trailingAnchor.constraint(equalTo: titleView.trailingAnchor, constant: -padding),
        textView.topAnchor.constraint(equalTo:  titleView.topAnchor, constant: padding),
        textView.bottomAnchor.constraint(equalTo: titleView.bottomAnchor, constant: -padding)
    ])
    

In addition I have a button constrained partially in the same area

let mainActionButton = UIButton()
titleView.addSubview(mainActionButton)
        
mainActionButton.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            mainActionButton.trailingAnchor.constraint(equalTo: titleView.trailingAnchor),
            mainActionButton.topAnchor.constraint(equalTo:  titleView.topAnchor),
            mainActionButton.heightAnchor.constraint(equalTo: mainActionButton.widthAnchor)
        ])

At runtime, after viewDidLoad is called, I use the following to make sure the text flows around the button

    textView.textContainer.exclusionPaths = [UIBezierPath(rect: mainActionButton.frame)]
    self.layoutSubviews()

Generally, this works nicely, as you can see from the screenshot:

working

However, when I changed the padding to anything less than 4 in the constraints for the uitextview, on small screens only, it does not display the last line, as you can see below:

not working

Again, this doesn't happen if:

  • There is no exclusion path (button)
  • It is on a large screen
  • The padding is more than 3 px

Does anyone have any idea why this might be happening?


Solution

  • Based on your "minimum reproducible example" ...

    I've found in the past that when setting .exclusionPaths in a UITextView, the layout needs to be "prompted" to update. It also seems much more reliable to set the .text after setting the exclusion paths.

    Also - while not related to what's going on here - I find it very helpful during development to give UI elements contrasting background colors to make it easy to see the framing at run-time.

    So, here is your example, almost as-is, with these changes:

    • constrained PrimaryView to the safe-area
    • gave bodyView a height, and added a label as a subview to identify it
    • gave all the views background colors

    your "helper" function - unmodified

    // Helper function
    extension UIView {
        enum ConstraintType: CaseIterable { case leading, trailing, top, bottom }
        func constrain(_ child: UIView, padding: CGFloat = 0, except: [ConstraintType] = []) {
            
            self.addSubview(child)
            child.translatesAutoresizingMaskIntoConstraints = false
            
            for type in ConstraintType.allCases where !except.contains(type) {
                switch type {
                case .leading:
                    child.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: padding).isActive = true
                case .trailing:
                    child.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -padding).isActive = true
                case .top:
                    child.topAnchor.constraint(equalTo: self.topAnchor, constant: padding).isActive = true
                case .bottom:
                    child.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -padding).isActive = true
                }
            }
        }
    }
    

    view controller class - modified to respect safe-area

    class ExclusionTestViewController: UIViewController {
        
        let primaryView = MyPrimaryView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            //self.view.constrain(primaryView)
            
            primaryView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(primaryView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                primaryView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                primaryView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                primaryView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                primaryView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            ])
            
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            primaryView.layoutTitle()
        }
    }
    

    PrimaryView class - original, except added colors and bodyView properties

    class MyPrimaryView: UIView {
        
        var mainScroll = UIScrollView()
        
        var mainStack: UIStackView = {
            let stackView = UIStackView()
            stackView.axis = .vertical
            return stackView
        } ()
        
        var titleTextView: UITextView = {
            let textView = UITextView()
            textView.font = UIFont.systemFont(ofSize: 36)
            textView.isScrollEnabled = false
            textView.isEditable = false
            return textView
        }()
        var mainActionButtonArea = UIView()
        var mainActionButton: UIButton = {
            let button = UIButton()
            button.setImage(UIImage(systemName: "square.and.pencil.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 36)), for: .normal)
            return button
        }()
        
        var bodyView = UIStackView()
        
        init() {
            super.init(frame: .zero)
            
            setUpMainStack()
    
            // set background colors to make it easy to see framing
            self.backgroundColor = .cyan
            mainScroll.backgroundColor = .systemBlue
            mainStack.backgroundColor = .systemYellow
            titleTextView.backgroundColor = .green
            mainActionButtonArea.backgroundColor = .red.withAlphaComponent(0.75)
            mainActionButton.backgroundColor = .white
            bodyView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            
            // let's give the bodyView a height constraint of 180
            bodyView.heightAnchor.constraint(equalToConstant: 180.0).isActive = true
            // and add a label to identify it
            let v = UILabel()
            v.font = .italicSystemFont(ofSize: 24.0)
            v.text = "Body View"
            bodyView.constrain(v, padding: 12.0, except: [.trailing, .bottom])
            
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        func setUpMainStack() {
            
            // Constrain scrollview to view
            self.constrain(mainScroll, padding: 30)
            
            // Constrain UIStackView to scroll view
            mainScroll.constrain(mainStack)
            mainScroll.widthAnchor.constraint(equalTo: mainStack.widthAnchor).isActive = true
            
            // Add child views
            mainStack.addArrangedSubview(titleTextView)
            mainStack.addArrangedSubview(bodyView)
            
            titleTextView.text = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19"
            
            // Add action button area + action button
            mainScroll.constrain(mainActionButtonArea, except: [.leading, .bottom])
            mainActionButtonArea.constrain(mainActionButton, padding: 10)
            mainActionButton.heightAnchor.constraint(equalTo: mainActionButton.widthAnchor).isActive = true
            
        }
                
        func layoutTitle() {
            // This adds the exception path; needs to be called after subviews are already laid out
            let buttonPath = UIBezierPath(rect: mainActionButtonArea.frame)
            titleTextView.textContainer.exclusionPaths = [buttonPath]
        }
        
    }
    

    and it looks like this:

    enter image description here

    As you pointed out, the "17 18 19" line is missing.

    So, let's force the text view to update its layout when we set the exclusion path. The only change will be a few lines added here:

    func layoutTitle() {
        // This adds the exception path; needs to be called after subviews are already laid out
        let buttonPath = UIBezierPath(rect: mainActionButtonArea.frame)
        titleTextView.textContainer.exclusionPaths = [buttonPath]
        
        // "re-set" the text view's text and force a layout pass
        let str: String = titleTextView.text ?? ""
        titleTextView.text = ""
        titleTextView.text = str
        titleTextView.setNeedsLayout()
        titleTextView.layoutIfNeeded()
    }
    

    Now when we run it, we get:

    enter image description here

    and there's no need to create a temporary label with sizing calculations that may, or may not, be reliable and flexible.


    As a side note, to get this to "play well" with dynamic size changes - such as device rotation - it's still got a bit of work to do.