forked from shadowfacts/Tusker
239 lines
10 KiB
Swift
239 lines
10 KiB
Swift
//
|
|
// ScrollingSegmentedControl.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 12/11/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecognizerDelegate, UIPointerInteractionDelegate {
|
|
|
|
private(set) var selectedOption: Value?
|
|
var options: [Option] = [] {
|
|
didSet {
|
|
createOptionViews()
|
|
}
|
|
}
|
|
var didSelectOption: ((Value?) -> Void)?
|
|
|
|
private let optionsStack = UIStackView()
|
|
private let selectedIndicatorView = UIView()
|
|
private var selectedIndicatorViewAlignmentConstraints: [NSLayoutConstraint] = []
|
|
private var changeSelectionPanRecognizer: UIGestureRecognizer!
|
|
private var selectedOptionAtStartOfPan: Value?
|
|
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
let buttonWidths = optionsStack.arrangedSubviews.map(\.intrinsicContentSize.width).reduce(0, +)
|
|
let spacing = (CGFloat(optionsStack.arrangedSubviews.count) - 1) * 8
|
|
// add 16 to account for the spacing around optionsStack
|
|
return CGSize(width: buttonWidths + spacing + 16, height: 44)
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
showsHorizontalScrollIndicator = false
|
|
|
|
optionsStack.axis = .horizontal
|
|
optionsStack.spacing = 8
|
|
optionsStack.distribution = .fillProportionally
|
|
optionsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(optionsStack)
|
|
NSLayoutConstraint.activate([
|
|
optionsStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor, constant: 8),
|
|
optionsStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor, constant: -8),
|
|
optionsStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
|
|
optionsStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
|
optionsStack.heightAnchor.constraint(equalTo: heightAnchor),
|
|
|
|
// add 16 to account for the spacing around optionsStack
|
|
widthAnchor.constraint(lessThanOrEqualTo: optionsStack.widthAnchor, constant: 16),
|
|
])
|
|
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
|
self.changeSelectionPanRecognizer = panRecognizer
|
|
panRecognizer.delegate = self
|
|
optionsStack.addGestureRecognizer(panRecognizer)
|
|
optionsStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(optionTapped)))
|
|
optionsStack.addInteraction(UIPointerInteraction(delegate: self))
|
|
|
|
self.panGestureRecognizer.delegate = self
|
|
|
|
selectedIndicatorView.isHidden = true
|
|
selectedIndicatorView.backgroundColor = .tintColor
|
|
selectedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(selectedIndicatorView)
|
|
NSLayoutConstraint.activate([
|
|
selectedIndicatorView.heightAnchor.constraint(equalToConstant: 4),
|
|
selectedIndicatorView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
|
|
private func createOptionViews() {
|
|
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
|
|
for (index, option) in options.enumerated() {
|
|
let label = UILabel()
|
|
label.text = option.name
|
|
label.font = .preferredFont(forTextStyle: .headline)
|
|
label.adjustsFontForContentSizeCategory = true
|
|
label.textColor = .secondaryLabel
|
|
label.textAlignment = .center
|
|
label.accessibilityTraits = .button
|
|
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
|
|
optionsStack.addArrangedSubview(label)
|
|
}
|
|
|
|
updateSelectedIndicatorView()
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
|
|
func setSelectedOption(_ value: Value, animated: Bool) {
|
|
guard selectedOption != value,
|
|
options.contains(where: { $0.value == value }) else {
|
|
return
|
|
}
|
|
|
|
if selectedOption != nil {
|
|
selectionChangedFeedbackGenerator.selectionChanged()
|
|
}
|
|
|
|
selectedOption = value
|
|
didSelectOption?(value)
|
|
updateSelectedIndicatorView()
|
|
|
|
if animated && !selectedIndicatorView.isHidden {
|
|
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
}
|
|
|
|
private func updateSelectedIndicatorView() {
|
|
guard let selectedOption,
|
|
let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }) else {
|
|
selectedIndicatorView.isHidden = true
|
|
return
|
|
}
|
|
let selectedOptionView = optionsStack.arrangedSubviews[selectedIndex]
|
|
selectedIndicatorView.isHidden = false
|
|
NSLayoutConstraint.deactivate(selectedIndicatorViewAlignmentConstraints)
|
|
selectedIndicatorViewAlignmentConstraints = [
|
|
selectedIndicatorView.leadingAnchor.constraint(equalTo: selectedOptionView.leadingAnchor),
|
|
selectedIndicatorView.trailingAnchor.constraint(equalTo: selectedOptionView.trailingAnchor),
|
|
]
|
|
NSLayoutConstraint.activate(selectedIndicatorViewAlignmentConstraints)
|
|
|
|
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() {
|
|
let label = optionView as! UILabel
|
|
label.textColor = index == selectedIndex ? .label : .secondaryLabel
|
|
label.accessibilityTraits = index == selectedIndex ? [.button, .selected] : .button
|
|
}
|
|
}
|
|
|
|
// MARK: Interaction
|
|
|
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
let beganOnSelectedOption: Bool
|
|
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
|
|
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
|
|
beganOnSelectedOption = true
|
|
} else {
|
|
beganOnSelectedOption = false
|
|
}
|
|
|
|
// only begin changing selection if the gesutre started on the currently selected item
|
|
// otherwise, let the scroll view handle things
|
|
if gestureRecognizer == self.changeSelectionPanRecognizer {
|
|
return beganOnSelectedOption
|
|
} else {
|
|
return !beganOnSelectedOption
|
|
}
|
|
}
|
|
|
|
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
let horizontalLocationInStack = CGPoint(x: recognizer.location(in: optionsStack).x, y: 0)
|
|
switch recognizer.state {
|
|
case .began:
|
|
selectedOptionAtStartOfPan = selectedOption
|
|
selectionChangedFeedbackGenerator.prepare()
|
|
|
|
case .changed:
|
|
if updateSelectionFor(location: horizontalLocationInStack) {
|
|
selectionChangedFeedbackGenerator.prepare()
|
|
}
|
|
|
|
case .ended:
|
|
if let selectedOptionAtStartOfPan {
|
|
self.selectedOptionAtStartOfPan = nil
|
|
if let selectedOption,
|
|
selectedOptionAtStartOfPan != selectedOption {
|
|
didSelectOption?(selectedOption)
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
@objc private func optionTapped(_ recognizer: UITapGestureRecognizer) {
|
|
let location = recognizer.location(in: optionsStack)
|
|
if updateSelectionFor(location: location) {
|
|
didSelectOption?(selectedOption!)
|
|
}
|
|
}
|
|
|
|
private func updateSelectionFor(location: CGPoint) -> Bool {
|
|
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() where optionView.frame.contains(location) {
|
|
if selectedOption != options[index].value {
|
|
selectedOption = options[index].value
|
|
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
|
self.updateSelectedIndicatorView()
|
|
self.scrollRectToVisible(optionView.frame.insetBy(dx: -16, dy: 0), animated: false)
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.startAnimation()
|
|
selectionChangedFeedbackGenerator.selectionChanged()
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
|
func distanceToAnyEdge(_ view: UIView) -> CGFloat {
|
|
min(abs(view.frame.minX - request.location.x), abs(view.frame.maxX - request.location.x))
|
|
}
|
|
let (view, index, _) = optionsStack.arrangedSubviews.enumerated().map { ($0.1, $0.0, distanceToAnyEdge($0.1)) }.min(by: { $0.2 < $1.2 })!
|
|
return UIPointerRegion(rect: view.frame.insetBy(dx: -8, dy: 0), identifier: index)
|
|
}
|
|
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
|
let index = region.identifier as! Int
|
|
let optionView = optionsStack.arrangedSubviews[index]
|
|
return UIPointerStyle(effect: .hover(UITargetedPreview(view: optionView)))
|
|
}
|
|
|
|
struct Option: Hashable {
|
|
let value: Value
|
|
let name: String
|
|
}
|
|
}
|