Even though there are multiple questions and answers to this questions here on SO I just cannot create a UIScrollView
with both static and dynamic content (by using a ContainerView
) and make the sizes work properly. I will therefore provide a step by step guide until the point where I cannot make any progress and someone can provide a solution. This way we will have a workable sample that can be followed step by step to make it work.
Please note: The output from all of the steps is uploaded to https://github.com/oysteinmyrmo/DynamicScrollablePage for convenience. The test Xcode project can be fetched from there and hacked on further.
Update: After @agibson007's answer, there are a few steps at the end to fix the original steps to a working solution. Errors are noted by stating ERROR, SEE FINAL STEPS.
Goal:
Have a long scrollable UIView
page with various static UIView
s and a ContainerView
with dynamic content. For completeness' sake the dynamic content will consist of some static UIView
s and a UITableView
that will be expanded to its entire contents. The last element seems to be a reoccurring theme in the various questions I have stumbled upon lately.
Method:
- We will start with a new Xcode project (Xcode 8.2.1) and use Swift 3.0.2 as language.
- We will step by step create test
UIView
s,UIViewController
s and other required items. - At some point we have a "template" that can be used to make the content expand dynamically by someone who are able.
Step 1: Project Creation
Open Xcode (8.2.1), start a new project.
- Select Tabbed Application. We will create the
UIScrollView
in the first tab. - Set product name to DynamicScrollablePage.
- Select location and create the project.
Step 2: Initial Changes to the Project
The changes to the UI will be done in the first tab. The procedure is heavily influenced by this answer, but we will add a couple of more items and a ContainerView
for our dynamic content.
- In
Main.storyboard
,First View
(i.e. tab 1) delete the two labels. - Click the
UIViewController
(named first). Go to its size inspector, change fromFixed
toFreeform
and change the height to 1500. This is only a visual change in the storyboard. - Rename the remaining
UIView
asRootView
. - Add a
UIScrollView
insideRootView
. Name itScrollView
. Constraints:- ScrollView[Top, Bottom, Leading, Trailing, Width] = RootView[Top, Bottom, Leading, Trailing, Width]. In my experience the width constraint must also be set to ensure covering the entire screen later on.
- Add a
UIView
insideScrollView
and name itContentView
. Constraints:- ContentView[Leading, Trailing, Top, Bottom, Width] = ScrollView[Leading, Trailing, Top, Bottom, Width]. The storyboard will now complain about scrolling height. It will not complain after the steps below.
- Add items to
ContentView
:- First add a
UIView
, name itRedView
. SetRedView
[Leading, Trailing, Top] =ContentView
[Leading, Trailing, Top]. SetRedView
[Height, Background Color] = [150, Red]. - Add a
UILabel
belowRedView
, set its name/text toFirstLabel
. SetFirstLabel
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetFirstLabel
[Top] =RedView
[Bottom]. - Add a
UIView
belowFirstLabel
, name itBlueView
. SetBlueView
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetBlueView
[Top] =FirstLabel
[Bottom]. SetBlueView
[Height, Background Color] = [450, Blue]. - Add a
UILabel
belowBlueView
, set its name/text toSecondLabel
. SetSecondLabel
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetSecondLabel
[Top] =BlueView
[Bottom]. - Add a
UIContainerView
belowSecondLabel
, name itContainerView
. SetContainerView
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetContainerView
[Top] =SecondLabel
[Bottom]. SetContainerView
[Intrinsic size] = [Placeholder] (see Size inspector for theContainerView
). Setting the intrinsic size to placeholder tells Xcode that the size of it is defined by its child views (as far as I understand). ERROR, SEE FINAL STEPS - Add a
UILabel
at the end, name itBottomLabel
. SetBottomLabel
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetBottomView
[Top] =ContainerView
[Bottom]. - Finally, control + drag from
ScrollView
toBottomView
and selectBottom Space to ScrollView
. This will ensure that theScrollView
's height is correct.
- First add a
Step 3: Create a ViewController with Dynamic Content
Now we will create the actual UIViewController
and xib
file that will be used to display the dynamic contents. We will create a UITableView
inside the xib
and thus we will also need a UITableViewCell
with a simple label for simplicity.
Create a Swift file,
TableViewCell.swift
with the contents:import UIKit class TableViewCell : UITableViewCell { }
Create a
xib
/View
file, namedTableViewCell.xib
. Do the following:- Remove the default
UIView
and replace it with aUITableViewCell
. - Add a
UILabel
to that cell, name itDataLabel
(it will also add a content viewUIView
). - Set
UITableViewCell
's custom class toTableViewCell
. - Set the Table View Cell identifier to
TableViewCellId
. In dual-view mode, ctrl+drag the label to the
TableViewCell
class. The result should be:import UIKit class TableViewCell : UITableViewCell { @IBOutlet weak var dataLabel: UILabel! }
- Remove the default
Create a file
DynamicEmbeddedViewController.swift
with the contents:import UIKit class DynamicEmbeddedViewController : UIViewController, UITableViewDataSource, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! let data = ["First", "Second", "Third", "Fourth", "Fifth", "Sixth", "Last"] override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell") } func numberOfSections(in tableView: UITableView) -> Int { return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return data.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell cell.dataLabel.text = data[indexPath.row] return cell } }
Create a
xib
/View
file, namedDynamicEmbeddedView.xib
. Rename the mainUIView
toContentView
and add three items within theContentView
:- Add a
UIView
, name itGreenView
. SetGreenView
[Leading, Trailing, Top] =ContentView
[Leading, Trailing, Top]. SetGreenView
[Height] = [150]. - Add a
UITableView
, name itTableView
. SetTableView
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetTableView
[Top] =GreenView
[Bottom]. Set Intrinsic size = Placeholder. I am not sure if this is the correct approach. ERROR, SEE FINAL STEPS - Add a
UIView
belowTableView
, name itPurpleView
. SetPurpleView
[Leading, Trailing] =ContentView
[Leading, Trailing]. SetPurpleView
[Top] =TableView
[Bottom]. - Note: At this point we might need some more constraints in the xib, but I am unsure what and how, if any.
- Set the
File's Owner
's custom class toDynamicEmbeddedViewController
. - Set the
File's Owner
's View outlet toContainerView
. - Set the
TableView
's dataSource and delegate toFile's Owner
. - Add the
IBOutlet
of theTableView
to theDynamicEmbeddedViewController
class.
- Add a
Connect the created
xib
andUIViewController
in theMain.storyboard
.- Set the Custom Class of the
ContainerView
's output View Controller toDynamicEmbeddedViewController
. - Delete the existing
View
in theContainerViews
output View Controller. I am not sure if this is really needed.
- Set the Custom Class of the
Images of Current Situation:
Step 4: Running the app:
Running the app and scrolling all the way to the bottom, including bounce area, this is the result:
From this we can conclude:
- The position of the
ContainerView
is correct (i.e. betweenSecondLabel
andBottomLabel
), but theBottomLabel
does not adhere its constraint to be below theContainerView
. - The
TableView
's height is obviously 0. This can also be seen sincefunc tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
is not called. If we set a height constraint on theTableView
, items will show up. - The content size of the
ScrollView
does not increase if the size of theContainerView
increases. - It is also desired to display all items in the dynamic
TableView
at all time and just scroll past them as if they were just static data in theScrollView
. - This thing is really messy.
Step 5: The questions!
- How can we make the
ScrollView
's content properly wrap all the contents, including the dynamic data in theTableView
living inside theContainerView
? - Are the constraints set up properly?
- Where and how should we calculate the proper heights/content sizes?
- Is all of this really necessary; are there easier ways to achieve this?
Step 6: Fixing the solution after @agibson007's answer:
Add
static let CELL_HEIGHT = 44
like this:import UIKit class TableViewCell : UITableViewCell { @IBOutlet weak var dataLabel: UILabel! static let CELL_HEIGHT = 44 }
Revert
TableView
's intrinsic size toDefault
fromPlaceholder
.- Set height constraint of for example 150 on the
TableView
. This value must be greater than one cell's height. - Add the height constraint to the
DynamicEmbeddedViewController
as anIBOutlet
. Add code to calculate and set
TableView
height constraint. Final class:import UIKit class DynamicEmbeddedViewController : UIViewController, UITableViewDataSource, UITableViewDelegate { @IBOutlet weak var tableView: UITableView! @IBOutlet weak var tableViewHeight: NSLayoutConstraint! let data = ["First", "Second", "Third", "Fourth", "Fifth", "Sixth", "Last"] override func viewDidLoad() { super.viewDidLoad() tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell") // Resize our constraint let totalHeight = data.count * TableViewCell.CELL_HEIGHT tableViewHeight.constant = CGFloat(totalHeight) self.updateViewConstraints() //in a real app a delegate call back would be good to update the constraint on the scrollview } func numberOfSections(in tableView: UITableView) -> Int { return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return data.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell cell.dataLabel.text = data[indexPath.row] return cell } }
Revert
ContainerView
's intrinsic size toDefault
fromPlaceholder
.- Set height constraint of for example 150 on the
ContainerView
. This value will be updated in code. - Add the height constraint to the
ContainerView
as anIBOutlet
in theFirstViewController
. - Add the
ContainerView
as anIBOutlet
in theFirstViewController
. - Create reference to the
DynamicEmbeddedViewController
inFirstViewController
so that it may be referenced for height calculation. Add code to calculate and set
ContainerView
height constraint. FinalFirstViewController
class:import UIKit class FirstViewController: UIViewController { @IBOutlet weak var containerView: UIView! @IBOutlet weak var containerViewHeightConstraint: NSLayoutConstraint! var dynamicView: DynamicEmbeddedViewController? override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. if dynamicView != nil{ dynamicView?.tableView.reloadData() let size = dynamicView?.tableView.contentSize.height //cheating on the 300 because the other views in that controller at 150 each containerViewHeightConstraint.constant = size! + 300 self.view.updateConstraintsIfNeeded() } } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if (segue.identifier == "ContainerViewSegue") { dynamicView = segue.destination as? DynamicEmbeddedViewController } }
}
And finally everything works as expected!
Please note: The output from all of the steps is uploaded to https://github.com/oysteinmyrmo/DynamicScrollablePage for convenience. The test Xcode project can be fetched from there and hacked on further.