UIKit layouts subviews even though it was not asked to - ios

I was messing with layoutSubviews method in UIViewControllers. I assumed that when you override layoutSubviews on the view, it doesn't layout its subviews, but that wasn't the case, the view and its subviews were correctly laid out.
class Vieww: UIView {
override func didMoveToSuperview() {
super.didMoveToSuperview()
let centered = UIView()
addSubview(centered)
centered.translatesAutoresizingMaskIntoConstraints = false
backgroundColor = .green
centered.backgroundColor = .red
centered.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1/2).isActive = true
centered.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 1/2).isActive = true
centered.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
centered.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
}
override func layoutSubviews() {
print("layoutSubviews")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.layout(Vieww()).center().leading(20).trailing(20).height(400)
}
}
I expected that because I was not calling layoutSubviews, my views wouldn't be laid out, but I get layout like if I did override this method.

Read the docs: https://developer.apple.com/documentation/uikit/uiview/1622482-layoutsubviews
"the default implementation uses any constraints you have set to determine the size and position of any subviews. Subclasses can override this method as needed to perform more precise layout of their subviews."

Related

Show fixed progress below navigation bar between different view controllers

I have a long register form that consists of 4 steps (the content is not relevant), here are the mockups:
My problem is that I need to share a progress view between multiple views. This view should have an animation of growth. What would be the right and clean way to do this with UIKit? Should I create a custom Navigation Bar with that progress? Or use child controllers in some way?
I've been searching over here but the other questions I found are very old (like 7 years ago) and I don't know if there could be better solutions.
Thanks a lot!
There are various ways to do this...
One common approach is to set the "progress view" as the navigation bar's Title View -- but that won't show it below the navigation bar.
So, another approach is to subclass UINavigationController and add a "progress view" as a subview. Then, implement willShow viewController and/or didShow viewController to update the progress.
As a quick example, assuming we have 4 "steps" to navigate to...
We'll start with defining a "base" view controller, with two properties that our custom nav controller class will use:
class MyBaseVC: UIViewController {
// this will be read by ProgressNavController
// to calculate the "progress percentage"
public let numSteps: Int = 4
// this will be set by each MyBaseVC subclass,
// and will be read by ProgressNavController
public var myStepNumber: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
// maybe some stuff common to the "step" controllers
}
}
Then, each "step" controller will be a subclass of MyBaseVC, and will set its "step number" (along with anything else specific to that controller):
class Step1VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 1
// maybe some other stuff specific to this "step"
}
}
class Step2VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 2
// maybe some other stuff specific to this "step"
}
}
class Step3VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 3
// maybe some other stuff specific to this "step"
}
}
class Step4VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
myStepNumber = 4
// maybe some other stuff specific to this "step"
}
}
Then we can setup our custom nav controller class like this (it's not really as complicated as it may look):
class ProgressNavController: UINavigationController, UINavigationControllerDelegate {
private let outerView = UIView()
private let innerView = UIView()
private var pctConstraint: NSLayoutConstraint!
override init(rootViewController: UIViewController) {
super.init(rootViewController: rootViewController)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
// for this example, we're using a simple
// green view inside a red view
// as our "progress view"
// we set it up here, but we don't add it as a subview
// until we navigate to a MyBaseVC
// we know we're setting
// outerView height to 20
// innerView height to 12 (4-points top/bottom "padding")
// so let's round the ends of the innerView
innerView.layer.cornerRadius = 8.0
outerView.backgroundColor = .systemRed
innerView.backgroundColor = .systemGreen
outerView.translatesAutoresizingMaskIntoConstraints = false
innerView.translatesAutoresizingMaskIntoConstraints = false
outerView.addSubview(innerView)
// initialize pctConstraint
pctConstraint = innerView.widthAnchor.constraint(equalTo: outerView.widthAnchor, multiplier: .leastNonzeroMagnitude)
NSLayoutConstraint.activate([
innerView.topAnchor.constraint(equalTo: outerView.topAnchor, constant: 4.0),
innerView.leadingAnchor.constraint(equalTo: outerView.leadingAnchor, constant: 4.0),
innerView.bottomAnchor.constraint(equalTo: outerView.bottomAnchor, constant: -4.0),
pctConstraint,
])
self.delegate = self
}
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// if the next VC to show
// is a MyBaseVC subclass
if let _ = viewController as? MyBaseVC {
// add the "progess view" if we're coming from a non-MyBaseVC controller
if outerView.superview == nil {
view.addSubview(outerView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
outerView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 4.0),
outerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
outerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
outerView.heightAnchor.constraint(equalToConstant: 20.0),
])
// .alpha to Zero so we can "fade it in"
outerView.alpha = 0.0
// we just added the progress view,
// so we'll let didShow "fade it in"
// and update the progress width
} else {
self.updateProgress(viewController)
}
} else {
if outerView.superview != nil {
// we want to quickly "fade-out" and remove the "progress view"
// if the next VC to show
// is NOT a MyBaseVC subclass
UIView.animate(withDuration: 0.1, animations: {
self.outerView.alpha = 0.0
}, completion: { _ in
self.outerView.removeFromSuperview()
self.pctConstraint.isActive = false
self.pctConstraint = self.innerView.widthAnchor.constraint(equalTo: self.outerView.widthAnchor, multiplier: .leastNonzeroMagnitude)
self.pctConstraint.isActive = true
})
}
}
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
// if the VC just shown
// is a MyBaseVC subclass
// AND
// outerView.alpha < 1.0 (meaning it was just added)
if let _ = viewController as? MyBaseVC, outerView.alpha < 1.0 {
self.updateProgress(viewController)
}
// otherwise, updateProgress() is called from willShow
}
private func updateProgress(_ viewController: UIViewController) {
if let vc = viewController as? MyBaseVC {
// update the innerView width -- the "progress"
let nSteps: CGFloat = CGFloat(vc.numSteps)
let thisStep: CGFloat = CGFloat(vc.myStepNumber)
var pct: CGFloat = .leastNonzeroMagnitude
// sanity check
// avoid error/crash if either values are Zero
if nSteps > 0.0, thisStep > 0.0 {
pct = thisStep / nSteps
}
// don't exceed 100%
pct = min(pct, 1.0)
// we can't update the multiplier directly, so
// deactivate / update / activate
self.pctConstraint.isActive = false
self.pctConstraint = self.innerView.widthAnchor.constraint(equalTo: self.outerView.widthAnchor, multiplier: pct, constant: -8.0)
self.pctConstraint.isActive = true
// if .alpha is already 1.0, this is effectively ignored
UIView.animate(withDuration: 0.1, animations: {
self.outerView.alpha = 1.0
})
// animate the "bar width"
UIView.animate(withDuration: 0.3, animations: {
self.outerView.layoutIfNeeded()
})
}
}
}
So, when we navigate to a new controller:
we check to see if it is an instance of MyBaseVC
if Yes
add the progress view (if it's not already there)
get the step number from the new controller
update the progress
if Not
remove the progress view
I put up a complete example you can check out and inspect here: https://github.com/DonMag/ProgressNavController

How to hide the navigation bar as scroll down, when constraints are programmatically?

I'm doing all my UI programatically, to avoid a massive view controller I have a class of type UIView where I'm declaring all my UI elements.
I'm declaring my scrollView like this:
class RegisterUIView: UIView {
lazy var scrollView: UIScrollView = {
let scroll: UIScrollView = UIScrollView()
scroll.contentSize = CGSize(width: self.frame.size.width, height: self.frame.size.height)
scroll.translatesAutoresizingMaskIntoConstraints = false
return scroll
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: self.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
])
}
}
After the declaration. I create an instance of RegisterUIView in my ViewController.
And in the viewWillAppear and viewWillDisappear I used the variable hidesBarsOnSwipe, to hide the navigation bar.
When I scroll down the bar hides, but when I scroll up the bar is not unhiding.
I read in other question here that I need to set the top constraint to the superview.
How can is set the constraints to the superview?, when I try to set it the app crashes, and is obviously because there is no superview.
class RegisterViewController: UIViewController {
private let registerView: RegisterUIView = {
let view: RegisterUIView = RegisterUIView(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.hidesBarsOnSwipe = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.navigationController?.hidesBarsOnSwipe = false
}
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
}
func setupLayout() {
view.addSubview(registerView)
NSLayoutConstraint.activate([
registerView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
registerView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor),
registerView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
registerView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor)
])
}
Add the scroll view to the subview before adding constraints. Your app crashes because you're adding constraints to an object, not in the subview.
view.addSubview(scrollView)
add that line of code to the top of your setupLayout() function before your start adding constraints.

