3

I have an issue where I'm trying to insert a SwiftUI view into an existing UIKit view. The SwiftUI view can change height dynamically, but the UIStackView does not adjust to the new size. I've created a (hideous) test project to highlight this.

Button:

class TestButton: UIButton {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupButton()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupButton()
        fatalError("init(coder:) has not been implemented")
    }
    
    func setupButton() {
        setTitleColor(.white, for: .normal)
        backgroundColor = .red
        titleLabel?.font = .boldSystemFont(ofSize: 25)
        layer.cornerRadius = 10
    }
}

SwiftUI View:

struct TestSwiftUIView: View {
    @State var text: [String] = ["This is a line of text"]
    var body: some View {
        VStack {
            ForEach(text, id: \.self) { text in
                Text(text)
            }
            Button {
                text.append("New line")
            } label: {
                Text("Add line")
            }
            .padding()
            .background(Color.red)
        }
        .foregroundColor(.white)
        .background(Color.green)
    }
}

ViewController:

class ViewController: UIViewController {
    
    var titleLabel = UILabel()
    var stackView = UIStackView()

    override func viewDidLoad() {
        super.viewDidLoad()
        configureTitleLabels()
        configureStackView()
    }
    
    func configureStackView() {
        view.addSubview(stackView)
        
        stackView.axis = .vertical
        stackView.distribution = .fillEqually
        stackView.spacing = 20
        
        addButtonsToStackView()
        setStackViewConstraints()
        
        let hostingController = UIHostingController(rootView: TestSwiftUIView())
        stackView.insertArrangedSubview(hostingController.view, at: 3)
    }
    
    func addButtonsToStackView() {
        let numberOfButtons = 5
        
        for i in 1...numberOfButtons {
            let button = TestButton()
            button.setTitle("\(i)", for: .normal)
            stackView.addArrangedSubview(button)
        }
    }
    
    func setStackViewConstraints() {
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 30)
            .isActive = true
        stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 50).isActive = true
        stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -50).isActive = true
        stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30).isActive = true
    }
    
    func configureTitleLabels() {
        view.addSubview(titleLabel)
        titleLabel.text = "Test project"
        titleLabel.font = .systemFont(ofSize: 30)
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 0
        titleLabel.adjustsFontSizeToFitWidth = true
        
        setTitleLabelConstaints()
    }
    
    func setTitleLabelConstaints() {
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20)
            .isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20).isActive = true
    }
}

Here is the result:

enter image description here

How can we ensure that the UIStackView allows enough space for the expanding SwiftUI view?

UPDATE

I had an idea that I may need to do something with layoutIfNeeded(). So I added a callback to the button within the SwiftUI View as follows:

let callback: () -> Void

And then in the button function:

Button {
    text.append("New line")
    callback()
} label: {
    Text("Add line")
        .font(.system(size: 18, weight: .bold, design: nil))
}

Then in ViewController:

let hostingController = UIHostingController(rootView: TestSwiftUIView(callback: {
            self.stackView.subviews.forEach { view in
                view.sizeToFit()
                view.layoutIfNeeded()
            }
        }))

Sadly this had no impact :(

DevB1
  • 1,235
  • 3
  • 17
  • 43

1 Answers1

6

You're running into a couple problems... actually, several :(


TL;DR version:

  • UIHostingController views don't work in the same way as UIKit views
  • you've set the stack view to .fillEqually, which means the arranged subviews will never change size.

The long version:

First, when UIKit loads a UIHostingController view, it uses that view's .intrinsicContentSize. But, because of the way SwiftUI views work, that doesn't "automatically" update.

You can see this by stripping your UIKit view controller down to only the hosting controller:

class SimpleViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let hostingController = UIHostingController(rootView: TestSwiftUIView())

        // let's unwrap the view to save a little typing
        guard let v = hostingController.view else { return }
        
        v.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(v)
        
        // now let's add that view leading/trailing with 40-points on each side
        //  and vertically centered (no Height constraint)
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
            v.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        // let's give the view a background color
        //  so we can see its frame
        v.backgroundColor = .systemYellow
        
    }
    
}

This is what we get:

enter image description here

enter image description here

enter image description here

As we see, the green vStack expands vertically, but the yellow view doesn't change.

There are a couple ways to "fix" that...

First, you were on the right track with your callback approach, but let's do it like this.

We'll add a var property to hold a reference to the hosted view:

// so we have a reference to the hosting controller's view
var hcView: UIView!

and when we get the callback, we'll update it like this:

