I want to implement a teaser carousel view(that is partial left and right view shown in the viewport along with the main view centered) using UIScrollView.
View Hierarchy:
ViewController -> ScrollView -> Horizontal Stack View -> and 3 views embedded inside it
class ViewController: UIViewController {
override func viewDidLoad() {
let scroll = CarouselView(views: [])
view.addSubview(scroll)
scroll.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
])
}
}
class CarouselView: UIView, UIScrollViewDelegate {
var views: [UIView] = []
init(views: [UIView]) {
super.init(frame: .zero)
self.views = views
scrollView.delegate = self
setupSubviews()
setupLayoutConstraints()
setupDummyViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupSubviews() {
addSubview(scrollView)
scrollView.addSubview(stackView)
}
private func setupLayoutConstraints() {
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: topAnchor),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
scrollView.heightAnchor.constraint(equalToConstant: 150.0),
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor)
])
}
private func setupDummyViews() {
let view1 = UIView()
view1.backgroundColor = .red
let view2 = UIView()
view2.backgroundColor = .yellow
let view3 = UIView()
view3.backgroundColor = .green
let views = [view1, view2, view3]
for view in views {
stackView.addArrangedSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
])
}
}
// MARK: - UI Components
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
let stackView: UIStackView = {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
stackView.spacing = 0.0
stackView.distribution = .fillEqually
stackView.alignment = .fill
return stackView
}()
}
Problems:
With your current code, the reason you cannot scroll is because your scroll
instance of CarouselView
has no height.
By default, a UIView
has .clipsToBounds = false
-- so you can see any subviews that extend outside the frame of the view.
If you set scroll.clipsToBounds = true
and run your code as-is:
let scroll = CarouselView(views: [])
scroll.clipsToBounds = true
You won't see anything.
Views that extend outside the frame of their superview cannot receive touches. So, even though you can see them, you cannot interact with them.
You missed an important line in your constraint setup:
scrollView.topAnchor.constraint(equalTo: topAnchor),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
scrollView.heightAnchor.constraint(equalToConstant: 150.0),
// you need this line to give a height to "self"
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
Now, you can scroll (and still see the views with scroll.clipsToBounds = true
).
Next step is to get your carousel subviews to be less than the full scroll
view width. However, setting scrollView.isPagingEnabled = true
pages the full width of the scroll view.
Many different ways to approach this, including disabling .isPagingEnabled
and handle the view positioning, deceleration, etc on our own; using a UICollectionView
with calculated content insets; etc.
Or, we can get a little tricky...
Let's set the Width of the scrollView to the desired "partial" width, like this - light gray is the Carousel view background, and we have a black outline around the scroll view):
Now, as we scroll, we see this:
and with paging enabled it will "snap" to the next subview:
Sounds like we are part of the way... but, we also want to see the partial previous/next subviews.
So, let's set scrollView.clipsToBounds = false
:
When trying that, though, we quickly find a problem.
Because the subviews are "visible but outside the frame" of the scroll view, we can only scroll by dragging within the scroll view itself (inside the black outline).
To allow dragging from any part of the view, we can implement hitTest(...)
(inside the Carouself view class) like this:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
return scrollView
}
return super.hitTest(point, with: event)
}
Now, if the touch is inside the view, we tell the scrollView to use that touch. If it's outside the view, we return super...
to allow the touch to act on any other UI elements.
Here's a complete example...
.contentLayoutGuide
and .frameLayoutGuide
scroll
view to myCarouselView
to avoid confusion.view controller class
class CarouselViewController: UIViewController {
override func viewDidLoad() {
let colors: [UIColor] = [
.red, .green, .blue,
.cyan, .magenta, .yellow,
]
let numViews: Int = 3
var views: [UIView] = []
for i in 0..<numViews {
let v = UIView()
v.backgroundColor = colors[i % colors.count]
views.append(v)
}
let myCarouselView = CarouselView(views: views)
view.addSubview(myCarouselView)
myCarouselView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
myCarouselView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
myCarouselView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
myCarouselView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
])
// so we can see the view framing
myCarouselView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
}
}
CarouselView class
class CarouselView: UIView, UIScrollViewDelegate {
var views: [UIView] = []
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
return scrollView
}
return super.hitTest(point, with: event)
}
init(views: [UIView]) {
super.init(frame: .zero)
self.views = views
scrollView.delegate = self
setupSubviews()
setupLayoutConstraints()
for view in views {
stackView.addArrangedSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
}
// so we can see the views that are outside the frame of the scroll view
scrollView.clipsToBounds = false
// let's give the scroll view a border to make it clear
scrollView.layer.borderWidth = 2
scrollView.layer.borderColor = UIColor.black.cgColor
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupSubviews() {
addSubview(scrollView)
scrollView.addSubview(stackView)
}
private func setupLayoutConstraints() {
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: topAnchor),
scrollView.heightAnchor.constraint(equalToConstant: 150.0),
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
scrollView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.60),
scrollView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.topAnchor.constraint(equalTo: cg.topAnchor),
stackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: cg.bottomAnchor),
stackView.heightAnchor.constraint(equalTo: fg.heightAnchor),
])
}
// MARK: - UI Components
let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
let stackView: UIStackView = {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
stackView.spacing = 0.0
stackView.distribution = .fillEqually
stackView.alignment = .fill
return stackView
}()
}
Edit
If you want spacing between the carousel views, don't change the stack view spacing.
Instead, design your subviews to include the spacing.
For example, we can add a white UIView
to act as the "visible frame", and add a centered label as a subiew. We will constrain the white view with 8-points on top and bottom, and 4-points on leading and trailing.
So, a single view will look like this:
and two of them side-by-side in the Zero-spacing stack view look like this:
We now have the visual effect of 8-points spacing between the subviews.
We can also "style" the subviews a little bit, like this:
So, no changes to the CarouselView
class posted above.
We'll create the beginnings of a CarouselCardView
class:
class CarouselCardView: UIView {
let label: UILabel = {
let v = UILabel()
v.textAlignment = .center
v.font = .systemFont(ofSize: 48.0, weight: .regular)
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
// add a view with rounded corners be the "visible frame"
let rv = UIView()
rv.backgroundColor = .white
rv.layer.cornerRadius = 12
// let's give it a very light shadow
rv.layer.shadowOffset = .init(width: 0.0, height: 1.0)
rv.layer.shadowColor = UIColor.black.cgColor
rv.layer.shadowRadius = 2.0
rv.layer.shadowOpacity = 0.5
rv.translatesAutoresizingMaskIntoConstraints = false
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(rv)
addSubview(label)
NSLayoutConstraint.activate([
rv.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
rv.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4.0),
rv.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4.0),
rv.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
}
and then in the example view controller, instead of "dummy" views with different color backgrounds, we'll create instances of CarouselCardView
:
class CarouselViewController: UIViewController {
override func viewDidLoad() {
let numViews: Int = 5
var views: [UIView] = []
for i in 0..<numViews {
let v = CarouselCardView()
v.label.text = "\(i)"
views.append(v)
}
let myCarouselView = CarouselView(views: views)
view.addSubview(myCarouselView)
myCarouselView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
myCarouselView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
myCarouselView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
myCarouselView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
])
// so we can see the view framing
myCarouselView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
}
}