One, foreword

In the previous part, we learned how to use UICollectionView to create a normal BannerView. In general products, in addition to displaying images, BannerView also needs to have the following small functions:

  • Support left and right infinite cycle round seeding;
  • Support for PageIndicator (a very small View component that is usually used with BannerView)
  • Support timing switch (including animation);
  • Support user manual touch, stop timing, and after the finger release, restart timing;

Let’s cut the crap and get to work.

Two, left and right infinite cycle round seeding

If you were reading the previous article, you might have noticed that the second argument in the initialization (convenience constructor) is loop: Bool. I wrote the last share, just left a “hole”, and did not realize the specific logic, however, the last article gives the source already, if there is a small partner has seen.

2.1. Add member variable loop

public class BannerPageView: UICollectionView UICollectionViewDelegate, UICollectionViewDataSource {/ / about whether to support an infinite loop, the default is true fileprivate var loop: Bool = true }Copy the code

2.2. Facilitate constructor assignment

// Extension BannerPageView {// Extension BannerPageView {// Extension BannerPageView public Convenience init(frame: CGRect, loop: Bool = true) { ...... // Call self.init. Designated keyword, convenience, the required "/ / https://juejin.cn/post/6932885089546141709. Self init (frame: Frame, collectionViewLayout: layout) // Whether infinite loop, default = true self.loop = loop...... }}Copy the code

2.3. Adjust the data source during input

Here’s a little more on how to make data loop indefinitely:

  1. Incoming source start data N;
  2. Modify the source data, insert the source data [n-1] in position 0, insert the source data [0] in position 0;
  3. Use the adjusted data as the dataSource of UICollectionView.
  4. When the data scrolls to the 0th position, adjust its subscript to the second-to-last position (no animation switch);
  5. When the data scrolls to the last position, adjust its subscript to the second positive-number position (no animation switch);

In this way, we can browse back and forth between [1 and n-2] to achieve an infinite loop; The code is as follows:

public class BannerPageView: UICollectionView.UICollectionViewDelegate.UICollectionViewDataSource {
    .
    public func setUrls(_ urls: [String]) {
        // Original data: [a, b, c]
        self.urls = urls
        reData()
    }
    
    public func setLoop(_ loop: Bool) {
        self.loop = loop
    }
    
    func reData(a) {
        // If infinite loop is supported, the data becomes: [c, a, b, c, a]
        if loop {
            urls!.insert(urls!.last!, at: 0)
            urls!.append(urls![1])
        }
        
        reloadData()
        layoutIfNeeded()
        
        if loop {
            // If the loop is infinite, the index 0 is now 1 because two additional items are added before and after the data
            scrollToItem(at: IndexPath(row: loop ? 1 : 0, section: 0),
                         at: UICollectionView.ScrollPosition(rawValue: 0),
                         // When repositioning subscripts, do not animate, otherwise the user will feel strange
                         animated: false)}}.
}
Copy the code

The above code is the adjustment made when the data is initially passed in; Or, if the data is later updated; At the same time, as I said above, every time we scroll the data, we need to determine whether we reach position 0 or position N-1, and if we do, we need to adjust; UICollectionView: UICollectionView delegate: UICollectionViewDelegate: UICollectionViewDelegate: UICollectionViewDelegate

public class BannerPageView: UICollectionView.UICollectionViewDelegate.UICollectionViewDataSource {
    .
    // MARK: UICollectionViewDelegate
    
    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        // Calculate page subscript = horizontal scroll offset/width
        var idx = Int(contentOffset.x / frame.size.width)
        
        // If infinite loop is enabled, you need to determine whether to reposition after each scroll
        if loop {
            // Take [c, a, b, c, a] for example
            if idx = = 0 {
                // If idx == 0, it indicates that we have slipped to the leftmost c, and we need to scroll it to the second from the bottom.
                scrollToItem(at: IndexPath(row: urls!.count - 2, section: 0), 
                             at: UICollectionView.ScrollPosition(rawValue: 0), 
                             animated: false)}else if idx = = urls!.count - 1 {
                // If idx == last, indicating that it has slipped to the far right of a, we need to scroll it to the first bit.
                scrollToItem(at: IndexPath(row: 1, section: 0), 
                             at: UICollectionView.ScrollPosition(rawValue: 0), 
                             animated: false)}}}.
}
Copy the code

PageIndicator

PageIndicator is well understood, which is to tell the user the scroll number of the current scroll chart, as shown below:

The dots in the red box:

  • The number represents the number of pictures in the rotation graph;
  • Pure white solid dot represents the current subscript;
  • Translucent dots represent unselected states;

PageIndicator is also a custom widget (we learned how to draw circles and color in the AD page earlier), so here’s the code:

import UIKit

fileprivate let kGap: CGFloat = 5.0
// Translucent white background
fileprivate let kBgColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.5).cgColor
// Solid white background
fileprivate let kFgColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor

class BannerPageIndicator: UIView {
    var indicators: [CAShapeLayer] = []
    var curIdx: Int = 0

    // Add dots
    public func addCircleLayer(_ nums: Int) {
        if nums > 0 {
            for _ in 0..<nums {
                let circle = CAShapeLayer()
                circle.fillColor = kBgColor
                indicators.append(circle)
                layer.addSublayer(circle)
            }
        }
    }
    
    // Count and center
    public override func layoutSubviews(a) {
        super.layoutSubviews()
        
        let count = indicators.count
        let d = bounds.height
        let totalWidth = d * CGFloat(count) + kGap * CGFloat(count)
        let startX = (bounds.width - totalWidth) / 2
        
        for i in 0..<count {
            let x = (d + kGap) * CGFloat(i) + startX
            let circle = indicators[i]
            circle.path = UIBezierPath(roundedRect: CGRect(x: x, y: 0, width: d, height: d), cornerRadius: d / 2).cgPath
        }
        
        setCurIdx(0)}// Sets the subscript of the currently displayed graph
    public func setCurIdx(_ idx: Int) {
        // Modify the current dot background (translucent)
        indicators[curIdx].fillColor = kBgColor
        
        // Modify the subscript
        curIdx = idx
        
        // Then modify the actual corresponding image subscript dot background (pure white)
        indicators[curIdx].fillColor = kFgColor
    }
}
Copy the code

4. BannerPageView associates with BannerPageIndicator

We already have two widgets, and their relationship is shown below:

When our BannerPageView switches, we need to call back to notify the BannerView, and the BannerView sets the indicator dot; In iOS, both OC and Swift are implemented via a Delegate (Protocol). Here, we define a BannerDelegate:

import Foundation

public protocol BannerDelegate: NSObjectProtocol {
    func didPageChange(idx: Int)
}
Copy the code

4.1 BannerView implements delegation

import UIKit

public class BannerView: UIView.BannerDelegate {
    fileprivate var banner: BannerPageView?
    fileprivate var indicators: BannerPageIndicator?
    
    public override init(frame: CGRect) {
        super.init(frame: frame)

        banner = BannerPageView(frame: frame, loop: true)
        // Set the delegate to itself
        banner?.bannerDelegate = self
        addSubview(banner!)
        
        indicators = BannerPageIndicator(frame: CGRect.zero)
        indicators?.translatesAutoresizingMaskIntoConstraints = false
        addSubview(indicators!)}required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    public func setData(_ urls: [String]._ loop: Bool) {
        banner?.setLoop(loop)
        banner?.setUrls(urls)
        adjustIndicator(urls.count)
    }
    
    // MARK: BannerDelegate
    public func didPageChange(idx: Int) {
        indicators?.setCurIdx(idx)
    }
    
    func adjustIndicator(_ count: Int) {
        indicators?.addCircleLayer(count)
        NSLayoutConstraint.activate([
            indicators!.widthAnchor.constraint(equalToConstant: frame.width),
            indicators!.heightAnchor.constraint(equalToConstant: 8),
            indicators!.centerXAnchor.constraint(equalTo: centerXAnchor),
            indicators!.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10)]}}Copy the code

4.2, Modify BannerPageView (delegate callback)

public class BannerPageView: UICollectionView.UICollectionViewDelegate.UICollectionViewDataSource {
    var bannerDelegate: BannerDelegate?
    .
    
