I've created a generic UIView class which contains a UICollectionView inside of it. Just as below. (Class Below also handles the protocols of UICollectionView with Default values)
class MyCollectionView: BaseView<CollectionViewModel> {
private lazy var myCollectionView: UICollectionView = {
let temp = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) // setting initial collectionView
temp.translatesAutoresizingMaskIntoConstraints = false
temp.delegate = self
temp.dataSource = self
temp.register(CollectionViewCell.self, forCellWithReuseIdentifier: CollectionViewCell.identifier)
temp.clipsToBounds = true
return temp
}()
}
I've created an instance of MyCollectionView(class above), and added as subview to the MainViewController(Class Below). So doing that made me show a MyCollectionView as a subview of MainViewController. I've accomplished so far.
class MainViewController: UIViewController {
private lazy var collectionView: MyCollectionView = {
let temp = MyCollectionView()
temp.translatesAutoresizingMaskIntoConstraints = false
temp.backgroundColor = .black
return temp
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
setUpConstraintsAndViews()
// Do any additional setup after loading the view.
}
Later on I tried to make UICollectionViewCell class and register that to myCollectionView. But still I can not see any cells on my screen. What might I could be missing?
Your MyCollectionView class is not a UICollectionView. It does not have a subview of a UICollectionView it has a private variable myCollectionView that creates a collection view.
As far as I can tell you're adding an instance of MyCollectionView as a subview of your MainViewController. I don't see how that adds a UICollectionView to your view MainViewController.
How is the consumer of your MyCollectionView supposed to get to the collection view that it owns?
I want to embed a UICollectionView inside a UIScrollView (I know it's not good practice but I have my reasons to do that).
I created a UICollectionView subclass that set intrinsicContentSize based on content size
class EmebedableCollectionView: UICollectionView {
override var intrinsicContentSize: CGSize {
let size = self.collectionViewLayout.collectionViewContentSize
let result = CGSize(width: max(size.width,1), height: max(size.height + 20,1))
self.setNeedsLayout()
return result
}
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
}
then in a container view, I create a height constraints for the collection view
#IBOutlet weak var productsHeightConstraint: NSLayoutConstraint!
and update it based on UICollectionView intrinsicContentSize
override func viewWillLayoutSubviews() {
for subview in productsContainerView.subviews.first!.subviews where subview is UICollectionView {
productsHeightConstraint.constant = subview.intrinsicContentSize.height
productsContainerView.layoutIfNeeded()
}
}
The problem is: the container view height is not correct, but when I push a new view controller and get back, it update the height to be correct. so what's wrong in my code?
I'm wondering how the UICollectionView will behave when we will embed it in UIStackView that is in the UIScrollView. As long as we will have to set collectionView's containerView intrinsicContentSize to be the collectionView.contentSize.height it will probably load all the data at the same time, so as we will add image fetching, shadows etc it will make it not super performant. Can we somehow avoid it? Of course it would be great to leave UIStackView as the collectionView is not the only embedded view in it :)
Main View
final class MainView: UIView {
lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.isDirectionalLockEnabled = true
return scrollView
}()
lazy var stackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fill
return stackView
}()
/// Adding subviews and setting constraints...
}
And here is the place where we embed our collectionView
final class ContainerView: UIView {
var collectionView: UICollectionView = {
let collectionView = UICollectionView()
return collectionView
}()
override var intrinsicContentSize: CGSize {
return CGSize(width: header.frame.width,
height: collectionView.frame.minY +
collectionView.contentSize.height)
}
/// Adding subviews and setting constraints...
}
This view is embeded in MainViewController stackview
I'm trying to follow the example described here for making a stretchy layout which includes a UIImageView and UIScrollView. https://github.com/TwoLivesLeft/StretchyLayout/tree/Step-6
The only difference is that I replace the UILabel used in the example with the view of a child UIViewController which itself contains a UICollectionView. This is how my layout looks - the blue items are the UICollectionViewCell.
This is my code:
import UIKit
import SnapKit
class HomeController: UIViewController, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let contentContainer = UIView()
private let collectionViewController = CollectionViewController()
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.delegate = self
imageView.image = UIImage(named: "burger")
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
let imageContainer = UIView()
imageContainer.backgroundColor = .darkGray
contentContainer.backgroundColor = .clear
let textBacking = UIView()
textBacking.backgroundColor = #colorLiteral(red: 0.7450980544, green: 0.1235740449, blue: 0.2699040081, alpha: 1)
view.addSubview(scrollView)
scrollView.addSubview(imageContainer)
scrollView.addSubview(textBacking)
scrollView.addSubview(contentContainer)
scrollView.addSubview(imageView)
self.addChild(collectionViewController)
contentContainer.addSubview(collectionViewController.view)
collectionViewController.didMove(toParent: self)
scrollView.snp.makeConstraints {
make in
make.edges.equalTo(view)
}
imageContainer.snp.makeConstraints {
make in
make.top.equalTo(scrollView)
make.left.right.equalTo(view)
make.height.equalTo(imageContainer.snp.width).multipliedBy(0.7)
}
imageView.snp.makeConstraints {
make in
make.left.right.equalTo(imageContainer)
//** Note the priorities
make.top.equalTo(view).priority(.high)
//** We add a height constraint too
make.height.greaterThanOrEqualTo(imageContainer.snp.height).priority(.required)
//** And keep the bottom constraint
make.bottom.equalTo(imageContainer.snp.bottom)
}
contentContainer.snp.makeConstraints {
make in
make.top.equalTo(imageContainer.snp.bottom)
make.left.right.equalTo(view)
make.bottom.equalTo(scrollView)
}
textBacking.snp.makeConstraints {
make in
make.left.right.equalTo(view)
make.top.equalTo(contentContainer)
make.bottom.equalTo(view)
}
collectionViewController.view.snp.makeConstraints {
make in
make.left.right.equalTo(view)
make.top.equalTo(contentContainer)
make.bottom.equalTo(view)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.scrollIndicatorInsets = view.safeAreaInsets
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: view.safeAreaInsets.bottom, right: 0)
}
//MARK: - Scroll View Delegate
private var previousStatusBarHidden = false
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if previousStatusBarHidden != shouldHideStatusBar {
UIView.animate(withDuration: 0.2, animations: {
self.setNeedsStatusBarAppearanceUpdate()
})
previousStatusBarHidden = shouldHideStatusBar
}
}
//MARK: - Status Bar Appearance
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .slide
}
override var prefersStatusBarHidden: Bool {
return shouldHideStatusBar
}
private var shouldHideStatusBar: Bool {
let frame = contentContainer.convert(contentContainer.bounds, to: nil)
return frame.minY < view.safeAreaInsets.top
}
}
Everything is the same as in this file: https://github.com/TwoLivesLeft/StretchyLayout/blob/Step-6/StretchyLayouts/StretchyViewController.swift with the exception of the innerText being replaced by my CollectionViewController.
As you can see, the UICollectionView is displayed properly - however I am unable to scroll up or down anymore. I'm not sure where my mistake is.
It looks like you are constraining the size of your collection view to fit within the bounds of the parent view containing the collection view's container view and the image view. As a result, the container scrollView has no contentSize to scroll over, and that's why you can't scroll. You need to ensure your collection view's content size is reflected in the parent scroll view's content size.
In the example you gave, this behavior was achieved by the length of the label requiring a height greater than the height between the image view and the rest of the view. In your case, the collection view container needs to behave as if it's larger than that area.
Edit: More precisely you need to pass the collectionView.contentSize up to your scrollView.contentSize. A scrollview's contentSize is settable, so you just need to increase the scrollView.contentSize by the collectionView.contentSize - collectionView.height (since your scrollView's current contentSize currently includes the collectionView's height). I'm not sure how you are adding your child view controller, but at the point you do that, I would increment your scrollView's contentSize accordingly. If your collectionView's size changes after that, though, you'll also need to ensure you delegate that change up to your scrollView. This could be accomplished by having a protocol such as:
protocol InnerCollectionViewHeightUpdated {
func collectionViewContentHeightChanged(newSize: CGSize)
}
and then making the controller containing the scrollView implement this protocol and update the scrollView contentSize accordingly. From your collectionView child controller, you would have a delegate property for this protocol (set this when creating the child view controller, setting the delegate as self, the controller containing the child VC and also the scrollView). Then whenever the collectionView height changes (if you add cells, for example) you can do delegate.collectionViewContentHeightChanged(... to ensure your scroll behavior will continue to function.
I'm trying to build a complex split view container controller that facilitates two variable height containers, each with their own nested view controller. There's a global pan gesture on the parent controller that allows the user to drag anywhere in the view container and slide the "divider" between views up and down. It also has some intelligent position threshold detection logic that will expand either view (or reset the divider position):
This works fine. There's also a lot of code to construct this, which I'm happy to share, but I don't think it's relevant, so I'll omit it for the time being.
I'm now trying to complicate things by adding a collection view to the bottom view:
I've been able to work it out so that I can scroll the split view up with a decisive pan gesture, and scroll the collection view with a quick flick of the finger (a swipe gesture, I suppose it is?), but this is a really sub-par experience: you can't pan the view and scroll the collection view at the same time, and expecting a user to consistently replicate similar, yet different gestures in order to control the view is too difficult of an interaction.
To attempt to solve this, I've tried several delegate/protocol solutions in which I detect the position of the divider in the split view and enable/disable canCancelTouchesInView and/or isUserInteractionEnable on the collection view based on whether the bottom view is fully expanded. This works to a point, but not in the following two scenarios:
When the split view divider is in its default position, if the user pans up to where the bottom view is fully expanded, then keeps on panning up, the collection view should begin scrolling until the gesture ends.
When the split view divider is at the top (bottom container view is fully expanded) and the collection view is not at the top, if the user pans down, the collection view should scroll instead of the split view divider moving, until the collection view reaches its top position, at which point the split view should return to its default position.
Here is an animation that illustrates this behavior:
Given this, I'm starting to think the only way to solve the problem is by creating a delegate method on the split view that tells the collection view when the bottom view is at maximum height, which then can intercept the parent's pan gesture or forward the screen touches to the collection view instead? But, I'm not sure how to do that. If I'm on the right track with a solution, then my question is simply: How can I forward or hand off a pan gesture to a collection view and have the collection view interact the same way it would if the touches had been captured by it in the first place? can I do something with pointInside or touches____ methods?
If I can't do it this way, how else can I solve this problem?
Update for bounty hunters: I've had some fragmented luck creating a delegate method on the collection view, and calling it on the split view container to set a property shouldScroll, by which I use some pan direction and positioning information to determine whether or not the scroll view should scroll. I then return this value in UIGestureRecognizerDelegate's gestureRecognizer:shouldReceive touch: delegate method:
// protocol delegate
protocol GalleryCollectionViewDelegate {
var shouldScroll: Bool? { get }
}
// shouldScroll property
private var _shouldScroll: Bool? = nil
var shouldScroll: Bool {
get {
// Will attempt to retrieve delegate value, or self set value, or return false
return self.galleryDelegate?.shouldScroll ?? self._shouldScroll ?? false
}
set {
self._shouldScroll = newValue
}
}
// UIGestureRecognizerDelegate method
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return shouldScroll
}
// ----------------
// Delegate property/getter called on the split view controller and the logic:
var shouldScroll: Bool? {
get {
return panTarget != self
}
}
var panTarget: UIViewController! {
get {
// Use intelligent position detection to determine whether the pan should be
// captured by the containing splitview or the gallery's collectionview
switch (viewState.currentPosition,
viewState.pan?.directionTravelled,
galleryScene.galleryCollectionView.isScrolled) {
case (.top, .up?, _), (.top, .down?, true): return galleryScene
default: return self
}
}
}
This works OK for when you begin scrolling, but doesn't perform well once scrolling is enabled on the collection view, because the scroll gesture almost always overrides the pan gesture. I'm wondering if I can wire something up with gestureRecognizer:shouldRecognizeSimultaneouslyWith:, but I'm not there yet.
What about making the child view for bottom view actually takes up the entire screen and set the collection view's contentInset.top to top view height. And then add the other child view controller above the bottom view. Then the only thing you need to do is make the parent view controller the delegate to listen to the bottom view's collection view's scroll offset and change the top view's position. No complicated gesture recognizer stuff. Only one scroll view(collection view)
Update: Try this!!
import Foundation
import UIKit
let topViewHeight: CGFloat = 250
class SplitViewController: UIViewController, BottomViewControllerScrollDelegate {
let topViewController: TopViewController = TopViewController()
let bottomViewController: BottomViewController = BottomViewController()
override func viewDidLoad() {
super.viewDidLoad()
automaticallyAdjustsScrollViewInsets = false
bottomViewController.delegate = self
addViewController(bottomViewController, frame: view.bounds, completion: nil)
addViewController(topViewController, frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: topViewHeight), completion: nil)
}
func bottomViewScrollViewDidScroll(_ scrollView: UIScrollView) {
print("\(scrollView.contentOffset.y)")
let offset = (scrollView.contentOffset.y + topViewHeight)
if offset < 0 {
topViewController.view.frame.origin.y = 0
topViewController.view.frame.size.height = topViewHeight - offset
} else {
topViewController.view.frame.origin.y = -(scrollView.contentOffset.y + topViewHeight)
topViewController.view.frame.size.height = topViewHeight
}
}
}
class TopViewController: UIViewController {
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
automaticallyAdjustsScrollViewInsets = false
view.backgroundColor = UIColor.red
label.text = "Top View"
view.addSubview(label)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.sizeToFit()
label.center = view.center
}
}
protocol BottomViewControllerScrollDelegate: class {
func bottomViewScrollViewDidScroll(_ scrollView: UIScrollView)
}
class BottomViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
var collectionView: UICollectionView!
weak var delegate: BottomViewControllerScrollDelegate?
let cellPadding: CGFloat = 5
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
automaticallyAdjustsScrollViewInsets = false
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = cellPadding
layout.minimumLineSpacing = cellPadding
layout.scrollDirection = .vertical
layout.sectionInset = UIEdgeInsets(top: cellPadding, left: 0, bottom: cellPadding, right: 0)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.contentInset.top = topViewHeight
collectionView.scrollIndicatorInsets.top = topViewHeight
collectionView.alwaysBounceVertical = true
collectionView.backgroundColor = .clear
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: String(describing: UICollectionViewCell.self))
view.addSubview(collectionView)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 30
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: UICollectionViewCell.self), for: indexPath)
cell.backgroundColor = UIColor.darkGray
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = floor((collectionView.frame.size.width - 2 * cellPadding) / 3)
return CGSize(width: width, height: width)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.bottomViewScrollViewDidScroll(scrollView)
}
}
extension UIViewController {
func addViewController(_ viewController: UIViewController, frame: CGRect, completion: (()-> Void)?) {
viewController.willMove(toParentViewController: self)
viewController.beginAppearanceTransition(true, animated: false)
addChildViewController(viewController)
viewController.view.frame = frame
viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(viewController.view)
viewController.didMove(toParentViewController: self)
viewController.endAppearanceTransition()
completion?()
}
}
You can't "hand off" a gesture, because the gesture recognizer remains the same object and its view is unvarying — it's the view to which the gesture recognizer is attached.
However, nothing stops you from telling some other view what to do in response to a gesture. The collection view is a scroll view, so you know how it is being scrolled at every instant and can do something else in parallel.
You should be able to achieve what you're looking for with a single collection view using UICollectionViewDelegateFlowLayout. If you need any special scrolling behavior for your top view such as parallax, you can still achieve that in a single collection view by implementing a custom layout object that inherits from UICollectionViewLayout.
Using the UICollectionViewDelegateFlowLayout approach is a little more straightforward than implementing a custom layout, so if you want to give that a shot, try the following:
Create your top view as a subclass of UICollectionViewCell and register it with your collection view.
Create your "divider" view as a subclass of UICollectionViewCell and register it with your collection view as a supplementary view using func register(_ viewClass: AnyClass?,
forSupplementaryViewOfKind elementKind: String,
withReuseIdentifier identifier: String)
Have your collection view controller conform to UICollectionViewDelegateFlowLayout, create a layout object as an instance of UICollectionViewFlowLayout assign your collection view controller as the delegate of your flow layout instance, and init your collection view with your flow layout.
Implement optional func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize returning the desired size of each of your diffrent views in your collecton view controller.