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:
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:
Again, this doesn't happen if:
Does anyone have any idea why this might be happening?
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:
PrimaryView
to the safe-areabodyView
a height, and added a label as a subview to identify ityour "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:
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:
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.