I am making a swiftUI app. I have some scrollable content for which I am using UIScrollView (Not using SwiftUI ScrollView due to some limitations). I have embedded my UIScrollView in a VStack. Above UIScrollView inside VStack I have a swiftUI view which has a tap gesture. My UIScrollView subview also has a tap gesture and UIScrollView subview can scroll over SwiftUI view inside VStack.
Problem:
When I scroll up and UIScrollView covers the SwiftUI View I can not tap on the portion of subview which is outside of the clipping bounds of UIScrollView, My SwiftUI View gets tapped instead, even when its not visible.
I have attached my sample code and some Images.
Code:
UIScrollViewWrapper
struct UIScrollViewWrapper<Content: View>: UIViewRepresentable {
var width: CGFloat
var height: CGFloat
#ViewBuilder var content: () -> Content
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.clipsToBounds = false
scrollView.delegate = context.coordinator
scrollView.isScrollEnabled = true
scrollView.showsVerticalScrollIndicator = false
let child = UIHostingController(rootView: content())
child.view.backgroundColor = .clear
context.coordinator.hostingController = child
scrollView.addSubview(child.view)
let newSize = child.view.sizeThatFits(CGSize(width: width, height: height))
child.view.frame = CGRect(origin: .zero, size: newSize)
scrollView.contentSize = newSize
return scrollView
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
context.coordinator.hostingController?.rootView = content()
if let child = context.coordinator.hostingController {
let newSize = child.view.sizeThatFits(CGSize(width: width, height: height))
child.view.frame = CGRect(origin: .zero, size: newSize)
scrollView.contentSize = newSize
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper
var hostingController: UIHostingController<Content>!
init(_ parent: UIScrollViewWrapper) {
self.parent = parent
}
}
}
ContentView
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 15)
.fill(.cyan)
.overlay(
Text("SwiftUI View")
.foregroundColor(.white)
)
.frame(height: 75)
.onTapGesture {
print("SwiftUI")
}
UIScrollViewWrapper(width: geometry.size.width, height: geometry.size.height - 75) {
RoundedRectangle(cornerRadius: 15)
.fill(.mint)
.frame(height: geometry.size.height * 2)
.onTapGesture {
print("UIScrollView")
}
}
.border(.red, width: 2)
}
}
}
}
Things I have tried:
I tried making UIScrollView fullscreen and offsetting the subview so my SwiftUI view is visible. If I do this touches to my SwiftUI view gets blocked by UIScrollView. With this approach I have tried changing the zIndex of SwiftUI View putting it on top of UIScrollView when necessary but this is not working with my animations and giving bad user experience.
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 15)
.fill(.cyan)
.overlay(
Text("SwiftUI View")
.foregroundColor(.white)
)
.frame(height: 75)
.onTapGesture {
print("SwiftUI")
}
UIScrollViewWrapper(width: geometry.size.width, height: geometry.size.height) {
RoundedRectangle(cornerRadius: 15)
.fill(.mint)
.frame(height: geometry.size.height * 2)
.offset(y: 75)
.onTapGesture {
print("UIScrollView")
}
}
.border(.red, width: 2)
}
}
}
}
Can you help me solve this problem ?
Thank You !
To do this with UIKit...
constrain the scroll view to fill the screen (safe area)
constrain the "sticky top view" to the scroll view's Frame Layout Guide
constrain the "scrollable content view" to the scroll view's Content Layout Guide
This will keep the top view "stuck in place" while allowing the content view to scroll normally.
Quick example:
class StickyTopScrollView: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let scrollView = UIScrollView()
let stickyTopView = UIView()
let contentView = UIView()
// label to add to the sticky top view
let stvLabel = UILabel()
stvLabel.font = .systemFont(ofSize: 26, weight: .light)
stvLabel.textAlignment = .center
stvLabel.textColor = .white
stvLabel.text = "Sticky Top View"
// vertical stack view to add to the "scrollable content" view
// to which we'll add some labels
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 32
scrollView.backgroundColor = .systemRed
stickyTopView.backgroundColor = .systemGreen
contentView.backgroundColor = .systemBlue
[scrollView, stickyTopView, contentView, stvLabel, stackView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
stickyTopView.addSubview(stvLabel)
contentView.addSubview(stackView)
scrollView.addSubview(stickyTopView)
scrollView.addSubview(contentView)
view.addSubview(scrollView)
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
let topViewHeight: CGFloat = 75.0
NSLayoutConstraint.activate([
// scroll view fills the safe area
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 0.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// center label in stick top view
stvLabel.centerXAnchor.constraint(equalTo: stickyTopView.centerXAnchor),
stvLabel.centerYAnchor.constraint(equalTo: stickyTopView.centerYAnchor),
// sticky top view should be full width and "stuck" to the top of the
// scroll view Frame Layout Guide
stickyTopView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0),
stickyTopView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
stickyTopView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
stickyTopView.heightAnchor.constraint(equalToConstant: topViewHeight),
// constrain "scrollable content" view to Content Layout Guide
contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: topViewHeight),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// constrain "scrollable content" view Width to Frame Layout Guide
contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
// constrain stack view in "scrollable content" view with
// 20-points "padding" on all 4 sides
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20.0),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20.0),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20.0),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),
])
// now, let's add some labels to the stack view so our
// "scrollable content" view will be tall enough to need to scroll
for i in 1...20 {
let v = UILabel()
v.font = .systemFont(ofSize: 26, weight: .light)
v.text = "Label \(i)"
v.backgroundColor = .yellow
stackView.addArrangedSubview(v)
}
// a little styling
stickyTopView.layer.cornerRadius = 16
contentView.layer.cornerRadius = 16
// and add tap gesture recognizers
stickyTopView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(stickyTopTapped(_:))))
contentView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(scrollContentTapped(_:))))
}
#objc func stickyTopTapped(_ g: UITapGestureRecognizer) {
print("Sticky Top View Tapped")
}
#objc func scrollContentTapped(_ g: UITapGestureRecognizer) {
print("Scrollable Content View Tapped")
}
}
Looks like this when running and scrolling:
While the "sticky top view" is visible, it can be tapped. As we "cover" it with the scrollable content view, the content view will get the tap.
You may be able to adapt that to SwiftUI with a UIScrollView in a UIViewRepresentable object.
Related
Problem:
I want my footer UIStackView to hug its content when views are laid out, and take priority over the UIScrollView . Currently the header UIStackView and main body UIScrollView hug its contents, causing the footer UIStackView to expand, therefore leaving a lot of space below its contents and not looking like it's not pinned to the bottom. I would like the header(UIStackView) and footer(UIStackView) to hug its contents, and the main body(UIScrollView) to expand as needed.
Platform specs:
iOS 15
Xcode 13.1
Context:
I have a UIViewController with the following view hierarchy
UIViewController
-UIView
-UIStackView(header)
-ScrollView(scrollable main body)
-UIView
-UIStackView
-UIStackView(footer)
Requirements for header and footer:
stay on screen all the time
Constraints:
self.view.addSubview(self.headerStackView)
NSLayoutConstraint.activate([
self.headerStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.headerStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.headerStackView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor)
])
self.view.addSubview(self.scrollView)
NSLayoutConstraint.activate([
self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.scrollView.topAnchor.constraint(equalTo: self.headerStackView.bottomAnchor)
])
self.view.addSubview(self.footerStackView)
NSLayoutConstraint.activate([
self.footerStackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.footerStackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.footerStackView.topAnchor.constraint(equalTo: self.scrollView.bottomAnchor),
self.footerStackView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor)
])
You can do this by constraining the Top of the footer view to the Bottom of the scroll view's content, but with .priority = .defaultLow, then constrain the Bottom of the footer view to less-than-or-equal-to the Bottom of the scroll view's frame.
Here's how it can look...
yellow is the Header Stack View
blue is the scroll view
light-gray is the scroll content
green is the footer view
Header and Scroll views are siblings -- subviews of view
Content and Footer views are siblings -- subviews of scrollView
A quick example:
class FooterVC: UIViewController {
let scrollView = UIScrollView()
let headerStack = UIStackView()
let footerStack = UIStackView()
let scrollContentLabel = UILabel()
var numLines: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// MARK: setup the header stack view with add/remove buttons
let addButton = UIButton()
addButton.setTitle("Add", for: [])
addButton.setTitleColor(.white, for: .normal)
addButton.setTitleColor(.lightGray, for: .highlighted)
addButton.backgroundColor = .systemRed
let removeButton = UIButton()
removeButton.setTitle("Remove", for: [])
removeButton.setTitleColor(.white, for: .normal)
removeButton.setTitleColor(.lightGray, for: .highlighted)
removeButton.backgroundColor = .systemRed
headerStack.axis = .horizontal
headerStack.alignment = .center
headerStack.spacing = 20
headerStack.backgroundColor = .systemYellow
// a couple re-usable objects
var vSpacer: UIView!
vSpacer = UIView()
vSpacer.widthAnchor.constraint(equalToConstant: 16.0).isActive = true
headerStack.addArrangedSubview(vSpacer)
headerStack.addArrangedSubview(addButton)
headerStack.addArrangedSubview(removeButton)
vSpacer = UIView()
vSpacer.widthAnchor.constraint(equalToConstant: 16.0).isActive = true
headerStack.addArrangedSubview(vSpacer)
// MARK: setup the footer stack view
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.backgroundColor = .systemGreen
["Footer Stack View", "with Two Labels"].forEach { str in
let vLabel = UILabel()
vLabel.text = str
vLabel.textAlignment = .center
vLabel.font = .systemFont(ofSize: 24.0, weight: .regular)
vLabel.textColor = .yellow
footerStack.addArrangedSubview(vLabel)
}
// MARK: setup scroll content
scrollContentLabel.font = .systemFont(ofSize: 44.0, weight: .light)
scrollContentLabel.numberOfLines = 0
scrollContentLabel.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
// so we can see the scroll view
scrollView.backgroundColor = .systemBlue
[scrollContentLabel, footerStack].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(v)
}
[headerStack, scrollView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// header stack at top of view
headerStack.topAnchor.constraint(equalTo: g.topAnchor),
headerStack.leadingAnchor.constraint(equalTo: g.leadingAnchor),
headerStack.trailingAnchor.constraint(equalTo: g.trailingAnchor),
headerStack.heightAnchor.constraint(equalToConstant: 72.0),
// make buttons equal widths
addButton.widthAnchor.constraint(equalTo: removeButton.widthAnchor),
// scroll view Top to header stack Bottom
scrollView.topAnchor.constraint(equalTo: headerStack.bottomAnchor),
// other 3 sides
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
// scroll content...
// let's inset the content label 12-points on each side
// to make it easier to see the framing
scrollContentLabel.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 12.0),
scrollContentLabel.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -12.0),
scrollContentLabel.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -24.0),
// top and bottom to content layout guide
scrollContentLabel.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
scrollContentLabel.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
// footer stack view - leading/trailing to frame layout guide
footerStack.leadingAnchor.constraint(equalTo: fg.leadingAnchor),
footerStack.trailingAnchor.constraint(equalTo: fg.trailingAnchor),
])
var tmpConstraint: NSLayoutConstraint!
// now, we want the footer to stick to the bottom of the content,
// but allow auto-layout to break the constraint when needed
tmpConstraint = footerStack.topAnchor.constraint(equalTo: scrollContentLabel.bottomAnchor)
tmpConstraint.priority = .defaultLow
tmpConstraint.isActive = true
// and we want the footer to stop at the bottom of the scroll view frame
// default is .required, but we'll set it here for emphasis
tmpConstraint = footerStack.bottomAnchor.constraint(lessThanOrEqualTo: fg.bottomAnchor)
tmpConstraint.priority = .required
tmpConstraint.isActive = true
// actions for the buttons
addButton.addTarget(self, action: #selector(addContent(_:)), for: .touchUpInside)
removeButton.addTarget(self, action: #selector(removeContent(_:)), for: .touchUpInside)
// add the first line to the content
addContent(nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// we need to set the bottom inset of the scroll view content
// so it can scroll up above the footer stack view
let h: CGFloat = footerStack.frame.height
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: h, right: 0)
}
#objc func addContent(_ sender: Any?) {
// add another line of text to the content
numLines += 1
scrollContentLabel.text = (1...numLines).map({"Line \($0)"}).joined(separator: "\n")
// scroll newly added line into view
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
let r = CGRect(x: 0, y: self.scrollView.contentSize.height - 1.0, width: 1.0, height: 1.0)
self.scrollView.scrollRectToVisible(r, animated: true)
})
}
#objc func removeContent(_ sender: Any?) {
numLines -= 1
numLines = max(1, numLines)
scrollContentLabel.text = (1...numLines).map({"Line \($0)"}).joined(separator: "\n")
}
}
and it will look like this when running:
As we add content (in this case, just adding more lines to a label), it will "push down" the footer view until it hits the bottom of the scroll view's frame... at which point we can scroll behind the footer view.
It turns out thew view within the footer stackview was not constrained properly. Adding the missing constraint fixed the issue.
I want to scroll the navigation bar as the user scrolls on the view controller. This should be similar to how the YouTube app's home page is working. When the user scrolls down, the navigation bar should be made visible. The navigation bar should move as much as the scroll amount.
I'm aware of hidesBarOnSwipe and setNavigationBarHidden, but these do not give precise control of the y-axis. I'm also reading that Apple does not support directly modifying the navigation bar frame.
So, how does YouTube do this? I'm looking for an MVP demonstrating navigation bar position change along with a UIScrollView offset change.
Without additional detail about what you want to do, I'll make some guesses.
First, the top of the YouTube app's home page is almost certainly not a UINavigationBar -- it doesn't behave like one, there is no pushing/popping of controllers going on, it's in a tab bar controller setup, etc.
So, let's assume it's a view with subviews - we'll call it a "sliding header view" - and your goal is:
don't let the header view's top scroll down
"push it up" when scrolling up
"pull it down" when scrolling down
We can accomplish this by constraining the Top of that header view to the Top of the scroll view's Frame Layout Guide.
when we start to scroll, we'll save the current .contentOffset.y
when we scroll, we'll get the relative scroll y distance
if we're scrolling Up, we'll change the Top Constraint .constant value to move the header view up
if we're scrolling Down, we'll change the Top Constraint .constant value to move the header view down
Here's how it will look at the start:
as we scroll up just a little:
after we've scrolled up farther:
as we scroll down just a little:
after we've scrolled down farther:
Here's the example code for that:
Simple two-label "header" view
class SlidingHeaderView: UIView {
// simple view with two labels
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
backgroundColor = .systemBlue
let v1 = UILabel()
v1.translatesAutoresizingMaskIntoConstraints = false
v1.text = "Label 1"
v1.backgroundColor = .yellow
addSubview(v1)
let v2 = UILabel()
v2.translatesAutoresizingMaskIntoConstraints = false
v2.text = "Label 2"
v2.backgroundColor = .yellow
addSubview(v2)
NSLayoutConstraint.activate([
v1.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
v1.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
v2.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
v2.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
v1.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
v2.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
v2.topAnchor.constraint(equalTo: v1.bottomAnchor, constant: 4.0),
v2.heightAnchor.constraint(equalTo: v1.heightAnchor),
])
}
}
example view controller
class SlidingHeaderViewController: UIViewController {
let scrollView: UIScrollView = {
let v = UIScrollView()
v.contentInsetAdjustmentBehavior = .never
return v
}()
let slidingHeaderView: SlidingHeaderView = {
let v = SlidingHeaderView()
return v
}()
let contentView: UIView = {
let v = UIView()
v.backgroundColor = .systemYellow
return v
}()
// Top constraint for the slidingHeaderView
var slidingViewTopC: NSLayoutConstraint!
// to track the scroll activity
var curScrollY: CGFloat = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
[scrollView, slidingHeaderView, contentView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
// add contentView and slidingHeaderView to the scroll view
[contentView, slidingHeaderView].forEach { v in
scrollView.addSubview(v)
}
// add scroll view to self.view
view.addSubview(scrollView)
let safeG = view.safeAreaLayoutGuide
let contentG = scrollView.contentLayoutGuide
let frameG = scrollView.frameLayoutGuide
// we're going to change slidingHeaderView's Top constraint relative to the Top of the scroll view FRAME
slidingViewTopC = slidingHeaderView.topAnchor.constraint(equalTo: frameG.topAnchor, constant: 0.0)
NSLayoutConstraint.activate([
// scroll view Top to view Top
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 0.0),
// scroll view Leading/Trailing/Bottom to safe area
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: 0.0),
// constrain slidingHeaderView Top to scroll view's FRAME
slidingViewTopC,
// slidingHeaderView to Leading/Trailing of scroll view FRAME
slidingHeaderView.leadingAnchor.constraint(equalTo: frameG.leadingAnchor, constant: 0.0),
slidingHeaderView.trailingAnchor.constraint(equalTo: frameG.trailingAnchor, constant: 0.0),
// no Height or Bottom constraint for slidingHeaderView
// content view Top/Leading/Trailing/Bottom to scroll view's CONTENT GUIDE
contentView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
// content view Width to scroll view's FRAME
contentView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
])
// add some content to the content view so we have something to scroll
addSomeContent()
// because we're going to track the scroll offset
scrollView.delegate = self
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if slidingHeaderView.frame.height == 0 {
// get the size of the slidingHeaderView
let sz = slidingHeaderView.systemLayoutSizeFitting(CGSize(width: scrollView.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
// use its Height for the scroll view's Top contentInset
scrollView.contentInset = UIEdgeInsets(top: sz.height, left: 0, bottom: 0, right: 0)
}
}
func addSomeContent() {
// create a vertical stack view with a bunch of labels
// and add it to our content view so we have something to scroll
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 32
stack.backgroundColor = .gray
stack.translatesAutoresizingMaskIntoConstraints = false
for i in 1...20 {
let v = UILabel()
v.text = "Label \(i)"
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.heightAnchor.constraint(equalToConstant: 48.0).isActive = true
stack.addArrangedSubview(v)
}
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16.0),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16.0),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16.0),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16.0),
])
}
}
extension SlidingHeaderViewController: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
curScrollY = scrollView.contentOffset.y
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let diffY = scrollView.contentOffset.y - curScrollY
var newY: CGFloat = slidingViewTopC.constant - diffY
if diffY < 0 {
// we're scrolling DOWN
newY = min(newY, 0.0)
} else {
// we're scrolling UP
if scrollView.contentOffset.y <= -slidingHeaderView.frame.height {
newY = 0.0
} else {
newY = max(-slidingHeaderView.frame.height, newY)
}
}
// update slidingHeaderView Top constraint constant
slidingViewTopC.constant = newY
curScrollY = scrollView.contentOffset.y
}
}
Everything is done via code - no #IBOutlet or #IBAction connections needed.
I'm trying to learn to build views without storyboard. I tried to build a scrollview. On that scrollview is a UISearchBar, a UIImageView with an image and a UILabel. It works but none of the content moves. The content is all just frozen in place like no matter how far I scroll the search bar will always be on top of the page. and the image on the bottom. I've attached a video to show what I mean. There's also a problem because none of the content is where I want it to be but that's another problem. I realize this is probably because I don't know enough about constraints and autolayout and building views without storyboards.
Here's the video
class HomePageViewController: UIViewController {
var searchedText: String = ""
let label = UILabel()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = "Where are you going?"
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.barTintColor = .systemCyan
searchBar.searchTextField.backgroundColor = .white
searchBar.layer.cornerRadius = 5
return searchBar
}()
let homeImage: UIImageView = {
let homeImage = UIImageView()
homeImage.translatesAutoresizingMaskIntoConstraints = false
homeImage.clipsToBounds = true
return homeImage
}()
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.backgroundColor = .systemMint
scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 30)
return scrollView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
// setupLayout()
// tried this here doesn't do anything for me
}
func setupLayout() {
view.addSubview(scrollView)
self.scrollView.addSubview(searchBar)
homeImage.image = UIImage(named: "Treehouse")
self.scrollView.addSubview(homeImage)
label.text = "Inspiration for your next trip..."
self.scrollView.addSubview(label)
// not sure where this label is being added I want it to be underneath the image but it isn't t
let safeG = view.safeAreaLayoutGuide
let viewFrame = view.bounds
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: -10),
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor),
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
searchBar.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 50.0),
searchBar.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 0.9),
searchBar.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
homeImage.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 150),
homeImage.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 1.1),
homeImage.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
homeImage.heightAnchor.constraint(equalToConstant: viewFrame.height/2),
label.topAnchor.constraint(equalTo: homeImage.bottomAnchor, constant: 100)
])
// was doing all this in viewDidLayoutSubviews but not sure if this is better place for it
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
setupLayout()
// tried this in viewDidLoad() and it didn't solve it.
}
}
any help would be appreciated
First, when constraining subviews in a UIScrollView, you should constrain them to the scroll view's Content Layout Guide. You're constraining them to the view's safe area layout guide, so they're never going to go anywhere.
Second, it's difficult to center subviews in a scroll view, because the scroll view can scroll both horizontally and vertically. So it doesn't really have a "center."
You can either put subviews in a stack view, or, quite common, use a UIView as a "content" view to hold the subviews. If you constrain that content view's Width to the scroll view's Frame Layout Guide width, you can then horizontally center the subviews.
Third, it can be very helpful to comment your constraints, so you know exactly what you expect them to do.
Here's a modified version of your posted code:
class HomePageViewController: UIViewController {
var searchedText: String = ""
let label: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = "Where are you going?"
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.barTintColor = .systemCyan
searchBar.searchTextField.backgroundColor = .white
searchBar.layer.cornerRadius = 5
return searchBar
}()
let homeImage: UIImageView = {
let homeImage = UIImageView()
homeImage.translatesAutoresizingMaskIntoConstraints = false
homeImage.clipsToBounds = true
return homeImage
}()
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.backgroundColor = .systemMint
// don't do this
//scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 30)
return scrollView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemPink
setupLayout()
}
func setupLayout() {
view.addSubview(scrollView)
//homeImage.image = UIImage(named: "Treehouse")
homeImage.image = UIImage(named: "natureBKG")
label.text = "Inspiration for your next trip..."
// let's use a UIView to hold the "scroll content"
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
// give it a green background so we can see it
contentView.backgroundColor = .green
contentView.addSubview(searchBar)
contentView.addSubview(homeImage)
contentView.addSubview(label)
scrollView.addSubview(contentView)
let safeG = view.safeAreaLayoutGuide
let svContentG = scrollView.contentLayoutGuide
let svFrameG = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scrollView to all 4 sides of view
// (generally, constrain to safe-area, but this is what you had)
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor),
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// constrain contentView to all 4 sides of scroll view's Content Layout Guide
contentView.topAnchor.constraint(equalTo: svContentG.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: svContentG.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: svContentG.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: svContentG.bottomAnchor, constant: 0.0),
// constrain contentView Width equal to scroll view's Frame Layout Guide Width
contentView.widthAnchor.constraint(equalTo: svFrameG.widthAnchor),
// constrain searchBar Top to contentView Top + 50
searchBar.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 50.0),
// constrain searchBar Width to 90% of contentView Width
searchBar.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.9),
// constrain searchBar centerX to contentView centerX
searchBar.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain homeImage Top to searchBar Bottom + 40
homeImage.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 40.0),
// constrain homeImage Width equal to contentView Width
homeImage.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0),
// constrain homeImage centerX to contentView centerX
homeImage.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain homeImage Height to 1/2 of scroll view frame Height
homeImage.heightAnchor.constraint(equalTo: svFrameG.heightAnchor, multiplier: 0.5),
// you probably won't get vertical scrolling yet, so increase the vertical space
// between the homeImage and the label by changing the constant
// from 100 to maybe 400
// constrain label Top to homeImage Bottom + 100
label.topAnchor.constraint(equalTo: homeImage.bottomAnchor, constant: 100.0),
// constrain label centerX to contentView centerX
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
// constrain label Bottom to contentView Bottom - 20
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.0),
])
}
}
I'm trying to add a number of buttons programmatically in a grid, where the number of rows exceeds the screen size, so I want them in a UIScrollView.
My minimal working example is the following:
class ViewController: UIViewController {
//center label and down a bit
let label = UILabel(frame: CGRect(x: UIScreen.main.bounds.width/2 - 100, y: 50, width: 200, height: 200))
// start scroll view underneath label (why not y: 250?)
let scrollView = UIScrollView(frame: CGRect(x: 0, y: 50, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height))
override func viewDidLoad() {
super.viewDidLoad()
// add label to view
self.view.addSubview(label)
// add Scrollview
self.view.addSubview(scrollView)
// uncommenting the following also doesn't work
//scrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: 4000)
// add buttons to scroll view in 5x21 grid
var idx = 1
for row in 0...20 {
for col in 0...4 {
let button = UIButton(frame: CGRect(x: col*50, y: row*50 + 200, width: 50, height: 50))
button.backgroundColor = .gray
button.setTitle("\(idx)", for: .normal)
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
// add button to scroll view
scrollView.addSubview(button)
idx += 1
}
}
}
#objc func buttonAction(sender: UIButton!) {
label.text = sender.title(for: .normal)
}
}
Unfortunately the scrolling doesn't work. Even when I explicitly set a larger vertical content size (see commented line) //scrollView.contentSize
I'm wondering if it has something to do with setting x and y values for the buttons fixed? But then how else would I align them in a grid?
So, how do I get the button alignment I want while getting a working ScrollView?
You really should be using Auto-Layout!!!
By far, the easiest way to add buttons (or any view) to a scroll view - in a grid layout like you're asking - and have it automatically determine the scrolling area is with stack views.
Here's a quick example:
class ViewController: UIViewController {
let label = UILabel()
let scrollView = UIScrollView()
override func viewDidLoad() {
super.viewDidLoad()
// start with some text in the label
label.text = "Tap a button"
// center the text in the label
label.textAlignment = .center
// so we can see the frames
label.backgroundColor = .yellow
scrollView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
// create a vertical stack view to hold the rows of buttons
let verticalStackView = UIStackView()
verticalStackView.axis = .vertical
// we're going to use auto-layout
label.translatesAutoresizingMaskIntoConstraints = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
verticalStackView.translatesAutoresizingMaskIntoConstraints = false
// add label to view
self.view.addSubview(label)
// add Scrollview to view
self.view.addSubview(scrollView)
// add stack view to scrollView
scrollView.addSubview(verticalStackView)
// now let's create the buttons and add them
var idx = 1
for row in 0...20 {
// create a "row" stack view
let rowStack = UIStackView()
// add it to the vertical stack view
verticalStackView.addArrangedSubview(rowStack)
for col in 0...4 {
let button = UIButton()
button.backgroundColor = .gray
button.setTitle("\(idx)", for: .normal)
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
// add button to row stack view
rowStack.addArrangedSubview(button)
// buttons should be 50x50
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 50.0),
button.heightAnchor.constraint(equalToConstant: 50.0),
])
idx += 1
}
}
// finally, let's set our constraints
// respect safe-area
let safeG = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// constrain label
// 50-pts from top
// 80% of the width
// centered horizontally
label.topAnchor.constraint(equalTo: safeG.topAnchor, constant: 50.0),
label.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 0.8),
label.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
// constrain scrollView
// 50-pts from bottom of label
// Leading and Trailing to safe-area with 8-pts "padding"
// Bottom to safe-area with 8-pts "padding"
scrollView.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 50.0),
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 8.0),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -8.0),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -8.0),
// constrain vertical stack view to scrollView Content Layout Guide
// 8-pts all around (so we have a little "padding")
verticalStackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8.0),
verticalStackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 8.0),
verticalStackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -8.0),
verticalStackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -8.0),
])
}
#objc func buttonAction(sender: UIButton!) {
label.text = sender.title(for: .normal)
}
}
and this is the result:
then we'll scroll down and tap a different button:
If you want spacing between the buttons, you can add something like this at the end of viewDidLoad():
// suppose we want 8-pts spacing between the buttons?
verticalStackView.spacing = 8.0
verticalStackView.arrangedSubviews.forEach { v in
if let stack = v as? UIStackView {
stack.spacing = 8.0
}
}
Now it looks like this:
If you want the grid of buttons centered horizontally, or if you want the spacing (or button sizes) to adjust to fit, there are a few different ways to do that... You didn't describe your ultimate goal in your question, but this should be a good starting point.
I did the same thing with a collection view which is a subclass of scrollview and that would fit your need in terms of rows/columns.
In addition, to manage touches for a scrollview you have to implement a UIScrollViewDelegate for the scrollview, in particular to distinguish between touching and scrolling otherwise you will possibly end up with unpredictable behaviour.
This has been asked before on Stackoverflow but the solutions don't work for me.
I'm building an app in SwiftUI but I need to use MapKit for maps, so I'm creating some views in UIKit (programmatically, no interface builder). My current problem is that I want to make a capsule background with some padding around a UILabel. It needs to accommodate arbitrary text, so I can't hardcode the sizes, but I can't find a way to determine the innate size of the UILabel text at runtime. I've tried UIEdgeInsets without success.
In the attached code, I am showing a SwiftUI version of what I'm trying to achieve, and then the UIKit attempt. I'd like to follow best practices, so please feel free to tell me any better ways of achieving this.
(Screenshot of what the code produces)
import SwiftUI
struct SwiftUICapsuleView: View {
var body: some View {
Text("Hello, World!")
.padding(6)
.background(Color.gray)
.cornerRadius(15)
}
}
struct UIKitCapsuleView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel(frame: .zero)
label.numberOfLines = 0
label.font = UIFont.systemFont(ofSize: 17)
label.text = "Goodbye, World!"
label.layer.cornerRadius = 15
label.layer.backgroundColor = UIColor.gray.cgColor
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
label.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true
label.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
return view
}
func updateUIView(_ view: UIView, context: Context) {
}
}
struct ExperimentView_Previews: PreviewProvider {
static var previews: some View {
VStack {
SwiftUICapsuleView()
UIKitCapsuleView()
}
}
}
You probably want to set the background color and rounded corners of the view, not the label.
You also should use a full set of constraints.
Give this a try:
struct UIKitCapsuleView: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
// set the view's background color
view.backgroundColor = .cyan
// set the cornerRadius on the view's layer
view.layer.cornerRadius = 15
let label = UILabel(frame: .zero)
label.numberOfLines = 0
label.font = UIFont.systemFont(ofSize: 17)
label.text = "Goodbye, World!"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
// you can adjust padding here
let padding: CGFloat = 6
// use full constraints
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.topAnchor, constant: padding),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding),
label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -padding),
])
return view
}
func updateUIView(_ view: UIView, context: Context) {
}
}