`UIScrollView` `bounds` are different in `viewDidAppear` than last `viewDidLayoutSubviews`

In attempt to improve the UI for smaller devices, when a UIScrollView contentSize.height is greater than it's bounds.height (I'm calling this an overflow) I want to tweak the UI.
My issue is: I'm finding the layout of the views is different after appearing on screen (in viewDidAppear(:)) than it is in the last viewDidLayoutSubviews() (could be called multiple times).
In order to determine if a scrollView is overflowing, we need to know both the scrollView.bounds and scrollView.contentSize. Which I thought would be determined in the last call of viewDidLayoutSubviews().
Below examples my scenario. It's running an interface built in xib with the scrollView set up there too. Note there are no hiding/showing of views to interfere with the layout values, simply runs through.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
printOverflow(type: #function)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
printOverflow(type: #function)
}
private func printOverflow(type: String) {
let isOverflowY = scrollView.contentSize.height > scrollView.bounds.height
debugPrint("[Overflow] \(type) \(isOverflowY ? "Overflow" : "Fits")")
}
// After running prints:
// "[Overflow] viewDidLayoutSubviews() Fits"
// "[Overflow] viewDidAppear(_:) Overflow"
My question is: why can I not get the final layout of the subviews in the last viewDidLayoutSubviews()?
I also tried adding a view.layoutIfNeeded() in viewWillAppear(:), indeed this fires another viewDidLayoutSubviews() as you'd expect but the bounds properties are still different to that in viewDidAppear(:) (despite actually now printing "Overflow")
From Apple's docs (italics are mine):
func viewDidLayoutSubviews()
When the bounds change for a view controller'€™s view, the view adjusts the positions of its subviews and then the system calls this method. However, this method being called does not indicate that the individual layouts of the view'€™s subviews have been adjusted. Each subview is responsible for adjusting its own layout.
So, viewDidLayoutSubviews() can (and will) be called before subviews have been completely configured by auto-layout. That's also why we see viewDidLayoutSubviews() being called multiple times during loading / displaying.
If you subclass UIScrollView like this:
class MyScrollView: UIScrollView {
override func layoutSubviews() {
super.layoutSubviews()
printOverflow(type: #function)
}
private func printOverflow(type: String) {
let isOverflowY = contentSize.height > bounds.height
debugPrint("MyScrollView {Content Size: \(contentSize.height) Bounds Height: \(bounds.height)} [Overflow] \(type) \(isOverflowY ? "Overflow" : "Fits")")
}
}
You'll see that you get the correct heights.
What exactly are trying to do to "improve the UI for smaller devices"? There may be other (better?) options...
Edit
Here's a very simple example showing the sizing at each stage. The custom view changes its height anchor (if necessary) in its layoutSubviews() function:
import UIKit
class MySizingView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
if frame.height != 200.0 {
heightAnchor.constraint(equalToConstant: 200.0).isActive = true
}
print(#function, "self height:", frame.height)
}
}
class SizingTestViewController: UIViewController {
let myView: MySizingView = {
let v = MySizingView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .cyan
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(myView)
NSLayoutConstraint.activate([
// constrain width and center X & Y
// its height will be set by its own height constraint
myView.widthAnchor.constraint(equalToConstant: 300.0),
myView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
print(#function, "myView height:", myView.frame.height)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print(#function, "myView height:", myView.frame.height)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
print(#function, "myView height:", myView.frame.height)
}
}
Debug console output should be:
viewWillAppear(_:) myView height: 0.0
viewDidLayoutSubviews() myView height: 0.0
layoutSubviews() self height: 0.0
viewDidLayoutSubviews() myView height: 200.0
layoutSubviews() self height: 200.0
viewDidAppear(_:) myView height: 200.0

Constraints not working when device is rotated

I have this UIViewController:
import UIKit
class ViewController: UIViewController {
var object: DraggableView?
override func viewDidLoad() {
super.viewDidLoad()
// Create the object
object = DraggableView(parent: self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Add subview
object?.setup()
}
}
And I have this class to add the view in this VC:
import UIKit
class DraggableView {
var parent: UIViewController!
let pieceOfViewToShow: CGFloat = 30.0
init(parent: UIViewController) {
self.parent = parent
}
func setup() {
let view = UIView(frame: parent.view.frame)
view.backgroundColor = UIColor.red
parent.view.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.leadingAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.leadingAnchor).isActive = true
view.trailingAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.trailingAnchor).isActive = true
view.heightAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.heightAnchor).isActive = true
// I need to show only a piece of the view at bottom, so:
view.topAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.topAnchor, constant: parent.view.frame.height - pieceOfViewToShow).isActive = true
}
}
Problem
Everything is correct but when the device rotates it loses the constraint and the added view is lost.
I think the problem is in the next line that is not able to update the correct height [parent.view.frame.height] when the device is rotated.
view.topAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.topAnchor, constant: parent.view.frame.height - pieceOfViewToShow).isActive = true
How could I make to update this constant when rotating?
I'm using Swift 3.
You can try using traitCollectionDidChange callback on the UIView to update the constraint when a rotation changes, for that to work you'll need to make DraggableView a subclass of the UIView:
import UIKit
class DraggableView: UIView {
var parent: UIViewController!
let pieceOfViewToShow: CGFloat = 30.0
// keep the constraint around to have access to it
var topConstraint: NSLayoutConstraint?
init(parent: UIViewController) {
super.init(frame: parent.view.frame)
self.parent = parent
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
self.backgroundColor = UIColor.red
parent.view.addSubview(self)
self.translatesAutoresizingMaskIntoConstraints = false
self.leadingAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.leadingAnchor).isActive = true
self.trailingAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.trailingAnchor).isActive = true
self.heightAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.heightAnchor).isActive = true
// keep a reference to the constraint
topConstraint = self.topAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.topAnchor, constant: parent.view.frame.height - pieceOfViewToShow)
topConstraint?.isActive = true
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// update the constraints constant
topConstraint?.constant = parent.view.frame.height - pieceOfViewToShow
}
}

UISearchBar width and position changes upon selection

I'm having a really frustrating problem that I'm sure has a simple solution but for the life of me, I can't figure it out.
I have a UITableView within a UIViewController. On the toolbar, I have a button that can show/hide a Search Bar. Everything works great except for the annoying fact that the search bar, upon selection, shifts up 8 pixels (the original margin between the UITableView and the SuperView) and expands in width to equal the full superview.
I have kind of fixed the width issue with the function searchBarFrame(), however, it cuts the "Cancel" button in half, so it isn't perfect (See Below). I'd really appreciate any thoughts on these two problems. I have tried every combination of Extend Edges and Scroll View Insets based on other solutions I've found, but nothing is working for me. I really don't want to use the navigation bar as the search bar nor do I want to convert completely to a UITableViewController. There must be a way to make this work!
Here is my (relevant?) code:
class ListVC: UIViewController UISearchControllerDelegate, UISearchBarDelegate, UISearchResultsUpdating {
#IBOutlet weak var tableView: UITableView!
#IBOutlet weak var searchBtn: UIBarButtonItem!
let searchController: UISearchController = UISearchController(searchResultsController: nil)
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
}
func searchBarFrame() {
var searchBarFrame = searchController.searchBar.frame
searchBarFrame.size.width = tableView.frame.size.width
searchController.searchBar.frame = searchBarFrame
}
func showSearchController() {
searchController.isActive = true
searchBarFrame()
searchController.searchResultsUpdater = self
searchController.searchBar.delegate = self
searchController.dimsBackgroundDuringPresentation = false
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true
searchController.searchBar.placeholder = "Search Places"
searchController.searchBar.roundCorners(corners: [.topLeft, .topRight, .bottomLeft, .bottomRight], radius: 5.0)
searchController.searchBar.barTintColor = UIColor.blurColor
tableView.tableHeaderView = searchController.searchBar
}
func hideSearchController() {
tableView.tableHeaderView = nil
searchController.isActive = false
}
#IBAction func onSearchBtnPress(sender: UIBarButtonItem) {
if !searchController.isActive {
showSearchController()
} else {
hideSearchController()
}
}
Following up, in case anyone else experienced this issue. After a lot of time and effort, I never got my original setup to work. Instead, I started from scratch and approached it differently.
In storyboard (you can do this programmatically too but I went the easier route because I was fed up), I put a UISearchBar inside a UIView inside a UIStackView. I set the Stackview's leading and trailing constraints to the uitableview, the bottom to the top of the uitableview and the top to the bottom of the top layout view. The UIView's only constraint is a height of 56 (the typical search bar height) with a priority of 999 (if you want to show and hide).
This fixed everything and the code was really simple too.
class MyVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
searchBar.delegate = self
searchView.isHidden = true
}
#IBAction func onSearchBtnPress(sender: UIBarButtonItem) {
if searchView.isHidden {
searchView.isHidden = false
} else {
searchView.isHidden = true
}
}
}
extension MyVC: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// do something
}
I imagine that the use of NSLayoutConstraint to position and size your views would solve this issue.
For example:
private let margin: CGFloat = 15.0
tableView.translatesAutoresizingMaskIntoConstraints = false
searchBar.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: margin),
tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: margin),
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
searchBar.widthAnchor.constraint(equalTo: tableView.widthAnchor)
])

Resources