0

My scenario is that I need to generate PNG Image data from an UIView whose layout I will setup myself. I create an UIView viewA in memory, which I will use to create PNG data. This happens in a framework I'm working on, meaning that my viewA can only exist in memory and will never get attached to view hierarchy/go on screen.

However, based on my current testing, autolayout can only be effective when the view is attached to view hierarchy so that the system can trigger layout pass and related events such as layoutSubview and updateConstraint. Without being on screen, calls like setNeedsLayout and layoutSubview are ineffective because no layout pass will be run at all.

I can still manually setup frame of ViewA, but just wondering is there a way to trigger autolayout even when the view stays offscreen.

1
  • I'm wondering if you put it into your view hierarchy, but with alpha at 0, it will trigger the autolayout, or put it behind another view...
    – Larme
    Commented Jan 19, 2022 at 12:08

1 Answer 1

2

It would help if you provided a more concrete description of what you want to do with your "offscreen view."

However, here's an example of getting a UIImage from a UIView that has never been added to the view hierarchy (so it is "offscreen").

First, the simple view subclass:

class MySimpleView: UIView {

    let label: UILabel = {
        let v = UILabel()
        v.textAlignment = .center
        v.numberOfLines = 0
        v.backgroundColor = .yellow
        v.text = "Multiline Label"
        return v
    }()
    let containerView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemBlue
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        [label, containerView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        containerView.addSubview(label)
        self.addSubview(containerView)
        NSLayoutConstraint.activate([
            
            containerView.topAnchor.constraint(equalTo: self.topAnchor, constant: 12.0),
            containerView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12.0),
            containerView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12.0),
            containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -12.0),
            
            label.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20.0),
            label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20.0),
            label.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20.0),
            label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -20.0),

            // label max width: 240
            label.widthAnchor.constraint(lessThanOrEqualToConstant: 240.0),
            
        ])
        
        self.backgroundColor = .systemRed
    }
    
}

It has a label - .numberOfLines = 0 and max-width of 240 - as a subview of a "container" view, which is a subview of itself. The label is constrained inside the "container" view, with 20-pts "padding" on all 4 sides. The "container" view is constrained with 12-pts "padding" on all 4 sides.

It looks like this to start:

enter image description here

Changing the label text to "This string will likely need to wrap onto two lines." and it looks like this:

enter image description here

So far, pretty basic.

To get a UIImage of it, we can add this property:

var image: UIImage {
    get {
        self.setNeedsLayout()
        self.layoutIfNeeded()
        let renderer = UIGraphicsImageRenderer(size: self.bounds.size)
        return renderer.image { _ in
            self.drawHierarchy(in: self.bounds, afterScreenUpdates: true)
        }
    }
    set {}
}

By including:

self.setNeedsLayout()
self.layoutIfNeeded()

we can get the view to update itself, even if it's not in the view hierarchy.

Here's the completed class, along with an example controller:

class MySimpleView: UIView {

    var image: UIImage {
        get {
            self.setNeedsLayout()
            self.layoutIfNeeded()
            let renderer = UIGraphicsImageRenderer(size: self.bounds.size)
            return renderer.image { _ in
                self.drawHierarchy(in: self.bounds, afterScreenUpdates: true)
            }
        }
        set {}
    }

    let label: UILabel = {
        let v = UILabel()
        v.textAlignment = .center
        v.numberOfLines = 0
        v.backgroundColor = .yellow
        v.text = "Multiline Label"
        return v
    }()
    let containerView: UIView = {
        let v = UIView()
        v.backgroundColor = .systemBlue
        return v
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        [label, containerView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
        }
        containerView.addSubview(label)
        self.addSubview(containerView)
        NSLayoutConstraint.activate([
            
            containerView.topAnchor.constraint(equalTo: self.topAnchor, constant: 12.0),
            containerView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12.0),
            containerView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12.0),
            containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -12.0),
            
            label.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20.0),
            label.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 20.0),
            label.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -20.0),
            label.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -20.0),

            // label max width: 240
            label.widthAnchor.constraint(lessThanOrEqualToConstant: 240.0),
            
        ])
        
        self.backgroundColor = .systemRed
    }
    
}