func updateSize() {
    hcView.setNeedsLayout()
    hcView.layoutIfNeeded()
    hcView.invalidateIntrinsicContentSize()
}

Here's a modified example:

struct CallbackTestSwiftUIView: View {
    let callback: () -> Void
    @State var strings: [String] = [
        "This is a line of text",
    ]
    var body: some View {
        VStack {
            ForEach(strings, id: \.self) { text in
                Text(text)
            }
            Button {
                strings.append("New line \(strings.count)")
                callback()
            } label: {
                Text("Add line")
            }
            .padding()
            .background(Color.red)
        }
        .foregroundColor(.white)
        .background(Color.green)
    }
}

class CallbackSimpleViewController: UIViewController {

    // so we have a reference to the hosting controller's view
    var hcView: UIView!
    
    func updateSize() {
        hcView.setNeedsLayout()
        hcView.layoutIfNeeded()
        hcView.invalidateIntrinsicContentSize()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let hostingController = UIHostingController(rootView: CallbackTestSwiftUIView(callback: {
            self.updateSize()
        }))
        
        // let's unwrap the view to save a little typing
        guard let v = hostingController.view else { return }
        
        // save the view reference
        self.hcView = v
        
        v.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(v)
        
        // now let's add that view leading/trailing with 40-points on each side
        //  and vertically centered (no Height constraint)
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            v.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
            v.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
            v.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        // let's give the view a background color
        //  so we can see its frame
        v.backgroundColor = .systemYellow
        
    }
    
}

and here's the output, with the yellow view growing along with the green view:

enter image description here

enter image description here

enter image description here

So -- woo hoo! Just implement that with your stack view layout and we're done, right?

Whoops, not quite.

You gave your stack view Top and Bottom constraints, and set its .distribution = .fillEqually.

So, the stack view says "I have 6 arranged subviews now, so make them all the same heights and never let the heights change!!!

You might try .distribution = .fillProportionally ... unfortunately, that fails miserably when the stack view's spacing is not Zero. There are ways around that, but it might not give you the desired result anyway.

So, let's use the CallbackTestSwiftUIView and a slightly modified version of your original ViewController with the stack view and buttons:

class CallbackInStackViewController: UIViewController {
    
    var titleLabel = UILabel()
    var stackView = UIStackView()
    
    // so we have a reference to the hosting controller's view
    var hcView: UIView!
    
    func updateSize() {
        hcView.setNeedsLayout()
        hcView.layoutIfNeeded()
        hcView.invalidateIntrinsicContentSize()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        configureTitleLabels()
        configureStackView()
    }
    
    func configureStackView() {
        view.addSubview(stackView)
        
        stackView.axis = .vertical
        
        // use .fill instead of .fillEqually
        //stackView.distribution = .fillEqually
        stackView.distribution = .fill
        
        stackView.spacing = 20

        addButtonsToStackView()
        setStackViewConstraints()
        
        let hostingController = UIHostingController(rootView: CallbackTestSwiftUIView(callback: {
            self.updateSize()
        }))
        
        // let's unwrap the view to save a little typing
        guard let v = hostingController.view else { return }
        
        // save the view reference
        self.hcView = v
        
        stackView.insertArrangedSubview(v, at: 3)
        
        // let's give the view a background color
        //  so we can see its frame
        v.backgroundColor = .systemYellow
    }
    
    func addButtonsToStackView() {
        let numberOfButtons = 5
        
        for i in 1...numberOfButtons {
            let button = TestButton()
            button.setTitle("\(i)", for: .normal)
            stackView.addArrangedSubview(button)
        }
    }
    
    func setStackViewConstraints() {
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 30).isActive = true
        stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 50).isActive = true
        stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -50).isActive = true
        
        // remove stack view's bottom anchor
        //stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -30).isActive = true
    }
    
    func configureTitleLabels() {
        view.addSubview(titleLabel)
        titleLabel.text = "Test project"
        titleLabel.font = .systemFont(ofSize: 30)
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 0
        titleLabel.adjustsFontSizeToFitWidth = true
        
        setTitleLabelConstaints()
    }
    
    func setTitleLabelConstaints() {
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20).isActive = true

        // don't let title label stretch vertically
        titleLabel.setContentHuggingPriority(.required, for: .vertical)

    }
    
}

Which may or may not give you what you need:

enter image description here

enter image description here

enter image description here

but at least we have the sizing in the stack view working.


Some searching comes up with various ways to make "auto-sizing" hosting controllers ... I do very little with SwiftUI, so I can't give you a recommendation on which approach might be the best.

DonMag
  • 69,424
  • 5
  • 50
  • 86