Search code examples
iosswiftuiscrollviewautolayoutcarousel

Teaser Carousel View using UIScrollView programatically


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:

  1. The views are not scrollable.
  2. Left and right partial views not showing up. Please Guide me!!! Including cases while handling just 1, 2 and 3 views

Solution

  • 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):

    enter image description here

    Now, as we scroll, we see this:

    enter image description here

    and with paging enabled it will "snap" to the next subview:

    enter image description here

    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:

    enter image description here

    enter image description here

    enter image description here

    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...

    • I'm setting up the "dummy" views in the controller, to make it easier to test 1, 2, 3, etc views.
    • I've made some changes to your constraints to use the scroll view's .contentLayoutGuide and .frameLayoutGuide
    • I've also re-named your 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:

    enter image description here

    and two of them side-by-side in the Zero-spacing stack view look like this:

    enter image description here

    We now have the visual effect of 8-points spacing between the subviews.

    We can also "style" the subviews a little bit, like this:

    enter image description here

    enter image description here

    enter image description here

    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)
            
        }
        
    }