class OffScreenTestViewController: UIViewController {

    let onScreentestStrings: [String] = [
        "Short String",
        "A bit longer String",
        "This string will likely need to wrap onto two lines.",
        "This string is going to be really, really long, and will almost certainly need to wrap onto more than two lines.",
    ]
    
    let offScreentestStrings: [String] = [
        "Off-screen String",
        "A bit longer Off-screen String",
        "Off-screen string will likely need to wrap onto two lines.",
        "This Off-screen string is going to be really, really long, and will almost certainly need to wrap onto more than two lines.",
    ]
    
    var onScreenIDX: Int = 0
    var offScreenIDX: Int = 0

    let onScreenTestView = MySimpleView()
    let offScreenTestView = MySimpleView()

    let resultsImageView: UIImageView = {
        let v = UIImageView()
        v.contentMode = .scaleAspectFit
        v.backgroundColor = .systemGreen
        return v
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        onScreenTestView.translatesAutoresizingMaskIntoConstraints = false
        resultsImageView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(onScreenTestView)
        view.addSubview(resultsImageView)
        
        let stack: UIStackView = {
            let v = UIStackView()
            v.spacing = 20
            v.distribution = .fillEqually
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        ["On Screen", "Off Screen"].forEach { title in
            let b = UIButton()
            b.backgroundColor = .systemBlue
            b.setTitleColor(.white, for: .normal)
            b.setTitleColor(.lightGray, for: .highlighted)
            b.setTitle(title, for: [])
            b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
            stack.addArrangedSubview(b)
        }
        view.addSubview(stack)
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            stack.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),

            onScreenTestView.topAnchor.constraint(equalTo: stack.bottomAnchor, constant: 20.0),
            onScreenTestView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
            resultsImageView.topAnchor.constraint(equalTo: onScreenTestView.bottomAnchor, constant: 20.0),
            resultsImageView.widthAnchor.constraint(equalToConstant: 240.0),
            resultsImageView.heightAnchor.constraint(equalTo: resultsImageView.widthAnchor),
            resultsImageView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
        ])
        
        // needs this, even though we're not adding it to the view hierarchy
        offScreenTestView.translatesAutoresizingMaskIntoConstraints = false
        
        // just to make it really, really clear that the
        //  off-screen view is being used to generate the image
        offScreenTestView.label.backgroundColor = .blue
        offScreenTestView.label.textColor = .yellow
        if let font = UIFont(name: "SnellRoundhand-Black", size: 22.0) {
            offScreenTestView.label.font = font
        }
        offScreenTestView.containerView.backgroundColor = .systemYellow
        offScreenTestView.backgroundColor = .systemOrange
        
    }

    @objc func btnTapped(_ btn: UIButton) {
        
        guard let t = btn.currentTitle else { return }
        
        if t == "On Screen" {
            onScreenTestView.label.text = onScreentestStrings[onScreenIDX % onScreentestStrings.count]
            onScreenIDX += 1
        } else {
            offScreenTestView.label.text = offScreentestStrings[offScreenIDX % offScreentestStrings.count]
            let img = offScreenTestView.image
            resultsImageView.image = img
            offScreenIDX += 1
        }
        
    }
    
}

When you run this, it will start out looking like this:

enter image description here

The green square is a UIImageView set to .scaleAspectFit with no image to begin with.

Each time we tap the "On Screen" button, the text in the custom view's label will cycle through 4 sample strings:

enter image description here enter image description here

enter image description here enter image description here

We've also created an instance of MySimpleView called offScreenTestView and changed some of its properties... label font and subview colors, just to make it abundantly clear it's not the same instance.

Each tap on the "Off Screen" button will cycle through a similar set of strings for the label and set the green image view's .image to offScreenTestView.image:

enter image description here enter image description here

enter image description here enter image description here

All of the green image view updates are happening while offScreenTestView - which is using constraints for its own sizing - has never been added to the view hierarchy.

1
  • Thanks, setNeedsLayout() and layoutIfNeeded() helped me out!
    – Borzh
    Commented Dec 8, 2022 at 19:17

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.