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
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:
- Duplicate item đầu tiên và cuối cùng.
- Chỉnh
contentOffset
của scroll view mỗi khi người dùng scroll hết hai item fake vừa tạo.
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).
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
Để lặp scroll view, tất cả những gì ta cần làm là điều chỉnh contentOffset
:
- Khi scroll hoàn chỉnh item fake ở đuôi, gán
contentOffset
bằng width của scroll view bởi đây là offset của item thật đầu tiên (đừng quên rằng item fake ở đuôi là bản sao của item thật ở đầu và mỗi item có width chính bằng chiều dài của scroll view 😅). - Khi scroll đến item fake ở đầu (hay chính là bản sao của item thật ở cuối), update
contentOffset
về offset của item thật ở cuối.
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