Set custom UIView frame in UIViewRepresentable SwiftUI - swift

I'm trying to use my custom UIView in SwiftUI using UIViewRepresentable and I want my UIView to have the same size as I set in .frame() so that I can use it like this:
MyViewRepresentable()
.frame(width: 400, height: 250, alignment: .center)
For example, I can set a frame as a property:
struct MyViewRepresentable: UIViewRepresentable {
var frame: CGRect
func makeUIView(context: Context) -> UIView {
let myView = MyView(frame: frame)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
Usage:
struct ContentView: View {
var body: some View {
MyViewRepresentable(frame: CGRect(x: 0, y: 0, width: 400, height: 250))
.frame(width: 400, height: 250, alignment: .center)
}
}
It is not a solution and I wonder how to make it right.

If MyView has correct internal layout (which depends only on own bounds), then there is not needs in external additional limitation, ie
struct MyViewRepresentable: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
return MyView(frame: .zero)
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
will be exactly sized below having 400x250 frame
struct ContentView: View {
var body: some View {
MyViewRepresentable()
.frame(width: 400, height: 250, alignment: .center)
}
}
if it is not then internal MyView layout has defects.

If Asperi's answer did not work out for you, then it's probably as they said: the internal MyView layout has defects.
To resolve this matter, you have a couple options:
Option A. Use AutoLayout Constraints within viewDidLoad
override func viewDidLoad() {
super.viewDidLoad()
// 1. View Hierarchy
self.addChild(self.mySubview)
self.view.addSubview(self.mySubview.view)
self.mySubview.didMove(toParent: self)
// 2. View AutoLayout Constraints
self.mySubview.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: self.mySubview.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: self.mySubview.view.trailingAnchor),
view.topAnchor.constraint(equalTo: self.mySubview.view.topAnchor),
view.bottomAnchor.constraint(equalTo: self.mySubview.view.bottomAnchor)
])
}
Option B. Set frame manually within viewDidLayoutSubviews
Simply within your UIViewController, set subviews frames in viewDidLayoutSubviews.
override func viewDidLoad() {
super.viewDidLoad()
// 1. Add your subviews once in `viewDidLoad`
self.addChild(self.mySubview)
self.view.addSubview(self.mySubview.view)
self.mySubview.didMove(toParent: self)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 2. Layout your subviews `viewDidLayoutSubviews`
// Update subview frame size
// Must be done in `viewDidLayoutSubviews`
// Otherwise in `viewDidLoad` the bounds equal `UIScreen.main.bounds`, even if you used explicit frame within SwiftUI and used GeometryReader to pass the `CGSize` yourself to this UIViewController!
let mySubviewFrame = self.view.bounds
self.mySubview.view.frame = mySubviewFrame
}
Supplementary Resources
Basically you have multiple layout methods in iOS. Here they are ordered from oldest/worst to newest/best. This ordering is opinionated of course:
Frame-Based Layout. Manually manipulate frame and bounds properties on the UIView.
Auto-Resizing Masks. Under the hood, an autoResizingMask uses archaic springs and struts. See autoResizingMask, this answer, and this article.
AutoLayout Constraints.
AutoLayout StackView.
SwiftUI's VStack and HStack! This is only for SwiftUI and all the above applies to UIKit only.
Probably should have made a small dev article out of this.

Related

Using UIScrollView correctly in SwiftUI

