├── LICENSE ├── README.md ├── TableOfContentsSelector.swift └── table-of-contents-new.gif /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christian Selig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📖 TableOfContentsSelector 2 | 3 | ![Table of Contents GIF](table-of-contents-new.gif) 4 | 5 | Are you familiar with `UITableView`'s `sectionIndexTitles` API? The little alphabet on the side of some tables for quickly jumping to sections? [Here's a tutorial if not](https://www.appcoda.com/ios-programming-index-list-uitableview/). 6 | 7 | This is a view very similar to that (very little in the way of originality here, folks) but offers a few nice changes I was looking for, so I thought I'd open source it in case anyone else wanted it too. 8 | 9 | ### Benefits 10 | 11 | The [UITableView API](https://www.appcoda.com/ios-programming-index-list-uitableview/) is great, and you should try to stick with built-in components when you can avoid adding in unnecessary dependencies. That being said, here are the advantages this brought me: 12 | 13 | - 🐇 Symbols support! SF Symbols are so pretty, and sometimes a section in your table doesn't map nicely to a letter. Maybe you have some quick actions that you could represent with a lightning bolt or bunny! 14 | - 🌠 Optional overlay support. I really liked on my old iPod nano how when you scrolled really quickly an a big overlay jumped up with the current alphabetical section you were in so you could quickly see where you were. Well, added! 15 | - 🖐 Delayed gesture activation to reduce gesture conflict. For my app, an issue I had was that I had an optional swipe gesture that could occur from the right side of the screen. Whenever a user activated that gesture, it would also activate the section index titles and jump everywhere. This view requires the user long-press it to begin interacting. No conflicts! 16 | - 🏛 Not tied to sections. If you have a less straight forward data structure for your table, where maybe you want to be able to jump to multiple specific items within a section, this doesn't require every index to be a section. Just respond to the delegate and you can do whatever you want. 17 | - 🏓 Not tied to tables. Heck, you don't even have to use this with tables at all. If you want to overlay it in the middle of a `UIImageView` and each index screams a different Celine Dion song, go for it. 18 | - 🏂 Let's be honest, a slightly better name. The Apple engineers created a beautiful API but I can never remember what it's called to Google. `sectionIndexTitles` doesn't roll off the tongue. 19 | - 🌝 Haha moon emoji 20 | 21 | ### How to Install 22 | 23 | No package managers here. Just drag and drop `TableOfContentsSelector.swift` into your Xcode project. You own this code now. You have to [raise it as your own](https://i.imgur.com/LqdUwQq.jpg). 24 | 25 | ### How to Use 26 | 27 | Create your view. 28 | 29 | ```swift 30 | let tableOfContentsSelector = TableOfContentsSelector() 31 | ``` 32 | 33 | (Optional: set a font. Supports increasing and decreasing font for accessibility purposes) 34 | 35 | ```swift 36 | tableOfContentsSelector.font = UIFont.systemFont(ofSize: 12.0, weight: .semibold) // Default 37 | ``` 38 | 39 | The table of contents needs to know the height it's working with in order to lay itself out properly, so let it know what it should be 40 | 41 | ```swift 42 | tableOfContentsSelector.frame.size.height = view.bounds.height 43 | ``` 44 | 45 | Set up your items. The items in the model are represented by the `TableOfContentsItem` enum, which supports either a letter (`.letter("A")`) case or a symbol case (`.symbol(name: "symbol-sloth", isCustom: true)`), which can also be a [custom SF Symbol](https://developer.apple.com/documentation/xcode/creating_custom_symbol_images_for_your_app) that you created yourself and imported into your project. As a helper, there's a variable called `TableOfContentsSelector.alphanumericItems` that supplies A-Z plus # just as the UITableView API does. 46 | 47 | ```swift 48 | let tableOfContentsItems: [TableOfContentsItem] = [ 49 | .symbol(name: "star", isCustom: false), 50 | .symbol(name: "house", isCustom: false), 51 | .symbol(name: "symbol-sloth", isCustom: true) 52 | ] 53 | + TableOfContentsSelector.alphanumericItems 54 | 55 | tableOfContentsSelector.updateWithItems(tableOfContentsItems) 56 | ``` 57 | 58 | At this point add it to your subview and position it how you see fit. You can use `sizeThatFits` to get the proper width as well. 59 | 60 | Lastly, implement the delegate methods so you can find out what's going on. 61 | 62 | ```swift 63 | func viewToShowOverlayIn() -> UIView? { 64 | return self.view 65 | } 66 | 67 | func selectedItem(_ item: TableOfContentsItem) { 68 | // You probably want to do something with the selection! :D 69 | } 70 | 71 | func beganSelection() {} 72 | func endedSelection() {} 73 | ``` 74 | 75 | ### License 76 | 77 | MIT 78 | -------------------------------------------------------------------------------- /TableOfContentsSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableOfContentsSelector.swift 3 | // TableOfContents 4 | // 5 | // Created by Christian Selig on 2021-04-24. 6 | // 7 | 8 | import UIKit 9 | 10 | /// A similar style control to the section index title selector optionally to the right of UITableView, but with more flexibility. 11 | class TableOfContentsSelector: UIView { 12 | var font: UIFont = UIFont.systemFont(ofSize: 12.0, weight: .semibold) { 13 | didSet { 14 | setLabel() 15 | } 16 | } 17 | 18 | let label: UILabel = UILabel() 19 | let longPressGestureRecognizer: UILongPressGestureRecognizer = UILongPressGestureRecognizer(target: nil, action: nil) 20 | let overlayView = TableOfContentsSelectionOverlay() 21 | private let selectionGenerator = UISelectionFeedbackGenerator() 22 | private let impactGenerator = UIImpactFeedbackGenerator(style: .rigid) 23 | 24 | weak var selectionDelegate: TableOfContentsSelectionDelegate? 25 | 26 | // MARK: - Constants 27 | 28 | private let itemHeight: CGFloat 29 | private let lineSpacing: CGFloat = 1.0 30 | private let sidePadding: CGFloat = 2.0 31 | private let verticalPadding: CGFloat = 7.0 32 | private let overlaySize: CGSize = CGSize(width: 110.0, height: 110.0) 33 | 34 | // MARK: - Model 35 | 36 | private var items: [TableOfContentsItem] = [] 37 | private var itemsShown: [TableOfContentsItem] = [] 38 | private var mostRecentSelection: TableOfContentsItem? 39 | 40 | init() { 41 | self.itemHeight = font.lineHeight + lineSpacing 42 | 43 | super.init(frame: .zero) 44 | 45 | label.numberOfLines = 0 46 | addSubview(label) 47 | 48 | label.layer.masksToBounds = true 49 | label.layer.cornerRadius = 10.0 50 | label.layer.cornerCurve = .continuous 51 | label.backgroundColor = .clear 52 | 53 | // We're going to twist UILongPressGR to be more of a UIPanGR to make delaying the gesture easier 54 | label.isUserInteractionEnabled = true 55 | longPressGestureRecognizer.minimumPressDuration = 0.3 56 | longPressGestureRecognizer.addTarget(self, action: #selector(labelLongPressed(gestureRecognizer:))) 57 | label.addGestureRecognizer(longPressGestureRecognizer) 58 | } 59 | 60 | required init?(coder aDecoder: NSCoder) { fatalError("\(#file) does not implement coder.") } 61 | 62 | override func layoutSubviews() { 63 | super.layoutSubviews() 64 | 65 | setLabel() 66 | } 67 | 68 | private func setLabel() { 69 | let totalItemsFittable = Int(bounds.height / itemHeight) 70 | 71 | if totalItemsFittable >= items.count { 72 | // We can fit all of them, so just show all, hurrah! 73 | showAllItemsInLabel() 74 | } else { 75 | // Can't fit all, mimic UITableView and have • characters spaced between to show that the 'visualization' is incomplete 76 | showIncompleteAmountOfItemsInLabel(totalItemsFittable) 77 | } 78 | 79 | let labelHeight = label.sizeThatFits(.zero).height + verticalPadding * 2.0 80 | label.frame = CGRect(x: 0.0, y: (bounds.height - labelHeight) / 2.0, width: bounds.width, height: labelHeight) 81 | } 82 | 83 | private func showAllItemsInLabel() { 84 | self.itemsShown = self.items 85 | showItemsInLabel(self.items) 86 | } 87 | 88 | private func showIncompleteAmountOfItemsInLabel(_ totalItemsFittable: Int) { 89 | // Only accept odd numbers of items to get the correct amount of • placeholders 90 | let isOddNumber = totalItemsFittable % 2 == 1 91 | var totalItemsToShow = isOddNumber ? totalItemsFittable : totalItemsFittable - 1 92 | 93 | // Subtract two so we can fit the first and last items the user provided 94 | totalItemsToShow -= 2 95 | 96 | // Since it's an odd number, this will integer round down so that there is 1 less index shown than placeholders 97 | let totalUserItemsToShow = totalItemsToShow / 2 98 | 99 | let showEveryNthCharacter = CGFloat(self.items.count - 2) / CGFloat(totalUserItemsToShow) 100 | 101 | var userItemsToShow: [TableOfContentsItem] = [] 102 | 103 | // Drop the first and last index because we have them covered by the user-provided items 104 | for i in stride(from: CGFloat(1), to: CGFloat(self.items.count - 1), by: showEveryNthCharacter) { 105 | userItemsToShow.append(self.items[Int(i.rounded())]) 106 | 107 | if userItemsToShow.count == totalUserItemsToShow { 108 | // Since we're incrementing by fractional numbers ensure we don't grab one too many and go beyond our indexes 109 | break 110 | } 111 | } 112 | 113 | var itemsToShow: [TableOfContentsItem] = [self.items.first!] 114 | 115 | // Every second one show a placeholder 116 | for item in userItemsToShow { 117 | itemsToShow.append(.letter(letter: "•")) 118 | itemsToShow.append(item) 119 | } 120 | 121 | // The finishing touches… 🍒 122 | itemsToShow.append(.letter(letter: "•")) 123 | itemsToShow.append(self.items.last!) 124 | 125 | self.itemsShown = itemsToShow 126 | showItemsInLabel(itemsToShow) 127 | } 128 | 129 | private func showItemsInLabel(_ items: [TableOfContentsItem]) { 130 | let mainAttributedString = NSMutableAttributedString() 131 | 132 | for item in items { 133 | switch item { 134 | case let .letter(letter): 135 | let paragraphStyle = NSMutableParagraphStyle() 136 | paragraphStyle.lineSpacing = lineSpacing 137 | paragraphStyle.alignment = .center 138 | 139 | mainAttributedString.append(NSAttributedString(string: "\(letter)\n", attributes: [.font: font, .paragraphStyle: paragraphStyle])) 140 | case let .symbol(symbolName, isCustom): 141 | // For symbols, we increase the line spacing slightly as well as shrinking the font's point size, which just makes them visually 'fit' better with normal letters. Note that this ever so slightly has an effect on touch point tracking, but given that this is an imprecise control anyway, it's more than within the realm of acceptable 142 | let paragraphStyle = NSMutableParagraphStyle() 143 | paragraphStyle.lineSpacing = lineSpacing + 2.0 144 | paragraphStyle.alignment = .center 145 | 146 | let symbolAttributedString = NSMutableAttributedString() 147 | 148 | let imageAttachment = NSTextAttachment() 149 | let font = UIFont(descriptor: self.font.fontDescriptor, size: self.font.pointSize - 1.0) 150 | let config = UIImage.SymbolConfiguration(font: font) 151 | 152 | let image: UIImage = { 153 | if isCustom { 154 | return UIImage(named: symbolName, in: nil, with: config)! 155 | } else { 156 | return UIImage(systemName: symbolName, withConfiguration: config)! 157 | } 158 | }() 159 | 160 | imageAttachment.image = image 161 | 162 | symbolAttributedString.append(NSAttributedString(attachment: imageAttachment)) 163 | symbolAttributedString.append(NSAttributedString(string: "\n")) 164 | symbolAttributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: symbolAttributedString.length)) 165 | 166 | mainAttributedString.append(symbolAttributedString) 167 | } 168 | } 169 | 170 | // Remove last newline 171 | mainAttributedString.mutableString.deleteCharacters(in: NSRange(location: mainAttributedString.mutableString.length - 1, length: 1)) 172 | 173 | let fullRange = NSRange(location: 0, length: mainAttributedString.length) 174 | mainAttributedString.addAttribute(.font, value: font, range: fullRange) 175 | 176 | label.attributedText = mainAttributedString 177 | } 178 | 179 | override func sizeThatFits(_ size: CGSize) -> CGSize { 180 | let labelSize = label.sizeThatFits(.zero) 181 | return CGSize(width: labelSize.width + sidePadding * 2.0, height: labelSize.height + verticalPadding * 2.0) 182 | } 183 | 184 | @objc private func labelLongPressed(gestureRecognizer: UILongPressGestureRecognizer) { 185 | let state = gestureRecognizer.state 186 | 187 | let percent = (gestureRecognizer.location(in: label).y - verticalPadding) / (label.bounds.height - verticalPadding * 2.0) 188 | let itemIndex = max(0, min(self.items.count - 1, Int((CGFloat(self.items.count) * percent)))) 189 | let selectedItem = self.items[itemIndex] 190 | 191 | if state == .began { 192 | impactGenerator.impactOccurred() 193 | selectionDelegate?.beganSelection() 194 | label.backgroundColor = UIColor(white: 0.85, alpha: 1.0) 195 | 196 | if let viewToOverlayIn = selectionDelegate?.viewToShowOverlayIn() { 197 | viewToOverlayIn.addSubview(overlayView) 198 | positionAndSizeOverlayView() 199 | } 200 | } 201 | 202 | showSelectedItem(selectedItem) 203 | selectionDelegate?.selectedItem(selectedItem) 204 | 205 | if state == .changed { 206 | if mostRecentSelection != selectedItem { 207 | selectionGenerator.selectionChanged() 208 | } 209 | 210 | mostRecentSelection = selectedItem 211 | } 212 | 213 | if [.ended, .cancelled, .failed].contains(state) { 214 | label.backgroundColor = .clear 215 | selectionDelegate?.endedSelection() 216 | overlayView.removeFromSuperview() 217 | } 218 | } 219 | 220 | private func showSelectedItem(_ selectedItem: TableOfContentsItem) { 221 | overlayView.updateSelectionTo(selectedItem) 222 | } 223 | 224 | func positionAndSizeOverlayView() { 225 | guard let viewToOverlayIn = selectionDelegate?.viewToShowOverlayIn(), let overlaySuperview = viewToOverlayIn.superview else { 226 | fatalError("Both should be available at this point") 227 | } 228 | 229 | overlayView.frame.size = overlaySize 230 | overlayView.frame.origin = CGPoint(x: (overlaySuperview.bounds.width - overlaySize.width) / 2.0, y: (overlaySuperview.bounds.height - overlaySize.height) / 2.0) 231 | overlayView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin] 232 | } 233 | 234 | // MARK: - Public API 235 | 236 | /// Update the Table of Contents with a list of items, supporting either letters or SF Symbols (or a combination therein) 237 | func updateWithItems(_ items: [TableOfContentsItem]) { 238 | self.items = items 239 | setLabel() 240 | } 241 | 242 | static var alphanumericItems: [TableOfContentsItem] = { 243 | return ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"].map { .letter(letter: $0) } 244 | }() 245 | } 246 | 247 | class TableOfContentsSelectionOverlay: UIVisualEffectView { 248 | let label: UILabel = UILabel() 249 | let imageView: UIImageView = UIImageView() 250 | 251 | let labelFontSize: CGFloat = 55.0 252 | let imageFontSize: CGFloat = 44.0 253 | 254 | init() { 255 | super.init(effect: UIBlurEffect(style: .systemMaterial)) 256 | 257 | layer.masksToBounds = true 258 | layer.cornerRadius = 20.0 259 | layer.cornerCurve = .continuous 260 | 261 | let baseFont = UIFont.systemFont(ofSize: labelFontSize, weight: .medium) 262 | let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded)!, size: baseFont.pointSize) 263 | 264 | let overlayTextColor = UIColor(white: 0.3, alpha: 1.0) 265 | 266 | label.font = roundedFont 267 | label.textAlignment = .center 268 | label.textColor = overlayTextColor 269 | label.isHidden = true 270 | contentView.addSubview(label) 271 | 272 | imageView.contentMode = .center 273 | imageView.tintColor = overlayTextColor 274 | imageView.isHidden = true 275 | contentView.addSubview(imageView) 276 | } 277 | 278 | required init?(coder aDecoder: NSCoder) { fatalError("\(#file) does not implement coder.") } 279 | 280 | override func layoutSubviews() { 281 | super.layoutSubviews() 282 | 283 | label.frame = bounds 284 | imageView.frame = bounds 285 | } 286 | 287 | func updateSelectionTo(_ newSelection: TableOfContentsItem) { 288 | switch newSelection { 289 | case let .letter(letter): 290 | label.text = "\(letter)" 291 | label.isHidden = false 292 | imageView.isHidden = true 293 | case let .symbol(name, isCustom): 294 | imageView.image = { 295 | // Symbols look a little different than letters, so we make them a little smaller and heavier weight in order to keep visual consistency between both 296 | let font = UIFont.systemFont(ofSize: imageFontSize, weight: .semibold) 297 | let config = UIImage.SymbolConfiguration(font: font) 298 | 299 | if isCustom { 300 | return UIImage(named: name, in: nil, with: config) 301 | } else { 302 | return UIImage(systemName: name, withConfiguration: config) 303 | } 304 | }() 305 | 306 | label.isHidden = true 307 | imageView.isHidden = false 308 | } 309 | } 310 | } 311 | 312 | 313 | enum TableOfContentsItem: Equatable { 314 | /// A standard letter 315 | case letter(letter: Character) 316 | 317 | /// An SF Symbol, either iOS-provided or custom. Can optionally specificy a font size modifier if size in standard font is not optimal. 318 | case symbol(name: String, isCustom: Bool) 319 | } 320 | 321 | protocol TableOfContentsSelectionDelegate: AnyObject { 322 | func viewToShowOverlayIn() -> UIView? 323 | func selectedItem(_ item: TableOfContentsItem) 324 | func beganSelection() 325 | func endedSelection() 326 | } 327 | -------------------------------------------------------------------------------- /table-of-contents-new.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christianselig/TableOfContentsSelector/b31e3bb506d2ebcf4b378c9c0cd037f15a9d48c1/table-of-contents-new.gif --------------------------------------------------------------------------------