Determine UIViewRepresentable with a custom UIView size dynamically - ios

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?

Related

UIViewRepresentable ignores constraints of wrapped UIView

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:

How to use UIScrollView to scroll to the top of the first view?

I currently have a UIScrollView with a UIImageView (top image) positioned below the UINavigationBar. However, I want to position the UIImageView at the very top of the screen (bottom image). Is there a way to implement this?
What I've tried so far: I added a UIScrollView extension (source) that is supposed to scroll down to the view parameter provided, but it hasn't worked for me.
extension UIScrollView {
// Scroll to a specific view so that it's top is at the top our scrollview
func scrollToView(view:UIView, animated: Bool) {
if let origin = view.superview {
// Get the Y position of your child view
let childStartPoint = origin.convert(view.frame.origin, to: self)
// Scroll to a rectangle starting at the Y of your subview, with a height of the scrollview
self.scrollRectToVisible(CGRect(x:0, y:childStartPoint.y,width: 1,height: self.frame.height), animated: animated)
}
}
// Bonus: Scroll to top
func scrollToTop(animated: Bool) {
let topOffset = CGPoint(x: 0, y: -contentInset.top)
setContentOffset(topOffset, animated: animated)
}
// Bonus: Scroll to bottom
func scrollToBottom() {
let bottomOffset = CGPoint(x: 0, y: contentSize.height - bounds.size.height + contentInset.bottom)
if(bottomOffset.y > 0) {
setContentOffset(bottomOffset, animated: true)
}
}
}
class MealDetailsVC: UIViewController {
private var mealInfo: MealInfo
init(mealInfo: MealInfo) {
self.mealInfo = mealInfo
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
scrollView.scrollToView(view: iv, animated: false) // used extension from above
}
lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView()
return scrollView
}()
lazy var iv: UIImageView = {
let iv = UIImageView()
iv.image = Image.defaultMealImage!
iv.contentMode = .scaleAspectFill
return iv
}()
}
extension MealDetailsVC {
func setupViews() {
addBackButton()
addSubviews()
autoLayoutViews()
constrainSubviews()
}
fileprivate func addBackButton() {
...
}
#objc func goBack(sender: UIBarButtonItem) {
...
}
fileprivate func addSubviews() {
view.addSubview(scrollView)
scrollView.addSubview(iv)
}
fileprivate func autoLayoutViews() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
iv.translatesAutoresizingMaskIntoConstraints = false
}
fileprivate func constrainSubviews() {
NSLayoutConstraint.activate([
scrollView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
scrollView.widthAnchor.constraint(equalTo: view.widthAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
NSLayoutConstraint.activate([
iv.topAnchor.constraint(equalTo: scrollView.topAnchor),
iv.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
iv.heightAnchor.constraint(equalTo: iv.widthAnchor, multiplier: 0.6)
])
}
}
This may help.
scrollView.contentInsetAdjustmentBehavior = .never
For more information,
This property specifies how the safe area insets are used to modify the content area of the scroll view. The default value of this property is UIScrollViewContentInsetAdjustmentAutomatic.
Don't set a height anchor and add a imageView centerXAnchor equal to ScrollView centerXAnchor constraint. set imageView.contentMode to .scaleAspectFit

Self sizing table view cells based on ui stack view content

i want to create cells with stack view in it. For different cells there would be different amount of arranged subviews in stack view.
Currently i create something like this but self sizing isn't working.
Table view initialization:
let tableView: UITableView = {
let tableView = UITableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 200
return tableView
}()
Table view cell:
import UIKit
final class CustomTableViewCell: UITableViewCell {
private let stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 8
stackView.distribution = .equalSpacing
stackView.alignment = .fill
return stackView
}()
// initializations...
private func setup() {
backgroundColor = .green
addSubviewsWithConstraints([stackView])
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
stackView.addArrangedSubviews([CustomView(), CustomView()])
// custom views will be added later depends on model
}
}
Custom view:
final class CustomView: UIView {
private let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(systemName: "heart.fill")
return imageView
}()
//MARK: - Override
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
private func setup() {
addSubviewsWithConstraints([iconImageView])
NSLayoutConstraint.activate([
iconImageView.topAnchor.constraint(equalTo: topAnchor, constant: 6),
iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
iconImageView.heightAnchor.constraint(equalToConstant: 80),
iconImageView.widthAnchor.constraint(equalToConstant: 80)
])
}
}
To add subviews i'm using custom extension to uiview:
func addSubviewWithConstraints(_ view: UIView) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
}
func addSubviewsWithConstraints(_ views: [UIView]) {
views.forEach {
addSubviewWithConstraints($0)
}
}
Result looks like this (using 2 cells with 2 custom views in stack views):
Your addSubviewsWithConstraint is adding views as a subview of the cell not as a subview of the contentView. Cells use the contentView for self sizing. As addSubviewsWithConstraint is an extension of UIView you should be able to do the following anywhere you want to add a subview to a cell.
contentView.addSubviewWithConstraints(...)
Just another thing that may be useful is that you can shorten your functions down by using variadic arguments like so:
func addSubviewWithConstraints(_ views: UIView...) {
views.forEach {
addSubviewWithConstraints($0)
}
}
Usage
addSubviewWithConstraints(mySubview)
addSubviewWithConstraints(mySubview, mySubview2, mySubview3)

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

Resources