Linh Tạ

Infinite ScrollView

Trong bài viết này, chúng ta sẽ cùng tạo một infinite CarouselView có khả năng scroll hai đầu như ví dụ dưới đây

Example we are going to build

CarouselView là một UIView chứa UIScrollView subview với nhiệm vụ config item của scroll view và quản lí logic lặp. Để tạo ra ảo giác rằng scroll view lặp vô hạn, chúng ta cần áp dụng hai mẹo sau:


Bước 1: Duplicate item đầu cuối

Vì người dùng có thể scroll cả hai phía, ta phải tạo 1 bản sao của item cuối ở đầu và một bản sao của item đầu ở cuối. Điều này có nghĩa với input n item, CarouselView sẽ thêm n + 2 subview cho scroll view (n item gốc và 2 item thêm vào).

Scroll view setup
Setup của scroll view

Với setup này, dù người dùng có scroll ngược lại ngay từ tương tác đầu tiên thì cũng vẫn có cảm giác rằng scroll view được loop.


class CarouselView: UIView {
    private let scrollView = UIScrollView()
    private let scrollContentView = UIView()

    //Bật tắt chế độ loop của scroll view
    var circular = true {
        didSet {
            guard circular != oldValue else { return }

            //Mỗi khi circular thay đổi, config lại layout của scroll view
            inputImages = { inputImages }()
        }
    }

    private var canLoop: Bool {
        //Không loop nếu circular == false hoặc chỉ có 1 ảnh
        return circular && (inputImages.count > 1)
    }

    //Input gốc được gán bởi người dùng
    var inputImages = [UIImage]() {
        didSet {
            guard canLoop,
                  let lastImage = inputImages.last,
                  let firstImage = inputImages.first
            else {
                scrollViewImages = inputImages
                return
            }

            scrollViewImages = [lastImage] + inputImages + [firstImage]
        }
    }

    //Mảng chứa input gốc và 2 input fake được thêm vào nếu canLoop == true
    private var scrollViewImages = [UIImage]() {
        didSet {
            //Config lại layout của scroll view
            reloadScrollView()
        }
    }
}

inputImages chứa các item gốc được người dùng truyền vào còn scrollViewImages là mảng chứa số item thực tế trong scroll view. Mỗi khi giá trị của inputImages thay đổi, ta muốn thêm hai item fake cho scrollViewImages trong trường hợp circular == true và số lượng item lớn hơn một.


private func reloadScrollView() {
    //Xoá item cũ
    for view in scrollContentView.subviews {
        view.removeFromSuperview()
    }

    //Add item mới
    //item.width == scrollView.bounds.width
    addScrollItems(for: scrollViewImages)
    layoutIfNeeded()

    //Nếu circular == true và 2 item fake được thêm vào
    //ta phải offset scroll view để giấu item fake ở đầu
    if circular && (scrollViewImages.count > 1) {
        let focusRect = CGRect(x: scrollView.bounds.width,
                               y: 0,
                               width: scrollView.bounds.width,
                               height: scrollView.bounds.height)

        scrollView.scrollRectToVisible(focusRect, animated: false)
    }
}

Mỗi khi nhận input mới, ta muốn xoá toàn bộ subview cũ, add subview mới, và điều chỉnh offset của scroll view để giấu item fake ở đầu nếu cần thiết.

Bước 2: Update contentOffset của scroll view

Logic update offset
Logic update offset

Để lặp scroll view, tất cả những gì ta cần làm là điều chỉnh contentOffset:


func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard canLoop else { return }

    if scrollView.contentOffset.x >= scrollView.bounds.width * CGFloat(inputImages.count + 1) {
        //Set offset về item thật ở đầu khi scroll hoàn chỉnh item fake ở đuôi
        scrollView.contentOffset = CGPoint(x: scrollView.bounds.width, y: 0)
    } else if scrollView.contentOffset.x <= 0 {
        //Set offset về item thật ở đuôi khi scroll hoàn chỉnh item fake ở đầu
        let maxNormalContentOffset = scrollView.bounds.width * CGFloat(inputImages.count)
        scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x + maxNormalContentOffset, y: 0)
    }
}


Bonus: Tự động chuyển item

CarouselView của chúng ta đã tương đối hoàn chỉnh với khả năng scroll hai đầu không tì vết 😎. Tính năng duy nhất cần bổ sung là chế độ tự động scroll khi không có tương tác người dùng. Ta có thể dễ dàng làm điều này với sự giúp đỡ của Timer.


private weak var scrollTimer: Timer?

var shouldScrollAutomatically = false {
    didSet {
        if shouldScrollAutomatically {
            enableAutomaticScroll()
        } else {
            disableAutomaticScroll()
        }
    }
}

var waitDuration: TimeInterval = 3 {
    didSet {
        guard shouldScrollAutomatically else { return }
        enableAutomaticScroll()
    }
}

private func enableAutomaticScroll() {
    guard shouldScrollAutomatically else { return }
    guard inputImages.count > 1 else { return }
    scrollTimer?.invalidate()
    scrollTimer = Timer.scheduledTimer(withTimeInterval: waitDuration, repeats: true) { [weak self] timer in
        guard let self = self else {
            timer.invalidate()
            return
        }
        self.scrollNext()
    }
}

private func disableAutomaticScroll() {
    scrollTimer?.invalidate()
    scrollTimer = nil
}

extension CarouselView: UIScrollViewDelegate {
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        disableAutomaticScroll()
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        enableAutomaticScroll()
    }
}

Với mỗi lần chạy, timer sẽ tính toán page tiếp theo và set contentOffset của scroll view tới đó. Ngoài ra ta sẽ tắt timer mỗi khi người dùng bắt đầu scroll và bật lại timer khi thao tác hoàn thành.

Tổng kết

Chỉ với một vài logic đơn giản chúng ta đã tạo ra một infinite CarouselView với đầy đủ tính năng mà không cần đến bất kì thư viện bên thứ 3 nào. Mặc dù subview của scroll view ở trên là UIImageView, không gì ngăn bạn cải tiến CarouselView và biến nó thành một class có khả năng lặp bất kỳ custom view nào hay thậm chí trao toàn bộ quyền config subview cho người dùng. Chúc bạn may mắn và hẹn gặp lại ở bài viết tới!

Source code: Carousel example