I wanted to implement pull to refresh for Scrollview of SwiftUi and so I tried to use the UIScrollView of UIKit to make use of its refreshControl. But I am getting a problem with the UIScrollview. There are several different views inside the scrollview and all of its data is fetched from network service using different API call and once the first api data is received the first view is shown inside the scrollview, similarly when second api data is received the second view is appended to the scrollview and similarly step by step all views are added to scrollview. Now in this process of step by step adding view, the scrollview doesn't get scrolled and its contents remain hiding above the navigationbar and below the bottom toolbar (may be the scrollView height takes full height of contents). But if i load the scrollView only after all the subview data is ready all subviews are loaded together then there is no issue. But i want to load the subviews as soon as i get the first data without waiting for all the data of every subviews to load completely.
The code that I have used is -
//UIScrollView
struct HomeScrollView: UIViewRepresentable {
private let uiScrollView: UIScrollView
init<Content: View>(#ViewBuilder content: () -> Content) {
let hosting = UIHostingController(rootView: content())
hosting.view.translatesAutoresizingMaskIntoConstraints = false
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
uiScrollView.showsVerticalScrollIndicator = false
uiScrollView.contentInsetAdjustmentBehavior = .never
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor, constant: 0),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor, constant: 0),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
}
func makeCoordinator() -> Coordinator {
Coordinator(legacyScrollView: self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.refreshControl = UIRefreshControl()
uiScrollView.refreshControl?.addTarget(context.coordinator, action:
#selector(Coordinator.handleRefreshControl), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
class Coordinator: NSObject {
let legacyScrollView: HomeScrollView
init(legacyScrollView: HomeScrollView) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
refreshData()
}
}
}
//SwiftUi inside view's body
var body: some View {
VStack {
HomeScrollView() {
ViewOne(data: modelDataOne)
ViewTwo(data: modelDataTwo)
ViewThree(data: modelDataThree)
...
}
}
}

SwiftUI View and UIHostingController in UIScrollView breaks scrolling

When I add a UIHostingController which contains a SwiftUI view as a childView, and then place that childView inside a UIScrollView, scrolling breaks.
Here I have my View
struct TestHeightView: View {
let color: UIColor
var body: some View {
VStack {
Text("THIS IS MY TEST")
.frame(height: 90)
}
.fixedSize(horizontal: false, vertical: true)
.background(Color(color))
.edgesIgnoringSafeArea(.all)
}
}
Then I have a UIViewController with a UIScrollView as the subView. Inside the UIScrollView there is a UIStackView that is correctly setup to allow loading UIViews and scrolling through them if the stack height becomes great enough. This works. If I were to load in 40 UILabels, it would scroll through them perfectly.
The problem arises when I add a plain old UIView, and then add a UIHostingController inside that container. I do so like this:
let container = UIView()
container.backgroundColor = color.0
stackView.insertArrangedSubview(container, at: 0)
let test = TestHeightView(color: color.1)
let vc = UIHostingController(rootView: test)
vc.view.backgroundColor = .clear
add(child: vc, in: container)
func add(child: UIViewController, in container: UIView) {
addChild(child)
container.addSubview(child.view)
child.view.translatesAutoresizingMaskIntoConstraints = false
child.view.topAnchor.constraint(equalTo: container.topAnchor, constant: 0).isActive = true
child.view.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 0).isActive = true
child.view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0).isActive = true
child.view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0).isActive = true
child.didMove(toParent: self)
}
In my example I added 3 of these containerViews/UIHostingController and then one UIView (green) to demonstrate what is happening.
You can see that as I scroll, all views are suspended as a gap is formed. What is happening is that the containing UIView (light color) is expanding its height. Once the height reaches a certain value, scrolling continues as normal until the next container/UIHostingController reaches the top and it begins again.
I have worked on several different solutions
.edgesIgnoringSafeArea(.all)
Does do something. I included it in my example because without it, the problem is exactly the same only more jarring and harder to explain using a video. Basically the same thing happens but without any animation, it just appears that the UIScrollView has stopped working, and then it works again
Edit:
I added another UIViewController just to make sure it wasn't children in general causing the issue. Nope. Only UIHostingControllers do this. Something in SwiftUI
Unbelievably this is the only answer I can come up with:
I found it on Twitter here https://twitter.com/b3ll/status/1193747288302075906?s=20 by Adam Bell
class EMHostingController<Content> : UIHostingController<Content> where Content : View {
func fixedSafeAreaInsets() {
guard let _class = view?.classForCoder else { return }
let safeAreaInsets: #convention(block) (AnyObject) -> UIEdgeInsets = { (sself : AnyObject!) -> UIEdgeInsets in
return .zero
}
guard let method = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaInsets)) else { return }
class_replaceMethod(_class, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
let safeAreaLayoutGuide: #convention(block) (AnyObject) ->UILayoutGuide? = { (sself: AnyObject!) -> UILayoutGuide? in
return nil
}
guard let method2 = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaLayoutGuide)) else { return }
class_replaceMethod(_class, #selector(getter: UIView.safeAreaLayoutGuide), imp_implementationWithBlock(safeAreaLayoutGuide), method_getTypeEncoding(method2))
}
override var prefersStatusBarHidden: Bool {
return true
}
}
Had the same issue recently, also confirm that safe area insets are breaking the scrolling. My fix on iOS 14+ with the ignoresSafeArea modifier:
public var body: some View {
if #available(iOS 14.0, *) {
contentView
.ignoresSafeArea()
} else {
contentView
}
}
I had a very similar issue and found a fix by adding the following to my UIHostingController subclass:
override func viewDidLoad() {
super.viewDidLoad()
edgesForExtendedLayout = []
}

