My problem seems obvious and duplicated but I can't manage to make it work.
I'm trying to achieve the famous stretchy header effect (image's top side stuck to top of UIScrollView
when scrolling), but with an UIPageViewController
instead of simply an image.
My structure is:
UINavigationBar
|-- UIScrollView
|-- UIView (totally optional container)
|-- UIPageViewController (as UIView, embedded with addChild()) <-- TO STICK
|-- UIHostingViewController (SwiftUI view with labels, also embedded)
|-- UITableView (not embedded but could be)
My UIPageViewController
contains images to make a carousel, nothing more.
All my views are laid out with NSLayoutConstraint
s (with visual format for vertical layout in the container).
I trie sticking topAnchor
of the page controller's view to the one of self.view
(with or without priority
) but no luck, and no matter what I do it changes absolutely nothing.
I finally tried to use SnapKit
but it doesn't work neither (I don't know much about it but it seems to only be a wrapper for NSLayoutConstaint
s so I'm not surprised it doesn't work too).
I followed this tutorial, this one and that one but none of them worked.
(How) can I achieve what I want?
EDIT 1:
To clarify, my carousel currently has a forced height of 350. I want to achieve this exact effect (that is shown with a single UIImageView
) on my whole carousel:
To clarify as much as possible, I want to replicate this effect to my whole UIPageViewController
/carousel so that the displayed page/image can have this effect when scrolled.
NOTE: as mentioned in the structure above, I have a (transparent) navigation bar, and my safe area insets are respected (nothing goes under the status bar). I don't think it would change the solution (as the solution is probably a way to stick the top of the carousel to self.view
, no matter the frame of self.view
) but I prefer you to know everything.
EDIT 2:
Main VC with @DonMag's answer:
private let info: UITableView = {
let v = UITableView(frame: .zero, style: .insetGrouped)
v.backgroundColor = .systemBackground
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
private lazy var infoHeightConstraint: NSLayoutConstraint = {
// Needed constraint because else standalone UITableView gets an height of 0 even with usual constraints
// I update this constraint in viewWillAppear & viewDidAppear when the table gets a proper contentSize
info.heightAnchor.constraint(equalToConstant: 0.0)
}()
private let scrollView: UIScrollView = {
let v = UIScrollView()
v.contentInsetAdjustmentBehavior = .never
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
...
// MARK: Views declaration
// Container for carousel
let stretchyView = UIView()
stretchyView.translatesAutoresizingMaskIntoConstraints = false
// Carousel
let carouselController = ProfileDetailCarousel(images: [
UIImage(named: "1")!,
UIImage(named: "2")!,
UIImage(named: "3")!,
UIImage(named: "4")!
])
addChild(carouselController)
let carousel: UIView = carouselController.view
carousel.translatesAutoresizingMaskIntoConstraints = false
stretchyView.addSubview(carousel)
carouselController.didMove(toParent: self)
// Container for below-carousel views
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
// Texts and bio
let bioController = UIHostingController(rootView: ProfileDetailBio())
addChild(bioController)
let bio: UIView = bioController.view
bio.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(bio)
bioController.didMove(toParent: self)
// Info table
info.delegate = tableDelegate
info.dataSource = tableDataSource
tableDelegate.viewController = self
contentView.addSubview(info)
[stretchyView, contentView].forEach { v in
scrollView.addSubview(v)
}
view.addSubview(scrollView)
// MARK: Constraints
let stretchyTop = stretchyView.topAnchor.constraint(equalTo: scrollView.frameLayoutGuide.topAnchor)
stretchyTop.priority = .defaultHigh
NSLayoutConstraint.activate([
// Scroll view
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
// Stretchy view
stretchyTop,
stretchyView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor),
stretchyView.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor),
stretchyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 350.0),
// Carousel
carousel.topAnchor.constraint(equalTo: stretchyView.topAnchor),
carousel.bottomAnchor.constraint(equalTo: stretchyView.bottomAnchor),
carousel.centerXAnchor.constraint(equalTo: stretchyView.centerXAnchor),
carousel.widthAnchor.constraint(equalTo: stretchyView.widthAnchor),
// Content view
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 350.0),
contentView.topAnchor.constraint(equalTo: stretchyView.bottomAnchor),
// Bio
bio.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0),
bio.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
bio.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
bio.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Info table
info.topAnchor.constraint(equalTo: bio.bottomAnchor, constant: 12.0),
info.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
info.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
infoHeightConstraint
])
}
Your view hierarchy should be:
To get the stretchy view to "stick to the top":
We constrain the stretchy view's Top to the scroll view's
.frameLayoutGuide
Top, but we give that constraint a less-than-required.priority
so we can "push it" up and off the screen.We also give the stretchy view a Height constraint of greater-than-or-equal-to 350. This will allow it to stretch - but not compress - vertically.
We'll call the view from the
UIHostingViewController
our "contentView" ... and we'll constrain its Top to the stretchy view's Bottom.Then, we give the content view another Top constraint -- this time to the scroll view's
.contentLayoutGuide
, with a constant of 350 (the height of the stretchy view). This, plus the Leading/Trailing/Bottom constraints defines the "scrollable area."When we scroll (pull) down, the content view will "pull down" the Bottom of the stretchy view.
When we scroll (push) up, the content view will "push up" the entire stretchy view.
Here's how it looks (too big to add as a gif here): https://i.stack.imgur.com/4wvxK.jpg
And here's the sample code to make that. Everything is done via code, so no
@IBOutlet
or other connections needed. Also note that I used three images for the page views - "ex1", "ex2", "ex3":View Controller
Controller for each Page
Sample Page View Controller
Edit
Looking at the code you posted in your question's Edit... it's a little tough, since I don't know what your
ProfileDetailBio
view is, but here are a couple tips to help debug this type of situation during development:.clipsToBounds = true
on views you're using as "containers" - such ascontentView
... if a subview is then "missing" you know it has extended outside the bounds of the containerSo, for your code...
If you run the app, you will likely see that
contentView
only extends to the bottom ofbio
- not to the bottom ofinfo
.If you then do this:
info
will likely not be visible at all.Checking your constraints, you have:
where it should be:
Run the app, and you should now again see
info
, andcontentView
extends to the bottom ofinfo
.Assuming
bio
andinfo
height combined are tall enough to require scrolling, you can undo the "debug / dev" changes and you should be good to go.