Created
February 16, 2018 05:37
-
-
Save RyogaK/dc016b9df3c77feb14619d2289d93123 to your computer and use it in GitHub Desktop.
横スクロールバナー用カスタムView
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// CircularPageView.swift | |
// Created by Ryoga Kitagawa on 2017/07/22. | |
// | |
import Foundation | |
private let preLoadedCellNum: Int = 1 | |
private let maxVisibleCellNum: Int = preLoadedCellNum * 2 + 1 | |
public protocol CircularPageViewDataSource: class { | |
func circularPageView(_ circularPageView: CircularPageView, cellForIndex index: Int) -> UIView | |
func numberOfContents(_ circularPageView: CircularPageView) -> Int | |
} | |
public protocol CircularPageViewDelegate: class { | |
func circularPageView(_ circularPageView: CircularPageView, pageDidChange page: Int) | |
func circularPageView(_ circularPageView: CircularPageView, didSelectIndex index: Int) | |
} | |
public class CircularPageView: UIScrollView { | |
public weak var circularPageViewDelegate: CircularPageViewDelegate? | |
public weak var dataSource: CircularPageViewDataSource? | |
public var autoscrollInterval: TimeInterval = 0 { | |
didSet { | |
timer = autoscrollInterval == 0 ? nil : ReusableTimer(interval: autoscrollInterval) { [weak self] in | |
guard let strongSelf = self else { return } | |
guard strongSelf.window != nil else { return } | |
let transform = CGAffineTransform(translationX: strongSelf.bounds.width, y: 0) | |
let contentOffset = strongSelf.contentOffset.applying(transform) | |
strongSelf.setContentOffset(contentOffset, animated: true) | |
} | |
} | |
} | |
/// The current index. (0..<numberOfContents) | |
public var currentIndex: Int { return Index(virtualIndex: currentVirtualIndex, numberOfContents: cachedNumberOfContents)?.value ?? 0 } | |
fileprivate let lock = NSRecursiveLock() | |
/// The flag for prevent to fire some process in reloadData. | |
fileprivate var isNowReloading = false | |
/// The current virtual index. (Int.min...Int.max) | |
fileprivate var currentVirtualIndex: Int = 0 | |
/// The number of contents cached when latest reloadData. | |
fileprivate var cachedNumberOfContents: Int = 0 | |
/// Displaying cells keyed by Index. Cells are contained into UIScrollView. | |
fileprivate var displayingCells: [Index: UIView] = [:] | |
/// Reusable cells that already are removed from UIScrollView. | |
fileprivate var cachedReusableCells: [UIView] = [] | |
/// The offset from the center of the middle content. | |
fileprivate var virtualOffset: CGFloat { return (contentOffset.x - bounds.width * CGFloat(preLoadedCellNum)) / bounds.width } | |
fileprivate var currentDisplayingVirtualIndices: [Int] { return (currentVirtualIndex - preLoadedCellNum...currentVirtualIndex + preLoadedCellNum).map { $0 } } | |
fileprivate var timer: ReusableTimer? | |
public convenience init() { | |
self.init(frame: .zero) | |
} | |
public override init(frame: CGRect) { | |
super.init(frame: frame) | |
initialize() | |
} | |
public required init?(coder aDecoder: NSCoder) { | |
super.init(coder: aDecoder) | |
initialize() | |
} | |
private func initialize() { | |
super.delegate = self | |
showsHorizontalScrollIndicator = false | |
isPagingEnabled = true | |
} | |
public override func layoutSubviews() { | |
super.layoutSubviews() | |
//Calculate the difference from centerX of content. | |
let oldOffset: CGFloat = (contentOffset.x + min(bounds.width, contentSize.width) / CGFloat(2)) - (contentSize.width + bounds.width) / CGFloat(2) | |
//Resize contentSize. | |
contentSize = CGSize(width: bounds.width * CGFloat(preLoadedCellNum * 2 + 1), height: bounds.height) | |
//Set new offset with old dirrence from centerX ofcontent. | |
contentOffset.x = contentSize.width / CGFloat(2) + oldOffset | |
displayingCells.forEach { index, cell in | |
if let physicalIndex = PhysicalIndex(virtualIndex: index.virtualIndex, currentVirtualIndex: currentVirtualIndex, numberOfContents: cachedNumberOfContents).value { | |
cell.frame = CGRect(x: CGFloat(physicalIndex) * bounds.width, y: 0, width: bounds.width, height: bounds.height) | |
} else { | |
cell.frame = CGRect.zero | |
} | |
} | |
} | |
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { | |
super.touchesBegan(touches, with: event) | |
timer?.disable() | |
} | |
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { | |
super.touchesEnded(touches, with: event) | |
if let firstPoint = touches.first?.preciseLocation(in: self) { | |
guard let touchedView = (subviews.first { subView in subView.frame.contains(firstPoint) }) else { return } | |
guard let index = displayingCells.first(where: { _, value -> Bool in value == touchedView })?.key else { return } | |
circularPageViewDelegate?.circularPageView(self, didSelectIndex: index.value) | |
} | |
timer?.enable() | |
} | |
public override func didMoveToSuperview() { | |
super.didMoveToSuperview() | |
if superview == nil { | |
timer?.disable() | |
} else { | |
timer?.enable() | |
} | |
} | |
public func reloadData() { | |
lock.lock() | |
isNowReloading = true | |
defer { | |
isNowReloading = false | |
lock.unlock() | |
} | |
let oldIndex = currentIndex | |
displayingCells.forEach { $0.value.removeFromSuperview() } | |
displayingCells = [:] | |
cachedNumberOfContents = 0 | |
guard let dataSource = dataSource else { return } | |
cachedNumberOfContents = dataSource.numberOfContents(self) | |
currentVirtualIndex = max(0, min(cachedNumberOfContents - 1, oldIndex)) | |
circularPageViewDelegate?.circularPageView(self, pageDidChange: currentIndex) | |
manipulateCells(with: dataSource) | |
} | |
public func dequeueReusableCell() -> UIView? { | |
lock.lock(); defer { lock.unlock() } | |
return cachedReusableCells.popLast() | |
} | |
fileprivate class ReusableTimer { | |
let interval: TimeInterval | |
let timerDidFired: () -> Void | |
var timer: Timer? | |
init(interval: TimeInterval, timerDidFired: @escaping () -> Void) { | |
self.interval = interval | |
self.timerDidFired = timerDidFired | |
enable() | |
} | |
func enable() { | |
disable() | |
timer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(_timerDidFired), userInfo: nil, repeats: true) | |
} | |
func disable() { | |
if let timer = timer, timer.isValid { | |
timer.invalidate() | |
} | |
timer = nil | |
} | |
@objc func _timerDidFired() { | |
timerDidFired() | |
} | |
} | |
///The index of element on DataSource. | |
fileprivate struct Index { | |
let virtualIndex: Int | |
let numberOfContents: Int | |
init?(virtualIndex: Int, numberOfContents: Int) { | |
guard numberOfContents > 0 else { return nil } | |
self.virtualIndex = virtualIndex | |
self.numberOfContents = numberOfContents | |
} | |
var value: Int { return (virtualIndex % numberOfContents + numberOfContents) % numberOfContents } | |
} | |
///The index of order of alignment from left of UIScrollView. | |
fileprivate struct PhysicalIndex { | |
let virtualIndex: Int | |
let currentVirtualIndex: Int | |
let numberOfContents: Int | |
var value: Int? { | |
let index = virtualIndex - currentVirtualIndex + preLoadedCellNum | |
return (0..<maxVisibleCellNum).contains(index) ? index : nil | |
} | |
} | |
} | |
private extension CircularPageView { | |
func manipulateCells(with dataSource: CircularPageViewDataSource) { | |
lock.lock(); defer { lock.unlock() } | |
func removeDisappearedCells(withCurrentDisplayingVirtualIndices currentDisplayingVirtualIndices: [Int], fromCache cache: inout [Index: UIView]) { | |
cache.keys.filter { !currentDisplayingVirtualIndices.contains($0.virtualIndex) }.forEach { index in | |
guard let removedCell = cache.removeValue(forKey: index) else { return } | |
removedCell.removeFromSuperview() | |
cachedReusableCells.append(removedCell) | |
} | |
} | |
func fillLackedCells(by dataSource: CircularPageViewDataSource, withCurrentDisplayingVirtualIndices currentDisplayingVirtualIndices: [Int], fromCache cache: inout [Index: UIView]) { | |
func fetchAndAddCellForIndex(_ index: Index) { | |
let cell = dataSource.circularPageView(self, cellForIndex: index.value) | |
cache[index] = cell | |
addSubview(cell) | |
} | |
let cachedCellVirtualIndices = cache.keys | |
currentDisplayingVirtualIndices.flatMap { Index(virtualIndex: $0, numberOfContents: cachedNumberOfContents) }.filter { !cachedCellVirtualIndices.contains($0) }.forEach(fetchAndAddCellForIndex) | |
} | |
let currentDisplayingVirtualIndices = self.currentDisplayingVirtualIndices | |
removeDisappearedCells(withCurrentDisplayingVirtualIndices: currentDisplayingVirtualIndices, fromCache: &displayingCells) | |
fillLackedCells(by: dataSource, withCurrentDisplayingVirtualIndices: currentDisplayingVirtualIndices, fromCache: &displayingCells) | |
} | |
} | |
extension CircularPageView: UIScrollViewDelegate { | |
public func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
guard !isNowReloading else { return } | |
if !(-0.5...0.5).contains(virtualOffset) { | |
let unit: Int = { | |
switch virtualOffset.sign { | |
case .plus: return 1 | |
case .minus: return -1 | |
} | |
}() | |
currentVirtualIndex += unit | |
contentOffset = CGPoint(x: contentOffset.x - bounds.width * CGFloat(unit), y: 0) | |
guard let dataSource = dataSource else { return } | |
manipulateCells(with: dataSource) | |
circularPageViewDelegate?.circularPageView(self, pageDidChange: currentIndex) | |
} | |
} | |
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { | |
timer?.enable() | |
} | |
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { | |
contentOffset.x = round(contentOffset.x / frame.size.width) * frame.size.width | |
} | |
} | |
extension CircularPageView.Index: Hashable, Equatable { | |
var hashValue: Int { return ((virtualIndex * 31) + numberOfContents) * 31 + 650 } | |
} | |
private func == (lhs: CircularPageView.Index, rhs: CircularPageView.Index) -> Bool { | |
return lhs.virtualIndex == rhs.virtualIndex && lhs.numberOfContents == rhs.numberOfContents | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment