UIViewRepresentable ignores constraints of wrapped UIView - ios

I have subclass of UIButton, which defines it's height inside by using NSLayoutConstraints, which I need to reuse in SwiftUI view by wrapping it into UIViewRepresentable.
So here is the code:
struct TestView: View {
var body: some View {
TestButtonWrapper()
.background(Color.red)
}
}
final class TestButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
func setup() {
translatesAutoresizingMaskIntoConstraints = false
setTitle("Hello", for: .normal)
// these are ignored:
heightAnchor.constraint(equalToConstant: 200).isActive = true
widthAnchor.constraint(equalToConstant: 300).isActive = true
}
}
struct TestButtonWrapper: UIViewRepresentable {
func makeUIView(context: Context) -> TestButton {
let view = TestButton()
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
return view
}
func updateUIView(_ uiView: TestButton, context: Context) {
}
}
Result is:
Important:
I can't remove constraints from TestButton and set frame inside TestView. This UIKit button is being reused in regular UIKit screens
How it can be solved? Why UIViewRepresentable ignores constraints of it's children?

SwiftUI and UIKit's layout constraints are not the same...
One approach is to override intrinsicContentSize in your TestButton instead of trying to set constraints.
Give this a try:
struct TestView: View {
var body: some View {
TestButtonWrapper()
.background(Color.red)
}
}
final class TestButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
func setup() {
translatesAutoresizingMaskIntoConstraints = false
setTitle("Hello", for: .normal)
// these are ignored:
//heightAnchor.constraint(equalToConstant: 200).isActive = true
//widthAnchor.constraint(equalToConstant: 300).isActive = true
}
// add this override
override var intrinsicContentSize: CGSize {
return .init(width: 300.0, height: 200.0)
}
}
struct TestButtonWrapper: UIViewRepresentable {
func makeUIView(context: Context) -> TestButton {
let view = TestButton()
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
return view
}
func updateUIView(_ uiView: TestButton, context: Context) {
}
}
Edit
For clarification...
When using UIKit auto-layout:
the constraints define the size
intrinsicContentSize has no effect
When using SwiftUI:
intrinsicContentSize defines the size
the constraints have no effect
We can use the same TestButton class in both environments:
// if this class is used in a UIKit auto-layout environment
// its width and height constraints will define the size of the button
// intrinsicContentSize will have no effect
// if this class is used in a SwiftUI environment
// its width and height constraints will have no effect
// intrinsicContentSize will define the size of the button (absent any other sizing actions)
final class TestButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
func setup() {
translatesAutoresizingMaskIntoConstraints = false
setTitle("Hello", for: .normal)
heightAnchor.constraint(equalToConstant: 200).isActive = true
widthAnchor.constraint(equalToConstant: 300).isActive = true
}
override var intrinsicContentSize: CGSize {
return .init(width: 300.0, height: 200.0)
}
}
So, for a SwiftUI implementation:
struct TestView: View {
var body: some View {
VStack(alignment: .center, spacing: 20.0) {
Text("SwiftUI")
TestButtonWrapper()
.background(Color.red)
}
}
}
struct TestButtonWrapper: UIViewRepresentable {
func makeUIView(context: Context) -> TestButton {
let view = TestButton()
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
return view
}
func updateUIView(_ uiView: TestButton, context: Context) {
}
}
and here is a Storyboard / UIKit implementation:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let label = UILabel()
label.text = "Storyboard / UIKit"
let btn1 = TestButton()
btn1.backgroundColor = .systemRed
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 20.0
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
stackView.addArrangedSubview(label)
stackView.addArrangedSubview(btn1)
}
}
Both produce the same output:

Related

Determine UIViewRepresentable with a custom UIView size dynamically

I have an issue when trying to convert a custom View that inherits from UIView into a SwiftUI view, the view takes the whole screen.
But it works pretty well if I inherit from UILabel instead of UIView.
Note This view is shown inside a UIHostingController.
class ViewController: UIViewController {
let hostingVC = UIHostingController(rootView: SwiftUIView())
override func viewDidLoad() {
super.viewDidLoad()
addChild(hostingVC)
view.addSubview(hostingVC.view)
hostingVC.didMove(toParent: self)
hostingVC.view.backgroundColor = .red
hostingVC.view.wh_activateConstraints(configuration: {
[
$0.topAnchor.constraint(equalTo: view.topAnchor),
$0.leadingAnchor.constraint(equalTo: view.leadingAnchor),
$0.trailingAnchor.constraint(equalTo: view.trailingAnchor),
$0.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]
})
}
}
Example 1:
this will work
struct SwiftUIView: View {
var body: some View {
VStack {
CustomLabel()
.fixedSize(horizontal: false, vertical: true)
}
}
struct CustomLabel: UIViewRepresentable {
func updateUIView(_ uiView: UIViewType, context: Context) {
}
func makeUIView(context: Context) -> some UIView {
let view = UILabel()
view.text = "hello world"
view.backgroundColor = .green
return view
}
}
Example 2: this will not work
struct SwiftUIView: View {
var body: some View {
VStack {
CustomLabel()
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct CustomLabel: UIViewRepresentable {
func updateUIView(_ uiView: UIViewType, context: Context) {
}
func makeUIView(context: Context) -> some UIView {
let view = CustomUIlabel()
view.backgroundColor = .green
return view
}
}
class CustomUIlabel: UIView {
init(){
super.init(frame: .zero)
let label = UILabel(frame: .zero)
label.text = "hello world"
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate(
[
label.topAnchor.constraint(equalTo: topAnchor),
label.leadingAnchor.constraint(equalTo: leadingAnchor),
label.trailingAnchor.constraint(equalTo: trailingAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor)
]
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
And when adding .fixedSize(horizontal: false, vertical: true) for second example the custom view will disappear.
and If I comment it, the view will take the whole screen.
Is there any solution to determine the size dynamically of a custom view that inherits from UIView?

How to dynamically resize text view inside a stack view

I'm trying to display a dynamically sized UITextView inside a stack view, but the text view is not adjusting to the size of the content.
First I have the arranged subview:
class InfoView: UIView {
private var title: String!
private var detail: String!
private var titleLabel: UILabel!
private var detailTextView: UITextView!
init(infoModel: InfoModel) {
self.title = infoModel.title
self.detail = infoModel.detail
super.init(frame: .zero)
configure()
setConstraint()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .rounded(ofSize: titleLabel.font.pointSize, weight: .bold)
titleLabel.textColor = .lightGray
titleLabel.sizeToFit()
titleLabel.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(titleLabel)
detailTextView = UITextView()
detailTextView.sizeToFit()
detailTextView.text = detail
detailTextView.font = UIFont.systemFont(ofSize: 19)
detailTextView.isEditable = false
detailTextView.textColor = .lightGray
detailTextView.isUserInteractionEnabled = false
detailTextView.isScrollEnabled = false
detailTextView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(detailTextView)
}
private func setConstraint() {
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: self.topAnchor),
titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 5),
titleLabel.heightAnchor.constraint(equalToConstant: 40),
detailTextView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
detailTextView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
detailTextView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
detailTextView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
}
}
Then I implement the stack view in a view controller:
class MyViewController: UIViewController {
var infoModelArr: [InfoModel]!
var stackView: UIStackView!
var scrollView: UIScrollView!
init(infoModelArr: [InfoModel]) {
self.infoModelArr = infoModelArr
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
var infoViewArr = [InfoView]()
for infoModel in infoModelArr {
let infoView = InfoView(infoModel: infoModel)
infoViewArr.append(infoView)
}
stackView = UIStackView(arrangedSubviews: infoViewArr)
stackView.axis = .vertical
stackView.spacing = 10
stackView.distribution = .fillProportionally
stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.contentSize = stackView.bounds.size
}
}
Finally, I call the view controller as following:
let myVC = MyViewController(infoModelArr: [InfoModel(title: "title", detail: "detail"), InfoModel(title: "title", detail: "detail")])
self.present(myVC, animated: true, completion: nil)
Notably, if I were to instantiate the stack view with a single arranged subview, the height of the stack view seems to be dynamically adjusted, but as soon as 2 or more subviews are introduced, the height doesn't reflect the content.
When I attempted to set the intrinsic size of the InfoView,
override func layoutSubviews() {
super.layoutSubviews()
height = titleLabel.bounds.height + detailTextView.bounds.height
}
var height: CGFloat! = 200 {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
let originalSize = super.intrinsicContentSize
return CGSize(width: originalSize.width, height: height)
}
detailTextView.bounds.height returns 0.
The fillProportionally distribution tries to scale the heights of the arranged subviews according to their intrinsic content size, as a proportion of of the stack view's height. e.g. if the stack view has a height of 120, and arranged subview A has an intrinsic height of 10, and arranged subview B has an intrinsic height of 20, then A and B will have a height of 40 and 80 respectively in the stack view.
Your stack view doesn't have a defined height, so fillProportionally doesn't make much sense here.
Instead, a distribution of fill should do the job:
stackView.distribution = .fill
(as an experiment, you can try adding a height constraint to the stack view, and you'll see how fillProportionally works)

Multiline label with UIViewRepresentable

I'm trying to use a component created with UIKit in SwiftUI by using UIViewRepresentable. The result I want to achieve is to have a textfield on top of my view and my UIKit view stacked at the bottom with an automatic height.
The problem is this component includes a multiline label and from what I see it makes it really hard to have an automatic height working properly, so my UIKit component takes all the available space in my VStack.
Expected result
Actual result
Here is my code included in a playground to test it. I tried to play with hugging priorities but nothing worked for me, and if I test with a swift UI view it works correctly.. Any ideas?
import UIKit
import Foundation
import SwiftUI
import PlaygroundSupport
class InformationView: UIView {
lazy var label = UILabel()
lazy var button = UIButton(type: .custom)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .red
addSubview(label)
addSubview(button)
label.text = "zaeiof azoeinf aozienf oaizenf oazeinf oaziefj oazeijf oaziejf aozijf oaizje foazeafjoj"
label.numberOfLines = 0
label.setContentHuggingPriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle("My button title", for: .normal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
addConstraints([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
label.trailingAnchor.constraint(equalTo: button.leadingAnchor, constant: -10),
button.trailingAnchor.constraint(equalTo: trailingAnchor),
button.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct InformationRepresenter: UIViewRepresentable {
func makeUIView(context: Context) -> InformationView {
let view = InformationView(frame: .zero)
view.setContentHuggingPriority(.required, for: .vertical)
return view
}
func updateUIView(_ uiView: InformationView, context: Context) {
}
}
struct Info: View {
var body: some View {
return HStack {
Text("zaeiof azoeinf aozienf oaizenf oazeinf oaziefj oazeijf oaziejf aozijf oaizje foazeafjoj")
Button("My button title", action: {
print("test")
})
}
}
}
struct ContentView: View {
#State var text = ""
var body: some View {
VStack {
TextField("Field", text: $text)
.padding()
Spacer()
// SwiftUI works
// Info().background(Color.red).padding()
// UIViewRepresentable doesn't
InformationRepresenter().padding()
}
}
}
PlaygroundPage.current.setLiveView(ContentView().frame(width: 400, height: 800, alignment: .top))
There's a few things going on here.
InformationView doesn't have an intrinsicContentSize. SwiftUI relies on this property to determine a UIView's ideal size.
No fixedSize modifier. This modifier forces a view to maintain its ideal size rather than grow to fill its parent.
You can't add an intrinsicContentSize because the content size is dynamic; it's based on the number of lines in your label.
You can't add the fixedSize modifier, because without an intrinsicContentSize, this will set the size to (0,0).
🐓🥚
One solution is to wrap InformationView in a UIView that measures the InformationView size, and updates its own intrinsicContentSize according to that measurement.
Your InformationView should fill the width of the screen; it doesn't have an intrinsic width. On the other hand, the intrinsic height should be equal to the height of the compressed system layout size. This is "the optimal size of the view based on its constraints".
Here is the wrapper:
class IntrinsicHeightView<ContentView: UIView>: UIView {
var contentView: ContentView
init(contentView: ContentView) {
self.contentView = contentView
super.init(frame: .zero)
backgroundColor = .clear
addSubview(contentView)
}
#available(*, unavailable) required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var contentHeight: CGFloat = .zero {
didSet { invalidateIntrinsicContentSize() }
}
override var intrinsicContentSize: CGSize {
.init(width: UIView.noIntrinsicMetric, height: contentHeight)
}
override var frame: CGRect {
didSet {
guard frame != oldValue else { return }
contentView.frame = self.bounds
contentView.layoutIfNeeded()
let targetSize = CGSize(width: frame.width, height: UIView.layoutFittingCompressedSize.height)
contentHeight = contentView.systemLayoutSizeFitting(
targetSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel).height
}
}
}
This IntrinsicHeightView is a generic UIView that, when its frame changes, recalculates the height of its intrinsicContentSize according to the compressed system layout height of its content view.
Here is the update to InformationRepresenter:
struct InformationRepresenter: UIViewRepresentable {
func makeUIView(context: Context) -> IntrinsicHeightView<InformationView> {
return IntrinsicHeightView(contentView: InformationView())
}
func updateUIView(_ uiView: IntrinsicHeightView<InformationView>, context: Context) {
}
}
And finally, when using it in SwiftUI:
InformationRepresenter()
.padding()
.fixedSize(horizontal: false, vertical: true)
Using the fixedSize modifier this way allows the UIView to fill the parent, but use intrinsicContentSize.height as its height. Here is the final result. Good luck!

Cannot make CustomView for UIViewRepresentable to work correctly in SwiftUI

I have this SwiftUI component
var body: some View {
let image = TypographyImage.image(for: typography, kind: kind)
let width = image.size.width + 4
let height = TypographyImage.height(for: typography) * (kind == .oneLine ? 1.0 : 2.0)
ScrollView(.horizontal) {
HStack {
Image(uiImage: image)
.frame(width: width, height: height, alignment: Alignment(horizontal: .center, vertical: .top))
.offset(y: TypographyImage.offset(for: typography))
.background(Color.red)
if isSwiftUI {
Text(text)
.typography(typography, theme: AmityTheme.shared)
.background(Color.red)
} else {
MyUILabel(text: text, typography: typography, theme: AmityTheme.shared).background(Color.red)
}
}
}
}
Which display this UI
Notice that in MyUILabel, I apply background red color.
The background color and alignment of MyUILabel inside HStack is display correctly when in MyUILabel I return a UILabel as first child view
As show below
struct MyUILabel: UIViewRepresentable {
var text: String
var typography: AmityTheme.Typography
var theme: AmityTheme
func makeUIView(context: Context) -> UILabel {
let view = UILabel(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
updateUIView(view, context: context)
return view
}
func updateUIView(_ view: UILabel, context: Context) {
view.attributedText = NSAttributedString(string: text, attributes: theme.textAttributes(for: typography, palette: .black))
}
}
However, if I change it to return my custom view like
struct MyUILabel: UIViewRepresentable {
var text: String
var typography: AmityTheme.Typography
var theme: AmityTheme
func makeUIView(context: Context) -> PaddedUILabel {
let view = PaddedUILabel(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
updateUIView(view, context: context)
return view
}
func updateUIView(_ view: PaddedUILabel, context: Context) {
view.update(text: text, typography: typography, theme: theme)
}
}
class PaddedUILabel: UIView {
private var label: UILabel!
private var topConstraint: NSLayoutConstraint!
private var bottomConstraint: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
let label = UILabel(frame: .zero)
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.setContentHuggingPriority(.defaultHigh, for: .vertical)
label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
self.label = label
addSubview(label)
topConstraint = label.topAnchor.constraint(equalTo: topAnchor)
bottomConstraint = label.bottomAnchor.constraint(equalTo: bottomAnchor)
let constraints: [NSLayoutConstraint] = [
label.leadingAnchor.constraint(equalTo: leadingAnchor),
label.trailingAnchor.constraint(equalTo: trailingAnchor),
topConstraint,
bottomConstraint
]
NSLayoutConstraint.activate(constraints)
}
func update(text: String, typography: AmityTheme.Typography, theme: AmityTheme) {
let attributes = theme.textAttributes(for: typography, palette: .black)
label.attributedText = NSAttributedString(string: text, attributes: attributes)
}
}
The result is
The background color is gone and HStack center vertical alignment is not respect. I can't seem to know why this happening.
Can anyone suggest me how to solve this?
Thanks
Seem like layout any constraint within CustomView and make it UIViewRepresentable will not work according to Here
In SwiftUI, parent asks child: what size do you want to be? Child tells parent, and parent sets size. Done.
AutoLayout however, needs more than one pass to determine the complete layout. It simply doesn't get that chance.
My rule-of-thumb for UIViewRepresentable is: keep it SUPER DUPER simple and only wrap a single view. More than one view is asking for layout problems.
Also this states that there is a limitation on how SwiftUI layout work which incompatible with vanilla layout constraint and intrinsincContentSize

Constrained UIButton frame is not updating to the buttons constrained position in Xcode storyboard

I have the following class that creates a UIButton subclass. I have set this up so the button automatically constrains itself to the superview and sets it's height.
#IBDesignable
class PrimaryButtonConstrained: UIButton {
#IBInspectable
var cornerRadius: CGFloat = 8 {
didSet {
setupPrimaryConstrainedButton()
}
}
#IBInspectable
var borderWidth: CGFloat = 0 {
didSet {
setupPrimaryConstrainedButton()
}
}
#IBInspectable
var topConstraint: CGFloat = 100 {
didSet {
layoutSubviews()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupPrimaryConstrainedButton()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupPrimaryConstrainedButton()
}
override class var requiresConstraintBasedLayout: Bool {
return true
}
override func layoutSubviews() {
super.layoutSubviews()
translatesAutoresizingMaskIntoConstraints = false
leadingAnchor.constraint(equalTo: superview!.layoutMarginsGuide.leadingAnchor).isActive = true
trailingAnchor.constraint(equalTo: superview!.layoutMarginsGuide.trailingAnchor).isActive = true
heightAnchor.constraint(equalToConstant: 50).isActive = true
topAnchor.constraint(equalTo: superview!.topAnchor, constant: topConstraint).isActive = true
}
func setupPrimaryConstrainedButton() {
setTitleColor(.white, for: .normal)
setTitleColor(.gray, for: .disabled)
setTitleColor(.orange, for: .highlighted)
layer.borderWidth = borderWidth
layer.cornerRadius = cornerRadius
if isEnabled {
backgroundColor = .orange
} else {
backgroundColor = .gray
}
}
override public func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
setupPrimaryConstrainedButton()
}
}
The button constraints itself correctly in interface builder when you set the button class, however the frame of the button does not update to the buttons new location as shown.
Does anyone have any ideas on how to fix the frame so it still encloses the button?

Resources