Created
September 26, 2016 21:54
-
-
Save kreeger/965cc093a140bec5445b4e1809dde217 to your computer and use it in GitHub Desktop.
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
// | |
// CollectionIndexView.swift | |
// Created by Ben Kreeger on 9/26/16. | |
// | |
import UIKit | |
@objc(BDKCollectionIndexView) | |
class CollectionIndexView: UIControl { | |
enum Direction { | |
case vertical | |
case horizontal | |
} | |
weak var delegate: CollectionIndexViewDelegate? | |
var indexTitles = [String]() { | |
didSet { | |
guard indexTitles != oldValue else { return } | |
reloadData() | |
} | |
} | |
private(set) var currentIndex = 0 | |
private(set) var direction: Direction = .vertical | |
var font = UIFont.boldSystemFont(ofSize: 12.0) { | |
didSet { | |
indexLabels.forEach { $0.font = font } | |
} | |
} | |
var touchStatusBackgroundColor: UIColor = .black | |
var touchStatusViewAlpha: CGFloat = 0.25 | |
var currentIndexTitle: String { | |
return indexTitles[currentIndex] | |
} | |
private lazy var touchStatusView: UIView = { | |
let view = UIView() | |
view.backgroundColor = UIColor.black.withAlphaComponent(0.0) | |
let dimension: CGFloat | |
switch self.direction { | |
case .horizontal: dimension = self.bounds.size.height | |
case .vertical: dimension = self.bounds.size.width | |
} | |
view.layer.cornerRadius = dimension / 2.0 | |
view.layer.masksToBounds = true | |
return view | |
}() | |
private var indexLabels = [UILabel]() | |
fileprivate var panner: UIPanGestureRecognizer = { | |
let nizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))) | |
return nizer | |
}() | |
private var tapper: UITapGestureRecognizer = { | |
return UITapGestureRecognizer(target: self, action: #selector(handleGesture(_:))) | |
}() | |
fileprivate lazy var longPresser: UILongPressGestureRecognizer = { | |
let nizer = UILongPressGestureRecognizer(target: self, action: #selector(handleGesture(_:))) | |
nizer.delegate = self | |
nizer.minimumPressDuration = 0.01 | |
return nizer | |
}() | |
private var theDimension: CGFloat = 0 | |
private var isBackgroundVisible: Bool = false { | |
didSet { | |
let alpha: CGFloat = isBackgroundVisible ? touchStatusViewAlpha : 0.0 | |
touchStatusView.backgroundColor = touchStatusBackgroundColor.withAlphaComponent(alpha) | |
} | |
} | |
// MARK: - Lifecycle | |
override func layoutSubviews() { | |
super.layoutSubviews() | |
var labelSize = CGSize.zero | |
var dimension: CGFloat = 0 | |
var totalLabelSize: CGFloat = 0 | |
var positionOffset: CGFloat = 0 | |
let isPad = UIDevice.current.userInterfaceIdiom == .pad | |
switch direction { | |
case .horizontal: | |
dimension = frame.size.height | |
labelSize = CGSize(width: dimension - (isPad ? 6 : 2), height: dimension) | |
totalLabelSize = CGFloat(indexLabels.count) * labelSize.width | |
while totalLabelSize > bounds.size.width { | |
labelSize = CGSize(width: labelSize.width - 1, height: labelSize.height) | |
totalLabelSize = CGFloat(indexLabels.count) * labelSize.width | |
} | |
positionOffset = bounds.size.width / 2.0 - totalLabelSize / 2.0 - 2.0 | |
case .vertical: | |
dimension = frame.size.width | |
labelSize = CGSize(width: dimension, height: dimension - (isPad ? 6 : 0)) | |
totalLabelSize = CGFloat(indexLabels.count) * labelSize.height | |
while totalLabelSize > bounds.size.height { | |
labelSize = CGSize(width: labelSize.width, height: labelSize.height - 1) | |
totalLabelSize = CGFloat(indexLabels.count) * labelSize.height | |
} | |
positionOffset = bounds.size.height / 2.0 - totalLabelSize / 2.0 - 2.0 | |
} | |
indexLabels.forEach { label in | |
switch self.direction { | |
case .horizontal: | |
label.frame = CGRect(origin: CGPoint(x: positionOffset, y: 0), size: labelSize) | |
positionOffset += label.frame.size.width | |
case .vertical: | |
label.frame = CGRect(origin: CGPoint(x: 0, y: positionOffset), size: labelSize) | |
positionOffset += label.frame.size.height | |
} | |
} | |
touchStatusView.frame = bounds.insetBy(dx: 2, dy: 2) | |
touchStatusView.layer.cornerRadius = floor(dimension / 2.75) | |
} | |
override func tintColorDidChange() { | |
super.tintColorDidChange() | |
if tintAdjustmentMode == .dimmed { | |
indexLabels.forEach { $0.textColor = .lightGray } | |
} else { | |
indexLabels.forEach { $0.textColor = self.tintColor } | |
} | |
} | |
// MARK: Accessibility | |
override func accessibilityDecrement() { | |
let newIndex = currentIndex + 1 | |
guard newIndex < indexLabels.count else { return } | |
currentIndex = newIndex | |
sendActions(for: .valueChanged) | |
announceNewSection() | |
} | |
override func accessibilityIncrement() { | |
let newIndex = currentIndex - 1 | |
guard newIndex >= 0 else { return } | |
currentIndex = newIndex | |
sendActions(for: .valueChanged) | |
announceNewSection() | |
} | |
// MARK: Public functions | |
func reloadData() { | |
indexLabels.forEach { $0.removeFromSuperview() } | |
buildIndexLabels() | |
} | |
// MARK: Private functions | |
private func setup() { | |
tintColor = .black | |
backgroundColor = .clear | |
addGestureRecognizer(panner) | |
panner.delegate = self | |
addGestureRecognizer(tapper) | |
addGestureRecognizer(longPresser) | |
addSubview(touchStatusView) | |
isAccessibilityElement = true | |
accessibilityTraits = UIAccessibilityTraitAdjustable | |
accessibilityLabel = NSLocalizedString("table index", comment: "title given to the section index control") | |
} | |
private func buildIndexLabels() { | |
indexLabels = indexTitles.map { title in | |
let label = UILabel() | |
label.text = title | |
label.font = self.font | |
label.backgroundColor = self.backgroundColor | |
label.textColor = self.tintColor | |
label.textAlignment = .center | |
label.isAccessibilityElement = false | |
self.addSubview(label) | |
return label | |
} | |
} | |
private func setNewIndex(for point: CGPoint) { | |
guard indexLabels.count > 0 else { return } | |
let doesMatchPoint: (UIView) -> Bool | |
switch direction { | |
case .horizontal: | |
doesMatchPoint = { return point.x <= $0.frame.minX && point.x <= $0.frame.maxX } | |
case .vertical: | |
doesMatchPoint = { return point.y <= $0.frame.minY && point.y <= $0.frame.maxY } | |
} | |
var newIndex = -1 | |
for (index, label) in indexLabels.enumerated() { | |
guard doesMatchPoint(label) else { continue } | |
newIndex = index | |
break | |
} | |
if newIndex == -1, let topLabel = indexLabels.first, let bottomLabel = indexLabels.last { | |
if point.y < topLabel.frame.origin.y { | |
newIndex = 0 | |
} else if point.y > bottomLabel.frame.origin.y { | |
newIndex = indexLabels.count - 1 | |
} | |
} | |
if newIndex > -1 && newIndex != currentIndex { | |
currentIndex = newIndex | |
sendActions(for: .valueChanged) | |
} | |
} | |
private func announceNewSection() { | |
let title = indexTitles[currentIndex] | |
let selectedString = NSLocalizedString("selected", comment: "word that indicates an item is selected") | |
let titleToAnnounce = title.accessibilityLabel ?? title | |
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, "\(titleToAnnounce) ,\(selectedString)") | |
} | |
@objc private func handleGesture(_ gesture: UIGestureRecognizer) { | |
isBackgroundVisible = gesture.state != .ended | |
setNewIndex(for: gesture.location(in: self)) | |
guard gesture == longPresser else { return } | |
if gesture.state == .ended { | |
delegate?.collectionIndexView(self, liftedFingerFrom: currentIndex) | |
} else { | |
delegate?.collectionIndexView(self, isPressedOn: currentIndex, title: currentIndexTitle) | |
} | |
} | |
} | |
// MARK: - UIGestureRecognizerDelegate | |
extension CollectionIndexView: UIGestureRecognizerDelegate { | |
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { | |
return gestureRecognizer != longPresser | |
} | |
} | |
// MARK: - CollectionIndexViewDelegate | |
protocol CollectionIndexViewDelegate: class { | |
func collectionIndexView(_ collectionIndexView: CollectionIndexView, isPressedOn pressedIndex: Int, title: String) | |
func collectionIndexView(_ collectionIndexView: CollectionIndexView, liftedFingerFrom index: Int) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This looks good, but is there a way to use this in Interface Builder? I've added a subview with this as superclass but got a Bad Access exception.