SwiftUI View and UIHostingController in UIScrollView breaks scrolling - swift

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 = []
}

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)
...
}
}
}

Swift: Make the background image of UITableViewController embedded in Navigation Controller fill the entire screen

I managed to create translucent and rounded UITableViewCells in a UITableViewController that is embedded inside a Navigation Controller with this line of code in viewDidLoad():
tableView.backgroundView = UIImageView(image: UIImage(named: "nightTokyo"))
But I want the background image to fill the entire phone screen. I changed the code (and only this line of code) to:
navigationController?.view = UIImageView(image: UIImage(named: "nightTokyo"))
Now the background image fills up the entire phone screen, but my table and even the iPhone's time and battery indicator icons are missing.
What I want is for the background image to fill the entire screen, but the tableView, its cells, the iPhone time, battery level icon, etc. to remain displayed.
navigationController?.setNavigationBarHidden(true, animated: true)
Here is what I did which worked for me using Swift 5, XCode 12.
Step 1 (Optional) - Create a custom UINavigationController class
class CustomNavigationController: UINavigationController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationBar.isTranslucent = true
}
Replace your UINavigationController with this UINavigationController subclass. I mark this as optional as this is based on preference, if you do not set this, your navigation bar will be opaque and you cannot see what's beneath it.
Setting the navigationBar.isTranslucent = true allows you to see the background beneath it which is what I like. A subclass is also optional but you might need to make other updates to your nav bar so I always like to make this a subclass.
Step 2 - Set up your background view constraints
class CustomViewController: UIViewController {
// your background view
let bgImageView: UIImageView = {
let bgImageView = UIImageView()
bgImageView.image = UIImage(named: "gradient_background")
bgImageView.contentMode = .scaleAspectFill
return bgImageView
}()
// Get the height of the nav bar and the status bar so you
// know how far up your background needs to go
var topBarHeight: CGFloat {
var top = self.navigationController?.navigationBar.frame.height ?? 0.0
if #available(iOS 13.0, *) {
top += UIApplication.shared.windows.first?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
} else {
top += UIApplication.shared.statusBarFrame.height
}
return top
}
var isLayoutConfigured = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
title = "Site Visit"
// you only want to do this once
if !isLayoutConfigured() {
isLayoutConfigured = true
configBackground()
}
}
private func configBackground() {
view.addSubview(bgImageView)
configureBackgroundConstraints()
}
// Set up your constraints, main one here is the top constraint
private func configureBackgroundConstraints() {
bgImageView.translatesAutoresizingMaskIntoConstraints = false
bgImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
constant: -topBarHeight).isActive = true
bgImageView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor,
constant: 0).isActive = true
bgImageView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: 0).isActive = true
bgImageView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: 0).isActive = true
view.layoutIfNeeded()
}
Before setting constraints:
After setting above constraints:

How can I centre a view when using a large nav title?

I have a search view controller, I'd like to show a loading indicator in the centre of the screen, but as I'm using large title navigation, it appears to be offset but the height of the large nav?
How can I offset this so it is in the true centre of the screen?
I am setting it currently using
tableView.addSubview(searchingView)
searchingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor),
searchingView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor)
])
My view controller is below
final class SearchViewController: UITableViewController {
private var searchLoadingController: SearchLoadingController?
private var searchController: UISearchController?
convenience init(searchLoadingController: SearchLoadingController, searchController: UISearchController) {
self.init(nibName: nil, bundle: nil)
self.searchLoadingController = searchLoadingController
self.searchController = searchController
}
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
}
}
extension SearchViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text, !text.isEmpty else { return }
searchLoadingController?.search(query: text)
}
}
extension SearchViewController: SearchErrorView {
func display(_ viewModel: SearchErrorViewModel) { }
}
private extension SearchViewController {
func configureUI() {
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.searchController = searchController
tableView.tableFooterView = UIView(frame: .zero)
searchController?.searchBar.searchBarStyle = .minimal
searchController?.searchResultsUpdater = self
searchController?.obscuresBackgroundDuringPresentation = false
if let searchingView = searchLoadingController?.view {
tableView.addSubview(searchingView)
searchingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor),
searchingView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor)
])
}
}
}
If you really want the activity indicator in the center of the screen you need to do some calculations:
func addActivityIndicatorToCenterOfScreen() {
let screenHeight = UIScreen.main.bounds.size.height
let activityIndicatorHeight = searchingView.bounds.size.height
let safeAreaBottomInset = tableView.safeAreaInsets.bottom
let yOffset = (screenHeight - activityIndicatorHeight) / 2 - safeAreaBottomInset
tableView.addSubview(searchingView)
searchingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor),
searchingView.bottomAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.bottomAnchor, constant: -yOffset),
])
}
Also ensure that you configure the constraints after your views are laid out. viewDidAppear is a good place for that. This is necessary because we need to know the size of the tableView and the safe area.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
addActivityIndicatorToCenterOfScreen()
}
Now for some explanation. tableView.safeAreaLayoutGuide includes the portion of the tableView between the navigation bar and the notch at the bottom of the screen. tableView.safeAreaInsets.bottom gives us the height of the notch at the bottom of the screen. The yOffset is the center of the screen minus half the height of the activity indicator minus the height of notch of the bottom of the screen. We will offset the bottom of the activity indicator from the bottom of the safe area. That will place the center of the activity indicator at the center of the screen.
If I were you, I would center the activity indicator between the navigation bar and the top of the notch. It's way cleaner and you can do this in viewDidLoad:
func addActivityIndicatorCenteredBetweenNavBarAndSafeAreaBottom() {
tableView.addSubview(searchingView)
searchingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingView.centerXAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.centerXAnchor),
searchingView.centerYAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.centerYAnchor),
])
}