Magnifier SwiftUI

I'm trying to render 2 SwiftUI views, in a VStack.
struct ContentView: View {
let content = // [...]
let magnifier = // [...]
var body: some View {
VStack {
self.content
self.magnifier
}
}
}
The first view (i.e., content) is a TextView, the second view (i.e., magnifier) is just a magnifier to the first view with a given scale and offset. This is basically the "zoom" functionality of some notes apps, Notability for example.
With UIKit I would have write a class Magnifier: UIView, and in its draw() function, something like
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
// [...]
self.contentView.layer.render(in: context)
}
Can we do something like that with SwiftUI or do we have to use UIKit and UIViewRepresentable?

UIViewRepresentable and its content's intrinsic size

I've created a UIViewRepresentable for UIVisualEffectView in order to make certain components vibrant. This works, however it seems to shrink controls vertically at times or just at random alter their bounds at runtime. I can't seem to make it work reliably. I need this to work with any SwiftUI content or even other UIViewRepresentable used in place of content. Wrapping the UIVisualEffectView inside of a UIView and using auto layout seems to help, but other controls (such as a custom UILabel wrapped inside of a UIViewRepresnetable gets vertically clipped).
public struct VibrantView<Content: View>: UIViewRepresentable {
private let content: UIView!
private let vibrancyBlurEffectStyle: UIBlurEffect.Style
init(vibrancyBlurEffectStyle: UIBlurEffect.Style, #ViewBuilder content: () -> Content) {
self.content = UIHostingController(rootView: content()).view
self.vibrancyBlurEffectStyle = vibrancyBlurEffectStyle
}
public func makeUIView(context: Context) -> UIView {
let containerView = UIView()
let blurEffect = UIBlurEffect(style: vibrancyBlurEffectStyle)
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect)
let blurView = UIVisualEffectView(effect: vibrancyEffect)
blurView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(blurView)
content.backgroundColor = .clear
content.translatesAutoresizingMaskIntoConstraints = false
blurView.contentView.addSubview(content)
NSLayoutConstraint.activate([
blurView.widthAnchor.constraint(equalTo: containerView.widthAnchor),
blurView.heightAnchor.constraint(equalTo: containerView.heightAnchor),
content.widthAnchor.constraint(equalTo: blurView.widthAnchor),
content.heightAnchor.constraint(equalTo: blurView.heightAnchor),
])
content.setContentHuggingPriority(.defaultLow, for: .vertical)
content.setContentHuggingPriority(.defaultLow, for: .horizontal)
content.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
content.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
blurView.setContentHuggingPriority(.defaultLow, for: .vertical)
blurView.setContentHuggingPriority(.defaultLow, for: .horizontal)
blurView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
blurView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
return containerView
}
public func updateUIView(_ uiView: UIView, context: Context) {
}
}
Used as:
...
VibrantView(vibrancyBlurEffectStyle: .dark) {
Text("Hello")
.foregroundColor(Color.gray)
}
When run on device with other views inside of a VStack, you'll see "Hello" clipped partially from the bottom. In Preview, you'll see a much larger blue rectangle (bounds) around "Hello", whereas I'd like this to be hugging the content. The VStack does not assume the full natural height of the overall view.
Using fixedSize() doesn't work and it produces even weirder results when used with other controls.
After trying various techniques and hacks - I simply could not get the UIKit container (i.e. VibrantView) to hug its SwiftUI contents reliably, without adding a fixed sized .frame(...) modifier on top - which makes it difficult to use this with dynamically sized Text.
What did work for me was a bit of a hack and probably won't work for every generic view out there (and probably won't scale well for dozens of views), but works well for simple use cases, especially if you're hosting this inside of a dynamically sized UITableViewCell.
The idea is to use a dummy version of the same view, and set the VibrantView in an .overlay( ... ). This will force the overlay to assume the same overall size of the parent SwitfUI View. Since the view being applied the modifier is a copy of the same view that VibrantView wraps, you end up with the correct dynamic size at runtime and in Xcode previews.
So something like this:
SomeView()
.frame(minWidth: 0, maxWidth: .infinity)
.overlay(
VibrantView(vibrancyBlurEffectStyle: .dark) {
SomeView()
.frame(minWidth: 0, maxWidth: .infinity)
}
)
I can imagine turning this into a modifier so that it wraps the above in a single call, but in my case to ensure it remains performant for Images, I'm doing something like this:
Circle()
.foregroundColor(.clear)
.frame(width: 33, height: 33)
.overlay(
VibrantView(vibrancyBlurEffectStyle: .systemMaterialDark) {
Image("some image")
.resizable()
}
)
Creating a Circle is arguably lighter weight compared to the actual image. I create a transparent circle, set the actual size of the image there, and then put the VibrantView container into the overlay.
why so complicated?
try this:
struct Blur: UIViewRepresentable {
#if os(iOS)
var style: UIBlurEffect.Style = .systemMaterial
#else
var style: UIBlurEffect.Style = .light
#endif
init(_ style: UIBlurEffect.Style = .dark) {
self.style = style
}
func makeUIView(context: Context) -> UIVisualEffectView {
return UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
and use:
Text("Great text with prominent")
.font(.largeTitle)
.padding()
.background(Blur(.prominent))
in general you should not use constraints in UIViewRepresentable -> the size will be defined by parent view like Text in this example or VStack, who gives the right size to the blur. Normally a blur is not standing alone but the typical is a blurred background because you want to put text on it so you can read the text better.

How to move or resize an NSView by setting the frame property?

I created the NSView in a storyboard. In this case, it is an NSTextField. In the NSViewController's viewDidLoad() method, I want to conditionally resize and reposition the NSTextField, but setting the frame has no effect.
For example:
class ViewController: NSViewController {
#IBOutlet var label: NSTextField!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
label.frame = NSRect(x: 0, y: 0, width: 200, height: 17)
label.setNeedsDisplay()
}
}
When the view loads, the label still has its original frame as set in interface builder, and not the newly set frame.
How does one programmatically move/resize the label?
The autolayout system is the culprit here. When you set the frame, the autolayout system overrides that to re-establish the implicit constraints set in the storyboard.
Set the translatesAutoresizingMaskIntoConstraints property of the label to true. This tells the autolayout system that it should create a new set of autolayout constraints that satisfy the new frame you've set:
class ViewController: NSViewController {
#IBOutlet var label: NSTextField!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
label.frame = NSRect(x: 0, y: 0, width: 200, height: 17)
label.translatesAutoresizingMaskIntoConstraints = true
label.setNeedsDisplay()
}
}