// // 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() scrollThumbToPosition(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() scrollThumbToPosition(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 } scrollThumbToPosition(position) setNeedsUpdate() } } /// Allows a unique id to be passed into the thumb and track of the thumb(scrubber). open var scrubberId: Int? { didSet { setNeedsUpdate() } } /// A callback when the scrubber position changes. Passes parameters (position). open var onScrubberDidChange: ((Int) -> Void)? //-------------------------------------------------- // 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 internal var trayOriginalCenter: CGPoint! 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.onScrubberDrag(_:))))) 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() { position = (position ?? 1) - 1 scrollThumbToPosition(position) } func onMoveForward() { position = (position ?? 1) + 1 scrollThumbToPosition(position) } // Drag scrollbar thumb to move it to the left or right. // Upon releases of drag, the scrollbar thumb snaps to the closest full position and the scrollbar returns to original size without delay. @objc func onScrubberDrag(_ sender: UIPanGestureRecognizer) { let translation = sender.translation(in: thumbView) if sender.state == UIGestureRecognizer.State.began { trayOriginalCenter = thumbView.center } else if sender.state == UIGestureRecognizer.State.changed { let draggedPositions = Int (ceil (Double(translation.x) / Double(actualThumbWidth))) setThumb(at: (position ?? 1) + draggedPositions) } else if sender.state == UIGestureRecognizer.State.cancelled || sender.state == UIGestureRecognizer.State.ended { let draggedPositions = Int (ceil (Double(translation.x) / Double(actualThumbWidth))) position = (((position ?? 1) + draggedPositions) < 1) ? 1 : ((position ?? 1) + draggedPositions) } } // Move the scrollbar thumb to the left while tapping on the left side of the scrubber. @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 self.onMoveBackward() } } // Move the scrollbar thumb to the right while tapping on the right side of the scrubber. @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 self.onMoveForward() } } // Minimum thumb width applied // Incomplete set moves a shorter distance than the standard increment value. 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() } // Update active overlay frames according to thumb position. 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 scrollThumbToPosition(_ position: Int?) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.setThumb(at: position) self?.onScrubberDidChange?(position ?? 1) } } private func setThumb(at position: Int?) { thumbView.frame.origin.x = CGFloat(Float((position ?? 1) - 1) * actualThumbWidth) + trackView.frame.origin.x updateActiveOverlays() } }