UISearchBar not getting centered when pinned below a UINavigationBar

I have this code below who configures the constraints of the view:
func configNavigationBarConstraints() {
navigationBar.autoPinEdge(toSuperviewEdge: .leading)
navigationBar.autoPinEdge(toSuperviewEdge: .trailing)
if #available(iOS 11.0, *) {
navigationBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
return
}
navigationBar.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
}
func configSearchBarConstraints() {
searchBar.autoPinEdge(.top, to: .bottom, of: navigationBar)
// taskHistorySearchBar.autoPinEdge(.top, to: .bottom, of: navigationBar, withOffset: 6) //This is a solution but no ideal, the height diff varies by device
searchBar.autoPinEdge(toSuperviewEdge: .left)
searchBar.autoPinEdge(toSuperviewEdge: .right)
}
This results into this view:
Note that the UISearchBar is not aligned, any idea of what I can do? Even if I set it to have a high height value by constraint, the center is offset.
You probably set the UINavigationBar's delegate and UISearchBar's delegate, both extends UIBarPositioningDelegate, so you need to do this:
extension ViewController: UINavigationBarDelegate {
func position(for bar: UIBarPositioning) -> UIBarPosition {
if bar is UINavigationBar {
return .topAttached
}
return .any
}
}
This will only apply the .topAttached setting for the UINavigationBar.

How to make an Xcode playground rotate to landscape

I am trying to test a landscape view, but so far I cannot make progress. My code looks like this:
import UIKit
import PlaygroundSupport
class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .white
let label = UILabel()
label.text = "Hallo"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
self.view = view
NSLayoutConstraint.activate([label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor)])
simulateDeviceRotation(toOrientation: .landscapeLeft)
}
override func viewDidAppear(_ animated: Bool) {
//simulateDeviceRotation(toOrientation: .landscapeLeft)
}
}
// Present the view controller in the Live View window
func simulateDeviceRotation(toOrientation orientation: UIDeviceOrientation) {
let orientationValue = NSNumber(value: orientation.rawValue)
UIDevice.current.setValue(orientationValue, forKey: "orientation")
}
PlaygroundPage.current.liveView = MyViewController()
When I uncomment the call to simulateDeviceRotation(toOrientation:) in loadView(), the result is:
Rotation in loadView()
And, when I uncomment simulateDeviceRotation(toOrientation:) in viewDidAppear(_:), the result is:
rotation in viewDidAppear(_:)
Of course, I would like to see the second result, but with the horizontal rotation of the first. Can you please point me in the right direction? I am missing something but I have not been able to finde it.
Don't mess with orientation, you won't succeed.
Change the last line of your code to:
var myViewController = MyViewController()
myViewController.preferredContentSize = CGSize(width: 668, height: 375)
PlaygroundPage.current.liveView = myViewController
and remove everything that handles with device orientation in loadView, viewDidAppear and remove method simulateDeviceRotation.
Update
If you set an arbitrary value as preferredContentSize, you will get into problems with constraints or worse like the view is displaced in the live view.
What I did: first read the default values of current content size:
print(myViewController.preferredContentSize)
This was 375.0 as width and 668.0 as height for me.
So just swap this values for the new preferredContentSize and everything should be fine.