// // CarouselScrollbar.swift // VDS // // Created by Kanamarlapudi, Vasavi on 12/03/24. // import Foundation import UIKit import VDSColorTokens import VDSFormControlsTokens import Combine /// A carousel scrollbar is a control that allows to navigate between items in a carousel. /// It's also a status indicator that conveys the relative amount of content in a carousel and a location within it. @objc(VDSCarouselScrollbar) open class CarouselScrollbar: View { //-------------------------------------------------- // MARK: - Initializers //-------------------------------------------------- required public init() { super.init(frame: .zero) } public override init(frame: CGRect) { super.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) } //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- /// Used to set total number of slides within carousel open var numberOfSlides: Int? { get { return _numberOfSlides } set { if let newValue { _numberOfSlides = newValue } else { _numberOfSlides = 1 } setThumbWidth() setThumb(position) setNeedsUpdate() } } /// The number of slides that can appear at once in a set in a carousel container. open var selectedLayout: Layout? { get { return _selectedLayout } set { if let newValue { _selectedLayout = newValue } else { _selectedLayout = .oneUP } setThumbWidth() setThumb(position) setNeedsUpdate() } } /// Enum used to describe the number of slides that can appear at once in a set in a carousel container. public enum Layout: String, CaseIterable { case oneUP = "1UP" case twoUP = "2UP" case threeUP = "3UP" case fourUP = "4UP" case fiveUP = "5UP" case sixUP = "6UP" case eightUP = "8UP" var value: Int { switch self { case .oneUP: 1 case .twoUP: 2 case .threeUP: 3 case .fourUP: 4 case .fiveUP: 5 case .sixUP: 6 case .eightUP: 8 } } } /// Used to set the position of the thumb(scrubber). This is used when the carousel container changes position, it will align the position of thumb(scrubber). open var position: Int? { get { return _position } set { if let newValue { checkPositions() _position = (newValue > totalPositions ?? 1) ? totalPositions : newValue } else { _position = 1 } setThumb(position) setNeedsUpdate() } } /// Allows a unique id to be passed into the thumb and track of the thumb(scrubber). open var scrubberId: Int? { didSet { setNeedsUpdate() } } //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- // Sizes are from InVision design specs. internal var containerSize: CGSize { CGSize(width: 45, height: 44) } internal var _selectedLayout: Layout = .oneUP internal var _numberOfSlides: Int? = 1 internal var heightConstraint: NSLayoutConstraint? internal var totalPositions: Int? = 1 internal var _position: Int? = 1 private let trackViewWidth = 96 private let trackViewHeight: CGFloat = 4 private let minThumbWidth: Float = 16.0 private var thumbWidth: Float = 16.0 private var actualThumbWidth: Float = 0.0 private let cornerRadius: CGFloat = 4.0 private let activeOpacity: Float = 0.15 private let defaultOpacity: Float = 1 /// Track view with fixed width internal var trackView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() /// Left Active Track overlay with variable width internal var leftActiveOverlay: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.tag = 2 } }() /// Right Active Track overlay with variable width internal var rightActiveOverlay: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.tag = 3 } }() /// Thumb view with variable width internal var thumbView: UIView = { return UIView().with { $0.translatesAutoresizingMaskIntoConstraints = false } }() //-------------------------------------------------- // MARK: - Configuration //-------------------------------------------------- private var thumbColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveScrollthumbOnlight , VDSColor.interactiveScrollthumbOndark) private var trackColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveScrolltrackOnlight , VDSColor.interactiveScrolltrackOndark) private var activeOverlayColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteWhite) //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- open override func initialSetup() { super.initialSetup() } open override func setup() { super.setup() accessibilityLabel = "Carousel Scrollbar" //create the wrapping view heightConstraint = self.heightAnchor.constraint(equalToConstant: containerSize.height) heightConstraint?.priority = .defaultHigh heightConstraint?.isActive = true //Trackview trackView.frame = CGRectMake(20, 20, CGFloat(trackViewWidth), trackViewHeight) trackView.layer.cornerRadius = cornerRadius addSubview(trackView) ///Left active overlay leftActiveOverlay.frame = CGRectMake(trackView.frame.origin.x, 20, CGFloat(trackViewWidth), trackViewHeight) leftActiveOverlay.isUserInteractionEnabled = true leftActiveOverlay.layer.cornerRadius = cornerRadius let leftPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.onThumbTouchStart(_:))) leftPressRecognizer.minimumPressDuration = 0 leftActiveOverlay.addGestureRecognizer(leftPressRecognizer) addSubview(leftActiveOverlay) ///Right active overlay rightActiveOverlay.frame = CGRectMake(thumbView.frame.origin.x + thumbView.frame.size.width, 20, CGFloat(trackViewWidth), trackViewHeight) rightActiveOverlay.isUserInteractionEnabled = true rightActiveOverlay.layer.cornerRadius = cornerRadius let rightPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.onThumbTouchEnd(_:))) rightPressRecognizer.minimumPressDuration = 0 rightActiveOverlay.addGestureRecognizer(rightPressRecognizer) addSubview(rightActiveOverlay) //Thumbview thumbView.frame = CGRectMake(20, 20, CGFloat(thumbWidth), trackViewHeight) thumbView.layer.cornerRadius = cornerRadius thumbView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action:(#selector(self.handleGesture(_:))))) addSubview(thumbView) updateActiveOverlays() } open override func updateView() { super.updateView() trackView.backgroundColor = trackColorConfiguration.getColor(surface) thumbView.backgroundColor = thumbColorConfiguration.getColor(surface) } open override func updateAccessibility() { super.updateAccessibility() } open override func reset() { for subview in subviews { for recognizer in subview.gestureRecognizers ?? [] { subview.removeGestureRecognizer(recognizer) } } super.reset() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- func onMoveBackward() { thumbView.frame.origin.x = thumbView.frame.origin.x - CGFloat(actualThumbWidth) updateActiveOverlays() } func onMoveForward() { thumbView.frame.origin.x = thumbView.frame.origin.x + CGFloat(actualThumbWidth) updateActiveOverlays() } @objc func handleGesture(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: print("began") break case .changed: print("changed") break case .cancelled: print("cancelled") break case .ended: print("ended") break default: break } } @objc func onThumbTouchStart(_ gesture: UILongPressGestureRecognizer) { if gesture.state == .began { leftActiveOverlay.backgroundColor = activeOverlayColorConfiguration.getColor(self) leftActiveOverlay.layer.opacity = activeOpacity } else if gesture.state == .cancelled { leftActiveOverlay.backgroundColor = .clear leftActiveOverlay.layer.opacity = defaultOpacity } else if gesture.state == .ended { leftActiveOverlay.backgroundColor = .clear leftActiveOverlay.layer.opacity = defaultOpacity DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.onMoveBackward() } } } @objc func onThumbTouchEnd(_ gesture: UILongPressGestureRecognizer) { if gesture.state == .began { rightActiveOverlay.backgroundColor = activeOverlayColorConfiguration.getColor(self) rightActiveOverlay.layer.opacity = activeOpacity } else if gesture.state == .cancelled { rightActiveOverlay.backgroundColor = .clear rightActiveOverlay.layer.opacity = defaultOpacity } else if gesture.state == .ended { rightActiveOverlay.backgroundColor = .clear rightActiveOverlay.layer.opacity = defaultOpacity DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.onMoveForward() } } } private func setThumbWidth() { let width = (Float(trackViewWidth) / Float(numberOfSlides ?? 1)) * Float(_selectedLayout.value) actualThumbWidth = (width > Float(trackViewWidth)) ? Float(trackViewWidth) : width thumbWidth = (width <= Float(trackViewWidth) && width > minThumbWidth) ? width : ((width > Float(trackViewWidth)) ? Float(trackViewWidth) : minThumbWidth) thumbView.frame.size.width = CGFloat(thumbWidth) thumbView.frame.origin.x = trackView.frame.origin.x checkPositions() updateActiveOverlays() } private func updateActiveOverlays() { // adjusting thumb position if it goes beyond trackView. let thumbPosition = thumbView.frame.origin.x + thumbView.frame.size.width let trackPosition = trackView.frame.origin.x + trackView.frame.size.width if thumbPosition > trackPosition { thumbView.frame.origin.x = trackPosition - thumbView.frame.size.width } else if thumbView.frame.origin.x < trackView.frame.origin.x { thumbView.frame.origin.x = trackView.frame.origin.x } //left active overlay position update leftActiveOverlay.frame.size.width = thumbView.frame.origin.x - trackView.frame.origin.x + cornerRadius //left active overlay position update rightActiveOverlay.frame.origin.x = thumbView.frame.origin.x + thumbView.frame.size.width - cornerRadius rightActiveOverlay.frame.size.width = (trackView.frame.origin.x + trackView.frame.size.width) - (thumbView.frame.origin.x + thumbView.frame.size.width) + cornerRadius } private func checkPositions() { totalPositions = Int (ceil (Double(numberOfSlides ?? 1) / Double(_selectedLayout.value))) } private func setThumb(_ position: Int?) { thumbView.frame.origin.x = CGFloat(Float((position ?? 1) - 1) * actualThumbWidth) + trackView.frame.origin.x updateActiveOverlays() } }