    // If the scrolling is cyclic, calculate whether to reposition after the scrolling ends
    func redirectPosition(a) {
        // Calculate page subscript = horizontal scroll offset/width
        var idx = Int(contentOffset.x / frame.size.width)
        
        // If infinite loop is enabled, you need to determine whether to reposition after each scroll
        if loop {
            // Take [c, a, b, c, a] for example
            if idx = = 0 {
                // If idx == 0, it indicates that we have slipped to the leftmost c, and we need to scroll it to the second from the bottom.
                scrollToItem(at: IndexPath(row: urls!.count - 2, section: 0), at: UICollectionView.ScrollPosition(rawValue: 0), animated: false)
                idx = urls!.count - 3
            } else if idx = = urls!.count - 1 {
                // If idx == last, indicating that it has slipped to the far right of a, we need to scroll it to the first bit.
                scrollToItem(at: IndexPath(row: 1, section: 0), at: UICollectionView.ScrollPosition(rawValue: 0), animated: false)
                idx = 0
            } else {
                idx - = 1
            }
        }

        bannerDelegate?.didPageChange(idx: idx)
    }
    
    // MARK: UICollectionViewDelegate

    // This method is called only when the user's finger touches the scroll
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        redirectPosition()
    }
    
    .
}
Copy the code

V. Timing switch (including animation)

For the AD page, we used the GCD Timer. Today, we will use another kind of Timer: Timer (Swift)/NSTimer (OC); Adding a timer to the Banner is easy (here’s a quick thumbs-up on Swift extension for code splitting) :

class BannerPageView: UICollectionView.UICollectionViewDelegate.UICollectionViewDataSource {
    fileprivate var timer: Timer?
    
    .
    
    public func setUrls(_ urls: [String]) {
        .
        startTimer()
    }
    
    // MARK: UICollectionViewDelegate
    
    // This method is executed when setContentOffset or scrollRectVisible is complete and animated = true
    // Note: This method will not be called if animated = false
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        redirectPosition()
    }
    .
}

// Extension: handle timer
extension BannerPageView {
    func startTimer(a) {
        endTimer()
        timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [weak self] _ in
            self?.next()
        })
    }
    
    // End the timer
    func endTimer(a) {
        timer?.invalidate()
        timer = nil
    }
    
    func next(a) {
        let idx = Int(contentOffset.x / frame.size.width)
        scrollToItem(at: IndexPath(row: idx + 1, section: 0), 
                     at: UICollectionView.ScrollPosition(rawValue: 0), 
                     animated: true)}}Copy the code

We already have the timer, however, there is a user experience problem: when the user fingers touch, because the timer is constantly triggered, it will still trigger the page turning, so we need to deal with:

  • When the user touches, the timer stops;
  • When the user releases, restart the timer;

The implementation is simple, we only need to deal with two methods in the UIScrollViewDelegate, as follows:

// Extension: handle timer
extension BannerPageView {
    // User finger touch stop timer
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        endTimer()
    }
    
    // Restart the timer after release
    func scrollViewDidEndDragging(_ scrollView: UIScrollView.willDecelerate decelerate: Bool) {
        startTimer()
    }
}
Copy the code

Handle click events

Banner clicking on this is easy, we just need to add Tap to the BannerView:

public class BannerView: UIView.BannerDelegate {
    .
    public override init(frame: CGRect) {
        super.init(frame: frame)
        
        isUserInteractionEnabled = true
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap)))
        .
    }
    
    @objc func handleTap(a) {
        print("handleTap ==== \(String(describing: indicators?.curIdx))")}}Copy the code

Seven,

So much for Banner, to summarize:

  • In this article, we inherit from UICollectionView. In actual development, we can also directly use UICollectionView as a BannerView.
  • Because we are using a double window, our BannerView has already finished a page turn by the time the countdown (5s) ends. This is not a problem if you use a single window; (In real development, network requests are also involved, so single/dual Windows have their own advantages);

We learned UICollectionView through Banner, this is just the most basic usage, we will use more complex scenes in the later “floors”.

All source code so far: Passgate

If you have any questions, please contact us. Thank you!