Skip to content

Instantly share code, notes, and snippets.

@kreeger
Created September 26, 2016 21:54
Show Gist options
  • Save kreeger/965cc093a140bec5445b4e1809dde217 to your computer and use it in GitHub Desktop.
Save kreeger/965cc093a140bec5445b4e1809dde217 to your computer and use it in GitHub Desktop.
//
// 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)
}
@bynelus
Copy link

bynelus commented Mar 31, 2017

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment