Skip to content

Instantly share code, notes, and snippets.

@RyogaK
Created February 16, 2018 05:37
Show Gist options
  • Save RyogaK/dc016b9df3c77feb14619d2289d93123 to your computer and use it in GitHub Desktop.
Save RyogaK/dc016b9df3c77feb14619d2289d93123 to your computer and use it in GitHub Desktop.
横スクロールバナー用カスタムView
//
// 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
}
//
// SlidableBannerView.swift
// Created by Ryoga Kitagawa on 2017/07/21.
//
import Foundation
public protocol SlidableBannerViewDataSource: class {
func slidableBannerView(_ slidableBannerView: SlidableBannerView, cellForIndex index: Int) -> UIView
func numberOfContents(_ slidableBannerView: SlidableBannerView) -> Int
}
public protocol SlidableBannerViewDelegate: class {
func slidableBannerView(_ slidableBannerView: SlidableBannerView, didSelectIndex index: Int)
}
public class SlidableBannerView: UIView {
public weak var dataSource: SlidableBannerViewDataSource?
public weak var delegate: SlidableBannerViewDelegate?
public var currentPageIndicatorTintColor: UIColor? {
get { return pageControl.currentPageIndicatorTintColor }
set { pageControl.currentPageIndicatorTintColor = newValue }
}
public var pageIndicatorTintColor: UIColor? {
get { return pageControl.pageIndicatorTintColor }
set { pageControl.pageIndicatorTintColor = newValue }
}
public var autoscrollInterval: TimeInterval {
get { return innerView.autoscrollInterval }
set { innerView.autoscrollInterval = newValue }
}
private var innerView: CircularPageView!
fileprivate var pageControl: UIPageControl!
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() {
innerView = {
let view = CircularPageView()
view.circularPageViewDelegate = self
view.dataSource = self
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.topAnchor.constraint(equalTo: topAnchor).isActive = true
view.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
view.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
return view
}()
pageControl = {
let pageControl = UIPageControl()
pageControl.isUserInteractionEnabled = false
addSubview(pageControl)
pageControl.translatesAutoresizingMaskIntoConstraints = false
pageControl.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor).isActive = true
pageControl.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor).isActive = true
pageControl.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
pageControl.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
return pageControl
}()
}
public func reloadData() {
pageControl.numberOfPages = dataSource?.numberOfContents(self) ?? 0
innerView.reloadData()
}
public func dequeueReusableCell() -> UIView? {
return innerView.dequeueReusableCell()
}
}
extension SlidableBannerView: CircularPageViewDelegate {
public func circularPageView(_ circularPageView: CircularPageView, pageDidChange page: Int) {
pageControl.currentPage = page
}
public func circularPageView(_ circularPageView: CircularPageView, didSelectIndex index: Int) {
delegate?.slidableBannerView(self, didSelectIndex: index)
}
}
extension SlidableBannerView: CircularPageViewDataSource {
public func circularPageView(_ circularPageView: CircularPageView, cellForIndex index: Int) -> UIView {
guard let dataSource = dataSource else { fatalError() }
return dataSource.slidableBannerView(self, cellForIndex: index)
}
public func numberOfContents(_ circularPageView: CircularPageView) -> Int {
guard let dataSource = dataSource else { return 0 }
return dataSource.numberOfContents(self)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment