How do you detect that a UITextView has re-flowed its contents?

74 views Asked by At

The background: I answered a user question about how to create a blurred image of a text view's contents using simple demo code.

I then decided to generalize my solution and refactored my demo app to create a BlurringView custom subclass of UIView that has blur bool and a child view. It manages a UIImageView that will display a blurred version of the child view's contents if the blur bool is true.

It works fairly well, but I ran into a problem that I can't solve cleanly when the child view is a UITextView:

If you rotate the device, the text view's bounds change and the text view re-flows it's text in the new bounds, but only after the device rotation animation completes.

There are various ways to get notified when the BlurringView is resized: You can add a didSet to the view's bounds; For device rotations you can implement a traitCollectionDidChange() method, you can listen for device rotation methods, and so on.

HOWEVER, all of those methods get triggered before the text view has re-flowed its contents into the new bounds, so if I update my blur view immediately when I get called by any of those methods, I fail to render a blurred version of the updated text layout.

The only way I've been able to get the blurred contents to update correctly is to delay updating the blur view for 0.33 seconds (1/3rd of a second) after being told that the text view has changed (via traitCollectionDidChange, a change to the view's bounds, a rotation notification, or any of those other ways of detecting the change.)

Is there a clean way to know when a text view has finished re-flowing its text contents into a new bounding box? (Granted, it might require that I add special case handling when my child view is a UITextView.)

My sample project is here. It currently uses the traitCollectionDidChange() method to detect that the text view has updated, and then waits 0.33 seconds before updating the blur view:

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        print("In \(#function))")
        
        //The below works to update the blur image after the TextView's frame changes, but only if we add a "magic number" delay of .33 seconds
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
            print("Updating blur image. Bounds = \(self.bounds)")
            self.updateBlurImage()
        }
    }

The same 0.33 second delay approach also works in a didSet on the view's bounds, or if I listen for device rotation notifications.

Looking for changes in the view's bounds is probably the best place for the current "hacky" approach, since that will get triggered even if the BlurringView is used in a context where it gets resized for some reason other than a device rotation.

1

There are 1 answers

6
DonMag On

Possibly worth a try...

Move the "blur" code from the switch action to a func:

func updateBlur() {
    let renderer = UIGraphicsImageRenderer(size: textView.bounds.size)
    let image = renderer.image { (context) in
        var bounds = textView.bounds
        bounds.origin = CGPoint.zero
        textView.drawHierarchy(in: bounds, afterScreenUpdates: true)
    }
    blurView.image = blur(image: image, withRadius: blurRadius );
    blurView.isHidden = false
}

extend the view controller to conform to NSLayoutManagerDelegate:

extension ViewController: NSLayoutManagerDelegate {
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        self.updateBlur()
    }
}

In viewDidLoad():

textView.layoutManager.delegate = self

Result looks much better with blurView.contentMode = .scaleToFill


Edit -- changed the "update" call in didCompleteLayoutFor to async.

extension ViewController: NSLayoutManagerDelegate {
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        if blurSwitch.isOn {
            DispatchQueue.main.async {
                self.updateBlur()
            }
        }
    }
}

Edit - weird animation layout...

enter image description here

Source:

class MyTextView: UITextView {
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        layoutManager.delegate = self
    }

    override var bounds: CGRect {
        didSet {
            print("bounds set:", bounds)
        }
    }
    override func layoutSubviews() {
        print(#function, bounds)
        super.layoutSubviews()
    }
}

extension MyTextView: NSLayoutManagerDelegate {
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        print("In: \(#function)", "TextContainerSize:", textContainer?.size)
    }
}

class TextViewLayoutTimingVC: UIViewController {
    
    var wConstraint: NSLayoutConstraint!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let textView = MyTextView()
        textView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(textView)
        
        let g = view.safeAreaLayoutGuide
        
        wConstraint = textView.widthAnchor.constraint(equalToConstant: 300.0)
        
        NSLayoutConstraint.activate([
            textView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            textView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            textView.heightAnchor.constraint(equalToConstant: 200.0),
            wConstraint,
        ])

        textView.backgroundColor = .yellow
        textView.layer.borderColor = UIColor.red.cgColor
        textView.layer.borderWidth = 1
        
        textView.text = "    Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.\n    Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."

    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("\nTouch")
        wConstraint.constant = wConstraint.constant == 300.0 ? 600.0 : 300.0
        UIView.animate(withDuration: 1.0, animations: {
            self.view.layoutIfNeeded()
        })
    }
}

and debug console output:

layoutSubviews() (0.0, 0.0, 0.0, 0.0)
UITextView 4,630,595,584 is switching to TextKit 1 compatibility mode because its layoutManager was accessed. Break on void _UITextViewEnablingCompatibilityMode(UITextView *__strong, BOOL) to debug.
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((0.0, 1.7976931348623157e+308))
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((300.0, 1.7976931348623157e+308))
bounds set: (0.0, 0.0, 300.0, 200.0)
layoutSubviews() (0.0, 0.0, 300.0, 200.0)
layoutSubviews() (0.0, 0.0, 300.0, 200.0)

Touch
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((600.0, 1.7976931348623157e+308))
bounds set: (0.0, 0.0, 600.0, 200.0)
layoutSubviews() (0.0, 0.0, 600.0, 200.0)

Touch
bounds set: (0.0, 0.0, 300.0, 200.0)
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((300.0, 1.7976931348623157e+308))

Touch
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((600.0, 1.7976931348623157e+308))
bounds set: (0.0, 0.0, 600.0, 200.0)
layoutSubviews() (0.0, 0.0, 600.0, 200.0)

Touch
bounds set: (0.0, 0.0, 300.0, 200.0)
In: layoutManager(_:didCompleteLayoutFor:atEnd:) TextContainerSize: Optional((300.0, 1.7976931348623157e+308))

Note that when growing the width:

  • didCompleteLayoutFor is called
  • bounds is set
  • layoutSubviews is called

when shrinking the width:

  • bounds is set
  • then didCompleteLayoutFor is called
  • layoutSubviews is NOT called