// // ScrollingSegmentedControl.swift // Tusker // // Created by Shadowfacts on 12/11/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit class ScrollingSegmentedControl: 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? #if !os(visionOS) private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator() #endif 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), ]) #if os(visionOS) registerForTraitChanges([UITraitPreferredContentSizeCategory.self], action: #selector(invalidateIntrinsicContentSize)) #endif } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } #if !os(visionOS) override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { invalidateIntrinsicContentSize() } } #endif 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 !os(visionOS) if selectedOption != nil { selectionChangedFeedbackGenerator.selectionChanged() } #endif 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 #if !os(visionOS) selectionChangedFeedbackGenerator.prepare() #endif case .changed: if updateSelectionFor(location: horizontalLocationInStack) { #if !os(visionOS) selectionChangedFeedbackGenerator.prepare() #endif } 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() #if !os(visionOS) selectionChangedFeedbackGenerator.selectionChanged() #endif 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 } }