Skip to content

Instantly share code, notes, and snippets.

@blwinters
Created December 18, 2017 14:15
Show Gist options
  • Save blwinters/1c4df9ff38a59bb92bd0453c2db7e4f2 to your computer and use it in GitHub Desktop.
Save blwinters/1c4df9ff38a59bb92bd0453c2db7e4f2 to your computer and use it in GitHub Desktop.
A protocol that creates a grid of multi-selectable buttons with contents specified by the delegate.
//
// ButtonGridDisplayable.swift
// Summit_iOS
//
// Created by Ben Winters on 9/26/17.
// Copyright © 2017 Goals LLC. All rights reserved.
//
import UIKit
@objc protocol ButtonGridDisplayable {
var hSeparatorContainerStack: UIStackView! { get }
var vSeparatorContainerStack: UIStackView! { get }
var buttonContainerStack: UIStackView! { get }
var columnCount: Int { get }
var separatorThickness: CGFloat { get }
var selectedColor: UIColor { get }
var separatorColor: UIColor { get }
var buttonValues: [Int] { get }
var selectedValues: Set<Int> { get }
func titleForButtonValue(_ value: Int) -> String?
@objc func didTapButton(sender: UIButton)
}
extension ButtonGridDisplayable {
var rowCount: Int {
var result = Int(buttonValues.count / columnCount)
if buttonValues.count % columnCount != 0 {
result += 1
}
return result
}
var buttonRowStacks: [UIStackView] {
return buttonContainerStack.arrangedSubviews.flatMap({$0 as? UIStackView})
}
var hSeparatorRowStacks: [UIStackView] {
return hSeparatorContainerStack.arrangedSubviews.flatMap({$0 as? UIStackView})
}
var vSeparatorRowStacks: [UIStackView] {
return vSeparatorContainerStack.arrangedSubviews.flatMap({$0 as? UIStackView})
}
private func gridIndexPaths() -> [IndexPath] {
var paths: [IndexPath] = []
for section in 0..<rowCount {
for item in 0..<columnCount {
paths.append(IndexPath(item: item, section: section))
}
}
return paths
}
private func buttonValue(for indexPath: IndexPath) -> Int? {
let sectionOffset = indexPath.section * columnCount
let valueIndex = sectionOffset + indexPath.item //e.g. IndexPath(item: 1, section: 2) with 4 columns = 9
guard valueIndex < buttonValues.count else { return nil }
return buttonValues[valueIndex]
}
private func hSeparatorIndexPaths(for buttonPath: IndexPath) -> [IndexPath] {
return [IndexPath(item: buttonPath.item, section: buttonPath.section),
IndexPath(item: buttonPath.item, section: buttonPath.section + 1),
]
}
private func vSeparatorIndexPaths(for buttonPath: IndexPath) -> [IndexPath] {
return [IndexPath(item: buttonPath.item, section: buttonPath.section),
IndexPath(item: buttonPath.item + 1, section: buttonPath.section),
]
}
private var monthButtonsBySection: [[UIButton]] {
return buttonRowStacks.map({ stack in
return stack.arrangedSubviews.flatMap({$0 as? UIButton})
})
}
func didSetSelectedValues() {
for row in buttonRowStacks {
for button in row.arrangedSubviews.flatMap({$0 as? UIButton}) {
button.isSelected = selectedValues.contains(button.tag)
}
}
updateSeparators(for: selectedValues)
}
///Each horizontal stack corresponds to a section and the index within that stack matches the IndexPath.item
func indexPath(forValue value: Int) -> IndexPath {
let section = Int(value / columnCount) //truncates decimals
let column = Int(value % columnCount) - 1
return IndexPath(item: column, section: section)
}
func setupStackViews() {
buttonContainerStack.axis = .vertical
buttonContainerStack.distribution = .fillEqually
buttonContainerStack.alignment = .fill
buttonContainerStack.spacing = 0
buttonContainerStack.translatesAutoresizingMaskIntoConstraints = false
vSeparatorContainerStack.axis = .vertical
vSeparatorContainerStack.distribution = .fillEqually //child horizontal stacks fill the vertical space
vSeparatorContainerStack.alignment = .fill //left and right margins of child stacks
vSeparatorContainerStack.spacing = 0
vSeparatorContainerStack.translatesAutoresizingMaskIntoConstraints = false
hSeparatorContainerStack.axis = .vertical
hSeparatorContainerStack.distribution = .equalSpacing //child horizontal stacks are 0.5pts tall and spaced equally vertically
hSeparatorContainerStack.alignment = .fill //left and right margins of child stacks
hSeparatorContainerStack.translatesAutoresizingMaskIntoConstraints = false
for _ in 0..<rowCount {
let buttonStack = UIStackView()
buttonStack.axis = .horizontal
buttonStack.distribution = .fillEqually
buttonStack.alignment = .fill
buttonStack.spacing = 0
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonContainerStack.addArrangedSubview(buttonStack)
let vSeparatorStack = UIStackView()
vSeparatorStack.axis = .horizontal
vSeparatorStack.distribution = .equalSpacing
vSeparatorStack.alignment = .fill
vSeparatorStack.translatesAutoresizingMaskIntoConstraints = false
vSeparatorContainerStack.addArrangedSubview(vSeparatorStack)
}
for _ in 0...rowCount {
let hSeparatorStack = UIStackView()
hSeparatorStack.axis = .horizontal
hSeparatorStack.distribution = .fillEqually
hSeparatorStack.alignment = .fill
hSeparatorStack.translatesAutoresizingMaskIntoConstraints = false
hSeparatorStack.heightAnchor.constraint(equalToConstant: separatorThickness).isActive = true
hSeparatorContainerStack.addArrangedSubview(hSeparatorStack)
}
}
func setupButtons() {
for stack in buttonRowStacks {
stack.removeAllSubviews()
}
let selectedBackgroundImage = UIImage.image(withColor: selectedColor)
for path in gridIndexPaths() {
if let bValue = buttonValue(for: path) {
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) //arbitrary frame
if let title = titleForButtonValue(bValue) {
let normalTitle = NSMutableAttributedString().regular(title, size: 17, color: Style.cellDarkText)
let selectedTitle = NSMutableAttributedString().medium(title, size: 17, color: Style.textOnPrimaryFill)
button.setAttributedTitle(normalTitle, for: .normal)
button.setAttributedTitle(selectedTitle, for: .selected)
}
button.tag = bValue
button.setBackgroundImage(selectedBackgroundImage, for: .selected)
button.addTarget(self, action: #selector(self.didTapButton(sender:)), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
let stack = buttonRowStacks[path.section]
stack.addArrangedSubview(button)
} else {
//values run out before last row is completed, need to insert invisible buttons for spacing
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) //arbitrary frame
button.backgroundColor = UIColor.clear
button.isUserInteractionEnabled = false
button.translatesAutoresizingMaskIntoConstraints = false
let stack = buttonRowStacks[path.section]
stack.addArrangedSubview(button)
}
}
}
func addSeparators() {
//vertical separators
for vSeparatorStack in vSeparatorRowStacks {
for _ in 0...columnCount {
let separator = UIView()
separator.backgroundColor = separatorColor
separator.translatesAutoresizingMaskIntoConstraints = false
separator.widthAnchor.constraint(equalToConstant: separatorThickness).isActive = true
vSeparatorStack.addArrangedSubview(separator)
}
}
//horizontal separators
for hSeparatorStack in hSeparatorRowStacks {
for _ in 0..<columnCount {
let separator = UIView()
separator.backgroundColor = separatorColor
separator.translatesAutoresizingMaskIntoConstraints = false
//The stack itself has a height constraint
hSeparatorStack.addArrangedSubview(separator)
}
}
}
func updateSeparators(for selectedValues: Set<Int>) {
var selectedButtonPaths: Set<IndexPath> = []
selectedValues.forEach({
selectedButtonPaths.insert(indexPath(forValue: $0))
})
//Get index paths for horizontal separators that touch the selected buttons
var selectedHSeparatorPaths: Set<IndexPath> = []
selectedButtonPaths.forEach({ buttonPath in
hSeparatorIndexPaths(for: buttonPath).forEach({ hSepPath in
selectedHSeparatorPaths.insert(hSepPath)
})
})
//Get index paths for vertical separators that touch the selected buttons
var selectedVSeparatorPaths: Set<IndexPath> = []
selectedButtonPaths.forEach({ buttonPath in
vSeparatorIndexPaths(for: buttonPath).forEach({ vSepPath in
selectedVSeparatorPaths.insert(vSepPath)
})
})
var hiddenVSeparatorPaths: Set<IndexPath> = []
gridIndexPaths().forEach({ buttonPath in
if buttonValue(for: buttonPath) == nil { //buttonValues.count < rowCount * columnCount
//Only hide the trailing separator
let hiddenSepPath = IndexPath(item: buttonPath.item + 1, section: buttonPath.section)
hiddenVSeparatorPaths.insert(hiddenSepPath)
}
})
//Apply the selected horizontal separator paths to update the separator background color
for (stackIndex, stack) in hSeparatorRowStacks.enumerated() {
for (separatorIndex, separator) in stack.arrangedSubviews.enumerated() {
let separatorPath = IndexPath(item: separatorIndex, section: stackIndex)
let isTopOrBottom = (stackIndex == 0 || stackIndex == rowCount) //hide separators that can overlap with cell separators to cause double thick lines
if isTopOrBottom || selectedHSeparatorPaths.contains(separatorPath) {
separator.backgroundColor = UIColor.clear
} else {
separator.backgroundColor = separatorColor
}
}
}
//Apply the selected vertical separator paths to update the separator background color
for (stackIndex, stack) in vSeparatorRowStacks.enumerated() {
for (separatorIndex, separator) in stack.arrangedSubviews.enumerated() {
let separatorPath = IndexPath(item: separatorIndex, section: stackIndex)
let shouldHidePath = hiddenVSeparatorPaths.contains(separatorPath) || selectedVSeparatorPaths.contains(separatorPath)
separator.backgroundColor = shouldHidePath ? UIColor.clear : separatorColor
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment